跳转至

商业许可证客户端:验证、检查状态、测试

状态: 候选协议;提供商选择和生产实现待定

日期: 2026-09-29

范围: 针对候选 vssaas 许可集成的公共客户端验证和刷新合约。本文档不选择支付/许可提供商,也不声称已维护的客户端实现了该协议。

私有服务实现路径、操作员流程、数据库布局和本地服务设置属于私有服务文档。它们有意不包含在本公共客户端合约中。在这里移除它们并不会移除早期的 Git 历史。

公共协议词汇:

  • 商业许可证:授权。
  • 许可证席位:一个被计数的安装。
  • 许可证密钥:VSLK-…,仅向被许可方显示一次。
  • 安装凭据:vsic_…,由客户端生成。
  • 许可证证书:vslc1.…,短期有效且可离线验证。

1. 验证许可证证书

1.1 算法

验证顺序是提议的线上合约的一部分;一致性测试向量对每种情况期望一个特定的错误。now 是完整的 Unix 秒。

信封规则。 这些规则同时适用于证书和背书。

  1. 信封最多为 8192 字节。按 UTF-8 字节计数,而不是字符。
  2. 它以这样的前缀开头:vslc1. 表示证书,vsle1. 表示背书。其余部分恰好是 <payload>.<signature>,没有第三个段。
  3. 两个段都是无填充的严格 base64url:只包含 A–Z a–z 0–9 - _,没有 =,没有 CR 或 LF,长度模 4 ≠ 1,并且尾部位是规范的(解码并重新编码会得到相同的字符串)。载荷非空,签名长度为 64 字节。
  4. 载荷是一个 JSON 对象,且恰好包含文档中列出的键。每个键出现一次,且没有一个为 null。整数没有小数部分或指数。没有 BOM,对象之后除了空白外没有其他内容。未知、缺失或重复的键都属于错误。

步骤 1 到 4 中的任何失败都视为 malformed。

背书(vsle1.)。其键为 v kid public_key env nbf exp root_kid。它根据内置根密钥进行验证:

  1. 通过 root_kid 查找根密钥。如果缺失,则返回 unknown_key。
  2. 验证对 "vssaas-license-signing-key-v1\x00" || payload_bytes 的 Ed25519 签名,使用精确解码后的字节,不重新编码。如果失败,则返回 bad_signature。
  3. env 必须等于客户端的环境。否则返回 wrong_environment。
  4. v == 1;public_key 是 32 字节的 base64url;kid == hex(SHA-256(public_key))[0:16];root_kid 是 16 个小写十六进制字符;0 < nbf < exp。否则返回 invalid_claims。
  5. 如果 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。

  1. 在受信任的密钥集合中查找 kid。如果缺失,则返回 unknown_key。
  2. 验证对 "vssaas-license-certificate-v1\x00" || payload_bytes 的 Ed25519 签名。如果失败,则返回 bad_signature。
  3. aud 必须为 "voicestudio-desktop"。否则返回 wrong_audience。
  4. env 必须等于客户端的环境。否则返回 wrong_environment。
  5. 以下条件必须全部成立,否则返回 invalid_claims:
  6. v == 1,iss == "vssaas",且 env 是 development|staging|production 之一。
  7. kid 是 16 个小写十六进制字符。
  8. serial、license_id、seat_id、organization_id、edition 和 agreement_version 各自匹配 [A-Za-z0-9._-]{1,64}。
  9. inst 是 32 字节的 base64url。
  10. iat > 0 且 updates_until > 0。
  11. 0 < nbf ≤ iat,exp > iat,hard_exp ≥ exp,term_ends_at ≥ hard_exp,且 refresh_after > iat。
  12. 时间窗口。 如果 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_at 30 天内时,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 签名密钥轮换检查

GET /v1/license-signing-keys
{ "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