Skip to content

Verify a render receipt

Every render response carries a signed receipt. Paste one here to check it was issued by pagenull and, optionally, that a file is the exact output it describes.

The sample invoice from /samples and its receipt are filled in (loading the PDF…). Press Verify receipt.

The file (optional)
or drop it here
Hashed in your browser to check it’s the exact output the receipt describes. It isn’t uploaded.

What a receipt proves, and what it doesn’t

A receipt is an Ed25519 signature over the render id, the SHA-256 of the output, the time, the region, the renderer build and stored: false. It proves that we produced exactly that file, then, there, with that renderer build, and that the API didn’t keep it.

The renderer build is a SHA-256 the renderer computes when it starts, over its own code, the versions of its dependencies and the Chromium version. Two receipts with the same value were made by the same build; a new value means we deployed a new one.

It does not prove deletion. Nothing can prove a negative from the outside. That claim rests on the architecture (there is no code path that writes request content or output; a test enforces it), the renderer running with a read-only filesystem, and our data processing agreement. The details are on trust.

From the command line

This script is all you need, with Node.js 18 or later and nothing to install. Save it as verify-receipt.mjs, or download it with curl -sO https://pagenull.dev/verify-receipt.mjs, or in PowerShell Invoke-WebRequest -Uri "https://pagenull.dev/verify-receipt.mjs" -OutFile verify-receipt.mjs.

verify-receipt.mjs
// verify-receipt.mjs: check a pagenull receipt. Node.js 18 or later, nothing to install.
// Usage: node verify-receipt.mjs "<pagenull-receipt header>" <file>
import { createHash, createPublicKey, verify } from "node:crypto";
import { readFileSync } from "node:fs";

const KEYS_URL = process.env.PAGENULL_KEYS_URL ?? "https://pagenull.dev/.well-known/pagenull-keys.json";
const [receipt, file] = process.argv.slice(2);
const [version, payloadPart, signaturePart] = (receipt ?? "").trim().split(".");
if (version !== "v1" || !signaturePart || !file) {
  console.error('Usage: node verify-receipt.mjs "<pagenull-receipt header>" <file>');
  process.exit(2);
}

const payload = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8"));
const { keys } = await (await fetch(KEYS_URL)).json();
const jwk = keys.find((k) => k.kid === payload.kid);
// Exit by setting exitCode, never process.exit() after fetch: on Windows that can crash Node while the
// connection is closing, and the exit code is lost.
if (!jwk) {
  console.error(`Unknown key id ${payload.kid}`);
  process.exitCode = 1;
} else {
  const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: jwk.x }, format: "jwk" });

  // The signature covers the payload part exactly as it appears in the receipt.
  const signatureValid = verify(null, Buffer.from(payloadPart), key, Buffer.from(signaturePart, "base64url"));
  const sha256 = createHash("sha256").update(readFileSync(file)).digest("hex");
  const fileMatches = sha256 === payload.sha256;

  console.log(`Signature: ${signatureValid ? "valid" : "NOT VALID"}`);
  console.log(`File:      ${fileMatches ? "matches the receipt" : "DOES NOT MATCH the receipt"} (sha256 ${sha256})`);
  console.log(payload);
  process.exitCode = signatureValid && fileMatches ? 0 : 1;
}
# Render, keeping the response headers, then check the receipt against the file
curl -sD headers.txt https://api.pagenull.dev/v1/pdf -H "Authorization: Bearer $PAGENULL_API_KEY" \
  -H "Content-Type: application/json" -d '{"html":"<h1>Hi</h1>"}' -o out.pdf
node verify-receipt.mjs "$(grep -i '^pagenull-receipt:' headers.txt | cut -d' ' -f2 | tr -d '\r')" out.pdf

It prints whether the signature is valid and whether the file matches, and exits with 0 only if both are true. The public key is at /.well-known/pagenull-keys.json (JWK set). Any other Ed25519 library works too: the signed bytes are the second dot-separated part of the receipt, exactly as sent.