Files
jiu/backend/internal/util/license_key.go
T
wangjia 40a80467ce 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>
2026-06-10 00:28:55 +08:00

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)
}