diff --git a/backend/cmd/genkey/main.go b/backend/cmd/genkey/main.go new file mode 100644 index 0000000..04cd24d --- /dev/null +++ b/backend/cmd/genkey/main.go @@ -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)) +} diff --git a/backend/config/config.go b/backend/config/config.go index 7762747..4d3409e 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -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") diff --git a/backend/internal/util/license_key.go b/backend/internal/util/license_key.go new file mode 100644 index 0000000..ed397f6 --- /dev/null +++ b/backend/internal/util/license_key.go @@ -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) +}