UNPKG

noa-receipt

Version:

NOA Agent Action Receipt — open, offline-verifiable provenance for AI-agent actions. The governance/receipt organ only; the NOA brain is separate and proprietary.

352 lines (351 loc) • 17.5 kB
import { validateReceiptShapeParsed } from "./schema.js"; import { receiptHashInput, checkpointHashInput } from "./canonicalize.js"; import { sha256Hex } from "./hash.js"; import { verifyEd25519 } from "./keys.js"; import { signingMessage, RECEIPT_SIG_DOMAIN, CHECKPOINT_SIG_DOMAIN } from "./signing.js"; import { nonNfcPaths, isNFC } from "./nfc.js"; import { parseDocument } from "./bytes.js"; import { inertOptions } from "./opts.js"; import { arrayPush, arrayIncludes, arrayEvery, arrayLength, arrayJoin, publishArray, dateParse, mapHas, mapGet, mapSet, newMap, newSet, objectKeys, objectGetOwnPropertyNames, isSafeInteger, arraySlice, setAdd, setSize, isArray, isNaNValue, jsonStringify } from "./intrinsics.js"; import { isSha256Hash, isRfc3339Instant } from "./scan.js"; import { frozenTable } from "./inert.js"; import { parseVerificationKeyring } from "./verification-keyring.js"; export const DEFAULT_MAX_RECEIPTS = 1_000_000; function fail(status, reason, chain, count, badSeq) { const r = { status, chain, count, signaturesVerified: false, tailChecked: false, reason, warnings: [] }; if (badSeq !== undefined) r.badSeq = badSeq; return r; } function describeTenant(t) { return t === undefined ? "(none)" : jsonStringify(t); } export function verifyChain(receipts, opts = {}) { const admitted = inertOptions(VERIFY_OPTION_SCHEMA, opts, "options"); if (!admitted.ok) return fail("MALFORMED", admitted.reason, null, 0); const o = admitted.value; const parsed = parseDocument(receipts, "receipts"); if (!parsed.ok) return fail("MALFORMED", parsed.reason, null, 0); return verifyParsedChain(parsed.value, o); } const VERIFY_OPTION_SCHEMA = Object.freeze(Object.assign(Object.create(null), { keyring: { kind: "document" }, checkpoint: { kind: "document" }, identityManifest: { kind: "document" }, maxReceipts: { kind: "count", max: DEFAULT_MAX_RECEIPTS }, requireTenantConsistency: { kind: "boolean" }, requireNFC: { kind: "boolean" }, })); function verifyParsedChain(receipts, o) { const maxReceipts = o.maxReceipts ?? DEFAULT_MAX_RECEIPTS; if (!isArray(receipts)) return fail("MALFORMED", "input is not an array of receipts", null, 0); const n = receipts.length; if (n === 0) return fail("MALFORMED", "empty receipt array", null, 0); if (n > maxReceipts) return fail("MALFORMED", `too many receipts (>${maxReceipts})`, null, n); const receiptsSnap = receipts; let checkpointSnap; if (o.checkpoint !== undefined) { const cpParsed = parseDocument(o.checkpoint, "checkpoint"); if (!cpParsed.ok) return fail("MALFORMED", cpParsed.reason, null, n); checkpointSnap = cpParsed.value; } const haveManifest = o.identityManifest !== undefined; const manifest = newMap(); let list; let chainId; let ordered; const tenantDriftMessages = []; try { if (haveManifest) { const mParsed = parseDocument(o.identityManifest, "identityManifest"); if (!mParsed.ok) return fail("MALFORMED", mParsed.reason, null, n); const live = mParsed.value; if (typeof live !== "object" || live === null || isArray(live)) { return fail("MALFORMED", "identityManifest must be an object (agent.id -> kid[])", null, 0); } const aids = objectGetOwnPropertyNames(live); for (let ai = 0; ai < aids.length; ai++) { const aid = aids[ai]; const kidsLive = live[aid]; if (!isArray(kidsLive)) { return fail("MALFORMED", `identityManifest["${aid}"] must be an array of kid strings`, null, 0); } const kids = arraySlice(kidsLive); if (!arrayEvery(kids, (k) => typeof k === "string")) { return fail("MALFORMED", `identityManifest["${aid}"] must be an array of kid strings`, null, 0); } mapSet(manifest, aid, kids); } } for (let idx = 0; idx < receiptsSnap.length; idx++) { const res = validateReceiptShapeParsed(receiptsSnap[idx]); if (!res.ok) { return fail("MALFORMED", `receipt[${idx}]: ${arrayJoin(res.errors, "; ")}`, null, receiptsSnap.length, idx); } } list = receiptsSnap; chainId = list[0].scope.chain; for (let i = 0; i < list.length; i++) { const r = list[i]; if (r.scope.chain !== chainId) { return fail("TAMPERED", "multiple chain partitions in one input", chainId, list.length); } } const bySeq = newMap(); for (let i = 0; i < list.length; i++) { const r = list[i]; if (mapHas(bySeq, r.chain.seq)) return fail("TAMPERED", `duplicate seq ${r.chain.seq}`, chainId, list.length, r.chain.seq); mapSet(bySeq, r.chain.seq, r); } ordered = []; for (let s = 0; s < list.length; s++) { const r = mapGet(bySeq, s); if (!r) return fail("TAMPERED", `seq gap: missing seq ${s}`, chainId, list.length, s); arrayPush(ordered, r); } let lastPresentTenant; let lastPresentSeq = -1; for (let i = 0; i < ordered.length; i++) { const curR = ordered[i]; const curT = curR.scope.tenant; if (i > 0) { const prevR = ordered[i - 1]; if (curT !== prevR.scope.tenant) { arrayPush(tenantDriftMessages, `tenant-drift: seq ${prevR.chain.seq} ${describeTenant(prevR.scope.tenant)} -> seq ${curR.chain.seq} ${describeTenant(curT)}`); } } if (curT === undefined) continue; if (lastPresentTenant !== undefined && curT !== lastPresentTenant) { const msg = `tenant-drift: seq ${lastPresentSeq} ${describeTenant(lastPresentTenant)} -> seq ${curR.chain.seq} ${describeTenant(curT)}`; if (o.requireTenantConsistency ?? true) { return fail("TAMPERED", msg, chainId, list.length, curR.chain.seq); } if (!arrayIncludes(tenantDriftMessages, msg)) arrayPush(tenantDriftMessages, msg); } lastPresentTenant = curT; lastPresentSeq = curR.chain.seq; } } catch { return fail("MALFORMED", "input object threw during validation/ordering", null, n); } const haveKeyring = o.keyring !== undefined; let keyring = {}; let verification; if (haveKeyring) { const kParsed = parseVerificationKeyring(o.keyring, "keyring"); if (!kParsed.ok) return fail("MALFORMED", kParsed.reason, chainId, list.length); verification = kParsed.value; keyring = verification.keyring; } const warnings = publishArray(arraySlice(tenantDriftMessages)); const pinnedKid = newMap(); let prev = null; try { for (let oi = 0; oi < ordered.length; oi++) { const r = ordered[oi]; const seq = r.chain.seq; let hashInput; try { hashInput = receiptHashInput(r); } catch { return fail("MALFORMED", "receipt contains non-canonicalizable content", chainId, list.length, seq); } const recomputed = "sha256:" + sha256Hex(hashInput); if (recomputed !== r.chain.hash) { return fail("TAMPERED", "hash mismatch (content altered)", chainId, list.length, seq); } const nonNfc = nonNfcPaths({ id: r.id, ts: r.ts, scope: r.scope, agent: r.agent, action: r.action, governance: r.governance, }); if (!isNFC(r.sig.kid)) arrayPush(nonNfc, "sig.kid"); if (arrayLength(nonNfc) > 0) { if (o.requireNFC) { return fail("MALFORMED", `non-NFC string(s) at seq ${seq}: ${arrayJoin(nonNfc, ", ")}`, chainId, list.length, seq); } const wn = arrayLength(nonNfc); for (let wi = 0; wi < wn; wi++) arrayPush(warnings, `non-nfc: seq ${seq} field ${nonNfc[wi]}`); } const pinned = mapGet(pinnedKid, r.agent.id); if (pinned === undefined) mapSet(pinnedKid, r.agent.id, r.sig.kid); else if (pinned !== r.sig.kid) { return fail("TAMPERED", `key swap for agent "${r.agent.id}" (kid ${pinned} -> ${r.sig.kid})`, chainId, list.length, seq); } if (haveKeyring) { if (verification.retiredKids[r.sig.kid] === true) { return fail("TAMPERED", `signing key "${r.sig.kid}" is retired; signer-chosen receipt time is not an independent witness`, chainId, list.length, seq); } const pub = keyring[r.sig.kid]; if (!pub) return fail("TAMPERED", `unknown signing key "${r.sig.kid}" not in keyring`, chainId, list.length, seq); const ok = verifyEd25519(pub, signingMessage(RECEIPT_SIG_DOMAIN, hashInput), r.sig.value); if (!ok) return fail("TAMPERED", `invalid signature (kid ${r.sig.kid})`, chainId, list.length, seq); } if (haveKeyring && haveManifest) { const allowed = mapGet(manifest, r.agent.id); if (allowed === undefined || !arrayIncludes(allowed, r.sig.kid)) { return fail("UNTRUSTED", `agent "${r.agent.id}" is not authorized for signing key "${r.sig.kid}" (identity manifest)`, chainId, list.length, seq); } } if (seq === 0) { if (r.chain.prevHash !== null) return fail("TAMPERED", "genesis prevHash must be null", chainId, list.length, 0); } else if (r.chain.prevHash !== prev.chain.hash) { return fail("TAMPERED", `broken linkage at seq ${seq}`, chainId, list.length, seq); } if (prev) { const a = dateParse(prev.ts); const b = dateParse(r.ts); if (!isNaNValue(a) && !isNaNValue(b) && b < a) { arrayPush(warnings, `non-monotonic timestamp at seq ${seq} (ts went backwards)`); } } prev = r; } } catch { return fail("MALFORMED", "receipt object threw during chain walk", chainId, list.length); } const head = ordered[ordered.length - 1]; let tailChecked = false; if (checkpointSnap !== undefined) { if (typeof checkpointSnap !== "object" || checkpointSnap === null || isArray(checkpointSnap)) { return fail("MALFORMED", "checkpoint must be an object", chainId, list.length); } const cp = checkpointSnap; const cpVerify = verifyCheckpointParsed(cp, verification); if (cpVerify === "bad spec" || cpVerify === "malformed checkpoint") { return fail("TAMPERED", `checkpoint invalid: ${cpVerify}`, chainId, list.length); } if (haveKeyring && cpVerify !== "ok") { if (cpVerify === "retired signing key") { return fail("TAMPERED", `checkpoint signing key "${cp.sig.kid}" is retired; signer-chosen checkpoint time is not an independent witness`, chainId, list.length, head.chain.seq); } return fail("TAMPERED", `checkpoint not authenticated against keyring (${cpVerify})`, chainId, list.length); } if (cp.chain !== chainId) return fail("TAMPERED", "checkpoint chain mismatch", chainId, list.length); if (cp.highestSeq !== head.chain.seq || cp.headHash !== head.chain.hash) { return fail("TAMPERED", "chain head does not match checkpoint (tail truncated/extended)", chainId, list.length, head.chain.seq); } if (haveKeyring && haveManifest) { const genesis = ordered[0]; const allowed = mapGet(manifest, genesis.agent.id); if (allowed === undefined || !arrayIncludes(allowed, cp.sig.kid)) { return fail("UNTRUSTED", `checkpoint signing key "${cp.sig.kid}" is not authorized for chain opener (genesis) agent "${genesis.agent.id}" (identity manifest)`, chainId, list.length, head.chain.seq); } const distinctAgents = newSet(); for (let ai = 0; ai < ordered.length; ai++) setAdd(distinctAgents, ordered[ai].agent.id); if (setSize(distinctAgents) > 1) { arrayPush(warnings, "checkpoint completeness is opener-scoped: the chain has more than one agent.id, and a co-agent's tail is NOT separately certified by the opener's checkpoint (the opener dropping a co-agent's tail needs the v1.0 external anchor)"); } } tailChecked = cpVerify === "ok"; if (cpVerify !== "ok") { arrayPush(warnings, "checkpoint present but not authenticated (no keyring) — tail NOT verified"); } else if (!haveManifest) { arrayPush(warnings, "checkpoint authenticated but no identityManifest supplied: the tail check is KID-LEVEL — any keyring-trusted key can mint a checkpoint over any head, so a co-trusted key holder can truncate the tail and still produce tailChecked:true (supply an identityManifest to bind checkpoint authority to the chain opener)"); } } else { arrayPush(warnings, "no checkpoint supplied: tail-truncation (deleting most-recent receipts) cannot be detected offline"); } arrayPush(warnings, "fork/equivocation is not detectable offline: this verifies the branch you were given, not that the signer signed no other history at the same seq (needs an external witness — v1.0)"); if (!haveKeyring) { arrayPush(warnings, "no keyring supplied: signatures were NOT authenticated (status UNVERIFIED, not VALID)"); } if (!haveManifest) { arrayPush(warnings, "no identityManifest supplied: attribution is kid-level — a VALID result proves a keyring-trusted key signed, NOT which agent.id (cross-agent impersonation undefended in a multi-key keyring)"); } else if (!haveKeyring) { arrayPush(warnings, "identityManifest supplied but no keyring: identity NOT bound — signatures are unauthenticated, so the (agent.id, kid) pairing was not enforced (status stays UNVERIFIED, never UNTRUSTED)"); } const status = haveKeyring ? "VALID" : "UNVERIFIED"; return { status, chain: chainId, count: list.length, signaturesVerified: haveKeyring, tailChecked, warnings }; } export function verifyChainText(text, opts = {}) { return verifyChain(text, opts); } const CHECKPOINT_KEYS = frozenTable(["spec", "chain", "highestSeq", "headHash", "ts", "sig"]); export function verifyCheckpoint(cp, keyring) { const cpParsed = parseDocument(cp, "checkpoint"); if (!cpParsed.ok) return "malformed checkpoint"; let verification; if (keyring !== undefined) { const kParsed = parseVerificationKeyring(keyring, "keyring"); if (!kParsed.ok) return "malformed checkpoint"; verification = kParsed.value; } return verifyCheckpointParsed(cpParsed.value, verification); } function verifyCheckpointParsed(cp, verification) { const snap = cp; const c = snap; if (typeof c !== "object" || c === null || isArray(c)) return "malformed checkpoint"; const cKeys = objectKeys(c); for (let i = 0; i < cKeys.length; i++) { if (!arrayIncludes(CHECKPOINT_KEYS, cKeys[i])) return "malformed checkpoint"; } if (c.spec !== "noa.checkpoint/0.1") return "bad spec"; if (typeof c.chain !== "string" || c.chain.length === 0) return "malformed checkpoint"; if (typeof c.highestSeq !== "number" || !isSafeInteger(c.highestSeq) || c.highestSeq < 0) return "malformed checkpoint"; if (typeof c.headHash !== "string" || !isSha256Hash(c.headHash)) return "malformed checkpoint"; if (typeof c.ts !== "string" || !isRfc3339Instant(c.ts)) return "malformed checkpoint"; const sig = c.sig; if (!sig || typeof sig !== "object" || isArray(sig)) return "malformed checkpoint"; const sigKeys = objectKeys(sig); for (let i = 0; i < sigKeys.length; i++) { const k = sigKeys[i]; if (k !== "alg" && k !== "kid" && k !== "value") return "malformed checkpoint"; } if (sig.alg !== "ed25519") return "malformed checkpoint"; if (typeof sig.kid !== "string" || sig.kid.length === 0 || typeof sig.value !== "string" || sig.value.length === 0) { return "malformed checkpoint"; } if (verification?.retiredKids[sig.kid] === true) return "retired signing key"; const pub = verification?.keyring[sig.kid]; if (!pub) return "unverified"; let msg; try { msg = signingMessage(CHECKPOINT_SIG_DOMAIN, checkpointHashInput(snap)); } catch { return "malformed checkpoint"; } const ok = verifyEd25519(pub, msg, sig.value); return ok ? "ok" : "bad checkpoint signature"; }