一份回执只有一项任务:在多年之后,由任何人、使用并非由我们编写的代码来进行检查。正是这一要求,决定了这种格式之中的每一项设计决策。本文将逐一介绍回执信封、规范化处理、签名以及密钥集合,并解释每一项背后所作出的选择。
同一种信封#
采用这种格式的回执共用同一种信封;不同回执类型之间只有 body 部分有所不同。像 Proof-of-Erasure 这样的签名证据记录使用一种同类信封,以 subject 和 claims 代替 body,规范化方式、签名和密钥集合都与下文所述相同。
{
"version": "1",
"receiptId": "01K8QZ3M5V0X7R2N9T4W6YBCDE",
"issuedAt": "2026-10-24T14:03:21.482Z",
"type": "example.receipt",
"signatureKeyId": "7sXkP2nVdQ4yTb1LhWc9Ew",
"body": {
"type": "example.receipt",
"contentSha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"contentType": "text/plain",
"sizeBytes": 5
},
"signature": "base64url-ed25519-signature"
}各个成员的简要说明如下:
| 成员 | 它是什么 |
|---|---|
version | 始终为 "1"。这是回执格式的版本号,而不是 API 的版本号。 |
receiptId | 一个由 26 个字符组成的 ULID,可以按照创建时间进行排序。 |
issuedAt | 以 UTC 表示、精确到毫秒的 ISO 8601 时间点,由 Trustvice 在签名时设置。 |
type | 回执类型。body.type 始终与它相等。 |
signatureKeyId | 签名密钥在已公开发布的密钥集合中的 kid:由 22 个 base64url 字符组成。 |
body | 与具体类型相关的各项事实。 |
signature | Ed25519 签名,采用不带填充的 base64url 编码:共 86 个字符。 |
签名的具体构建方式,决定了以下两项特性,而这两项特性对于任何一位编写验证器的人来说都非常重要。
除了 signature 之外,每一个成员都经过了签名。 这其中包括未来版本可能新增的任何成员。验证器绝不能在检查之前丢弃它所不认识的成员,否则有效的回执也会验证失败。在版本 1 之内,新增的可选字段只会以追加的方式出现,因此读取方应当忽略那些它所不了解的字段,但在进行验证时必须将它们保留下来。
回执中并不会写明您的工作区或者账户。 工作区是您列出和获取回执时所使用的范围,但它并不会被写进经过签名的信封之中。一份回执是关于某一项事实的、自成一体的声明,而不是关于究竟是谁请求了它的证明。
回执 ID 是 ULID#
一个回执 ID 就是一个 ULID:由 26 个 Crockford base32 字符组成。前十个字符是一个 48 位的毫秒级时间戳,其余的十六个字符则是随机生成的。因此,这些 ID 会按照创建时间进行排序。
ID 中所包含的时间,与 issuedAt 之间会相差几毫秒。请将 issuedAt 视为经过签名的时间,而将 ID 仅仅视为一个标识符。
为什么要使用规范化 JSON#
一个签名所覆盖的是字节,而并不是含义。对于同一个值,JSON 在字节层面上有许多种不同的写法:键可以按任意顺序排列、空白字符是可选的、可以写成 4 或者 4.0、可以写成 "é" 或者 "\u00e9"。如果我们对恰好发送出去的那些字节进行签名,那么一个重新缩进了响应的代理,或者一个在您保存时重新排列了键顺序的程序库,就会让一份完全有效的回执验证失败。
因此,签名所覆盖的是一种规范化形式,它由 RFC 8785,即 JSON 规范化方案(JCS)所定义。每一个正确的实现,都会把同一个解析后的值转换为完全相同的字节。对于一份回执来说,这些规则最终可以归结为以下五条:
- 键会被排序,在每一层嵌套中都按照 UTF-16 码元进行排序。
- 各个标记之间不含任何空白字符。
- 数字采用 ECMAScript 的数字转字符串形式:整数在输出时不带小数点,也不带指数。回执的 body 中只包含整数。
- 字符串采用最少的 JSON 转义;非 ASCII 字符会以原始的 UTF-8 形式输出,从来都不会写成
\uXXXX,而正斜杠也不会被转义。 - 数组会保持原有的顺序,并且
null和false值都会被保留下来,而不会被丢弃。
经过签名的消息,是移除了 signature 成员之后的信封的 JCS 形式,并采用 UTF-8 编码。在签名一侧,那些无法在任何地方都以完全相同的方式表示的值,例如 NaN、无穷大、undefined 或者 bigint,都会被直接拒绝,而不会被强制转换。
这对您所带来的实际影响是:您可以对一份回执进行美化输出、把它存储在一个会重新排列键顺序的数据库中,或者让它经过任何一种能够保留值的 JSON 工具,它依然可以通过验证。您不能做的,是修改某个值、删除某个值为 null 的成员,或者对某个数字进行四舍五入。而这些恰恰就是验证器应当捕捉到的那些改动。
为什么选择 Ed25519#
签名采用的是 RFC 8032 中所规定的纯 Ed25519:不进行预哈希,也不使用上下文字符串。我们之所以选择它,是因为它体积小、速度快、具有确定性,并且已经在人们手头现有的标准程序库中得到了实现。Node 20 及更高版本、当前的主流浏览器、Deno、Bun 以及 Cloudflare Workers,仅仅依靠 WebCrypto 就可以验证它;Python 可以借助 cryptography 软件包来验证;而 OpenSSL 3 则可以直接在 shell 中完成验证。验证一份 Trustvice 回执的验证器,永远都不应该需要依赖任何 Trustvice 程序库。
一个签名有 64 个字节,也就是信封中那 86 个 base64url 字符。一个公钥则有 32 个字节。
Trustvice 将私钥保存在 AWS KMS 的硬件安全模块之中,每个数据区域一把密钥,它们从来都不会离开那里。签名方把规范化之后的字节发送给 KMS,然后接收返回的签名;它可以签名,但无法导出任何一把密钥。
密钥 ID 是推导出来的,而不是分配的#
回执中的 signatureKeyId,与已公开发布的集合中某一把密钥的 kid 相对应。这个 kid 并不是一个由我们随意选定的标签。它是根据密钥本身计算得出的:
kid = base64url( first 16 bytes of SHA-256( the 32 raw bytes of x ) )不带填充,因此结果是 22 个字符。由此可以带来两项非常有用的结果,具体如下。
第一,密钥集合中的每一个条目都是可以自我核验的:任何人都可以根据 x 重新计算出 kid,而两者不相符的条目,就不是一个有效的条目。用 JavaScript 来写的话:
const raw = Uint8Array.from(atob(x.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(x.length / 4) * 4, "=")), (c) => c.charCodeAt(0));
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw)).slice(0, 16);
const kid = btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");第二,由于 signatureKeyId 位于经过签名的字节之中,因此一份回执不可能在不使其失效的情况下,被改为指向另一把密钥。而且由于 kid 是由 x 推导出来的,因此对于一个您已经持有的 kid 来说,一个发生了变化的 x 不可能是合法的。
已公开发布的密钥集合#
公钥以 JSON Web Key Set 的形式发布在 https://trustvice.com/.well-known/receipt-keys.json,该地址会通过 HTTP 308 重定向到 https://trustvice.com/api/v1/receipts/keys。它以 JSON 形式提供,可以缓存一个小时,并且带有 Access-Control-Allow-Origin: *,因此您自己域名下的页面也可以获取它,并直接在浏览器中验证回执。
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "…",
"kid": "…",
"alg": "EdDSA",
"use": "sig"
}
]
}进行验证时,您只需要 kid、kty、crv 和 x。这个集合可以包含多把密钥,已退役的密钥也会继续保持公开。来自同一个工作区的回执,随着时间的推移可能会带有不同的密钥 ID,因此请始终针对每一份回执分别查找对应的密钥。
这个集合是根据已公开发布的配置构建的,因此验证过程永远都不依赖于对 AWS 的任何调用。其中只会出现那些可以公开的字段。
固定您所信任的密钥#
默认情况下,一个验证器会信任密钥端点在它发出请求的那一刻所返回的任何内容,而这种信任的强度,仅仅等同于那一次 TLS 连接的强度。对于真正重要的证据,请对这个集合进行固定:从一个您所信任的网络中把它下载一次,根据每一个 x 重新计算出对应的 kid,把这些键值对存放在一个只有经过审核才能修改的地方,然后依据那个文件来进行验证。请按照固定的计划定期刷新它,并在添加任何一把新密钥之前,先对其进行审核。
请把您所使用的密钥集合,与您作为证据保存下来的每一份回执存放在一起。一份回执,加上它当初据以通过验证的密钥集合,本身就可以被独立地验证。
坦率地谈谈密钥轮换#
一份回执会写明为它签名的那把密钥,因此向集合中添加一把新密钥,永远都不会影响到较早的回执。目前并没有自动轮换机制,密钥也没有过期时间。轮换是一项需要手动执行、在带外进行的仪式,而且集合中并不带有任何表示已停用或已吊销的标记。轮换是以追加的方式进行的:已停用的密钥会保持发布状态,以便由它签名的回执仍然可以被验证。这是一项政策,而并不是由密钥端点来强制执行的技术机制,这也是您应当保留一份属于您自己的固定副本的又一个理由。
目前也没有任何吊销列表。如果某一把签名密钥真的遭到了泄露,补救措施将是停止发布它,而这会使由它签名的每一份回执都以 unknown_key 验证失败,其中也包括那些真实有效的回执。保存一份您在接受某份回执时所获得的密钥集合副本,并把这份回执记录在一个由您自己控制的系统之中,无论发生什么情况,都能让您的证据依然可以使用。
接下来可以阅读什么#
- Receipts 操作列出了用于回执的 Agentic API 操作。
- 验证回执会在您的浏览器中执行同样的检查,不会为了得出结论而向我们发送任何内容。
- 离线验证一份回执会根据这个测试向量,一步一步地构建出一个可以实际运行的验证器。
