feat(backend): 授权改为时长兑换券体系 + 退役 ed25519/HMAC + 平台生码工具
- 新增 license_codes 码池表 + model.LicenseCode;licenses 加 tier 档位列 - LicenseService.Redeem:单事务 FOR UPDATE 校验码未用 → 时长叠加(可叠加,0=永久) → 写 type/tier/max_devices → 绑设备(超限整笔回滚) → 标记已用 → 即时失效 phase 缓存 路由仍 POST /license/activate,客户端零破坏 - util.GenerateRedeemCode/NormalizeCode:JIUKU-XXXX-XXXX 短码(crypto/rand) - cmd/gencode:平台批量生成兑换码并落库;删除 cmd/issue、cmd/genkey - 退役 ed25519 + HMAC:删 util/license_key、GenerateKey、License 全部 config 字段 及生产启动私钥校验;trial 改直接建行(无需私钥、去 Fatal) - tier 档位钩子默认 standard,分档消费模式后续设计 - 测试:Redeem 全场景(叠加/过期重置/永久/一码一次/无效/设备上限回滚) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,106 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLicenseKeyRoundTrip(t *testing.T) {
|
||||
priv, pub, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
exp := time.Now().Add(30 * 24 * time.Hour).Unix()
|
||||
payload := LicensePayload{
|
||||
ShopID: 42,
|
||||
LicenseID: 7,
|
||||
Type: "annual",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: &exp,
|
||||
MaxDevices: 3,
|
||||
}
|
||||
|
||||
token, err := IssueLicenseToken(payload, priv)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
got, err := VerifyLicenseToken(token, pub)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload.ShopID, got.ShopID)
|
||||
assert.Equal(t, payload.Type, got.Type)
|
||||
assert.Equal(t, payload.MaxDevices, got.MaxDevices)
|
||||
assert.Equal(t, *payload.ExpiresAt, *got.ExpiresAt)
|
||||
}
|
||||
|
||||
func TestVerifyLicenseToken_TamperedPayload(t *testing.T) {
|
||||
priv, pub, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
exp := time.Now().Add(30 * 24 * time.Hour).Unix()
|
||||
token, err := IssueLicenseToken(LicensePayload{
|
||||
ShopID: 1, Type: "trial", IssuedAt: time.Now().Unix(), ExpiresAt: &exp, MaxDevices: 1,
|
||||
}, priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Flip the last byte of the signature to simulate tampering
|
||||
tampered := token[:len(token)-2] + "XX"
|
||||
_, err = VerifyLicenseToken(tampered, pub)
|
||||
assert.Error(t, err, "tampered token must be rejected")
|
||||
}
|
||||
|
||||
func TestVerifyLicenseToken_WrongKey(t *testing.T) {
|
||||
priv, _, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
_, otherPub, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
exp := time.Now().Add(30 * 24 * time.Hour).Unix()
|
||||
token, err := IssueLicenseToken(LicensePayload{
|
||||
ShopID: 1, Type: "trial", IssuedAt: time.Now().Unix(), ExpiresAt: &exp, MaxDevices: 1,
|
||||
}, priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = VerifyLicenseToken(token, otherPub)
|
||||
assert.ErrorIs(t, err, ErrInvalidLicenseSignature)
|
||||
}
|
||||
|
||||
func TestVerifyLicenseToken_InvalidFormat(t *testing.T) {
|
||||
_, pub, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = VerifyLicenseToken("not-a-valid-token", pub)
|
||||
assert.ErrorIs(t, err, ErrInvalidLicenseToken)
|
||||
}
|
||||
|
||||
// TestLicenseTokenFitsColumnLimit verifies that a realistic (even worst-case)
|
||||
// license token fits within the VARCHAR(768) column limit imposed by the
|
||||
// InnoDB index constraint (768 chars × 4 bytes/char = 3072 bytes max).
|
||||
func TestLicenseTokenFitsColumnLimit(t *testing.T) {
|
||||
const columnLimit = 768
|
||||
|
||||
priv, _, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use a large features map to simulate a worst-case payload.
|
||||
exp := time.Now().Add(365 * 24 * time.Hour).Unix()
|
||||
payload := LicensePayload{
|
||||
ShopID: 999999999,
|
||||
LicenseID: 999999999,
|
||||
Type: "lifetime",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: &exp,
|
||||
MaxDevices: 99,
|
||||
Features: map[string]any{
|
||||
"finance": true,
|
||||
"inventory": true,
|
||||
"reports": true,
|
||||
"export": true,
|
||||
"multi_shop": true,
|
||||
"api_access": true,
|
||||
},
|
||||
}
|
||||
|
||||
token, err := IssueLicenseToken(payload, priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.LessOrEqual(t, len(token), columnLimit,
|
||||
"license token length %d exceeds VARCHAR(%d) column limit", len(token), columnLimit)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// codeAlphabet 兑换码字母表:剔除易混字符 0/O/1/I/L,避免人工抄录歧义。
|
||||
const codeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
|
||||
// codePrefix 兑换码固定前缀,便于一眼识别归属。
|
||||
const codePrefix = "JIUKU"
|
||||
|
||||
// GenerateRedeemCode 生成一个随机兑换码,展示格式 JIUKU-XXXX-XXXX(8 位随机段)。
|
||||
// 使用 crypto/rand + 无歧义字母表;归一化(NormalizeCode)后入库与比对。
|
||||
func GenerateRedeemCode() string {
|
||||
const n = 8
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// crypto/rand 失败属系统级异常,调用方(CLI 生码)应直接失败而非产出弱码。
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
out := make([]byte, n)
|
||||
for i, b := range buf {
|
||||
out[i] = codeAlphabet[int(b)%len(codeAlphabet)]
|
||||
}
|
||||
return codePrefix + "-" + string(out[:4]) + "-" + string(out[4:])
|
||||
}
|
||||
|
||||
// NormalizeCode 归一化兑换码:转大写、去掉连字符/空格,得到入库与查表用的规范形式。
|
||||
func NormalizeCode(code string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(code) {
|
||||
if r == '-' || r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user