La façon la plus solide de contrôler un reçu Trustvice, c’est avec du code que vous maîtrisez, sur une machine que vous maîtrisez, contre des clés que vous avez épinglées vous-même. Cet article construit ce vérifieur à partir de rien. À la fin, vous aurez un seul module JavaScript sans dépendance, un vecteur de test qui prouve qu’il marche, et une liste de cas qui prouvent qu’il échoue quand il le doit.
Ce qu’il vous faut#
Node 20 ou plus, ou tout runtime dont crypto.subtle gère Ed25519 : navigateurs actuels, Deno, Bun et Cloudflare Workers conviennent tous. Aucun paquet à installer. Pas besoin non plus de compte Trustvice : le vecteur de test ci-dessous est signé par une clé jetable générée pour cet article.
Étape 1 : enregistrer le vecteur de test#
Enregistrez ceci sous receipt.json. C’est un reçu de test signé avec cette clé jetable. Son corps n’est qu’illustratif ; ce qui compte, c’est qu’il est réellement signé.
{
"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"
}Et ceci sous keys.json, le jeu de clés correspondant :
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "ccD5LEjW7QZxbBMRAWEldWdTDZaQ8BK7OK7zG2ZKgJ0",
"kid": "LfPAIisXFvNmFhD6vBQbBw",
"alg": "EdDSA",
"use": "sig"
}
]
}Pour de vrais reçus, vous remplacerez keys.json par le jeu publié sur https://trustvice.com/.well-known/receipt-keys.json, idéalement une copie que vous avez épinglée. La procédure ne change pas.
Étape 2 : canonicaliser#
La signature couvre la forme canonique RFC 8785 du reçu sans son membre signature. En JavaScript, JSON.stringify produit déjà la forme numérique ECMAScript et l’échappement minimal des chaînes. Reste à trier les clés d’objet, récursivement, par unité de code UTF-16, ce qui est l’ordre par défaut d’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));Pour le vecteur de test, la chaîne canonique tient sur une ligne sans espace :
{"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"}Si votre sortie diffère de cette chaîne d’un seul octet, arrêtez-vous et corrigez. Toutes les étapes suivantes dépendent de ces octets.
Étape 3 : décoder le base64url#
Signatures et clés sont en base64url sans remplissage. WebCrypto veut des octets :
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));
}Étape 4 : contrôler la forme, trouver la clé, vérifier#
Voici le vérifieur lui-même. Il ne lève jamais d’exception sur une entrée hostile ; il renvoie l’une de trois raisons, les mêmes que la page de vérification et l’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" };
}
}Notez la ligne de déstructuration. Elle retire signature et garde tout le reste, y compris des membres que ce code ne connaît pas. C’est voulu : chaque membre sauf signature est signé, donc un vérifieur qui écarte un membre qu’il ne reconnaît pas rejetterait des reçus valides.
Réunissez les trois extraits dans verify-receipt.mjs. C’est tout le vérifieur.
Étape 5 : l’exécuter#
Un petit wrapper en ligne de commande lit les fichiers et sort avec 0 pour un reçu valide, 1 sinon. Il accepte aussi l’enveloppe { "receipt": … } que renvoie l’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.jsonLa sortie pour le vecteur de test :
{"ok":true,"signedAt":"2026-10-01T00:00:00.000Z","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw"}Coupez le réseau et relancez. La réponse ne change pas, car rien dans le contrôle n’a besoin de nous.
Étape 6 : prouver qu’il dit non#
Un vérifieur qui ne dit jamais que « valide » est pire que rien. Modifiez receipt.json et lancez chaque cas :
| Modification | Résultat attendu |
|---|---|
byteLength de 4 à 5 | bad_signature |
N’importe quel caractère d’issuedAt ou de receiptId | bad_signature |
Ajouter "note": "x" au premier niveau | bad_signature, car chaque membre sauf signature est signé |
Le dernier caractère de signature | bad_signature |
signatureKeyId à AAAAAAAAAAAAAAAAAAAAAA | unknown_key |
body.type à autre chose que type | malformed_envelope |
Tout le fichier à {"version":"1"} | malformed_envelope, sans exception |
Gardez-les comme tests automatisés à côté de votre vérifieur. Ils ne coûtent rien et attrapent les erreurs que nous voyons le plus souvent dans les portages.
Facultatif : contrôler l’entrée de clé elle-même#
Le kid dérive de la clé : le base64url des 16 premiers octets du SHA-256 des 32 octets bruts de x. Pour la clé de test, cela donne LfPAIisXFvNmFhD6vBQbBw. Le recalculer confirme que l’entrée de votre fichier de clés est cohérente :
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(/=+$/, "");
}Le même contrôle en Python et en shell#
La procédure est partout la même, mais deux langages demandent de l’attention.
Python. json.dumps n’est pas JCS : il écrit les flottants entiers sous la forme 1.0, complète les exposants et échappe le non-ASCII par défaut. Un portage Python a besoin d’un petit sérialiseur RFC 8785 qui trie les clés par unité de code UTF-16, et peut vérifier avec Ed25519PublicKey du paquet cryptography.
Shell. Avec jq et OpenSSL 3, jq -S -c 'del(.signature)' produit les octets canoniques des reçus, car leurs corps ne contiennent que chaînes, entiers, booléens, null, tableaux et objets. Ce n’est pas une implémentation JCS générale, mais pour un reçu, soit elle concorde, soit elle échoue ; elle n’en valide jamais un qu’elle ne devrait pas. La clé publique est enveloppée en SubjectPublicKeyInfo DER en préfixant un en-tête fixe de 12 octets aux 32 octets bruts, et openssl pkeyutl -verify -rawin fait le reste. Le LibreSSL livré avec macOS n’y arrive pas ; installez OpenSSL 3.
Les erreurs que nous voyons dans les portages#
- Vérifier le texte mis en forme au lieu de la forme canonique.
- Perdre des membres
nullen parsant vers un modèle typé, ou arrondir des nombres à l’entrée. - Écarter des membres que le vérifieur ne reconnaît pas.
- Une bibliothèque JSON qui échappe
/ou les caractères non ASCII. - Coller l’enveloppe d’API
{ "receipt": … }là où le reçu nu est attendu.
Le remède est partout le même : parser vers un arbre générique, retirer signature, canonicaliser le reste, vérifier.
Ce que veut dire un résultat valide#
Une signature valide prouve l’origine, l’intégrité et une heure signée : le détenteur d’une clé de signature Trustvice a signé exactement ces octets, rien n’a changé depuis, et Trustvice affirme l’heure dans issuedAt. Elle ne prouve pas qu’un fait que vous avez fourni est vrai, et elle ne contient pas votre contenu. Pour montrer qu’un document concorde avec un reçu, hachez-le vous-même et comparez. Nous traitons ces limites dans Ce que prouve un reçu, et ce qu’il ne prouve pas.
Pour un contrôle rapide sans écrire de code, collez n’importe quel reçu dans /verify. La même procédure tourne dans votre navigateur et ne nous envoie jamais le reçu.
