The strongest way to check a Trustvice receipt is with code you control, on a machine you control, against keys you pinned yourself. This post builds that verifier from nothing. By the end you will have a single JavaScript module with no dependencies, a test vector that proves it works, and a list of cases that prove it fails when it should.
What you need#
Node 20 or later, or any runtime whose crypto.subtle supports Ed25519: current browsers, Deno, Bun and Cloudflare Workers all qualify. No package to install. You do not need a Trustvice account either: the test vector below is signed by a throwaway key generated for this post.
Step 1: save the test vector#
Save this as receipt.json. It is a test receipt signed with that throwaway key. Its body is illustrative only; what matters is that it is genuinely signed.
{
"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"
}And this as keys.json, the matching key set:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "ccD5LEjW7QZxbBMRAWEldWdTDZaQ8BK7OK7zG2ZKgJ0",
"kid": "LfPAIisXFvNmFhD6vBQbBw",
"alg": "EdDSA",
"use": "sig"
}
]
}For real receipts you will replace keys.json with the published set from https://trustvice.com/.well-known/receipt-keys.json, ideally a copy you pinned. The procedure does not change.
Step 2: canonicalise#
The signature covers the RFC 8785 canonical form of the receipt without its signature member. In JavaScript, JSON.stringify already produces the ECMAScript number form and minimal string escaping. What is left is sorting object keys, recursively, by UTF-16 code unit, which is the default order of 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));For the test vector, the canonical string is one line with no whitespace:
{"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"}If your output differs from that string by one byte, stop here and fix it. Every later step depends on these bytes.
Step 3: decode base64url#
Signatures and keys are base64url without padding. WebCrypto wants bytes:
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));
}Step 4: check the shape, find the key, verify#
Now the verifier itself. It never throws on hostile input; it returns one of three reasons, the same ones the verify page and the API use.
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" };
}
}Note the destructuring line. It removes signature and keeps everything else, including members this code has never heard of. That is deliberate: every member except signature is signed, so a verifier that drops a member it does not recognise would reject valid receipts.
Put the three snippets together in verify-receipt.mjs. That is the whole verifier.
Step 5: run it#
A small command-line wrapper reads the files and exits 0 for a valid receipt and 1 otherwise. It also accepts the { "receipt": … } wrapper the API returns:
// 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.jsonThe output for the test vector:
{"ok":true,"signedAt":"2026-10-01T00:00:00.000Z","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw"}Disconnect from the network and run it again. The answer does not change, because nothing in the check needs us.
Step 6: prove it says no#
A verifier that only ever says "valid" is worse than none. Edit receipt.json and run each case:
| Change | Expected result |
|---|---|
byteLength from 4 to 5 | bad_signature |
Any character of issuedAt or receiptId | bad_signature |
Add "note": "x" at the top level | bad_signature, because every member except signature is signed |
The last character of signature | bad_signature |
signatureKeyId to AAAAAAAAAAAAAAAAAAAAAA | unknown_key |
body.type to something other than type | malformed_envelope |
The whole file to {"version":"1"} | malformed_envelope, without throwing |
Keep these as automated tests next to your verifier. They are cheap, and they catch the mistakes we see most often in ports.
Optional: check the key entry itself#
The kid is derived from the key: the base64url of the first 16 bytes of the SHA-256 of the 32 raw bytes of x. For the test key it comes out as LfPAIisXFvNmFhD6vBQbBw. Recomputing it confirms the entry in your key file is self-consistent:
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(/=+$/, "");
}The same check in Python and the shell#
The procedure is the same everywhere, but two languages need care.
Python. json.dumps is not JCS: it prints integral floats as 1.0, pads exponents and escapes non-ASCII by default. A Python port needs a small RFC 8785 serialiser that sorts keys by UTF-16 code unit, and can verify with Ed25519PublicKey from the cryptography package.
Shell. With jq and OpenSSL 3, jq -S -c 'del(.signature)' produces the canonical bytes for receipts, because their bodies contain only strings, integers, booleans, null, arrays and objects. It is not a general JCS implementation, but for a receipt it either matches or fails; it never passes one it should not. The public key is wrapped as a DER SubjectPublicKeyInfo by prefixing a fixed 12-byte header to the 32 raw bytes, and openssl pkeyutl -verify -rawin does the rest. The LibreSSL that ships with macOS cannot do this; install OpenSSL 3.
Mistakes we see in ports#
- Verifying the pretty-printed text instead of the canonical form.
- Losing
nullmembers by parsing into a typed model, or rounding numbers on the way in. - Dropping members the verifier does not recognise.
- A JSON library that escapes
/or non-ASCII characters. - Pasting the API wrapper
{ "receipt": … }where the bare receipt is expected.
The cure for all of them is the same: parse into a generic tree, remove signature, canonicalise the rest, verify.
What a valid result means#
A valid signature proves origin, integrity and a signed time: the holder of a Trustvice signing key signed exactly these bytes, nothing changed since, and Trustvice asserts the time in issuedAt. It does not prove that a fact you supplied is true, and it does not contain your content. To show a document matches a receipt, hash the document yourself and compare. We cover those boundaries in What a receipt proves, and what it does not.
For a quick check without writing code, paste any receipt into /verify. It runs the same procedure in your browser and never sends the receipt to us.