56ffca767d
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).
86 lines
3.0 KiB
Go
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
|
|
}
|