Am stärksten prüfen Sie einen Trustvice-Beleg mit Code, den Sie kontrollieren, auf einem Rechner, den Sie kontrollieren, gegen Schlüssel, die Sie selbst gepinnt haben. Dieser Beitrag baut diesen Prüfer von null auf. Am Ende haben Sie ein einziges JavaScript-Modul ohne Abhängigkeiten, einen Testvektor, der beweist, dass es funktioniert, und Fälle, die beweisen, dass es scheitert, wenn es soll.
Was Sie brauchen#
Node 20 oder neuer oder eine Runtime, deren crypto.subtle Ed25519 unterstützt: Aktuelle Browser, Deno, Bun und Cloudflare Workers erfüllen das alle. Nichts zu installieren. Auch ein Trustvice-Konto brauchen Sie nicht: Der Testvektor unten ist mit einem Wegwerfschlüssel signiert, der für diesen Beitrag erzeugt wurde.
Schritt 1: Testvektor speichern#
Speichern Sie dies als receipt.json. Es ist ein Testbeleg, der mit diesem Wegwerfschlüssel signiert ist. Sein Body dient nur zur Illustration; entscheidend ist, dass er echt signiert ist.
{
"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"
}Und dies als keys.json, das passende Key-Set:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "ccD5LEjW7QZxbBMRAWEldWdTDZaQ8BK7OK7zG2ZKgJ0",
"kid": "LfPAIisXFvNmFhD6vBQbBw",
"alg": "EdDSA",
"use": "sig"
}
]
}Für echte Belege ersetzen Sie keys.json durch das publizierte Set unter https://trustvice.com/.well-known/receipt-keys.json, idealerweise eine gepinnte Kopie. Das Verfahren bleibt gleich.
Schritt 2: kanonisieren#
Die Signatur deckt die kanonische Form nach RFC 8785 des Belegs ohne sein Feld signature ab. In JavaScript erzeugt JSON.stringify bereits die ECMAScript-Zahlendarstellung und minimales String-Escaping. Bleibt das rekursive Sortieren der Objekt-Keys nach UTF-16-Codeeinheit, die Standardreihenfolge von 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));Für den Testvektor ist der kanonische String eine Zeile ohne Leerraum:
{"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"}Weicht Ihre Ausgabe um ein Byte von diesem String ab, halten Sie hier an und beheben Sie das. Jeder spätere Schritt hängt an diesen Bytes.
Schritt 3: base64url dekodieren#
Signaturen und Schlüssel sind base64url ohne Padding. WebCrypto will 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));
}Schritt 4: Form prüfen, Schlüssel finden, verifizieren#
Jetzt der Prüfer selbst. Er wirft bei feindseliger Eingabe nie; er liefert einen von drei Gründen, dieselben wie Prüfseite und 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" };
}
}Beachten Sie die Destrukturierungszeile. Sie entfernt signature und behält alles andere, auch Felder, die dieser Code nie gesehen hat. Das ist Absicht: Jedes Feld außer signature ist signiert, also würde ein Prüfer, der ein Feld verwirft, das er nicht kennt, gültige Belege ablehnen.
Fügen Sie die drei Snippets in verify-receipt.mjs zusammen. Das ist der ganze Prüfer.
Schritt 5: ausführen#
Ein kleiner Kommandozeilen-Wrapper liest die Dateien und endet mit 0 bei gültigem Beleg, sonst mit 1. Er akzeptiert auch den Wrapper { "receipt": … }, den die API liefert:
// 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.jsonDie Ausgabe für den Testvektor:
{"ok":true,"signedAt":"2026-10-01T00:00:00.000Z","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw"}Trennen Sie die Netzwerkverbindung und führen Sie es erneut aus. Die Antwort bleibt gleich, denn nichts an der Prüfung braucht uns.
Schritt 6: beweisen, dass er Nein sagt#
Ein Prüfer, der immer nur „gültig“ sagt, ist schlimmer als keiner. Bearbeiten Sie receipt.json und testen Sie jeden Fall:
| Änderung | Erwartetes Ergebnis |
|---|---|
byteLength von 4 auf 5 | bad_signature |
Ein beliebiges Zeichen von issuedAt oder receiptId | bad_signature |
"note": "x" auf oberster Ebene ergänzen | bad_signature, denn jedes Feld außer signature ist signiert |
Das letzte Zeichen von signature | bad_signature |
signatureKeyId auf AAAAAAAAAAAAAAAAAAAAAA | unknown_key |
body.type auf etwas anderes als type | malformed_envelope |
Die ganze Datei auf {"version":"1"} | malformed_envelope, ohne Exception |
Behalten Sie diese als automatisierte Tests neben Ihrem Prüfer. Sie kosten wenig und fangen die Fehler, die wir in Portierungen am häufigsten sehen.
Optional: den Schlüsseleintrag selbst prüfen#
Die kid ist aus dem Schlüssel abgeleitet: base64url der ersten 16 Bytes des SHA-256 der 32 Rohbytes von x. Für den Testschlüssel ergibt das LfPAIisXFvNmFhD6vBQbBw. Die Neuberechnung bestätigt, dass der Eintrag in Ihrer Schlüsseldatei in sich stimmig ist:
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(/=+$/, "");
}Dieselbe Prüfung in Python und der Shell#
Das Verfahren ist überall gleich, aber zwei Sprachen brauchen Sorgfalt.
Python. json.dumps ist kein JCS: Es gibt ganzzahlige Floats als 1.0 aus, füllt Exponenten auf und escaped standardmäßig Nicht-ASCII. Eine Python-Portierung braucht einen kleinen RFC-8785-Serialisierer, der Keys nach UTF-16-Codeeinheit sortiert, und kann mit Ed25519PublicKey aus dem Paket cryptography prüfen.
Shell. Mit jq und OpenSSL 3 erzeugt jq -S -c 'del(.signature)' die kanonischen Bytes für Belege, weil deren Bodys nur Strings, Ganzzahlen, Booleans, null, Arrays und Objekte enthalten. Das ist keine allgemeine JCS-Implementierung, aber bei einem Beleg passt es oder scheitert; es lässt nie einen durch, der scheitern sollte. Der öffentliche Schlüssel wird als DER SubjectPublicKeyInfo verpackt, indem man den 32 Rohbytes einen festen 12-Byte-Header voranstellt, und openssl pkeyutl -verify -rawin erledigt den Rest. Das LibreSSL von macOS kann das nicht; installieren Sie OpenSSL 3.
Fehler, die wir in Portierungen sehen#
- Den hübsch formatierten Text prüfen statt der kanonischen Form.
null-Felder verlieren, weil in ein typisiertes Modell geparst wird, oder Zahlen beim Einlesen runden.- Felder verwerfen, die der Prüfer nicht kennt.
- Eine JSON-Bibliothek, die
/oder Nicht-ASCII-Zeichen escaped. - Den API-Wrapper
{ "receipt": … }einfügen, wo der nackte Beleg erwartet wird.
Die Kur ist bei allen dieselbe: in einen generischen Baum parsen, signature entfernen, den Rest kanonisieren, prüfen.
Was ein gültiges Ergebnis bedeutet#
Eine gültige Signatur beweist Herkunft, Integrität und eine signierte Zeit: Der Inhaber eines Trustvice-Signaturschlüssels signierte genau diese Bytes, seither änderte sich nichts, und Trustvice bestätigt die Zeit in issuedAt. Sie beweist nicht, dass ein von Ihnen gelieferter Fakt stimmt, und sie enthält Ihre Inhalte nicht. Um zu zeigen, dass ein Dokument zu einem Beleg passt, hashen Sie es selbst und vergleichen. Diese Grenzen behandelt Was ein Beleg beweist und was nicht.
Für eine schnelle Prüfung ohne Code fügen Sie einen beliebigen Beleg in /verify ein. Dort läuft dasselbe Verfahren in Ihrem Browser, und der Beleg geht nie an uns.
