La forma más sólida de comprobar un recibo de Trustvice es con código que controlas, en una máquina que controlas, contra claves que fijaste tú. Este artículo construye ese verificador desde cero. Al final tendrás un único módulo de JavaScript sin dependencias, un vector de prueba que demuestra que funciona y una lista de casos que demuestran que falla cuando debe.
Qué necesitas#
Node 20 o posterior, o cualquier runtime cuyo crypto.subtle admita Ed25519: los navegadores actuales, Deno, Bun y Cloudflare Workers sirven. No hay paquete que instalar. Tampoco necesitas una cuenta de Trustvice: el vector de prueba de abajo está firmado con una clave desechable generada para este artículo.
Paso 1: guarda el vector de prueba#
Guarda esto como receipt.json. Es un recibo de prueba firmado con esa clave desechable. Su body es solo ilustrativo; lo que importa es que está firmado de verdad.
{
"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"
}Y esto como keys.json, el juego de claves correspondiente:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "ccD5LEjW7QZxbBMRAWEldWdTDZaQ8BK7OK7zG2ZKgJ0",
"kid": "LfPAIisXFvNmFhD6vBQbBw",
"alg": "EdDSA",
"use": "sig"
}
]
}Para recibos reales sustituirás keys.json por el juego publicado en https://trustvice.com/.well-known/receipt-keys.json, idealmente una copia que hayas fijado. El procedimiento no cambia.
Paso 2: canonicaliza#
La firma cubre la forma canónica RFC 8785 del recibo sin su miembro signature. En JavaScript, JSON.stringify ya produce la forma numérica de ECMAScript y un escapado mínimo de cadenas. Solo falta ordenar las claves de los objetos, de forma recursiva, por unidad de código UTF-16, que es el orden por defecto 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 el vector de prueba, la cadena canónica es una sola línea sin espacios:
{"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 tu salida difiere de esa cadena en un solo byte, para aquí y corrígelo. Todos los pasos siguientes dependen de estos bytes.
Paso 3: decodifica base64url#
Las firmas y las claves van en base64url sin relleno. WebCrypto quiere 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));
}Paso 4: comprueba la forma, busca la clave, verifica#
Ahora, el verificador en sí. Nunca lanza excepciones ante entradas hostiles; devuelve uno de tres motivos, los mismos que usan la página de verificación y la 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" };
}
}Fíjate en la línea de desestructuración. Quita signature y conserva todo lo demás, incluidos miembros que este código no conoce. Es intencionado: todo miembro salvo signature está firmado, así que un verificador que descarte un miembro que no reconoce rechazaría recibos válidos.
Junta los tres fragmentos en verify-receipt.mjs. Ese es todo el verificador.
Paso 5: ejecútalo#
Un pequeño wrapper de línea de comandos lee los archivos y sale con 0 si el recibo es válido y con 1 si no. También acepta el envoltorio { "receipt": … } que devuelve la 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 salida para el vector de prueba:
{"ok":true,"signedAt":"2026-10-01T00:00:00.000Z","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw"}Desconéctate de la red y vuelve a ejecutarlo. La respuesta no cambia, porque nada en la comprobación nos necesita.
Paso 6: demuestra que dice que no#
Un verificador que solo dice «válido» es peor que no tener ninguno. Edita receipt.json y ejecuta cada caso:
| Cambio | Resultado esperado |
|---|---|
byteLength de 4 a 5 | bad_signature |
Cualquier carácter de issuedAt o receiptId | bad_signature |
Añadir "note": "x" en el nivel superior | bad_signature, porque todo miembro salvo signature está firmado |
El último carácter de signature | bad_signature |
signatureKeyId a AAAAAAAAAAAAAAAAAAAAAA | unknown_key |
body.type a algo distinto de type | malformed_envelope |
Todo el archivo a {"version":"1"} | malformed_envelope, sin lanzar excepción |
Conserva estos casos como tests automáticos junto a tu verificador. Son baratos y detectan los errores que más vemos en los ports.
Opcional: comprueba la propia entrada de la clave#
El kid se deriva de la clave: es el base64url de los primeros 16 bytes del SHA-256 de los 32 bytes en bruto de x. Para la clave de prueba sale LfPAIisXFvNmFhD6vBQbBw. Recalcularlo confirma que la entrada de tu archivo de claves es coherente:
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(/=+$/, "");
}La misma comprobación en Python y en la shell#
El procedimiento es el mismo en todas partes, pero dos lenguajes requieren cuidado.
Python. json.dumps no es JCS: imprime los floats enteros como 1.0, rellena los exponentes y escapa por defecto lo que no es ASCII. Una versión en Python necesita un pequeño serializador RFC 8785 que ordene las claves por unidad de código UTF-16, y puede verificar con Ed25519PublicKey del paquete cryptography.
Shell. Con jq y OpenSSL 3, jq -S -c 'del(.signature)' produce los bytes canónicos de los recibos, porque sus bodies solo contienen cadenas, enteros, booleanos, null, arrays y objetos. No es una implementación general de JCS, pero con un recibo o coincide o falla; nunca da por bueno uno que no debe. La clave pública se envuelve como SubjectPublicKeyInfo DER anteponiendo una cabecera fija de 12 bytes a los 32 bytes en bruto, y openssl pkeyutl -verify -rawin hace el resto. El LibreSSL que trae macOS no puede hacerlo; instala OpenSSL 3.
Errores que vemos en los ports#
- Verificar el texto con formato legible en lugar de la forma canónica.
- Perder miembros
nullal parsear a un modelo tipado, o redondear números al leerlos. - Descartar miembros que el verificador no reconoce.
- Una librería JSON que escapa
/o los caracteres no ASCII. - Pegar el envoltorio de la API
{ "receipt": … }donde se espera el recibo sin más.
La solución para todos es la misma: parsea a un árbol genérico, quita signature, canonicaliza el resto y verifica.
Qué significa un resultado válido#
Una firma válida prueba origen, integridad y una hora firmada: quien tiene una clave de firma de Trustvice firmó exactamente estos bytes, nada ha cambiado desde entonces y Trustvice afirma la hora de issuedAt. No prueba que un hecho que aportaste sea cierto, ni contiene tu contenido. Para demostrar que un documento coincide con un recibo, calcula tú el hash del documento y compáralo. Tratamos esos límites en Qué prueba un recibo y qué no.
Para una comprobación rápida sin escribir código, pega cualquier recibo en /verify. Ejecuta el mismo procedimiento en tu navegador y nunca nos envía el recibo.
