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

  1. 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 });
  2. Run it with a verdict id

    node verify-receipt.mjs 6e07586d-e028-43d6-bec3-0f4821b22ca0

    It works for schema 1.1.0 receipts 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.

  3. Read the result

    All three true means ClearRun signed this receipt, the receipt is unchanged, and the public verdict matches it. On 1.1.0 receipts 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

Two schema versions
schemaVersionverdictHash 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.