Verify a proof

Every completed real-engine verdict has an append-only, Ed25519-signed proof chain. This page describes the strict relying-party check: signature, hash chain, terminal head, and binding to the request you expected.

A passing check means the proof bytes were signed by a trusted proof key and have not changed. It does not mean the verdict is correct.

Inputs

You need the envelope entries, trusted proof keys, the trusted terminal head, and your expected verification_id / submission_hash. If a holder hands you an envelope, treat every authority field inside it as untrusted until independently checked.

GET /v1/verifications/{id}/proof/envelope     (Authorization: Bearer <your API key>)

{
  "verification_id": "...",
  "format": "acretix-verify/proof/2",
  "signing_domain": "acretix-verify/proof-sig/2:",
  "jwks_uri": "/.well-known/acretix-verify/keys.json",
  "proof_head_hash": "<server-served terminal content_hash>",
  "entries": [
    {
      "seq": 0,
      "key_id": "ed25519-...",
      "algo": "ed25519",
      "format": "acretix-verify/proof/2",
      "prev_hash": null,
      "content_hash": "<sha256 hex>",
      "signature": "<base64 Ed25519>",
      "envelope": "<the exact canonical JSON bytes that were hashed>"
    }
  ]
}
GET /.well-known/acretix-verify/keys.json     (public, no auth)

{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "<base64url public key>", "kid": "ed25519-...", "use": "sig", "alg": "EdDSA" } ] }

Anti-forgery rules

Required checks

  1. Sort entries by seq and require contiguous order from 0.
  2. Recompute SHA-256 over the exact entry.envelope bytes.
  3. Require entry.content_hash to equal the recomputed hash.
  4. Parse the signed envelope and require format acretix-verify/proof/2 and algo ed25519.
  5. Bind side-channel key_id, algo, format, seq, and prev_hash to the signed body.
  6. Verify Ed25519 over acretix-verify/proof-sig/2: plus the recomputed content hash.
  7. Verify chain continuity: every prev_hash equals the previous recomputed content hash.
  8. Require the final recomputed content hash to equal the trusted server-served proof_head_hash.
  9. Require signed verification_id and submission_hash to match the request you expected.

Node crypto sketch

This sketch uses only Node built-ins. The JWKS argument must be fetched from your configured Acretix Verify origin or another pinned key source, and the expected head must come from a trusted side channel.

import { readFileSync } from 'node:fs';
import { createHash, createPublicKey, verify } from 'node:crypto';

const FORMAT = 'acretix-verify/proof/2';
const DOMAIN = 'acretix-verify/proof-sig/2:';
const [proofPath, jwksPath, expectedHead, expectedVerificationId, expectedSubmissionHash] = process.argv.slice(2);

if (!proofPath || !jwksPath || !expectedHead || !expectedVerificationId || !expectedSubmissionHash) {
  throw new Error('usage: node verify-proof.mjs envelope.json trusted-jwks.json expected-head verification-id submission-hash');
}

// The JWKS file must come from your pinned/configured trust root, not envelope.jwks_uri.
// expectedHead must come from the server-served trusted proof_head_hash or your own pinned receipt,
// not from a holder-supplied envelope.
const proof = JSON.parse(readFileSync(proofPath, 'utf8'));
const jwks = JSON.parse(readFileSync(jwksPath, 'utf8'));
const entries = [...proof.entries].sort((a, b) => a.seq - b.seq);

let expectedPrev = null;
let terminalHash = null;

for (let index = 0; index < entries.length; index += 1) {
  const entry = entries[index];
  const hash = createHash('sha256').update(entry.envelope, 'utf8').digest('hex');
  if (hash !== entry.content_hash) throw new Error('content_hash_mismatch');

  const signed = JSON.parse(entry.envelope);
  if (signed.format !== FORMAT || signed.algo !== 'ed25519') throw new Error('unsupported_format');
  if (
    entry.key_id !== signed.key_id ||
    entry.algo !== signed.algo ||
    entry.format !== signed.format ||
    entry.seq !== signed.seq ||
    entry.prev_hash !== signed.prev_hash
  ) {
    throw new Error('envelope_mismatch');
  }

  if (entry.seq !== index || signed.seq !== index || entry.prev_hash !== expectedPrev) {
    throw new Error('chain_invalid');
  }
  if (signed.verification_id !== expectedVerificationId || signed.submission_hash !== expectedSubmissionHash) {
    throw new Error('subject_mismatch');
  }

  const jwk = jwks.keys.find((k) =>
    k.kid === signed.key_id && k.kty === 'OKP' && k.crv === 'Ed25519'
  );
  if (!jwk) throw new Error('unknown_key');

  const key = createPublicKey({ key: jwk, format: 'jwk' });
  const ok = verify(null, Buffer.from(DOMAIN + hash, 'utf8'), key, Buffer.from(entry.signature, 'base64'));
  if (!ok) throw new Error('signature_invalid');

  expectedPrev = hash;
  terminalHash = hash;
}

if (entries.length === 0) throw new Error('empty_chain');
if (terminalHash !== expectedHead) throw new Error('terminal_head_mismatch');

console.log('VERIFIED');

Fail closed on unknown keys, unsupported format, content-hash mismatch, envelope mismatch, broken chain, signature failure, missing trusted terminal head, terminal-head mismatch, or subject mismatch. For the reusable library version, see @trevor-ux/proof-verifier.