检查一份 Trustvice 回执最可靠的方式,是使用您自己掌控的代码,在您自己掌控的机器上,用您自己固定下来的密钥来进行验证。本文将从零开始构建这样一个验证器。读完之后,您将拥有一个没有任何依赖的 JavaScript 模块、一个能够证明它确实可以正常工作的测试向量,以及一份能够证明它会在应当失败时如期失败的测试用例清单。
您需要准备什么#
Node 20 或更高版本,或者任何一个 crypto.subtle 支持 Ed25519 的运行时:当前的主流浏览器、Deno、Bun 以及 Cloudflare Workers 都符合这一条件。不需要安装任何软件包。您也不需要拥有一个 Trustvice 账户:下面的测试向量是由一把专门为本文生成的一次性密钥签名的。
第 1 步:保存测试向量#
将下面的内容保存为 receipt.json。这是一份用那把一次性密钥签名的测试回执。它的回执正文仅仅用于演示;真正重要的是,它确实经过了真实的签名。
{
"version": "1",
"receiptId": "01JTEST0000000000000000000",
"issuedAt": "2026-10-01T00:00:00.000Z",
"type": "example.receipt",
"signatureKeyId": "LfPAIisXFvNmFhD6vBQbBw",
"body": {
"type": "example.receipt",
"contentSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"contentType": "text/plain",
"byteLength": 4
},
"signature": "w9-IPP1IkiJQYdkfkMd4fQXIJgHw7fLlaCcS_O4dXKv-gOSsaxmMCrVZbmjnHidFmRi-O_9Dt6UHVFssoac_BQ"
}再将下面的内容保存为 keys.json,也就是与之相匹配的密钥集合:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "ccD5LEjW7QZxbBMRAWEldWdTDZaQ8BK7OK7zG2ZKgJ0",
"kid": "LfPAIisXFvNmFhD6vBQbBw",
"alg": "EdDSA",
"use": "sig"
}
]
}对于真实的回执,您需要将 keys.json 替换为从 https://trustvice.com/.well-known/receipt-keys.json 获取的已公开发布的密钥集合,最好是一份您自己已经固定下来的副本。整个验证流程本身不会有任何变化。
第 2 步:规范化处理#
签名所覆盖的,是去掉 signature 成员之后的回执所对应的 RFC 8785 规范化形式。在 JavaScript 中,JSON.stringify 本身就已经会生成 ECMAScript 的数字格式,并且只做最少量的字符串转义。剩下需要做的,就是按照 UTF-16 码元对对象的键进行递归排序,而这恰好就是 Array.prototype.sort 的默认排序方式:
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
);
}
return value;
}
export const jcs = (value) => JSON.stringify(canonicalize(value));对于这个测试向量,得到的规范化字符串是单独的一行,不含任何空白字符:
{"body":{"byteLength":4,"contentSha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08","contentType":"text/plain","type":"example.receipt"},"issuedAt":"2026-10-01T00:00:00.000Z","receiptId":"01JTEST0000000000000000000","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw","type":"example.receipt","version":"1"}如果您的输出与这个字符串哪怕只相差一个字节,请先停在这里,把问题修复好。后面的每一个步骤,都依赖于这些字节完全正确。
第 3 步:解码 base64url#
签名和密钥都采用不带填充的 base64url 编码。而 WebCrypto 需要的是原始字节:
function base64urlToBytes(text) {
const padded = text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
}第 4 步:检查结构、找到密钥,然后验证#
现在来编写验证器本身。无论输入多么恶意,它都永远不会抛出异常;它只会返回三种原因之一,与验证页面和 API 所使用的那三种原因完全相同。
export async function verifyReceipt(receipt, keys) {
const body = receipt?.body;
if (
!receipt || receipt.version !== "1" ||
typeof receipt.receiptId !== "string" || typeof receipt.issuedAt !== "string" ||
typeof receipt.type !== "string" || typeof receipt.signatureKeyId !== "string" ||
typeof receipt.signature !== "string" || !body || typeof body !== "object" ||
body.type !== receipt.type
) return { ok: false, reason: "malformed_envelope" };
const key = keys.find((k) => k.kid === receipt.signatureKeyId);
if (!key) return { ok: false, reason: "unknown_key" };
// The signed bytes: every member of the envelope except "signature", canonicalized.
const { signature, ...unsigned } = receipt;
const message = new TextEncoder().encode(jcs(unsigned));
try {
const publicKey = await crypto.subtle.importKey(
"jwk", { kty: key.kty, crv: key.crv, x: key.x }, { name: "Ed25519" }, false, ["verify"],
);
const valid = await crypto.subtle.verify({ name: "Ed25519" }, publicKey, base64urlToBytes(signature), message);
return valid
? { ok: true, signedAt: receipt.issuedAt, signatureKeyId: receipt.signatureKeyId }
: { ok: false, reason: "bad_signature" };
} catch {
return { ok: false, reason: "bad_signature" };
}
}请注意那一行解构赋值。它移除了 signature,并保留了其余的全部内容,其中也包括这段代码从来都不认识的那些成员。这样做是有意为之的:除了 signature 之外,每一个成员都经过了签名,因此,一个丢弃了自己无法识别的成员的验证器,就会错误地拒绝那些本来有效的回执。
把这三段代码合并在一起,放进 verify-receipt.mjs 中。这就是完整的验证器了。
第 5 步:运行它#
一个小小的命令行包装程序负责读取这些文件,对于有效的回执以 0 退出,否则以 1 退出。它同样也能接受 API 所返回的那种 { "receipt": … } 包装格式:
// verify-cli.mjs: node verify-cli.mjs receipt.json [keys.json]
import { readFileSync } from "node:fs";
import { verifyReceipt } from "./verify-receipt.mjs";
const [receiptPath, keysPath] = process.argv.slice(2);
let receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
receipt = receipt?.receipt ?? receipt;
const keySet = keysPath
? JSON.parse(readFileSync(keysPath, "utf8"))
: await (await fetch("https://trustvice.com/.well-known/receipt-keys.json")).json();
const result = await verifyReceipt(receipt, keySet.keys ?? []);
console.log(JSON.stringify(result));
process.exit(result.ok ? 0 : 1);node verify-cli.mjs receipt.json keys.json对于这个测试向量,得到的输出如下:
{"ok":true,"signedAt":"2026-10-01T00:00:00.000Z","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw"}现在断开网络连接,然后再运行一次。得到的答案不会有任何变化,因为整个检查过程中没有任何一个环节需要我们参与。
第 6 步:证明它会说“不”#
一个永远只会回答“有效”的验证器,比根本没有验证器还要糟糕。请编辑 receipt.json,然后逐一运行下面的每一种情况:
| 修改内容 | 预期的结果 |
|---|---|
将 byteLength 从 4 改为 5 | bad_signature |
修改 issuedAt 或 receiptId 中的任意一个字符 | bad_signature |
在顶层添加 "note": "x" | bad_signature,因为除了 signature 之外的每一个成员都经过了签名 |
修改 signature 的最后一个字符 | bad_signature |
将 signatureKeyId 改为 AAAAAAAAAAAAAAAAAAAAAA | unknown_key |
将 body.type 改为与 type 不同的值 | malformed_envelope |
将整个文件改为 {"version":"1"} | malformed_envelope,并且不会抛出异常 |
请把这些用例作为自动化测试,放在您的验证器旁边。它们的成本很低,却能发现我们在各种移植版本中最常见到的那些错误。
可选步骤:检查密钥条目本身#
kid 是从密钥本身推导出来的:先取 x 的 32 个原始字节计算 SHA-256,再取其前 16 个字节进行 base64url 编码。对于这把测试密钥,计算结果为 LfPAIisXFvNmFhD6vBQbBw。重新计算一遍,就可以确认您的密钥文件中的这个条目是自洽的:
export async function keyIdOf(x) {
const digest = await crypto.subtle.digest("SHA-256", base64urlToBytes(x));
let bin = "";
for (const b of new Uint8Array(digest).slice(0, 16)) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}在 Python 和 shell 中进行同样的检查#
无论在哪里,验证流程都是相同的,但有两种语言需要格外小心。
Python。 json.dumps 并不是 JCS:默认情况下,它会把整数值的浮点数输出为 1.0,会对指数进行补位,还会转义所有非 ASCII 字符。Python 移植版需要一个小型的 RFC 8785 序列化器,按照 UTF-16 码元对键进行排序,并可以使用 cryptography 软件包中的 Ed25519PublicKey 来完成验证。
Shell。 借助 jq 和 OpenSSL 3,jq -S -c 'del(.signature)' 就能为回执生成规范化的字节,因为回执的正文中只包含字符串、整数、布尔值、null、数组和对象。它并不是一个通用的 JCS 实现,但对于一份回执来说,它要么完全匹配,要么直接失败;它永远不会让一份本不该通过的回执通过验证。公钥的处理方式,是在 32 个原始字节前面加上一个固定的 12 字节头部,将其包装成 DER 格式的 SubjectPublicKeyInfo,剩下的工作则由 openssl pkeyutl -verify -rawin 来完成。macOS 自带的 LibreSSL 无法做到这一点;请安装 OpenSSL 3。
我们在各种移植版本中常见的错误#
- 验证的是经过美化排版的文本,而不是规范化形式。
- 因为解析成带类型的模型而丢失了
null成员,或者在读取时对数字进行了舍入。 - 丢弃了验证器无法识别的成员。
- 使用了一个会转义
/或非 ASCII 字符的 JSON 程序库。 - 在需要裸回执的地方,粘贴了 API 的包装格式
{ "receipt": … }。
所有这些问题的解决方法都是一样的:解析成一棵通用的数据树,移除 signature,对其余部分进行规范化处理,然后进行验证。
一个有效的结果意味着什么#
一个有效的签名可以证明来源、完整性以及一个经过签名的时间:一把 Trustvice 签名密钥的持有者,恰好对这些字节进行了签名,自那以后没有任何内容发生过变化,并且 Trustvice 声明了 issuedAt 中的那个时间。它并不能证明您所提供的某项事实是真实的,而且它也不包含您的内容本身。要证明某份文档与一份回执相符,请您自己计算这份文档的哈希值,然后进行比较。我们在一份回执能证明什么,又不能证明什么一文中详细讨论了这些边界。
如果想在不编写任何代码的情况下快速检查,可以将任意一份回执粘贴到 /verify 中。它会在您的浏览器中运行完全相同的验证流程,并且永远不会把这份回执发送给我们。