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:
wangjia
2026-06-10 00:28:55 +08:00
parent 18cd2497c1
commit 40a80467ce
3 changed files with 168 additions and 1 deletions
+59
View File
@@ -0,0 +1,59 @@
// genkey generates an Ed25519 keypair for license signing.
// Run once; store the private key in Bitwarden and set the public key in config.
//
// Usage: go run ./cmd/genkey
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/wangjia/jiu/backend/internal/util"
)
func main() {
priv, pub, err := util.GenerateEd25519KeyPair()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to generate keypair: %v\n", err)
os.Exit(1)
}
fmt.Println("=== Ed25519 License Keypair ===")
fmt.Println()
fmt.Println("[Bitwarden] Private key (keep secret, never commit):")
fmt.Println(priv)
fmt.Println()
fmt.Println("[Config / LICENSE_ED25519_PUBLIC_KEY] Public key:")
fmt.Println(pub)
fmt.Println()
// Demo: issue and verify a sample token to confirm the keypair works
now := time.Now()
exp := now.Add(30 * 24 * time.Hour).Unix()
sample := util.LicensePayload{
ShopID: 1,
LicenseID: 1,
Type: "trial",
IssuedAt: now.Unix(),
ExpiresAt: &exp,
MaxDevices: 3,
}
token, err := util.IssueLicenseToken(sample, priv)
if err != nil {
fmt.Fprintf(os.Stderr, "demo sign failed: %v\n", err)
os.Exit(1)
}
verified, err := util.VerifyLicenseToken(token, pub)
if err != nil {
fmt.Fprintf(os.Stderr, "demo verify failed: %v\n", err)
os.Exit(1)
}
out, _ := json.MarshalIndent(verified, "", " ")
fmt.Println("[Demo] Sample token (30-day trial, shop_id=1):")
fmt.Println(token)
fmt.Println()
fmt.Println("[Demo] Verified payload:")
fmt.Println(string(out))
}
+3 -1
View File
@@ -32,7 +32,8 @@ type JWTConfig struct {
}
type LicenseConfig struct {
HMACSecret string `mapstructure:"hmac_secret"` // 许可证签名密钥
HMACSecret string `mapstructure:"hmac_secret"` // legacy, kept for backward compat
Ed25519PublicKey string `mapstructure:"ed25519_public_key"` // base64 Ed25519 public key for token verification
}
type StorageConfig struct {
@@ -57,6 +58,7 @@ func Load() {
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
_ = viper.BindEnv("jwt.secret", "JWT_SECRET")
_ = viper.BindEnv("license.hmac_secret", "LICENSE_HMAC_SECRET")
_ = viper.BindEnv("license.ed25519_public_key", "LICENSE_ED25519_PUBLIC_KEY")
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
+106
View File
@@ -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)
}