feat(backend): Ed25519 license signing utilities (21A)
- 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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user