Files
opsx-licence/backend/utils/offline_license.go
T
Logan.LiandCursor 8cc2c83f9d feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 20:25:46 +08:00

177 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}