/** * KeyVault License SDK for Node.js * * 单文件、零依赖:只用到 node:crypto / node:fs / 全局 fetch(Node 18+)。 * 复制到你的项目里即可使用,协议说明见 /developers。 * * const kv = new KeyVaultClient({ baseUrl, appId, appSecret, publicKey }); * const deviceId = getDeviceId(appId); * await kv.activate({ licenseKey, deviceId }); * const state = await kv.check({ licenseKey, deviceId }); // 联网失败时自动使用离线缓存 */ import { execFileSync } from "node:child_process"; import { createHash, createHmac, createPublicKey, randomBytes, verify as verifySignature, type KeyObject } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { arch, homedir, hostname, platform } from "node:os"; import { dirname, join } from "node:path"; export const SDK_VERSION = "1.0.0"; export type LicenseStatus = "active" | "suspended" | "revoked" | "expired"; /** 服务端签名覆盖的数据,时间字段均为 Unix 秒 */ export interface LicenseData { action: "activate" | "verify" | "deactivate"; appId: string; deviceId: string; keyFingerprint: string; status: LicenseStatus; activated: boolean; activatedAt: number | null; expiresAt: number | null; validUntil: number | null; maxDevices: number; activeDevices: number; } export interface LicenseState extends LicenseData { signedAt: number; /** true 表示这次结果来自本地缓存(离线宽限期内) */ offline: boolean; } export interface KeyVaultOptions { /** 商城地址,如 https://shop.example.com */ baseUrl: string; appId: string; appSecret: string; /** Ed25519 公钥:PEM、SPKI DER 的 base64,或 32 字节原始公钥的 base64 */ publicKey: string; /** 离线缓存文件;默认 ~/.keyvault/.json,传 false 关闭 */ cacheFile?: string | false; /** 单次请求超时,默认 10 秒 */ timeoutMs?: number; /** 允许的时钟偏差,默认 300 秒 */ clockToleranceSeconds?: number; fetch?: typeof fetch; } export interface ActivateInput { licenseKey: string; deviceId: string; deviceName?: string; platform?: string; appVersion?: string; } export interface DeviceInput { licenseKey: string; deviceId: string; } /** * 服务端错误码(见文档「错误码」),以及 SDK 自身的: * NETWORK_ERROR 联网失败 · BAD_RESPONSE 响应格式不对 · TAMPERED_RESPONSE 响应签名或绑定校验失败 */ export class KeyVaultError extends Error { readonly code: string; readonly status: number; /** 错误响应是否带有效签名(可信地来自服务端) */ readonly signed: boolean; constructor(code: string, message: string, status = 0, signed = false) { super(message); this.name = "KeyVaultError"; this.code = code; this.status = status; this.signed = signed; } } /** 收到这些「已签名」的错误时,SDK 会清除本地缓存 */ const REVOKING_CODES = new Set([ "LICENSE_NOT_FOUND", "LICENSE_SUSPENDED", "LICENSE_REVOKED", "LICENSE_EXPIRED", "DEVICE_NOT_ACTIVATED", ]); interface CacheEntry { version: 1; data: LicenseData; signedAt: number; nonce: string; signature: string; } type ServerResponse = | { ok: true; data: LicenseData; signedAt: number; signature: string } | { ok: false; error: { code: string; message: string }; signedAt?: number; signature?: string }; export class KeyVaultClient { private readonly baseUrl: string; private readonly appId: string; private readonly appSecret: string; private readonly publicKey: KeyObject; private readonly cacheFile: string | null; private readonly timeoutMs: number; private readonly tolerance: number; private readonly fetchImpl: typeof fetch; constructor(options: KeyVaultOptions) { this.baseUrl = options.baseUrl.replace(/\/+$/, ""); this.appId = options.appId; this.appSecret = options.appSecret; this.publicKey = loadPublicKey(options.publicKey); this.cacheFile = options.cacheFile === false ? null : (options.cacheFile ?? join(homedir(), ".keyvault", `${options.appId}.json`)); this.timeoutMs = options.timeoutMs ?? 10_000; this.tolerance = options.clockToleranceSeconds ?? 300; this.fetchImpl = options.fetch ?? fetch; } /** 在当前设备上激活。同一设备重复调用是幂等的 */ async activate(input: ActivateInput): Promise { return this.call("activate", input, input); } /** 联网校验,同时作为心跳。不会回退到离线缓存,需要回退请用 check() */ async verify(input: DeviceInput & { appVersion?: string }): Promise { return this.call("verify", input, input); } /** 解绑当前设备,成功后清除本地缓存 */ async deactivate(input: DeviceInput): Promise { const state = await this.call("deactivate", input, input); this.clearCache(); return state; } /** * 推荐在启动和定时心跳时调用:先联网校验;网络不可用、服务端 5xx 或被限流时, * 回退到上一次的签名缓存(validUntil 之前有效)。授权被吊销等明确结论会直接抛错。 */ async check(input: DeviceInput & { appVersion?: string }): Promise { try { return await this.verify(input); } catch (err) { if (err instanceof KeyVaultError && isTransient(err)) { const cached = this.loadOffline(input); if (cached) return cached; } throw err; } } /** 读取并校验离线缓存;无效(签名不符、换了设备或授权码、已过宽限期、时钟回拨)时返回 null */ loadOffline(input: DeviceInput, now = Date.now()): LicenseState | null { if (!this.cacheFile) return null; let entry: CacheEntry; try { entry = JSON.parse(readFileSync(this.cacheFile, "utf8")); } catch { return null; } if (entry?.version !== 1 || !entry.data || typeof entry.signature !== "string") return null; if (!this.verifyPayload(canonicalJson({ data: entry.data, nonce: entry.nonce, signedAt: entry.signedAt }), entry.signature)) { return null; } const d = entry.data; const nowSec = Math.floor(now / 1000); const valid = d.appId === this.appId && d.deviceId === input.deviceId && d.keyFingerprint === fingerprint(input.licenseKey) && d.activated && d.status === "active" && typeof d.validUntil === "number" && nowSec <= d.validUntil && nowSec >= entry.signedAt - this.tolerance; return valid ? { ...d, signedAt: entry.signedAt, offline: true } : null; } clearCache(): void { if (this.cacheFile) rmSync(this.cacheFile, { force: true }); } // ---------- internals ---------- private async call( action: "activate" | "verify" | "deactivate", payload: object, input: DeviceInput, ): Promise { const body = JSON.stringify(payload); const timestamp = String(Math.floor(Date.now() / 1000)); const nonce = randomBytes(16).toString("hex"); const signature = createHmac("sha256", this.appSecret).update(`${timestamp}\n${nonce}\n${body}`, "utf8").digest("hex"); let res: Response; let json: ServerResponse; try { res = await this.fetchImpl(`${this.baseUrl}/api/v1/licenses/${action}`, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": `keyvault-node/${SDK_VERSION}`, "X-KV-App-Id": this.appId, "X-KV-Timestamp": timestamp, "X-KV-Nonce": nonce, "X-KV-Signature": signature, }, body, signal: AbortSignal.timeout(this.timeoutMs), }); } catch (err) { throw new KeyVaultError("NETWORK_ERROR", `无法连接授权服务器:${(err as Error).message}`); } try { json = (await res.json()) as ServerResponse; } catch { throw new KeyVaultError("BAD_RESPONSE", `授权服务器返回了无法解析的内容(HTTP ${res.status})`, res.status); } if (json.ok !== true) { const error = json.error ?? { code: "BAD_RESPONSE", message: "响应缺少 error 字段" }; const signed = typeof json.signedAt === "number" && typeof json.signature === "string" && this.verifyPayload(canonicalJson({ error, nonce, signedAt: json.signedAt }), json.signature); if (signed && REVOKING_CODES.has(error.code)) this.clearCache(); throw new KeyVaultError(error.code, error.message, res.status, signed); } const { data, signedAt } = json; if (!this.verifyPayload(canonicalJson({ data, nonce, signedAt }), json.signature)) { throw new KeyVaultError("TAMPERED_RESPONSE", "响应签名校验失败", res.status); } if (data.appId !== this.appId || data.deviceId !== input.deviceId || data.keyFingerprint !== fingerprint(input.licenseKey)) { throw new KeyVaultError("TAMPERED_RESPONSE", "响应与本次请求不匹配", res.status); } if (action !== "deactivate") this.saveCache({ version: 1, data, signedAt, nonce, signature: json.signature }); return { ...data, signedAt, offline: false }; } private verifyPayload(payload: string, signature: string): boolean { try { return verifySignature(null, Buffer.from(payload, "utf8"), this.publicKey, Buffer.from(signature, "base64")); } catch { return false; } } private saveCache(entry: CacheEntry) { if (!this.cacheFile) return; try { mkdirSync(dirname(this.cacheFile), { recursive: true }); const tmp = `${this.cacheFile}.${process.pid}.tmp`; writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 }); renameSync(tmp, this.cacheFile); } catch { // 缓存写入失败不影响在线结果 } } } function isTransient(err: KeyVaultError) { return err.code === "NETWORK_ERROR" || err.code === "BAD_RESPONSE" || err.code === "RATE_LIMITED" || err.status >= 500; } /** SHA-256(授权码) 的 hex,与服务端 data.keyFingerprint 对应 */ export function fingerprint(licenseKey: string): string { return createHash("sha256").update(licenseKey.trim(), "utf8").digest("hex"); } /** 规范化 JSON:键按字典序、无空白,与服务端签名时的序列化完全一致 */ export function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") return value === undefined ? "null" : JSON.stringify(value); if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v)).join(",")}]`; const obj = value as Record; const keys = Object.keys(obj) .filter((k) => obj[k] !== undefined) .sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`; } const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); function loadPublicKey(input: string): KeyObject { const text = input.trim(); if (text.startsWith("-----BEGIN")) return createPublicKey(text); const bytes = Buffer.from(text, "base64"); const der = bytes.length === 32 ? Buffer.concat([ED25519_SPKI_PREFIX, bytes]) : bytes; return createPublicKey({ key: der, format: "der", type: "spki" }); } /** * 生成稳定的设备指纹:读取系统机器 ID(macOS IOPlatformUUID / Windows MachineGuid / Linux machine-id), * 与 salt(建议传 appId)一起做 SHA-256,不直接上报原始硬件信息。 */ export function getDeviceId(salt = ""): string { const raw = readMachineId() ?? `${hostname()}|${platform()}|${arch()}`; return createHash("sha256").update(`${salt}:${raw}`, "utf8").digest("hex"); } function readMachineId(): string | null { try { if (process.platform === "darwin") { const out = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 3000 }); return out.match(/"IOPlatformUUID" = "([^"]+)"/)?.[1] ?? null; } if (process.platform === "win32") { const out = execFileSync("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], { encoding: "utf8", timeout: 3000, }); return out.match(/MachineGuid\s+REG_SZ\s+(\S+)/)?.[1] ?? null; } for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) { try { const id = readFileSync(file, "utf8").trim(); if (id) return id; } catch { // 尝试下一个 } } } catch { // 读取失败时退回主机名 } return null; }