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 {
|
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 {
|
type StorageConfig struct {
|
||||||
@@ -57,6 +58,7 @@ func Load() {
|
|||||||
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
|
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
|
||||||
_ = viper.BindEnv("jwt.secret", "JWT_SECRET")
|
_ = viper.BindEnv("jwt.secret", "JWT_SECRET")
|
||||||
_ = viper.BindEnv("license.hmac_secret", "LICENSE_HMAC_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.upload_dir", "STORAGE_UPLOAD_DIR")
|
||||||
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
||||||
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_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