Files
aura-crm/production/backend/internal/license/license.go
T
root 56ffca767d feat: offline license/trial system
7-day full-access trial, then gated behind an Ed25519-signed license key
(internal/license) verified entirely offline — no phone-home dependency.
Middleware 402s non-exempt API routes once the trial lapses with no valid
key; frontend shows a countdown banner in the last 3 days and a blocking
overlay once actually gated, both pointing at the new Settings -> Лицензия
tab. tools/gen-license mints keys for the vendor side (never ships the
private key).
2026-08-21 18:44:54 +00:00

86 lines
3.0 KiB
Go

// Package license verifies purchased Aura CRM licenses entirely offline —
// no server to phone home to, no crash loop if that server is ever
// unreachable (see this repo's own history for exactly that failure mode
// in a different product this business runs). A license key is a JSON
// payload signed with an Ed25519 private key the vendor keeps offline
// (see tools/gen-license); this package only ever holds the matching
// PUBLIC key, so shipping this source (or the compiled binary) to a
// customer can never leak the ability to mint new licenses — verifying and
// signing use different keys, unlike an HMAC scheme where they'd be the
// same secret.
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
)
// publicKeyHex is safe to be public — see package doc comment. Generated
// once per vendor; changing it invalidates every license issued under the
// old key.
const publicKeyHex = "e1f8a79b84ad09cf357ae0548fd2bb77184a33eca9516dee67903b784245e99f"
var ErrInvalidSignature = errors.New("license: invalid signature")
var ErrMalformed = errors.New("license: malformed key")
// Payload is what a license key actually asserts. ExpiresAt nil means a
// perpetual license (paid in full, not a subscription) — Valid() treats
// that as never expiring.
type Payload struct {
Customer string `json:"customer"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// Valid reports whether this payload's own terms are currently satisfied —
// it does NOT re-verify the signature (Parse already did that; a Payload
// only ever exists after a successful Parse).
func (p *Payload) Valid() bool {
return p.ExpiresAt == nil || p.ExpiresAt.After(time.Now())
}
// Parse verifies a license key's Ed25519 signature and decodes its
// payload. Format: base64(payload-json) + "." + base64(signature-over-the-
// undecoded-payload-b64-string) — signing the base64 text rather than the
// raw JSON bytes means there's exactly one canonical byte sequence to sign
// and verify, no risk of a JSON re-serialization producing different bytes
// on the two sides.
func Parse(key string) (*Payload, error) {
key = strings.TrimSpace(key)
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 {
return nil, ErrMalformed
}
payloadB64, sigB64 := parts[0], parts[1]
sig, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
return nil, ErrMalformed
}
pubKey, err := hex.DecodeString(publicKeyHex)
if err != nil {
// Only possible if publicKeyHex itself was edited to something
// invalid — a build-time bug, not a runtime/user-input error.
return nil, errors.New("license: invalid embedded public key")
}
if !ed25519.Verify(ed25519.PublicKey(pubKey), []byte(payloadB64), sig) {
return nil, ErrInvalidSignature
}
payloadJSON, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, ErrMalformed
}
var p Payload
if err := json.Unmarshal(payloadJSON, &p); err != nil {
return nil, ErrMalformed
}
return &p, nil
}