商业许可证客户端:验证、检查状态、测试¶
状态: 候选协议;提供商选择和生产实现待定
日期: 2026-09-29
范围: 针对候选 vssaas 许可集成的公共客户端验证和刷新合约。本文档不选择支付/许可提供商,也不声称已维护的客户端实现了该协议。
私有服务实现路径、操作员流程、数据库布局和本地服务设置属于私有服务文档。它们有意不包含在本公共客户端合约中。在这里移除它们并不会移除早期的 Git 历史。
公共协议词汇:
- 商业许可证:授权。
- 许可证席位:一个被计数的安装。
- 许可证密钥:
VSLK-…,仅向被许可方显示一次。 - 安装凭据:
vsic_…,由客户端生成。 - 许可证证书:
vslc1.…,短期有效且可离线验证。
1. 验证许可证证书¶
1.1 算法¶
验证顺序是提议的线上合约的一部分;一致性测试向量对每种情况期望一个特定的错误。now 是完整的 Unix 秒。
信封规则。 这些规则同时适用于证书和背书。
- 信封最多为 8192 字节。按 UTF-8 字节计数,而不是字符。
- 它以这样的前缀开头:
vslc1.表示证书,vsle1.表示背书。其余部分恰好是<payload>.<signature>,没有第三个段。 - 两个段都是无填充的严格 base64url:只包含
A–Z a–z 0–9 - _,没有=,没有 CR 或 LF,长度模 4 ≠ 1,并且尾部位是规范的(解码并重新编码会得到相同的字符串)。载荷非空,签名长度为 64 字节。 - 载荷是一个 JSON 对象,且恰好包含文档中列出的键。每个键出现一次,且没有一个为
null。整数没有小数部分或指数。没有 BOM,对象之后除了空白外没有其他内容。未知、缺失或重复的键都属于错误。
步骤 1 到 4 中的任何失败都视为 malformed。
背书(vsle1.)。其键为 v kid public_key env nbf exp root_kid。它根据内置根密钥进行验证:
- 通过
root_kid查找根密钥。如果缺失,则返回unknown_key。 - 验证对
"vssaas-license-signing-key-v1\x00" || payload_bytes的 Ed25519 签名,使用精确解码后的字节,不重新编码。如果失败,则返回bad_signature。 env必须等于客户端的环境。否则返回wrong_environment。v == 1;public_key是 32 字节的 base64url;kid == hex(SHA-256(public_key))[0:16];root_kid是 16 个小写十六进制字符;0 < nbf < exp。否则返回invalid_claims。- 如果
now < nbf,则not_yet_valid。如果now ≥ exp,则expired。
每个通过验证的背书都会将 kid → public_key 添加到受信任的密钥集合中。根密钥永远不会出现在该集合中。
证书(vslc1.)。其键为 v kid iss aud env serial license_id seat_id organization_id inst edition agreement_version term_ends_at updates_until iat nbf exp hard_exp refresh_after。整数键为 v、term_ends_at、updates_until、iat、nbf、exp、hard_exp 和 refresh_after。
- 在受信任的密钥集合中查找
kid。如果缺失,则返回unknown_key。 - 验证对
"vssaas-license-certificate-v1\x00" || payload_bytes的 Ed25519 签名。如果失败,则返回bad_signature。 aud必须为"voicestudio-desktop"。否则返回wrong_audience。env必须等于客户端的环境。否则返回wrong_environment。- 以下条件必须全部成立,否则返回
invalid_claims: v == 1,iss == "vssaas",且env是development|staging|production之一。kid是 16 个小写十六进制字符。serial、license_id、seat_id、organization_id、edition和agreement_version各自匹配[A-Za-z0-9._-]{1,64}。inst是 32 字节的 base64url。iat > 0且updates_until > 0。0 < nbf ≤ iat,exp > iat,hard_exp ≥ exp,term_ends_at ≥ hard_exp,且refresh_after > iat。- 时间窗口。 如果
now < nbf,则not_yet_valid。如果now ≥ hard_exp,则expired。否则证书有效,并且当exp ≤ now < hard_exp时处于宽限期。
使用与证书相同的 now 来验证背书。vssaas 绝不会签发其 hard_exp 超过对应密钥背书有效期的证书,因此缓存的背书可以覆盖证书的整个生命周期。客户端还应从一个非递减的时钟获取 now:即系统时间与其已见过的最新时间(例如刷新响应中的 server_time)中的较大者。否则,将时钟调回会延长证书的有效期。
应用程序必须在验证器之外额外执行的检查。 下面的 verifyCertificate / verify_certificate 以及 CLI 包装器仅证明 vssaas 已对该证书签名。它们是参考实现和测试工具,而不是完整的关卡。客户端在启用 Pro 之前还必须完成以下两项:
- 绑定到当前安装。 将验证后的
inst声明与此安装自己的许可标识符进行比较,即认领时以installation_id_hash发送的 32 字节(一个仅用于许可的随机标识符,独立于分析和硬件标识符)。如果不匹配,则将该证书视为无效,并且不启用 Pro。如果没有这一步,一个有效的证书被复制到另一台机器上时,即使没有持有席位,也可以离线工作到其hard_exp。 - 持久化时间下限。 存储客户端已接受的最高的
now(系统时间或刷新响应中的server_time,取较大者),并在每次启动时使用max(system time, stored floor)作为now。CLI 仅出于测试目的从命令行或系统时钟获取now。如果生产代码在重启时读取原始时钟,则被回拨的时钟可能会拉长 44 天的离线限制(§2.4)。
1.2 根密钥的来源¶
信任始于编译到客户端构建中的一个或两个根公钥,每个环境一组。它们是公开值,因此可以提交或由 CI 注入。一种良好的格式参照 vssaas LICENSE_ROOT_PUBLIC_KEYS:以逗号分隔的无填充 base64url,每个 32 字节,通过构建期定义(例如 VOICESTUDIO_LICENSE_ROOT_PUBLIC_KEYS)提供,与 electron/electron.vite.config.ts 中现有的 VOICESTUDIO_PRO_* 定义放在一起。
- 生产根。 它们来自离线密钥仪式。在生产签名之前必须先进行生产签名密钥仪式;本草案不建立任何生产根。生产构建只包含生产根。
- 开发与暂存根。 使用通过私有服务设置过程提供的专用非生产根;切勿在生产环境中信任这些根。
- 绝不要使用 golden root。 任何构建都不信任 golden-vector 根
A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg,它被标记为 TEST ONLY。 - 并非来自网络。
GET /v1/license-signing-keys提供的是背书(endorsements),而不是根。信任只来自编译进构建的根。 - 两个根。 停用其中一个根需要发布客户端版本;同时保留两个可让客户端在此期间继续锚定。
1.3 TypeScript(Node crypto){#13-typescript-node-crypto}¶
将此文件保存为 license-certificate.ts。它可以原样运行于 node --experimental-strip-types(Node 22.6 或更高版本)或 bun,且不需要任何依赖。JSON.parse 会保留最后一个重复键,而 Buffer.from(…, 'base64url') 是宽容的,因此该文件自行扫描令牌。
// Verifies vssaas License Certificates offline.
import { createHash, createPublicKey, verify, type KeyObject } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
export type Environment = 'development' | 'staging' | 'production';
export type ErrorCode =
| 'malformed' | 'unknown_key' | 'bad_signature' | 'wrong_audience'
| 'wrong_environment' | 'not_yet_valid' | 'expired' | 'invalid_claims';
export class LicenseCertificateError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode) {
super(code);
this.code = code;
}
}
const fail = (code: ErrorCode): never => {
throw new LicenseCertificateError(code);
};
type Shape = Record<string, 'str' | 'int'>;
const CLAIMS: Shape = {
v: 'int', kid: 'str', iss: 'str', aud: 'str', env: 'str', serial: 'str', license_id: 'str',
seat_id: 'str', organization_id: 'str', inst: 'str', edition: 'str', agreement_version: 'str',
term_ends_at: 'int', updates_until: 'int', iat: 'int', nbf: 'int', exp: 'int', hard_exp: 'int',
refresh_after: 'int',
};
const ENDORSEMENT: Shape = {
v: 'int', kid: 'str', public_key: 'str', env: 'str', nbf: 'int', exp: 'int', root_kid: 'str',
};
const ENVIRONMENTS = ['development', 'staging', 'production'];
const TOKEN = /^[A-Za-z0-9._-]{1,64}$/;
const KID = /^[0-9a-f]{16}$/;
// Strict, unpadded, canonical base64url (Buffer.from alone is lenient).
export function b64url(text: string): Buffer {
if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) fail('malformed');
const bytes = Buffer.from(text, 'base64url');
if (bytes.toString('base64url') !== text) fail('malformed');
return bytes;
}
const is32 = (text: string) => {
try {
return b64url(text).length === 32;
} catch {
return false;
}
};
export const keyIdFor = (raw: Uint8Array) => createHash('sha256').update(raw).digest('hex').slice(0, 16);
export const ed25519Key = (raw: Uint8Array): KeyObject =>
createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' });
// One flat JSON object: exactly the keys in `shape`, each once, never null,
// with the declared type, and nothing but whitespace after it. JSON.parse
// would silently keep the last duplicate key, so scan the tokens.
function exactObject(payload: Buffer, shape: Shape): Record<string, any> {
const text = new TextDecoder('utf-8', { ignoreBOM: true }).decode(payload);
const out = new Map<string, string | number>();
let at = 0;
const take = (re: RegExp) => {
re.lastIndex = at;
const m = re.exec(text);
if (m) at = re.lastIndex;
return m ? m[0] : null;
};
const ws = () => take(/[ \t\n\r]*/y);
const str = () => take(/"(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9A-Fa-f]{4}))*"/y);
const num = () => take(/-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y);
ws();
if (text[at++] !== '{') fail('malformed');
ws();
if (text[at] === '}') at++;
else
for (;;) {
ws();
const rawKey = str() ?? fail('malformed');
const key = JSON.parse(rawKey) as string;
if (!Object.hasOwn(shape, key) || out.has(key)) fail('malformed');
ws();
if (text[at++] !== ':') fail('malformed');
ws();
if (text[at] === '"') {
const value = str();
if (value === null || shape[key] !== 'str') fail('malformed');
out.set(key, JSON.parse(value!));
} else {
const value = num(); // null, true, false, {} and [] all fail here
if (value === null || shape[key] !== 'int' || !/^-?(?:0|[1-9][0-9]*)$/.test(value)) fail('malformed');
if (!Number.isSafeInteger(Number(value))) fail('malformed');
out.set(key, Number(value));
}
ws();
const next = text[at++];
if (next === '}') break;
if (next !== ',') fail('malformed');
}
ws();
if (at !== text.length || out.size !== Object.keys(shape).length) fail('malformed');
return Object.fromEntries(out);
}
function open(envelope: string, prefix: string, domain: string, shape: Shape) {
if (Buffer.byteLength(envelope, 'utf8') > 8192 || !envelope.startsWith(prefix)) fail('malformed');
const parts = envelope.slice(prefix.length).split('.');
if (parts.length !== 2) fail('malformed');
const payload = b64url(parts[0]);
const signature = b64url(parts[1]);
if (payload.length === 0 || signature.length !== 64) fail('malformed');
const fields = exactObject(payload, shape);
return { fields, message: Buffer.concat([Buffer.from(domain, 'utf8'), payload]), signature };
}
// roots: root_kid -> root public key. Returns the signing key it vouches for.
export function verifyEndorsement(value: string, roots: Map<string, KeyObject>, env: Environment, now: number) {
const { fields: e, message, signature } = open(value, 'vsle1.', 'vssaas-license-signing-key-v1\x00', ENDORSEMENT);
const root = roots.get(e.root_kid) ?? fail('unknown_key');
if (!verify(null, message, root, signature)) fail('bad_signature');
if (e.env !== env) fail('wrong_environment');
if (e.v !== 1 || !ENVIRONMENTS.includes(e.env) || !is32(e.public_key)) fail('invalid_claims');
const raw = b64url(e.public_key);
if (e.kid !== keyIdFor(raw) || !KID.test(e.root_kid) || e.nbf <= 0 || e.exp <= e.nbf) fail('invalid_claims');
if (now < e.nbf) fail('not_yet_valid');
if (now >= e.exp) fail('expired');
return { kid: e.kid as string, key: ed25519Key(raw) };
}
// keys: kid -> signing key whose endorsement verified at the same `now`.
export function verifyCertificate(envelope: string, keys: Map<string, KeyObject>, env: Environment, now: number) {
const { fields: c, message, signature } = open(envelope, 'vslc1.', 'vssaas-license-certificate-v1\x00', CLAIMS);
const key = keys.get(c.kid) ?? fail('unknown_key');
if (!verify(null, message, key, signature)) fail('bad_signature');
if (c.aud !== 'voicestudio-desktop') fail('wrong_audience');
if (c.env !== env) fail('wrong_environment');
const valid =
c.v === 1 && c.iss === 'vssaas' && ENVIRONMENTS.includes(c.env) && KID.test(c.kid) &&
[c.serial, c.license_id, c.seat_id, c.organization_id, c.edition, c.agreement_version].every((t) => TOKEN.test(t)) &&
is32(c.inst) && c.iat > 0 && c.updates_until > 0 && c.nbf > 0 && c.nbf <= c.iat && c.exp > c.iat &&
c.hard_exp >= c.exp && c.term_ends_at >= c.hard_exp && c.refresh_after > c.iat;
if (!valid) fail('invalid_claims');
if (now < c.nbf) fail('not_yet_valid');
if (now >= c.hard_exp) fail('expired');
return { claims: c, inGrace: now >= c.exp };
}
// Builds the key set from GET /v1/license-signing-keys, trusting only `roots`.
export function trustedKeys(published: { endorsement: string }[], rootKeys: string[], env: Environment, now: number) {
const roots = new Map(rootKeys.map((r) => [keyIdFor(b64url(r)), ed25519Key(b64url(r))] as const));
const keys = new Map<string, KeyObject>();
for (const { endorsement } of published) {
try {
const k = verifyEndorsement(endorsement, roots, env, now);
keys.set(k.kid, k.key);
} catch {} // an endorsement that does not verify contributes no key
}
return keys;
}
// CLI: <certificate or response.json> <signing-keys.json> <root,...> <env> [now]
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const [certFile, keysFile, roots, env, nowArg] = process.argv.slice(2);
const raw = readFileSync(certFile, 'utf8').trim();
const envelope = raw.startsWith('vslc1.') ? raw : JSON.parse(raw).certificate;
const now = nowArg ? Number(nowArg) : Math.floor(Date.now() / 1000);
const keys = trustedKeys(JSON.parse(readFileSync(keysFile, 'utf8')).keys, roots.split(','), env as Environment, now);
try {
const { claims, inGrace } = verifyCertificate(envelope, keys, env as Environment, now);
console.log(JSON.stringify({ valid: true, in_grace: inGrace, claims }, null, 2));
} catch (e) {
console.log(JSON.stringify({ valid: false, error: (e as LicenseCertificateError).code ?? String(e) }));
process.exitCode = 1;
}
}
1.4 Python(cryptography){#14-python-cryptography}¶
将以下代码保存为 license_certificate.py。cryptography 已经是 VoiceStudio 的直接依赖项。在项目虚拟环境(venv)之外,可使用 uv run --no-project --with cryptography python license_certificate.py … 运行它。
"""Verifies vssaas License Certificates offline."""
from __future__ import annotations
import base64
import hashlib
import json
import re
import sys
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
ENVIRONMENTS = ("development", "staging", "production")
CLAIMS = {"v": int, "kid": str, "iss": str, "aud": str, "env": str, "serial": str, "license_id": str,
"seat_id": str, "organization_id": str, "inst": str, "edition": str, "agreement_version": str,
"term_ends_at": int, "updates_until": int, "iat": int, "nbf": int, "exp": int, "hard_exp": int,
"refresh_after": int}
ENDORSEMENT = {"v": int, "kid": str, "public_key": str, "env": str, "nbf": int, "exp": int, "root_kid": str}
TOKEN = re.compile(r"[A-Za-z0-9._-]{1,64}") # always fullmatch: "$" would accept a trailing newline
KID = re.compile(r"[0-9a-f]{16}")
class LicenseCertificateError(Exception):
"""code: malformed, unknown_key, bad_signature, wrong_audience,
wrong_environment, not_yet_valid, expired or invalid_claims."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def b64url(text: str) -> bytes:
"""Strict, unpadded, canonical base64url."""
if not re.fullmatch(r"[A-Za-z0-9_-]*", text) or len(text) % 4 == 1:
raise LicenseCertificateError("malformed")
raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
if base64.urlsafe_b64encode(raw).rstrip(b"=").decode() != text:
raise LicenseCertificateError("malformed")
return raw
def _is32(text: str) -> bool:
try:
return len(b64url(text)) == 32
except LicenseCertificateError:
return False
def key_id_for(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()[:16]
class _Pairs(list):
pass
def _reject(_constant):
raise ValueError("NaN and Infinity are not JSON")
def _exact_object(payload: bytes, shape: dict) -> dict:
try: # json.loads rejects a leading BOM and trailing data
pairs = json.loads(payload.decode("utf-8", "replace"), object_pairs_hook=_Pairs, parse_constant=_reject)
except (ValueError, RecursionError):
raise LicenseCertificateError("malformed") from None
if not isinstance(pairs, _Pairs):
raise LicenseCertificateError("malformed")
out = {}
for key, value in pairs:
want = shape.get(key)
# type() excludes bool and null; the int range is Go's int64.
if want is None or key in out or type(value) is not want or (want is int and not -2**63 <= value < 2**63):
raise LicenseCertificateError("malformed")
out[key] = value
if len(out) != len(shape):
raise LicenseCertificateError("malformed")
return out
def _open(envelope: str, prefix: str, domain: bytes, shape: dict):
if len(envelope.encode("utf-8", "surrogatepass")) > 8192 or not envelope.startswith(prefix):
raise LicenseCertificateError("malformed")
parts = envelope[len(prefix):].split(".")
if len(parts) != 2:
raise LicenseCertificateError("malformed")
payload, signature = b64url(parts[0]), b64url(parts[1])
if not payload or len(signature) != 64:
raise LicenseCertificateError("malformed")
return _exact_object(payload, shape), domain + payload, signature
def _signed(key: Ed25519PublicKey, signature: bytes, message: bytes) -> bool:
try:
key.verify(signature, message)
return True
except InvalidSignature:
return False
def verify_endorsement(value: str, roots: dict, env: str, now: int) -> tuple[str, Ed25519PublicKey]:
"""roots: root_kid -> root public key. Returns (kid, signing key)."""
e, message, signature = _open(value, "vsle1.", b"vssaas-license-signing-key-v1\x00", ENDORSEMENT)
root = roots.get(e["root_kid"])
if root is None:
raise LicenseCertificateError("unknown_key")
if not _signed(root, signature, message):
raise LicenseCertificateError("bad_signature")
if e["env"] != env:
raise LicenseCertificateError("wrong_environment")
if (e["v"] != 1 or e["env"] not in ENVIRONMENTS or not _is32(e["public_key"])
or e["kid"] != key_id_for(b64url(e["public_key"])) or not KID.fullmatch(e["root_kid"])
or e["nbf"] <= 0 or e["exp"] <= e["nbf"]):
raise LicenseCertificateError("invalid_claims")
if now < e["nbf"]:
raise LicenseCertificateError("not_yet_valid")
if now >= e["exp"]:
raise LicenseCertificateError("expired")
return e["kid"], Ed25519PublicKey.from_public_bytes(b64url(e["public_key"]))
def verify_certificate(envelope: str, keys: dict, env: str, now: int) -> tuple[dict, bool]:
"""keys: kid -> signing key whose endorsement verified at the same `now`.
Returns (claims, in_grace)."""
c, message, signature = _open(envelope, "vslc1.", b"vssaas-license-certificate-v1\x00", CLAIMS)
key = keys.get(c["kid"])
if key is None:
raise LicenseCertificateError("unknown_key")
if not _signed(key, signature, message):
raise LicenseCertificateError("bad_signature")
if c["aud"] != "voicestudio-desktop":
raise LicenseCertificateError("wrong_audience")
if c["env"] != env:
raise LicenseCertificateError("wrong_environment")
tokens = ("serial", "license_id", "seat_id", "organization_id", "edition", "agreement_version")
if not (c["v"] == 1 and c["iss"] == "vssaas" and c["env"] in ENVIRONMENTS and KID.fullmatch(c["kid"])
and all(TOKEN.fullmatch(c[t]) for t in tokens) and _is32(c["inst"])
and c["iat"] > 0 and c["updates_until"] > 0 and 0 < c["nbf"] <= c["iat"] and c["exp"] > c["iat"]
and c["hard_exp"] >= c["exp"] and c["term_ends_at"] >= c["hard_exp"] and c["refresh_after"] > c["iat"]):
raise LicenseCertificateError("invalid_claims")
if now < c["nbf"]:
raise LicenseCertificateError("not_yet_valid")
if now >= c["hard_exp"]:
raise LicenseCertificateError("expired")
return c, now >= c["exp"]
def trusted_keys(published: list, root_keys: list[str], env: str, now: int) -> dict:
"""Key set from GET /v1/license-signing-keys, trusting only root_keys."""
roots = {key_id_for(b64url(r)): Ed25519PublicKey.from_public_bytes(b64url(r)) for r in root_keys}
keys = {}
for item in published:
try:
kid, key = verify_endorsement(item["endorsement"], roots, env, now)
keys[kid] = key
except LicenseCertificateError:
pass # an endorsement that does not verify contributes no key
return keys
if __name__ == "__main__": # <certificate or response.json> <signing-keys.json> <root,...> <env> [now]
cert_file, keys_file, roots_csv, env_name = sys.argv[1:5]
now = int(sys.argv[5]) if len(sys.argv) > 5 else int(time.time())
raw = open(cert_file).read().strip()
envelope = raw if raw.startswith("vslc1.") else json.loads(raw)["certificate"]
key_set = trusted_keys(json.load(open(keys_file))["keys"], roots_csv.split(","), env_name, now)
try:
claims, in_grace = verify_certificate(envelope, key_set, env_name, now)
print(json.dumps({"valid": True, "in_grace": in_grace, "claims": claims}, indent=2))
except LicenseCertificateError as exc:
print(json.dumps({"valid": False, "error": exc.code}))
sys.exit(1)
1.5 黄金向量测试¶
以下示例需要一个已批准的符合性测试夹具,并需逐字节复制到 tests/fixtures/licensing/golden.json;本草案未捆绑该夹具。所引用的夹具的 SHA-256 为 f9008f69434581a3941700680955cac7669c8963a7d88d10d87dba08e23edbec,其中包含:
- 一个仅用于测试的根密钥和签名密钥;
- 一个背书(endorsement),在其自身的
now时间点验证; - 3 个有效信封(
active、in_grace、clamped_to_term); - 8 个无效信封,每种错误一个。
对现有符合性向量所做的更改都应视为协议变更。下面的代码片段是候选测试工具,并非受维护的应用程序已实现此协议的证据。
TypeScript:node --experimental-strip-types --test license-certificate.test.ts。相同的断言可直接移植到 Vitest。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { b64url, keyIdFor, trustedKeys, verifyCertificate, type Environment } from './license-certificate.ts';
const golden = JSON.parse(readFileSync(new URL('./golden.json', import.meta.url), 'utf8'));
const env = golden.environment as Environment;
const keys = trustedKeys([{ endorsement: golden.endorsement.value }], [golden.root.public_key], env, golden.endorsement.now);
const codes: Record<string, string> = {
ErrMalformed: 'malformed', ErrUnknownKey: 'unknown_key', ErrBadSignature: 'bad_signature',
ErrWrongAudience: 'wrong_audience', ErrWrongEnvironment: 'wrong_environment',
ErrNotYetValid: 'not_yet_valid', ErrExpired: 'expired', ErrInvalidClaims: 'invalid_claims',
};
test('file shape and key IDs', () => {
assert.equal(createHash('sha256').update(readFileSync(new URL('./golden.json', import.meta.url))).digest('hex'),
'f9008f69434581a3941700680955cac7669c8963a7d88d10d87dba08e23edbec');
assert.equal(keyIdFor(b64url(golden.root.public_key)), golden.root.kid);
assert.equal(keyIdFor(b64url(golden.signing_key.public_key)), golden.signing_key.kid);
assert.deepEqual([...keys.keys()], [golden.signing_key.kid]); // the root itself is never a certificate key
assert.equal(golden.valid.length, 3);
assert.equal(golden.invalid.length, 8);
});
for (const v of golden.valid) {
test(`accepts ${v.name}`, () => {
const { claims, inGrace } = verifyCertificate(v.envelope, keys, env, v.now);
assert.deepEqual(claims, v.claims);
assert.equal(inGrace, v.in_grace);
const payload = b64url(v.envelope.split('.')[1]);
assert.equal(payload.toString('utf8'), v.payload_json);
assert.equal(createHash('sha256').update(payload).digest('hex'), v.payload_sha256);
});
}
for (const v of golden.invalid) {
test(`rejects ${v.name}`, () => {
assert.throws(() => verifyCertificate(v.envelope, keys, env, v.now), { code: codes[v.error] });
});
}
Python:uv run --no-project --with pytest --with cryptography python -m pytest test_license_certificate.py。
import hashlib
import json
from pathlib import Path
import pytest
from license_certificate import LicenseCertificateError, b64url, key_id_for, trusted_keys, verify_certificate
GOLDEN_PATH = Path(__file__).with_name("golden.json")
GOLDEN = json.loads(GOLDEN_PATH.read_text())
ENV = GOLDEN["environment"]
KEYS = trusted_keys([{"endorsement": GOLDEN["endorsement"]["value"]}], [GOLDEN["root"]["public_key"]], ENV,
GOLDEN["endorsement"]["now"])
CODES = {"ErrMalformed": "malformed", "ErrUnknownKey": "unknown_key", "ErrBadSignature": "bad_signature",
"ErrWrongAudience": "wrong_audience", "ErrWrongEnvironment": "wrong_environment",
"ErrNotYetValid": "not_yet_valid", "ErrExpired": "expired", "ErrInvalidClaims": "invalid_claims"}
def test_file_shape_and_key_ids():
assert hashlib.sha256(GOLDEN_PATH.read_bytes()).hexdigest() == \
"f9008f69434581a3941700680955cac7669c8963a7d88d10d87dba08e23edbec"
assert key_id_for(b64url(GOLDEN["root"]["public_key"])) == GOLDEN["root"]["kid"]
assert key_id_for(b64url(GOLDEN["signing_key"]["public_key"])) == GOLDEN["signing_key"]["kid"]
assert list(KEYS) == [GOLDEN["signing_key"]["kid"]] # the root itself is never a certificate key
assert (len(GOLDEN["valid"]), len(GOLDEN["invalid"])) == (3, 8)
@pytest.mark.parametrize("vector", GOLDEN["valid"], ids=lambda v: v["name"])
def test_accepts(vector):
claims, in_grace = verify_certificate(vector["envelope"], KEYS, ENV, vector["now"])
assert claims == vector["claims"] and in_grace == vector["in_grace"]
payload = b64url(vector["envelope"].split(".")[1])
assert payload.decode() == vector["payload_json"]
assert hashlib.sha256(payload).hexdigest() == vector["payload_sha256"]
@pytest.mark.parametrize("vector", GOLDEN["invalid"], ids=lambda v: v["name"])
def test_rejects(vector):
with pytest.raises(LicenseCertificateError) as caught:
verify_certificate(vector["envelope"], KEYS, ENV, vector["now"])
assert caught.value.code == CODES[vector["error"]]
黄金向量集未覆盖上述严格性规则。请添加探针(probe),用改动重新封装 active 载荷,使解析器在检查签名之前失败。以下每项都必须为 malformed:
- 重复的
v,或未知的键; "v":1.0或"v":true;"edition":null,或缺少某个键;- 对象后尾随
{}; - 开头有 BOM;
- 段内有 CR 或 LF;
- 第三个段;
- 信封达到或超过 8193 字节。
对象后的尾随空格可以解析成功,因此该探针会在更晚阶段以 bad_signature 失败。
2. 检查许可证是否未被撤销或暂停¶
证书证明席位在签发时是有效的。只有刷新操作才能告知客户端:许可证此后已被暂停、撤销或终止,或者席位已被释放。
2.1 刷新调用¶
POST /v1/license-seats/{seat_id}/certificates
Authorization: Bearer vsic_<43 base64url characters>
Content-Type: application/json
{ "last_certificate_serial": "<serial of the certificate held now>" }
承载令牌(bearer)是客户端在声明(claim)时生成并哈希的安装凭据(Installation Credential)。服务器以恒定时间比较整个 vsic_… 字符串的 SHA-256。
请求体是可选的。它可以是空或 {},未知字段会被拒绝。当发送 serial 时,它必须是 16 个 Base62 字符。与最新 serial 不同的 serial 本身不会拒绝刷新。
没有 Idempotency-Key。
200 响应与 claim 具有相同的结构,并带有 Cache-Control: no-store:
| 字段 | 值 |
|---|---|
seat.id、seat.license_id |
16 字符 Base62 ID |
seat.state |
在 200 上始终为 active |
seat.claimed_at |
RFC 3339 |
license.id、license.edition(pro)、license.seat_limit(1–1000) |
许可证摘要 |
license.state |
模式允许 active、suspended 或 revoked。在 200 上始终为 active,因为被暂停或撤销的许可证会以 403 拒绝。 |
license.ends_at、license.updates_until |
RFC 3339 期限 |
certificate |
一个至多 8192 字节的 vslc1.… 信封。在存储之前先验证它(§1)。 |
certificate_expires_at、certificate_hard_expires_at、refresh_after |
声明中 exp、hard_exp 和 refresh_after 的 RFC 3339 副本。以已验证的声明为准。 |
server_time |
服务器时钟。它未被签名;用它来检测时钟偏差,并作为客户端时钟的下限。 |
许可证状态:
- 存储的状态为
active、suspended(可恢复)和revoked(终态)。 - 已过期(now ≥
ends_at)和尚未生效(now <starts_at)是从期限推导出来的,从不存储。它们只以错误码形式出现。 - 当多个条件同时成立时,按以下顺序优先:
license_revoked、license_suspended、license_not_active、license_expired。 - seat 要么是
active要么是released,释放后永远不会撤销。暂停、撤销和过期永远不会释放 seat。
2.2 结果与客户端行为¶
每个错误都使用 {"error": {"code", "message", "request_id", "details"}} 格式。按 code 映射,并保留 request_id 以供支持。
| 代码 | HTTP | 含义 | 客户端操作 |
|---|---|---|---|
| — | 200 | seat 和许可证正常 | 验证并存储新证书,然后安排下一次刷新(§2.3) |
license_revoked |
403 | 已撤销(终态) | 立即关闭 Pro 并删除证书。停止刷新。提示“使用不同的 License Key”。Release 仍然有效。 |
license_suspended |
403 | 已被操作员暂停 | 立即关闭 Pro 并删除证书。每 6 小时以及用户点击“立即检查”时继续重试;恢复后的许可证可正常刷新。 |
license_expired |
403 | now ≥ ends_at |
立即关闭 Pro,删除证书,并停止定时刷新。在此候选合约下,期限在签发时固定,因此继续使用需要新的许可证和 License Key。 |
license_not_active |
403 | starts_at 在未来 |
立即关闭 Pro 并删除证书。实际操作中在 claim 时出现;在开始日期之后重试。 |
seat_released |
409 | 该 seat 已被此 Installation 或操作员释放,且凭据正确 | 关闭 Pro。删除 seat、证书和凭据。再次 claim 需要 License Key。 |
invalid_installation_credential |
401 | 未知的 seat 或错误的凭据。两种原因下响应体相同。 | 保留当前证书直到其 hard_exp,并停止刷新。要求用户输入 License Key 以再次 claim;如果随后出现 409 installation_conflict,则意味着操作员必须释放旧 seat。 |
license_authority_unavailable |
503 | 没有可用的活动签名密钥、背书(endorsement)太短或签名者失败 | 保留证书并退避 |
dependency_unavailable |
503 | 数据库不可用 | 保留证书并退避 |
rate_limited |
429 | 超出预算 | 精确等待 Retry-After 秒(至少 1 秒) |
invalid_request |
400 | 请求体或 serial 格式错误 | 客户端 bug。记录 code 和 request_id。 |
任意 404 |
404 | authority 没有许可证配置,因此其路由未注册 | 视为 license_authority_unavailable |
| 网络错误或超时 | — | 离线 | 保留证书并退避 |
规则:明确的答案(403 或 seat_released)立即生效——即使证书仍然可以验证,也要立即丢弃所持有的证书并禁用 Pro。证书自身直到 hard_exp(§2.4)的宽限期仅用于覆盖未得到明确答案的 claim 或刷新:vssaas 不可达、超时,或 5xx、429、license_authority_unavailable 响应。瞬时故障绝不会缩短仍可验证的证书的有效期,而明确的 403 也绝不会等待宽限期。
2.3 何时调用¶
- 安排下一次刷新时间为已验证声明中的
min(refresh_after, exp)。服务器设置refresh_after = iat + 24h + uniform[0, 6h),因此它已包含抖动。在距ends_at30 天内时,exp和hard_exp都等于ends_at:没有宽限期,且refresh_after可能落在exp之后。 - 还要刷新:在应用启动时如果刷新已到期或逾期、系统唤醒后以及网络恢复时。
- 重新签发窗口。 刷新或重放的 claim 可能返回相同的证书和
refresh_after;客户端必须接受未变化的有效证书,不要假设每个请求都会签发新证书。 - 429。 遵守
Retry-After;不要依赖私有服务的限流预算,也不要立即重试。 - 503、404 或网络故障。 从 1 分钟开始指数退避,每倍递增至最多 6 小时(宽限期内最多 1 小时),并加入抖动。
2.4 离线限制¶
安装只有在刷新到达 vssaas 时才会得知撤销或暂停。在线安装通常在 24 到 30 小时内的下一次 refresh_after 时得知。离线安装会一直持有可验证的证书,直到该证书的 hard_exp。hard_exp = min(exp + 14 days, ends_at),且 exp = min(iat + 30 days, ends_at),因此这最长是证书签发后的 44 天。这是候选协议的限制,而非已发布的产品承诺;在发布前,所选提供商和付费条款必须达成一致。客户端无法在没有网络调用的情况下缩短该期限。§1.1 中单调不减的时钟阻止用户通过回拨时钟来延长它。
2.5 签名密钥轮换检查¶
{ "keys": [ { "kid": "0310b053afb12114", "public_key": "AxCwU6-…", "state": "active", "endorsement": "vsle1.…" } ] }
- 此路由无需认证且可缓存(
Cache-Control: public, max-age=300)。它会列出active密钥和任何retiring密钥。 - 当证书的
kid没有可验证的已缓存背书时调用它;这种情况发生在首次声明时,以及 vssaas 轮换其签名密钥之后。 - 在相同的
now(§1 中的trustedKeys/trusted_keys)下,对照内嵌根验证每一个endorsement,然后重新验证证书。绝不要在没有背书的情况下使用public_key或state。 - 绝不要将一个可验证的证书替换为无法验证的证书。
- 如果一次成功获取不再列出所持证书的
kid,请立即刷新。该密钥已被退役或泄露,vssaas 会以活动密钥签发的证书来响应;其重新签发窗口期永远不会返回由泄露密钥签名的证书。 - 正在退役的密钥会一直保持发布,直到其最后一张证书已过了
hard_exp;因此常规轮换绝不会让离线客户端滞留。
3. 客户端隐私与生产前提条件¶
生成一个仅用于许可的随机标识符;不要从 OS 机器标识符、主机名、用户名、硬件属性、分析标识符 或应用程序加密密钥中派生它。请勿将许可证、席位、组织和安装 值纳入分析数据中。激活 UI 和经审核的隐私政策必须在 发布前解释所选提供商、提交的字段和保留期限。 本草案对私有服务的存储或操作日志不作任何声明。
在启用此协议之前,请选择提供商,发布已批准的符合性测试 夹具,验证安装绑定和持久化时钟处理,完成 生产签名密钥仪式,并记录实际离线时段。私有 运营者的运行手册和服务内部细节必须保留在私有服务的 受控文档中。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio