// Package keyvault 是 KeyVault License API 的 Go 客户端。 // // 单文件、只用标准库(crypto/ed25519、crypto/hmac、net/http),可以直接复制到你的项目里。 // // kv, err := keyvault.New(keyvault.Config{BaseURL: ..., AppID: ..., AppSecret: ..., PublicKey: ...}) // deviceID := keyvault.DeviceID(appID) // state, err := kv.Activate(ctx, keyvault.ActivateInput{LicenseKey: key, DeviceID: deviceID}) // state, err = kv.Check(ctx, key, deviceID) // 联网失败时自动使用离线缓存 // // 协议说明见商城的 /developers 页面。 package keyvault import ( "bytes" "context" "crypto/ed25519" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "runtime" "sort" "strconv" "strings" "time" ) const SDKVersion = "1.0.0" // LicenseData 是服务端签名覆盖的数据,时间字段均为 Unix 秒,nil 表示没有值。 type LicenseData struct { Action string `json:"action"` AppID string `json:"appId"` DeviceID string `json:"deviceId"` KeyFingerprint string `json:"keyFingerprint"` Status string `json:"status"` Activated bool `json:"activated"` ActivatedAt *int64 `json:"activatedAt"` ExpiresAt *int64 `json:"expiresAt"` ValidUntil *int64 `json:"validUntil"` MaxDevices int `json:"maxDevices"` ActiveDevices int `json:"activeDevices"` } // State 是一次调用(或离线缓存)的结果。 type State struct { LicenseData SignedAt int64 // Offline 为 true 表示结果来自本地缓存(离线宽限期内) Offline bool } // Error 是服务端错误码(见文档「错误码」),或 SDK 自身的 // NETWORK_ERROR(联网失败)、BAD_RESPONSE(响应格式不对)、TAMPERED_RESPONSE(签名或绑定校验失败)。 type Error struct { Code string Message string Status int // Signed 表示错误响应带有效签名(可信地来自服务端) Signed bool } func (e *Error) Error() string { return e.Code + ": " + e.Message } // Transient 表示可以回退到离线缓存的临时错误。 func (e *Error) Transient() bool { return e.Code == "NETWORK_ERROR" || e.Code == "BAD_RESPONSE" || e.Code == "RATE_LIMITED" || e.Status >= 500 } var revokingCodes = map[string]bool{ "LICENSE_NOT_FOUND": true, "LICENSE_SUSPENDED": true, "LICENSE_REVOKED": true, "LICENSE_EXPIRED": true, "DEVICE_NOT_ACTIVATED": true, } type Config struct { // BaseURL 商城地址,如 https://shop.example.com BaseURL string AppID string AppSecret string // PublicKey 可以是 PEM、SPKI DER 的 base64,或 32 字节原始公钥的 base64 PublicKey string // CacheFile 离线缓存文件;为空时使用 <用户配置目录>/keyvault/.json CacheFile string // DisableCache 关闭离线缓存 DisableCache bool // Timeout 单次请求超时,默认 10 秒 Timeout time.Duration // ClockTolerance 允许的时钟偏差,默认 300 秒 ClockTolerance time.Duration HTTPClient *http.Client } type Client struct { baseURL string appID string appSecret []byte publicKey ed25519.PublicKey cacheFile string tolerance int64 http *http.Client } func New(cfg Config) (*Client, error) { pub, err := ParsePublicKey(cfg.PublicKey) if err != nil { return nil, err } c := &Client{ baseURL: strings.TrimRight(cfg.BaseURL, "/"), appID: cfg.AppID, appSecret: []byte(cfg.AppSecret), publicKey: pub, cacheFile: cfg.CacheFile, tolerance: 300, http: cfg.HTTPClient, } if cfg.ClockTolerance > 0 { c.tolerance = int64(cfg.ClockTolerance / time.Second) } if c.http == nil { timeout := cfg.Timeout if timeout == 0 { timeout = 10 * time.Second } c.http = &http.Client{Timeout: timeout} } if cfg.DisableCache { c.cacheFile = "" } else if c.cacheFile == "" { dir, err := os.UserConfigDir() if err != nil { dir = os.TempDir() } c.cacheFile = filepath.Join(dir, "keyvault", cfg.AppID+".json") } return c, nil } type ActivateInput struct { LicenseKey string `json:"licenseKey"` DeviceID string `json:"deviceId"` DeviceName string `json:"deviceName,omitempty"` Platform string `json:"platform,omitempty"` AppVersion string `json:"appVersion,omitempty"` } type deviceInput struct { LicenseKey string `json:"licenseKey"` DeviceID string `json:"deviceId"` AppVersion string `json:"appVersion,omitempty"` } // Activate 在当前设备上激活。同一设备重复调用是幂等的。 func (c *Client) Activate(ctx context.Context, in ActivateInput) (*State, error) { return c.call(ctx, "activate", in, in.LicenseKey, in.DeviceID) } // Verify 联网校验,同时作为心跳。不会回退到离线缓存,需要回退请用 Check。 func (c *Client) Verify(ctx context.Context, licenseKey, deviceID string) (*State, error) { return c.call(ctx, "verify", deviceInput{LicenseKey: licenseKey, DeviceID: deviceID}, licenseKey, deviceID) } // Deactivate 解绑当前设备,成功后清除本地缓存。 func (c *Client) Deactivate(ctx context.Context, licenseKey, deviceID string) (*State, error) { s, err := c.call(ctx, "deactivate", deviceInput{LicenseKey: licenseKey, DeviceID: deviceID}, licenseKey, deviceID) if err == nil { c.ClearCache() } return s, err } // Check 推荐在启动和定时心跳时调用:先联网校验;网络不可用、服务端 5xx 或被限流时, // 回退到上一次的签名缓存(ValidUntil 之前有效)。授权被吊销等明确结论会直接返回错误。 func (c *Client) Check(ctx context.Context, licenseKey, deviceID string) (*State, error) { s, err := c.Verify(ctx, licenseKey, deviceID) var kvErr *Error if err != nil && errors.As(err, &kvErr) && kvErr.Transient() { if cached := c.LoadOffline(licenseKey, deviceID, time.Now()); cached != nil { return cached, nil } } return s, err } type cacheEntry struct { Version int `json:"version"` Data json.RawMessage `json:"data"` SignedAt json.Number `json:"signedAt"` Nonce string `json:"nonce"` Signature string `json:"signature"` } // LoadOffline 读取并校验离线缓存;无效(签名不符、换了设备或授权码、已过宽限期、时钟回拨)时返回 nil。 func (c *Client) LoadOffline(licenseKey, deviceID string, now time.Time) *State { if c.cacheFile == "" { return nil } raw, err := os.ReadFile(c.cacheFile) if err != nil { return nil } var entry cacheEntry if decodeNumbers(raw, &entry) != nil || entry.Version != 1 { return nil } if !c.verifySigned("data", entry.Data, entry.Nonce, entry.SignedAt, entry.Signature) { return nil } var d LicenseData signedAt, err := entry.SignedAt.Int64() if err != nil || json.Unmarshal(entry.Data, &d) != nil { return nil } nowSec := now.Unix() valid := d.AppID == c.appID && d.DeviceID == deviceID && d.KeyFingerprint == Fingerprint(licenseKey) && d.Activated && d.Status == "active" && d.ValidUntil != nil && nowSec <= *d.ValidUntil && nowSec >= signedAt-c.tolerance if !valid { return nil } return &State{LicenseData: d, SignedAt: signedAt, Offline: true} } func (c *Client) ClearCache() { if c.cacheFile != "" { _ = os.Remove(c.cacheFile) } } type serverResponse struct { OK bool `json:"ok"` Data json.RawMessage `json:"data"` Error json.RawMessage `json:"error"` SignedAt json.Number `json:"signedAt"` Signature string `json:"signature"` } func (c *Client) call(ctx context.Context, action string, payload any, licenseKey, deviceID string) (*State, error) { body, err := json.Marshal(payload) if err != nil { return nil, err } timestamp := strconv.FormatInt(time.Now().Unix(), 10) nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { return nil, err } nonce := hex.EncodeToString(nonceBytes) mac := hmac.New(sha256.New, c.appSecret) mac.Write([]byte(timestamp + "\n" + nonce + "\n")) mac.Write(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/licenses/"+action, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "keyvault-go/"+SDKVersion) req.Header.Set("X-KV-App-Id", c.appID) req.Header.Set("X-KV-Timestamp", timestamp) req.Header.Set("X-KV-Nonce", nonce) req.Header.Set("X-KV-Signature", hex.EncodeToString(mac.Sum(nil))) res, err := c.http.Do(req) if err != nil { return nil, &Error{Code: "NETWORK_ERROR", Message: "无法连接授权服务器:" + err.Error()} } defer res.Body.Close() raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return nil, &Error{Code: "NETWORK_ERROR", Message: "读取响应失败:" + err.Error(), Status: res.StatusCode} } var r serverResponse if err := decodeNumbers(raw, &r); err != nil { return nil, &Error{Code: "BAD_RESPONSE", Message: fmt.Sprintf("授权服务器返回了无法解析的内容(HTTP %d)", res.StatusCode), Status: res.StatusCode} } if !r.OK { var e struct { Code string `json:"code"` Message string `json:"message"` } if len(r.Error) == 0 || json.Unmarshal(r.Error, &e) != nil || e.Code == "" { return nil, &Error{Code: "BAD_RESPONSE", Message: "响应缺少 error 字段", Status: res.StatusCode} } signed := r.Signature != "" && r.SignedAt != "" && c.verifySigned("error", r.Error, nonce, r.SignedAt, r.Signature) if signed && revokingCodes[e.Code] { c.ClearCache() } return nil, &Error{Code: e.Code, Message: e.Message, Status: res.StatusCode, Signed: signed} } if len(r.Data) == 0 || r.SignedAt == "" || r.Signature == "" { return nil, &Error{Code: "BAD_RESPONSE", Message: "响应缺少 data / signedAt / signature", Status: res.StatusCode} } if !c.verifySigned("data", r.Data, nonce, r.SignedAt, r.Signature) { return nil, &Error{Code: "TAMPERED_RESPONSE", Message: "响应签名校验失败", Status: res.StatusCode} } var d LicenseData signedAt, err := r.SignedAt.Int64() if err != nil || json.Unmarshal(r.Data, &d) != nil { return nil, &Error{Code: "BAD_RESPONSE", Message: "响应 data 格式不正确", Status: res.StatusCode} } if d.AppID != c.appID || d.DeviceID != deviceID || d.KeyFingerprint != Fingerprint(licenseKey) { return nil, &Error{Code: "TAMPERED_RESPONSE", Message: "响应与本次请求不匹配", Status: res.StatusCode} } if action != "deactivate" { c.saveCache(cacheEntry{Version: 1, Data: r.Data, SignedAt: r.SignedAt, Nonce: nonce, Signature: r.Signature}) } return &State{LicenseData: d, SignedAt: signedAt}, nil } // verifySigned 校验 Ed25519(canonicalJSON({: value, nonce, signedAt}))。 func (c *Client) verifySigned(field string, value json.RawMessage, nonce string, signedAt json.Number, signature string) bool { var v any if decodeNumbers(value, &v) != nil { return false } payload, err := CanonicalJSON(map[string]any{field: v, "nonce": nonce, "signedAt": signedAt}) if err != nil { return false } sig, err := base64.StdEncoding.DecodeString(signature) if err != nil { return false } return ed25519.Verify(c.publicKey, payload, sig) } func (c *Client) saveCache(entry cacheEntry) { if c.cacheFile == "" { return } raw, err := json.Marshal(entry) if err != nil { return } dir := filepath.Dir(c.cacheFile) if os.MkdirAll(dir, 0o700) != nil { return } tmp, err := os.CreateTemp(dir, ".keyvault-*") if err != nil { return } _, werr := tmp.Write(raw) cerr := tmp.Close() if werr != nil || cerr != nil || os.Rename(tmp.Name(), c.cacheFile) != nil { _ = os.Remove(tmp.Name()) } } func decodeNumbers(raw []byte, v any) error { dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() return dec.Decode(v) } // CanonicalJSON 规范化 JSON:键按字典序、无空白,字符串转义规则与 JavaScript 的 JSON.stringify 一致 // (不转义 <、>、& 和非 ASCII 字符),与服务端签名时的序列化完全一致。 func CanonicalJSON(v any) ([]byte, error) { var buf bytes.Buffer if err := writeCanonical(&buf, v); err != nil { return nil, err } return buf.Bytes(), nil } func writeCanonical(buf *bytes.Buffer, v any) error { switch x := v.(type) { case nil: buf.WriteString("null") case bool: buf.WriteString(strconv.FormatBool(x)) case json.Number: buf.WriteString(x.String()) case string: writeString(buf, x) case int: buf.WriteString(strconv.Itoa(x)) case int64: buf.WriteString(strconv.FormatInt(x, 10)) case []any: buf.WriteByte('[') for i, item := range x { if i > 0 { buf.WriteByte(',') } if err := writeCanonical(buf, item); err != nil { return err } } buf.WriteByte(']') case map[string]any: keys := make([]string, 0, len(x)) for k := range x { keys = append(keys, k) } sort.Strings(keys) buf.WriteByte('{') for i, k := range keys { if i > 0 { buf.WriteByte(',') } writeString(buf, k) buf.WriteByte(':') if err := writeCanonical(buf, x[k]); err != nil { return err } } buf.WriteByte('}') default: return fmt.Errorf("keyvault: unsupported type %T in canonical JSON", v) } return nil } func writeString(buf *bytes.Buffer, s string) { buf.WriteByte('"') for _, r := range s { switch r { case '"': buf.WriteString(`\"`) case '\\': buf.WriteString(`\\`) case '\b': buf.WriteString(`\b`) case '\f': buf.WriteString(`\f`) case '\n': buf.WriteString(`\n`) case '\r': buf.WriteString(`\r`) case '\t': buf.WriteString(`\t`) default: if r < 0x20 { fmt.Fprintf(buf, `\u%04x`, r) } else { buf.WriteRune(r) } } } buf.WriteByte('"') } // Fingerprint 返回 SHA-256(授权码) 的 hex,与服务端 data.keyFingerprint 对应。 func Fingerprint(licenseKey string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(licenseKey))) return hex.EncodeToString(sum[:]) } // ParsePublicKey 解析 PEM、SPKI DER 的 base64,或 32 字节原始公钥的 base64。 func ParsePublicKey(value string) (ed25519.PublicKey, error) { text := strings.TrimSpace(value) var der []byte if strings.HasPrefix(text, "-----BEGIN") { block, _ := pem.Decode([]byte(text)) if block == nil { return nil, errors.New("keyvault: invalid PEM public key") } der = block.Bytes } else { b, err := base64.StdEncoding.DecodeString(text) if err != nil { return nil, fmt.Errorf("keyvault: invalid base64 public key: %w", err) } if len(b) == ed25519.PublicKeySize { return ed25519.PublicKey(b), nil } der = b } key, err := x509.ParsePKIXPublicKey(der) if err != nil { return nil, fmt.Errorf("keyvault: invalid public key: %w", err) } pub, ok := key.(ed25519.PublicKey) if !ok { return nil, errors.New("keyvault: public key is not Ed25519") } return pub, nil } // DeviceID 生成稳定的设备指纹:系统机器 ID 与 salt(建议传 AppID)一起做 SHA-256。 // 算法与 Node / Python SDK 一致,同一台机器在三种 SDK 下得到相同的 deviceId。 func DeviceID(salt string) string { raw := readMachineID() if raw == "" { host, _ := os.Hostname() raw = host + "|" + nodePlatform() + "|" + nodeArch() } sum := sha256.Sum256([]byte(salt + ":" + raw)) return hex.EncodeToString(sum[:]) } var ( macUUID = regexp.MustCompile(`"IOPlatformUUID" = "([^"]+)"`) machineGUID = regexp.MustCompile(`MachineGuid\s+REG_SZ\s+(\S+)`) ) func readMachineID() string { switch runtime.GOOS { case "darwin": out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output() if err == nil { if m := macUUID.FindSubmatch(out); m != nil { return string(m[1]) } } case "windows": out, err := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid").Output() if err == nil { if m := machineGUID.FindSubmatch(out); m != nil { return string(m[1]) } } default: for _, p := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} { if b, err := os.ReadFile(p); err == nil { if id := strings.TrimSpace(string(b)); id != "" { return id } } } } return "" } func nodePlatform() string { if runtime.GOOS == "windows" { return "win32" } return runtime.GOOS } func nodeArch() string { switch runtime.GOARCH { case "amd64": return "x64" case "386": return "ia32" default: return runtime.GOARCH } }