40a80467ce
- util/license_key.go: LicensePayload struct, GenerateEd25519KeyPair, IssueLicenseToken (sign), VerifyLicenseToken (verify offline) - cmd/genkey: one-shot keypair generator with demo sign+verify - config: add Ed25519PublicKey field (LICENSE_ED25519_PUBLIC_KEY env) Token format: base64url(header).base64url(payload).base64url(ed25519-sig) Private key stored in Bitwarden; public key embedded in config/client. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
107 lines
3.6 KiB
Go
107 lines
3.6 KiB
Go
package util
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidLicenseToken = errors.New("invalid license token")
|
|
ErrInvalidLicenseSignature = errors.New("invalid license token signature")
|
|
)
|
|
|
|
// LicensePayload is the verified content extracted from a signed license token.
|
|
type LicensePayload struct {
|
|
ShopID uint64 `json:"shop_id"`
|
|
LicenseID uint64 `json:"license_id,omitempty"`
|
|
Type string `json:"type"` // trial | monthly | annual | lifetime
|
|
IssuedAt int64 `json:"issued_at"`
|
|
ExpiresAt *int64 `json:"expires_at,omitempty"` // unix seconds; nil = perpetual
|
|
MaxDevices int `json:"max_devices"`
|
|
Features map[string]any `json:"features,omitempty"`
|
|
}
|
|
|
|
// GenerateEd25519KeyPair generates a new Ed25519 keypair.
|
|
// Returns standard base64-encoded private key (64 bytes) and public key (32 bytes).
|
|
// The private key must be stored securely (Bitwarden); the public key goes in config.
|
|
func GenerateEd25519KeyPair() (privKeyB64, pubKeyB64 string, err error) {
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(priv),
|
|
base64.StdEncoding.EncodeToString(pub),
|
|
nil
|
|
}
|
|
|
|
// IssueLicenseToken signs a LicensePayload with the Ed25519 private key and returns
|
|
// a compact token: base64url(header).base64url(payload).base64url(signature).
|
|
// privKeyB64 is the standard base64-encoded 64-byte Ed25519 private key.
|
|
func IssueLicenseToken(payload LicensePayload, privKeyB64 string) (string, error) {
|
|
privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyB64)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode private key: %w", err)
|
|
}
|
|
if len(privKeyBytes) != ed25519.PrivateKeySize {
|
|
return "", fmt.Errorf("private key must be %d bytes, got %d", ed25519.PrivateKeySize, len(privKeyBytes))
|
|
}
|
|
privKey := ed25519.PrivateKey(privKeyBytes)
|
|
|
|
header := rawB64([]byte(`{"alg":"EdDSA","typ":"LIC"}`))
|
|
payloadJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
body := rawB64(payloadJSON)
|
|
signingInput := header + "." + body
|
|
sig := ed25519.Sign(privKey, []byte(signingInput))
|
|
return signingInput + "." + rawB64(sig), nil
|
|
}
|
|
|
|
// VerifyLicenseToken verifies the Ed25519 signature of a license token and returns
|
|
// the decoded payload. Does NOT check expiry — callers must check ExpiresAt themselves.
|
|
// pubKeyB64 is the standard base64-encoded 32-byte Ed25519 public key.
|
|
func VerifyLicenseToken(token, pubKeyB64 string) (*LicensePayload, error) {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return nil, ErrInvalidLicenseToken
|
|
}
|
|
|
|
pubKeyBytes, err := base64.StdEncoding.DecodeString(pubKeyB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode public key: %w", err)
|
|
}
|
|
if len(pubKeyBytes) != ed25519.PublicKeySize {
|
|
return nil, fmt.Errorf("public key must be %d bytes, got %d", ed25519.PublicKeySize, len(pubKeyBytes))
|
|
}
|
|
pubKey := ed25519.PublicKey(pubKeyBytes)
|
|
|
|
signingInput := parts[0] + "." + parts[1]
|
|
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
return nil, ErrInvalidLicenseToken
|
|
}
|
|
if !ed25519.Verify(pubKey, []byte(signingInput), sigBytes) {
|
|
return nil, ErrInvalidLicenseSignature
|
|
}
|
|
|
|
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return nil, ErrInvalidLicenseToken
|
|
}
|
|
var p LicensePayload
|
|
if err := json.Unmarshal(payloadJSON, &p); err != nil {
|
|
return nil, ErrInvalidLicenseToken
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func rawB64(data []byte) string {
|
|
return base64.RawURLEncoding.EncodeToString(data)
|
|
}
|