Third-Party Verification Guide
How to verify an AgnCred work receipt yourself, offline, without trusting AgnCred's database or API. Everything you need is in one public response:
GET /api/v1/receipts/:id/verification-bundle
The bundle (bundle_version: "agncred-verification-bundle.v2"; v1 bundles you saved earlier keep verifying under the v1 rules) contains the exact immutable canonical payload and its hash, the attestation (decision, approved public fields, signature, signing key ID), the full sequenced event chain, the company's complete public key history, the server's own verification report (which you are about to reproduce), and formula instructions. v2 bundles for build-identified receipts (work-receipt.v0.4) additionally carry registered_manifests (every manifest hash registered per version, with its registration time) and lineage_events (the per-lineage registry chain, hash-linked from genesis exactly like the receipt chain).
Five independent checks (the fifth applies to v0.4 receipts only), and two separate facts to read at the end:
1. Verify the payload hash
Canonicalization is RFC 8785 (JCS): object keys sorted recursively by UTF-16 code units, ECMAScript number formatting, undefined members dropped. Compliance vectors live in contracts/test-vectors/jcs-vectors.json — your serializer must reproduce every expected string byte-for-byte.
work-receipt.v0.3andwork-receipt.v0.4:payload_hash=sha256:+ SHA-256 over the JCS serialization of the entirecanonical_payload.work-receipt.v0.2(legacy): the hash is over the canonical payload minus itssignatureblock.
2. Verify the attestation signature
The signature is Ed25519. Find the public key in signing_keys by the attestation's signing_key_id.
Scheme
agncred-attestation.v1: the signed message is the JCS serialization of exactly this object (key order in JCS is alphabetical regardless of how you build it):{ "scheme": "agncred-attestation.v1", "receipt_id": "<receipt.receipt_id>", "receipt_version": <attestation.receipt_version>, "payload_hash": "<version.payload_hash>", "company_id": "<attestation.company_id>", "decision": "<attestation.decision>", "approved_public_fields": <attestation.approved_public_fields or {}>, "attested_at": "<attestation.attested_at>" }Verify
attestation.signature(hex) over the UTF-8 bytes of that string. Because the message binds receipt ID, version, payload hash, company, decision, and approved fields, the signature cannot be replayed onto different content. Cross-check:attestation.signed_payload_hashequalssha256:+ SHA-256 of the same message.Scheme
agncred-attestation.v2: identical to v1 with two additional members inside the signed object, in JCS order:confirmed_dimensions(object of dimension name to reviewer-confirmed value,{}when none) andcomplexity_band("S" | "M" | "L" | "XL"ornull). Read both values fromattestation.approved_public_fields.confirmed_dimensionsand.complexity_band; the signature binds them, so a tampered dimension fails verification.Scheme
receipt.v0.2(legacy): the signature lives inside the payload itself (canonical_payload.signature.signature_value), and the signed message is the JCS serialization of the canonical payload minus itssignatureblock.
3. Verify the event chain
Events are per-receipt, sequenced, and hash-chained; the chain starts at the literal string genesis.
receipt-event.v0.2: each event carriessequence_number(must equal its 1-based position) andprevious_event_hash(must equal the previous event'sevent_hash, orgenesisfor the first).event_hash=sha256:+ SHA-256 over the JCS serialization of the canonical event with its ownevent_hashremoved —previous_event_hashandsequence_numberare inside the hashed content, so any edit, reorder, or fork breaks every subsequent hash.receipt-event.v0.1(legacy, pre-migration):event_hash=sha256:+ SHA-256 overJCS(event minus event_hash) + "\n" + <previous event's hash, or "genesis">, andprevious_event_idmust reference the previous event. Mixed chains occur for migrated receipts: the first v0.2 event links to the last legacy event's hash.
4. Verify the manifest binding (v0.4 receipts, VF-1)
Build-identified receipts carry manifest_hash and version_ref inside the canonical payload, so the payload-hash check (step 1) already covers their integrity. The new fact to establish is that the manifest was registered against that version before the run started:
- Find an entry in
registered_manifestswith the payload'sversion_refandmanifest_hash, and checkregistered_at <= canonical_payload.started_at. - Verify the
lineage_eventschain exactly like the receipt chain (per-lineagesequence_numberfrom 1,previous_event_hashfromgenesis,event_hash=sha256:+ SHA-256 over the JCS canonical event minus its ownevent_hash), and confirm aversion_registeredorrevision_registeredevent whosemetadata.manifest_hashequals the payload'smanifest_hashsits on that valid chain at or beforestarted_at.
A v0.2 or v0.3 receipt has no manifest binding; this check reports not applicable.
5. Check key status and lifecycle status — separately
These are two different facts. Do not conflate them:
- Historical signature validity (steps 1–3): was this exact version signed by this key? A disputed or revoked receipt keeps a valid historical signature — that is by design.
- Current state:
receipt.current_status(accepted,disputed,revoked, ...),receipt.privacy_erased, and the signing key'sstatus(active/retired/revoked, with timestamps and revocation reason).retiredmeans routine rotation — normal.revokedmeans the key was invalidated (possibly compromise); weigh signatures from beforerevoked_atunder your own policy.
Runnable verifier
The script below uses only node:crypto for hashing and @noble/curves for Ed25519 (the same library and major version AgnCred signs with — v2 renamed its module paths, so pin v1):
npm install @noble/curves@1
node verify-receipt.mjs https://<agncred-host>/api/v1/receipts/<receipt-id>/verification-bundle
# or against a saved bundle:
node verify-receipt.mjs ./bundle.json
// verify-receipt.mjs — offline verifier for agncred-verification-bundle.v1 and .v2
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { ed25519 } from "@noble/curves/ed25519";
// RFC 8785 (JCS): keys sorted by UTF-16 code units, undefined dropped,
// ECMAScript number formatting (JSON.stringify provides both string
// escaping and number formatting per RFC 8785).
function jcs(value) {
if (typeof value === "number" && !Number.isFinite(value)) throw new Error("non-finite number");
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map(jcs).join(",")}]`;
const entries = Object.entries(value)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${jcs(v)}`).join(",")}}`;
}
const sha256 = (s) => "sha256:" + createHash("sha256").update(s, "utf8").digest("hex");
const hex = (s) => Uint8Array.from(Buffer.from(s, "hex"));
const utf8 = (s) => new TextEncoder().encode(s);
const results = [];
const check = (name, ok, detail = "") => results.push({ name, ok, detail });
// Bundle events arrive in the stored camelCase shape; hashing uses canonical snake_case.
function canonicalEvent(e) {
const strip = (obj) => JSON.parse(JSON.stringify(obj)); // drops undefined members
if (e.sequenceNumber !== undefined) {
return strip({
schema_version: e.schemaVersion, event_id: e.eventId, receipt_id: e.receiptId,
sequence_number: e.sequenceNumber, previous_event_hash: e.previousEventHash,
event_type: e.eventType, actor_type: e.actorType, actor_id: e.actorId,
created_at: e.createdAt, event_hash: e.eventHash, metadata: e.metadata
});
}
return strip({
schema_version: e.schemaVersion, event_id: e.eventId, receipt_id: e.receiptId,
previous_event_id: e.previousEventId, event_type: e.eventType, actor_type: e.actorType,
actor_id: e.actorId, created_at: e.createdAt, event_hash: e.eventHash, metadata: e.metadata
});
}
const source = process.argv[2];
if (!source) { console.error("usage: node verify-receipt.mjs <bundle-url-or-file>"); process.exit(2); }
const bundle = source.startsWith("http")
? await (await fetch(source)).json()
: JSON.parse(readFileSync(source, "utf8"));
// --- 1. payload hash ---------------------------------------------------------
const { version, attestation, events, signing_keys: keys, receipt } = bundle;
const payloadMessage = version.schema_version === "work-receipt.v0.3"
? jcs(version.canonical_payload)
: jcs((({ signature, ...rest }) => rest)(version.canonical_payload));
check("payload hash matches canonical payload", sha256(payloadMessage) === version.payload_hash);
// --- 2. attestation signature --------------------------------------------------
if (attestation) {
const key = keys.find((k) => k.key_id === attestation.signing_key_id);
check("signing key present in key history", Boolean(key), attestation.signing_key_id);
if (key) {
let ok = false;
if (attestation.signature_scheme === "agncred-attestation.v1" || attestation.signature_scheme === "agncred-attestation.v2") {
const base = {
scheme: attestation.signature_scheme,
receipt_id: receipt.receipt_id,
receipt_version: attestation.receipt_version,
payload_hash: version.payload_hash,
company_id: attestation.company_id,
decision: attestation.decision,
approved_public_fields: attestation.approved_public_fields ?? {},
attested_at: attestation.attested_at
};
const message = attestation.signature_scheme === "agncred-attestation.v2"
? jcs({
...base,
confirmed_dimensions: attestation.approved_public_fields?.confirmed_dimensions ?? {},
complexity_band: attestation.approved_public_fields?.complexity_band ?? null
})
: jcs(base);
ok = ed25519.verify(hex(attestation.signature), utf8(message), hex(key.public_key));
check("signed_payload_hash matches message", sha256(message) === attestation.signed_payload_hash);
} else if (attestation.signature_scheme === "receipt.v0.2") {
const sig = version.canonical_payload.signature?.signature_value;
ok = Boolean(sig) && ed25519.verify(hex(sig), utf8(payloadMessage), hex(key.public_key));
}
check(`attestation signature valid (${attestation.signature_scheme})`, ok);
check("key status (separate fact)", true, `${key.status}${key.revoked_at ? `, revoked_at ${key.revoked_at}` : ""}`);
}
} else {
check("attestation present", false, "receipt carries no company attestation — it proves nothing yet");
}
// --- 3. event chain -------------------------------------------------------------
let previousHash = "genesis";
let previousLegacy = null;
let chainOk = true;
events.forEach((stored, index) => {
const e = canonicalEvent(stored);
const { event_hash: recorded, ...unhashed } = e;
if (e.schema_version === "receipt-event.v0.1") {
if (e.previous_event_id !== (previousLegacy?.event_id ?? undefined)) chainOk = false;
if (recorded !== sha256(`${jcs(unhashed)}\n${previousLegacy?.event_hash ?? "genesis"}`)) chainOk = false;
previousLegacy = e;
} else {
if (e.sequence_number !== index + 1) chainOk = false;
if (e.previous_event_hash !== previousHash) chainOk = false;
if (recorded !== sha256(jcs(unhashed))) chainOk = false;
previousLegacy = null;
}
previousHash = e.event_hash;
});
check(`event chain valid (${events.length} events)`, chainOk);
// --- 4. manifest binding (VF-1, v0.4 receipts in v2 bundles) ---------------------
const payload = version.canonical_payload;
if (payload.schema_version === "work-receipt.v0.4") {
const registrations = bundle.registered_manifests ?? [];
const entry = registrations.find(
(item) => item.version_ref === payload.version_ref && item.manifest_hash === payload.manifest_hash
);
check("manifest registered against version_ref", Boolean(entry));
if (entry) {
check(
"manifest registered before the run started",
Date.parse(entry.registered_at) <= Date.parse(payload.started_at),
`${entry.registered_at} vs ${payload.started_at}`
);
}
const lineageEvents = bundle.lineage_events ?? [];
let lineagePrevious = "genesis";
let lineageChainOk = lineageEvents.length > 0;
lineageEvents.forEach((event, index) => {
const { event_hash: recorded, ...unhashed } = event;
if (event.sequence_number !== index + 1) lineageChainOk = false;
if (event.previous_event_hash !== lineagePrevious) lineageChainOk = false;
if (recorded !== sha256(jcs(unhashed))) lineageChainOk = false;
lineagePrevious = event.event_hash;
});
check(`lineage chain valid (${lineageEvents.length} events)`, lineageChainOk);
const registeredEvent = lineageEvents.find(
(event) =>
(event.event_type === "version_registered" || event.event_type === "revision_registered") &&
event.metadata?.manifest_hash === payload.manifest_hash
);
check(
"registration event on the lineage chain at or before started_at",
Boolean(registeredEvent) && Date.parse(registeredEvent.created_at) <= Date.parse(payload.started_at)
);
} else {
check("manifest binding (separate fact)", true, "not applicable before work-receipt.v0.4");
}
// --- 5. current state (separate facts) -------------------------------------------
check("lifecycle status (separate fact)", true,
`${receipt.current_status}, version ${receipt.current_version}, privacy_erased=${receipt.privacy_erased}`);
// --- report ----------------------------------------------------------------------
let failed = 0;
for (const r of results) {
if (!r.ok) failed += 1;
console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name}${r.detail ? ` — ${r.detail}` : ""}`);
}
console.log(failed === 0 ? "\nVERIFIED" : `\nNOT VERIFIED (${failed} failed)`);
process.exit(failed === 0 ? 0 : 1);
Reading the result
VERIFIED means: this exact payload existed, this company's key signed this exact version with this exact decision and these exact public fields, and the lifecycle history has not been reordered or forked. It does not by itself mean the receipt is currently in good standing — read the separate facts: a disputed status or a revoked key changes what you should conclude, without changing what was historically signed.