A forma mais forte de conferir um recibo Trustvice é com código que você controla, numa máquina que você controla, contra chaves que você mesmo fixou. Este post monta esse verificador do zero. No fim você terá um único módulo JavaScript sem dependências, um vetor de teste que prova que ele funciona e uma lista de casos que provam que ele falha quando deve.
Do que você precisa#
Node 20 ou superior, ou qualquer runtime cujo crypto.subtle suporte Ed25519: navegadores atuais, Deno, Bun e Cloudflare Workers servem. Nenhum pacote a instalar. Nem conta na Trustvice: o vetor de teste abaixo é assinado por uma chave descartável gerada para este post.
Passo 1: salve o vetor de teste#
Salve isto como receipt.json. É um recibo de teste assinado com essa chave descartável. O body é só ilustrativo; o que importa é que ele é assinado de verdade.
{
"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 isto como keys.json, o conjunto de chaves correspondente:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "ccD5LEjW7QZxbBMRAWEldWdTDZaQ8BK7OK7zG2ZKgJ0",
"kid": "LfPAIisXFvNmFhD6vBQbBw",
"alg": "EdDSA",
"use": "sig"
}
]
}Para recibos reais, você troca keys.json pelo conjunto publicado em https://trustvice.com/.well-known/receipt-keys.json, de preferência uma cópia que você fixou. O procedimento não muda.
Passo 2: canonicalize#
A assinatura cobre a forma canônica RFC 8785 do recibo sem o membro signature. Em JavaScript, JSON.stringify já produz a forma numérica do ECMAScript e o escape mínimo de strings. Falta ordenar as chaves dos objetos, recursivamente, por unidade de código UTF-16, que é a ordem padrão de 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));Para o vetor de teste, a string canônica é uma linha sem espaços:
{"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 sua saída diferir dessa string em um byte, pare aqui e corrija. Todos os passos seguintes dependem desses bytes.
Passo 3: decodifique base64url#
Assinaturas e chaves são base64url sem padding. O WebCrypto quer 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));
}Passo 4: confira a forma, ache a chave, verifique#
Agora o verificador em si. Ele nunca lança exceção com entrada hostil; devolve um de três motivos, os mesmos que a página de verificação e a API usam.
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" };
}
}Repare na linha de desestruturação. Ela remove signature e mantém todo o resto, inclusive membros que este código nunca viu. É proposital: todo membro exceto signature é assinado, então um verificador que descarta um membro que não reconhece rejeitaria recibos válidos.
Junte os três trechos em verify-receipt.mjs. Esse é o verificador inteiro.
Passo 5: rode#
Um pequeno wrapper de linha de comando lê os arquivos e sai com 0 para um recibo válido e 1 caso contrário. Ele também aceita o wrapper { "receipt": … } que a API devolve:
// 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.jsonA saída para o vetor de teste:
{"ok":true,"signedAt":"2026-10-01T00:00:00.000Z","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw"}Desconecte-se da rede e rode de novo. A resposta não muda, porque nada na checagem depende de nós.
Passo 6: prove que ele diz não#
Um verificador que só diz "válido" é pior que nenhum. Edite receipt.json e rode cada caso:
| Mudança | Resultado esperado |
|---|---|
byteLength de 4 para 5 | bad_signature |
Qualquer caractere de issuedAt ou receiptId | bad_signature |
Incluir "note": "x" no nível superior | bad_signature, pois todo membro exceto signature é assinado |
O último caractere de signature | bad_signature |
signatureKeyId para AAAAAAAAAAAAAAAAAAAAAA | unknown_key |
body.type para algo diferente de type | malformed_envelope |
O arquivo inteiro para {"version":"1"} | malformed_envelope, sem lançar exceção |
Mantenha esses casos como testes automatizados ao lado do verificador. São baratos e pegam os erros que mais vemos em portes.
Opcional: confira a própria entrada da chave#
O kid deriva da chave: é o base64url dos 16 primeiros bytes do SHA-256 dos 32 bytes crus de x. Para a chave de teste, o resultado é LfPAIisXFvNmFhD6vBQbBw. Recalculá-lo confirma que a entrada no seu arquivo de chaves é coerente:
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(/=+$/, "");
}A mesma checagem em Python e no shell#
O procedimento é o mesmo em todo lugar, mas duas linguagens pedem cuidado.
Python. json.dumps não é JCS: ele imprime floats inteiros como 1.0, preenche expoentes e escapa não ASCII por padrão. Um port em Python precisa de um pequeno serializador RFC 8785 que ordene chaves por unidade de código UTF-16, e pode verificar com Ed25519PublicKey do pacote cryptography.
Shell. Com jq e OpenSSL 3, jq -S -c 'del(.signature)' produz os bytes canônicos de recibos, porque seus bodies só contêm strings, inteiros, booleanos, null, arrays e objetos. Não é uma implementação JCS geral, mas para um recibo ou bate ou falha; nunca aprova um que não deveria. A chave pública vira um SubjectPublicKeyInfo DER com um cabeçalho fixo de 12 bytes antes dos 32 bytes crus, e openssl pkeyutl -verify -rawin faz o resto. O LibreSSL que vem no macOS não consegue; instale o OpenSSL 3.
Erros que vemos em portes#
- Verificar o texto formatado em vez da forma canônica.
- Perder membros
nullao ler para um modelo tipado, ou arredondar números na entrada. - Descartar membros que o verificador não reconhece.
- Uma biblioteca JSON que escapa
/ou caracteres não ASCII. - Colar o wrapper da API
{ "receipt": … }onde se espera o recibo puro.
A cura para todos é a mesma: ler numa árvore genérica, remover signature, canonicalizar o resto, verificar.
O que um resultado válido significa#
Uma assinatura válida prova origem, integridade e um horário assinado: quem detém uma chave de assinatura da Trustvice assinou exatamente estes bytes, nada mudou desde então, e a Trustvice afirma o horário em issuedAt. Ela não prova que um fato que você forneceu é verdadeiro, e não contém seu conteúdo. Para mostrar que um documento corresponde a um recibo, gere você mesmo o hash do documento e compare. Tratamos desses limites em O que um recibo prova, e o que não prova.
Para uma checagem rápida sem escrever código, cole qualquer recibo em /verify. Ele roda o mesmo procedimento no seu navegador e nunca nos envia o recibo.
