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,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))
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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