支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
177 lines
5.5 KiB
Go
177 lines
5.5 KiB
Go
package utils
|
||
|
||
import (
|
||
"crypto/ed25519"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
// OfflineLicensePrefix 离线序列号前缀(与在线 LIC- 区分) // Logan.Li
|
||
OfflineLicensePrefix = "LICOFF-1."
|
||
// OfflineMaxDurationDays 离线证最长有效期(禁止永久离线证) // Logan.Li
|
||
OfflineMaxDurationDays = 365
|
||
)
|
||
|
||
// OfflineLicenseClaims 离线许可证声明(自包含,桌面本地验签) // Logan.Li
|
||
type OfflineLicenseClaims struct {
|
||
V int `json:"v"`
|
||
ProductCode string `json:"pid"`
|
||
DeviceID string `json:"did"`
|
||
Email string `json:"email,omitempty"`
|
||
PlanType string `json:"pt"`
|
||
IssuedAt int64 `json:"iat"`
|
||
ExpiresAt int64 `json:"exp"`
|
||
MaxDevices int `json:"md"`
|
||
CustomerName string `json:"cn,omitempty"`
|
||
}
|
||
|
||
// OfflineKeyPair 离线签发密钥对 // Logan.Li
|
||
type OfflineKeyPair struct {
|
||
Private ed25519.PrivateKey
|
||
Public ed25519.PublicKey
|
||
}
|
||
|
||
// DeriveOfflineKeyPair 从 32 字节 seed(hex)派生 Ed25519 密钥对 // Logan.Li
|
||
func DeriveOfflineKeyPair(seedHex string) (*OfflineKeyPair, error) {
|
||
seedHex = strings.TrimSpace(seedHex)
|
||
if seedHex == "" {
|
||
return nil, errors.New("离线签发 seed 为空")
|
||
}
|
||
raw, err := hex.DecodeString(seedHex)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("离线签发 seed 不是合法 hex: %w", err)
|
||
}
|
||
if len(raw) != ed25519.SeedSize {
|
||
// 允许任意长度输入,统一 hash 成 32 字节 // Logan.Li
|
||
sum := sha256.Sum256(raw)
|
||
raw = sum[:]
|
||
}
|
||
priv := ed25519.NewKeyFromSeed(raw)
|
||
pub := priv.Public().(ed25519.PublicKey)
|
||
return &OfflineKeyPair{Private: priv, Public: pub}, nil
|
||
}
|
||
|
||
// OfflinePublicKeyHex 返回公钥 hex(供桌面端嵌入) // Logan.Li
|
||
func (k *OfflineKeyPair) OfflinePublicKeyHex() string {
|
||
return hex.EncodeToString(k.Public)
|
||
}
|
||
|
||
// IsOfflineLicenseKey 判断是否为离线序列号 // Logan.Li
|
||
func IsOfflineLicenseKey(key string) bool {
|
||
return strings.HasPrefix(strings.TrimSpace(key), "LICOFF-")
|
||
}
|
||
|
||
// SignOfflineLicense 签发离线序列号:LICOFF-1.<payload_b64url>.<sig_b64url> // Logan.Li
|
||
func SignOfflineLicense(kp *OfflineKeyPair, claims OfflineLicenseClaims) (string, error) {
|
||
if kp == nil || len(kp.Private) == 0 {
|
||
return "", errors.New("离线签发密钥未配置")
|
||
}
|
||
if err := validateOfflineClaims(claims, true); err != nil {
|
||
return "", err
|
||
}
|
||
claims.V = 1
|
||
payload, err := json.Marshal(claims)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
|
||
sig := ed25519.Sign(kp.Private, []byte(payloadB64))
|
||
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
|
||
return OfflineLicensePrefix + payloadB64 + "." + sigB64, nil
|
||
}
|
||
|
||
// VerifyOfflineLicense 验签并解析离线序列号(不校验设备与过期,由调用方决定) // Logan.Li
|
||
func VerifyOfflineLicense(publicKey ed25519.PublicKey, serial string) (*OfflineLicenseClaims, error) {
|
||
serial = strings.TrimSpace(serial)
|
||
if !strings.HasPrefix(serial, OfflineLicensePrefix) {
|
||
return nil, errors.New("不是合法的离线序列号(需 LICOFF-1. 前缀)")
|
||
}
|
||
body := strings.TrimPrefix(serial, OfflineLicensePrefix)
|
||
parts := strings.Split(body, ".")
|
||
if len(parts) != 2 {
|
||
return nil, errors.New("离线序列号格式错误")
|
||
}
|
||
payloadB64, sigB64 := parts[0], parts[1]
|
||
sig, err := base64.RawURLEncoding.DecodeString(sigB64)
|
||
if err != nil {
|
||
return nil, errors.New("离线序列号签名无法解码")
|
||
}
|
||
if len(publicKey) != ed25519.PublicKeySize {
|
||
return nil, errors.New("离线验签公钥无效")
|
||
}
|
||
if !ed25519.Verify(publicKey, []byte(payloadB64), sig) {
|
||
return nil, errors.New("离线序列号签名无效")
|
||
}
|
||
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
||
if err != nil {
|
||
return nil, errors.New("离线序列号载荷无法解码")
|
||
}
|
||
var claims OfflineLicenseClaims
|
||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||
return nil, errors.New("离线序列号载荷无法解析")
|
||
}
|
||
if err := validateOfflineClaims(claims, false); err != nil {
|
||
return nil, err
|
||
}
|
||
return &claims, nil
|
||
}
|
||
|
||
// ParseOfflinePublicKeyHex 解析公钥 hex // Logan.Li
|
||
func ParseOfflinePublicKeyHex(hexKey string) (ed25519.PublicKey, error) {
|
||
raw, err := hex.DecodeString(strings.TrimSpace(hexKey))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(raw) != ed25519.PublicKeySize {
|
||
return nil, errors.New("公钥长度错误")
|
||
}
|
||
return ed25519.PublicKey(raw), nil
|
||
}
|
||
|
||
func validateOfflineClaims(c OfflineLicenseClaims, issuing bool) error {
|
||
if strings.TrimSpace(c.DeviceID) == "" {
|
||
return errors.New("device_id 不能为空")
|
||
}
|
||
pt := strings.TrimSpace(c.PlanType)
|
||
if pt != "trial" && pt != "yearly" {
|
||
return errors.New("离线证仅允许 trial / yearly,禁止永久")
|
||
}
|
||
if c.ExpiresAt <= 0 || c.IssuedAt <= 0 {
|
||
return errors.New("签发/到期时间无效")
|
||
}
|
||
if c.ExpiresAt <= c.IssuedAt {
|
||
return errors.New("到期时间必须晚于签发时间")
|
||
}
|
||
maxSpan := int64(OfflineMaxDurationDays) * 24 * 3600
|
||
if c.ExpiresAt-c.IssuedAt > maxSpan+3600 { // 容许 1h 时钟
|
||
return errors.New("离线证有效期不得超过 365 天")
|
||
}
|
||
if issuing {
|
||
now := time.Now().Unix()
|
||
if c.ExpiresAt <= now {
|
||
return errors.New("到期时间已过")
|
||
}
|
||
if c.ExpiresAt-now > maxSpan+3600 {
|
||
return errors.New("离线证有效期不得超过 365 天")
|
||
}
|
||
}
|
||
if c.MaxDevices <= 0 {
|
||
c.MaxDevices = 1
|
||
}
|
||
if c.MaxDevices > 1 {
|
||
// 离线证绑定单设备,强制 1 // Logan.Li
|
||
return errors.New("离线证仅支持 1 台设备")
|
||
}
|
||
if strings.TrimSpace(c.ProductCode) == "" {
|
||
return errors.New("product_code 不能为空")
|
||
}
|
||
return nil
|
||
}
|