Il modo più forte di controllare una ricevuta Trustvice è con codice che controlli tu, su una macchina che controlli tu, con chiavi che hai fissato tu. Questo post costruisce quel verificatore da zero. Alla fine avrai un unico modulo JavaScript senza dipendenze, un test vector che prova che funziona e una lista di casi che provano che fallisce quando deve.
Cosa ti serve#
Node 20 o successivo, o qualsiasi runtime il cui crypto.subtle supporti Ed25519: browser attuali, Deno, Bun e Cloudflare Workers vanno tutti bene. Nessun pacchetto da installare. Non ti serve nemmeno un account Trustvice: il test vector qui sotto è firmato con una chiave usa e getta generata per questo post.
Passo 1: salva il test vector#
Salva questo come receipt.json. È una ricevuta di test firmata con quella chiave usa e getta. Il body è solo illustrativo; conta che sia firmata davvero.
{
"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"
}E questo come keys.json, il set di chiavi corrispondente:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "ccD5LEjW7QZxbBMRAWEldWdTDZaQ8BK7OK7zG2ZKgJ0",
"kid": "LfPAIisXFvNmFhD6vBQbBw",
"alg": "EdDSA",
"use": "sig"
}
]
}Per le ricevute reali sostituirai keys.json con il set pubblicato su https://trustvice.com/.well-known/receipt-keys.json, idealmente una copia che hai fissato. La procedura non cambia.
Passo 2: canonicalizza#
La firma copre la forma canonica RFC 8785 della ricevuta senza il suo membro signature. In JavaScript, JSON.stringify produce già la forma numerica ECMAScript e l’escape minimo delle stringhe. Resta da ordinare le chiavi degli oggetti, ricorsivamente, per unità di codice UTF-16, che è l’ordine di default di 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));Per il test vector, la stringa canonica è una riga senza spazi:
{"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"}Se il tuo output differisce da quella stringa anche di un solo byte, fermati qui e correggilo. Ogni passo successivo dipende da questi byte.
Passo 3: decodifica il base64url#
Firme e chiavi sono in base64url senza padding. WebCrypto vuole byte:
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));
}Passo 4: controlla la forma, trova la chiave, verifica#
Ora il verificatore vero e proprio. Non lancia mai eccezioni su input ostili; restituisce uno di tre motivi, gli stessi usati dalla pagina di verifica e dall’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" };
}
}Nota la riga di destructuring. Rimuove signature e tiene tutto il resto, compresi i membri che questo codice non ha mai visto. È voluto: ogni membro tranne signature è firmato, quindi un verificatore che scarta un membro che non riconosce rifiuterebbe ricevute valide.
Metti insieme i tre snippet in verify-receipt.mjs. Quello è tutto il verificatore.
Passo 5: eseguilo#
Un piccolo wrapper da riga di comando legge i file ed esce con 0 per una ricevuta valida e 1 altrimenti. Accetta anche il wrapper { "receipt": … } restituito dall’API:
// 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.jsonL’output per il test vector:
{"ok":true,"signedAt":"2026-10-01T00:00:00.000Z","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw"}Scollegati dalla rete ed eseguilo di nuovo. La risposta non cambia, perché nulla nel controllo ha bisogno di noi.
Passo 6: prova che sa dire di no#
Un verificatore che dice sempre e solo «valida» è peggio di nessun verificatore. Modifica receipt.json ed esegui ogni caso:
| Modifica | Risultato atteso |
|---|---|
byteLength da 4 a 5 | bad_signature |
Un carattere qualsiasi di issuedAt o receiptId | bad_signature |
Aggiungi "note": "x" al primo livello | bad_signature, perché ogni membro tranne signature è firmato |
L’ultimo carattere di signature | bad_signature |
signatureKeyId in AAAAAAAAAAAAAAAAAAAAAA | unknown_key |
body.type in un valore diverso da type | malformed_envelope |
L’intero file in {"version":"1"} | malformed_envelope, senza eccezioni |
Tienili come test automatici accanto al tuo verificatore. Costano poco e intercettano gli errori che vediamo più spesso nei port.
Facoltativo: controlla la voce della chiave#
Il kid deriva dalla chiave: è il base64url dei primi 16 byte dello SHA-256 dei 32 byte grezzi di x. Per la chiave di test risulta LfPAIisXFvNmFhD6vBQbBw. Ricalcolarlo conferma che la voce nel tuo file di chiavi è coerente con sé stessa:
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(/=+$/, "");
}Lo stesso controllo in Python e nella shell#
La procedura è la stessa ovunque, ma due linguaggi richiedono attenzione.
Python. json.dumps non è JCS: stampa i float interi come 1.0, riempie gli esponenti e di default fa l’escape dei caratteri non ASCII. Un port in Python ha bisogno di un piccolo serializzatore RFC 8785 che ordini le chiavi per unità di codice UTF-16, e può verificare con Ed25519PublicKey del pacchetto cryptography.
Shell. Con jq e OpenSSL 3, jq -S -c 'del(.signature)' produce i byte canonici delle ricevute, perché i loro body contengono solo stringhe, interi, booleani, null, array e oggetti. Non è un’implementazione JCS generale, ma per una ricevuta o corrisponde o fallisce; non ne fa mai passare una che non dovrebbe. La chiave pubblica viene incapsulata in una SubjectPublicKeyInfo DER anteponendo un header fisso di 12 byte ai 32 byte grezzi, e openssl pkeyutl -verify -rawin fa il resto. La LibreSSL inclusa in macOS non ci riesce; installa OpenSSL 3.
Errori che vediamo nei port#
- Verificare il testo formattato invece della forma canonica.
- Perdere i membri
nullfacendo il parsing in un modello tipizzato, o arrotondare i numeri in ingresso. - Scartare i membri che il verificatore non riconosce.
- Una libreria JSON che fa l’escape di
/o dei caratteri non ASCII. - Incollare il wrapper dell’API
{ "receipt": … }dove è attesa la ricevuta nuda.
Il rimedio per tutti è lo stesso: fai il parsing in un albero generico, rimuovi signature, canonicalizza il resto, verifica.
Cosa significa un risultato valido#
Una firma valida prova origine, integrità e un’ora firmata: il titolare di una chiave di firma Trustvice ha firmato esattamente questi byte, nulla è cambiato da allora, e Trustvice afferma l’ora in issuedAt. Non prova che un fatto da te fornito sia vero, e non contiene i tuoi contenuti. Per mostrare che un documento corrisponde a una ricevuta, calcola tu l’hash del documento e confrontalo. Trattiamo questi confini in What a receipt proves, and what it does not.
Per un controllo rapido senza scrivere codice, incolla una ricevuta qualsiasi su /verify. Esegue la stessa procedura nel tuo browser e non ci invia mai la ricevuta.
