How to check a ClearRun receipt signature yourself
Fetch the receipt from the public lookup and the public keys from /.well-known/jwks.json. Then check the Ed25519 signature over the receipt's 11 signed fields, written as JSON with sorted keys. The script below does it in Node.js with no extra packages.
Last updated
Steps
Save the script
// verify-receipt.mjs (Node.js 18 or newer) import { createHash, createPublicKey, verify } from 'node:crypto'; import { readFileSync } from 'node:fs'; const BASE = 'https://api.clearrun.net'; const sortValue = (v) => Array.isArray(v) ? v.map(sortValue) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => [k, sortValue(v[k])])) : v; const canonical = (v) => JSON.stringify(sortValue(v)); const sha256 = (s) => createHash('sha256').update(s).digest('hex'); const [id, savedFile] = process.argv.slice(2); const body = await (await fetch(`${BASE}/api/verify/${encodeURIComponent(id)}`)).json(); const jwks = await (await fetch(`${BASE}/.well-known/jwks.json`)).json(); const r = body.receipt; const fields = ['alg','encKeyId','inputHash','keyId','outputHash','prevHash', 'receiptId','timestamp','verdictHash','verdictId','workspaceId']; const signed = Object.fromEntries(fields.map((f) => [f, r[f]])); const jwk = jwks.keys.find((k) => k.kid === r.keyId); const signatureValid = !!jwk && verify(null, Buffer.from(canonical(signed)), createPublicKey({ key: jwk, format: 'jwk' }), Buffer.from(r.signature, 'base64')); const receiptHashValid = sha256(canonical({ ...signed, signature: r.signature })) === r.receiptHash; // verdictHash. Schema 1.1.0 hashes the coarse fields plus signalsDigest. // Schema 1.0.0 hashes the whole public verdict as it was stored. // For a 1.0.0 id checked from the lookup alone, verdictHashValid prints false, which is expected; verified and signatureValid confirm it. const source = savedFile ? JSON.parse(readFileSync(savedFile, 'utf8')).verdict : body.verdict; const { signatureKeyId, signatureAlg, signature, ...v } = source ?? {}; const COARSE = ['verdictId','schemaVersion','score','grade','risk','summary','timestamp','signalsDigest']; const hashed = v.schemaVersion === '1.1.0' ? Object.fromEntries(COARSE.map((k) => [k, v[k] ?? (k === 'summary' || k === 'timestamp' ? '' : v[k])])) : v; const verdictHashValid = sha256(canonical(hashed)) === r.verdictHash; console.log({ signatureValid, receiptHashValid, verdictHashValid, issuedAt: r.timestamp });Run it with a verdict id
node verify-receipt.mjs 6e07586d-e028-43d6-bec3-0f4821b22ca0It works for schema
1.1.0receipts from the lookup alone. To check against your own copy instead, add the API response you saved:node verify-receipt.mjs 6e07586d-e028-43d6-bec3-0f4821b22ca0 response.json.Read the result
All three
truemeans ClearRun signed this receipt, the receipt is unchanged, and the public verdict matches it. On1.1.0receipts the public check covers a digest of the named signals, not the signals themselves.
Already have the receipt?
Use the receipt from your original API response instead of the lookup, and keep a copy of the key set. Then you don't need ClearRun to be online.
Two schema versions
schemaVersion | verdictHash covers |
|---|---|
1.1.0 (new receipts) | The coarse fields (ids, score, grade, risk, summary, time) and a digest of the named signals |
1.0.0 (older receipts) | The whole public verdict, as it was stored |
A 1.0.0 receipt can't be recomputed from public lookup data, because the lookup now shows only the coarse fields. The server check confirms it: verified and signatureValid are true in the lookup response.
What it does not check
Whether the answer is true. And it can't check the input or output hashes unless you still have the exact text.