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.

223 lines (222 loc) • 11.2 kB
import { verifyEd25519 } from "../keys.js"; import { canonicalize } from "../jcs.js"; import { signingMessage } from "../signing.js"; import { parseDocument } from "../bytes.js"; import { isSha256Hash, isRfc3339Instant } from "../scan.js"; import { inertOptions } from "../opts.js"; import { arrayLength, isArray, isFiniteNumber, isSafeInteger, mapGet, mapHas, mapSet, newMap, newSet, mapValuesToArray, dateParse, setAdd, setHas, isNaNValue } from "../intrinsics.js"; export const ANCHOR_SIG_DOMAIN = "NOA-Federation-Anchor-v0.1-sig"; export function anchorSigningInput(a) { const jcs = canonicalize({ chain: a.chain, highestSeq: a.highestSeq, headHash: a.headHash, ts: a.ts, }); return signingMessage(ANCHOR_SIG_DOMAIN, jcs); } const B64_SPKI_MIN = 1; const SNAPSHOT_NOTE = "snapshot check: complete:true means a quorum of pinned witnesses confirmed this head AS OF the supplied " + "anchor snapshot; full non-deletion additionally requires the §10 live frontier-query layer"; function r(complete, classification, reason, confirmations = 0, stale = 0, freshnessEnforced = false) { return { complete, scope: "snapshot", classification, note: SNAPSHOT_NOTE, reason, confirmations, stale, freshnessEnforced }; } function parseAnchorTsMs(ts) { if (!isRfc3339Instant(ts)) return null; const ms = dateParse(ts); return isNaNValue(ms) ? null : ms; } function isContradiction(c) { return c === "beyond" || c === "divergent"; } export function verifyCompleteness(headBytes, anchorsBytes, trustSetBytes, opts = {}) { const hParsed = parseDocument(headBytes, "head"); if (!hParsed.ok) return r(false, "INVALID_INPUT", hParsed.reason); const aParsed = parseDocument(anchorsBytes, "anchors"); if (!aParsed.ok) return r(false, "INVALID_INPUT", aParsed.reason); const tParsed = parseDocument(trustSetBytes, "trustSet"); if (!tParsed.ok) return r(false, "INVALID_INPUT", tParsed.reason); const admitted = inertOptions(COMPLETENESS_OPTION_SCHEMA, opts, "options"); if (!admitted.ok) return r(false, "INVALID_INPUT", admitted.reason); let freshness; if (admitted.value.freshness !== undefined) { const f = admitted.value.freshness; if (f.now === undefined || f.maxAgeMs === undefined) { return r(false, "INVALID_INPUT", "opts.freshness must be an object { now, maxAgeMs, skewMs? }"); } freshness = f; } return verifyCompletenessParsed(hParsed.value, aParsed.value, tParsed.value, freshness === undefined ? {} : { freshness }); } const FRESHNESS_OPTION_SCHEMA = Object.freeze(Object.assign(Object.create(null), { now: { kind: "count", max: Number.MAX_SAFE_INTEGER }, maxAgeMs: { kind: "count", max: Number.MAX_SAFE_INTEGER }, skewMs: { kind: "count", max: Number.MAX_SAFE_INTEGER }, })); const COMPLETENESS_OPTION_SCHEMA = Object.freeze(Object.assign(Object.create(null), { freshness: { kind: "nested", schema: FRESHNESS_OPTION_SCHEMA }, })); export function verifyCompletenessParsed(head, anchors, trustSet, opts = {}) { if (typeof head !== "object" || head === null) return r(false, "INVALID_INPUT", "head is not an object"); if (typeof head.chain !== "string" || head.chain.length === 0) { return r(false, "INVALID_INPUT", "head.chain must be a non-empty string"); } if (typeof head.seq !== "number" || !isSafeInteger(head.seq) || head.seq < 0) { return r(false, "INVALID_INPUT", "head.seq must be a non-negative safe integer"); } if (typeof head.hash !== "string" || !isSha256Hash(head.hash)) { return r(false, "INVALID_INPUT", "head.hash must be sha256:<64-hex>"); } const fresh = (typeof opts === "object" && opts !== null) ? opts.freshness : undefined; const freshnessEnforced = fresh !== undefined; let windowMin = -Infinity; let windowMax = Infinity; if (freshnessEnforced) { if (typeof fresh !== "object" || fresh === null) return r(false, "INVALID_INPUT", "opts.freshness must be an object { now, maxAgeMs, skewMs? }"); if (typeof fresh.now !== "number" || !isFiniteNumber(fresh.now)) return r(false, "INVALID_INPUT", "opts.freshness.now must be a finite epoch-ms number"); if (typeof fresh.maxAgeMs !== "number" || !isFiniteNumber(fresh.maxAgeMs) || fresh.maxAgeMs < 0) return r(false, "INVALID_INPUT", "opts.freshness.maxAgeMs must be a non-negative number"); const skewMs = fresh.skewMs ?? 0; if (typeof skewMs !== "number" || !isFiniteNumber(skewMs) || skewMs < 0) return r(false, "INVALID_INPUT", "opts.freshness.skewMs must be a non-negative number"); windowMin = fresh.now - fresh.maxAgeMs; windowMax = fresh.now + skewMs; } if (typeof trustSet !== "object" || trustSet === null) return r(false, "INVALID_INPUT", "trustSet is not an object"); if (!isArray(trustSet.witnesses)) return r(false, "INVALID_INPUT", "trustSet.witnesses must be an array"); const k = arrayLength(trustSet.witnesses); if (k < 2) return r(false, "INVALID_INPUT", `trustSet must pin k >= 2 witnesses (got ${k})`); const q = trustSet.quorum; if (typeof q !== "number" || !isSafeInteger(q)) return r(false, "INVALID_INPUT", "trustSet.quorum must be an integer"); if (q <= 1) return r(false, "INVALID_INPUT", `quorum must be > 1 (got ${q}); a single witness is not a quorum`); if (q > k) return r(false, "INVALID_INPUT", `quorum q=${q} exceeds pinned witness count k=${k} (unsatisfiable)`); const pinned = newMap(); const seenPubkeys = newSet(); const witnesses = trustSet.witnesses; for (let wi = 0; wi < arrayLength(witnesses); wi++) { const w = witnesses[wi]; if (typeof w !== "object" || w === null) return r(false, "INVALID_INPUT", "trustSet.witnesses[] entry is not an object"); if (typeof w.kid !== "string" || w.kid.length === 0) return r(false, "INVALID_INPUT", "witness.kid must be a non-empty string"); if (typeof w.pubkey !== "string" || w.pubkey.length < B64_SPKI_MIN) { return r(false, "INVALID_INPUT", `witness "${w.kid}" pubkey must be a non-empty base64 SPKI string`); } if (mapHas(pinned, w.kid)) return r(false, "INVALID_INPUT", `trustSet pins duplicate witness kid "${w.kid}" (witnesses must be distinct)`); if (setHas(seenPubkeys, w.pubkey)) return r(false, "INVALID_INPUT", `trustSet pins the same witness pubkey under two kids (witnesses must be distinct KEYS, not just distinct ids)`); mapSet(pinned, w.kid, w.pubkey); setAdd(seenPubkeys, w.pubkey); } if (!isArray(anchors)) return r(false, "INVALID_INPUT", "anchors must be an array"); const witnessClass = newMap(); for (let ai = 0; ai < arrayLength(anchors); ai++) { const a = anchors[ai]; if (typeof a !== "object" || a === null) continue; if (typeof a.chain !== "string") continue; if (typeof a.highestSeq !== "number" || !isSafeInteger(a.highestSeq) || a.highestSeq < 0) continue; if (typeof a.headHash !== "string" || !isSha256Hash(a.headHash)) continue; if (typeof a.ts !== "string" || a.ts.length === 0) continue; const sig = a.sig; if (typeof sig !== "object" || sig === null) continue; if (sig.alg !== "ed25519") continue; if (typeof sig.kid !== "string" || sig.kid.length === 0) continue; if (typeof sig.value !== "string" || sig.value.length === 0) continue; const pub = mapGet(pinned, sig.kid); if (pub === undefined) continue; if (a.chain !== head.chain) continue; const ok = verifyEd25519(pub, anchorSigningInput(a), sig.value); if (!ok) continue; let cls; if (a.highestSeq > head.seq) { cls = "beyond"; } else if (a.highestSeq === head.seq) { if (a.headHash === head.hash) { if (freshnessEnforced) { const tsMs = parseAnchorTsMs(a.ts); cls = (tsMs !== null && tsMs >= windowMin && tsMs <= windowMax) ? "confirm" : "stale"; } else { cls = "confirm"; } } else { cls = "divergent"; } } else { continue; } const prior = mapGet(witnessClass, sig.kid); if (prior === undefined) { mapSet(witnessClass, sig.kid, cls); } else if (prior === cls) { } else if (isContradiction(prior) || isContradiction(cls)) { mapSet(witnessClass, sig.kid, "divergent"); } else { mapSet(witnessClass, sig.kid, prior === "confirm" || cls === "confirm" ? "confirm" : "stale"); } } let confirm = 0; let beyond = 0; let divergent = 0; let stale = 0; const classes = mapValuesToArray(witnessClass); for (let ci = 0; ci < arrayLength(classes); ci++) { const cls = classes[ci]; if (cls === "confirm") confirm++; else if (cls === "beyond") beyond++; else if (cls === "stale") stale++; else divergent++; } const fnote = freshnessEnforced ? "" : " (freshness NOT enforced — currency is the caller's burden, §10)"; if (divergent > 0) { return r(false, "FORK", `FORK: ${divergent} pinned witness(es) reported a frontier on chain "${head.chain}" that is not the presented head at seq ${head.seq} (divergent history — needs gossip to attribute; fail-closed)`, confirm, stale, freshnessEnforced); } if (beyond > 0) { return r(false, "TRUNCATED", `TRUNCATED: ${beyond} reachable pinned witness(es) anchored a frontier extending PAST the presented head (seq ${head.seq}) — records beyond H were anchored and then withheld`, confirm, stale, freshnessEnforced); } if (confirm >= q) { return r(true, "QUORUM_CONFIRMED", `QUORUM_CONFIRMED (snapshot): ${confirm} of ${k} pinned witnesses (quorum ${q}) validly anchored exactly the presented head at seq ${head.seq} within the freshness window, and no reachable witness saw past it${fnote}`, confirm, stale, freshnessEnforced); } if (freshnessEnforced && confirm + stale >= q) { return r(false, "STALE", `STALE: only ${confirm} FRESH confirmation(s) (quorum q=${q}); ${stale} further pinned witness(es) confirmed H but outside the freshness window — head not provably current (fail-closed)`, confirm, stale, freshnessEnforced); } return r(false, "NOT_ESTABLISHED", `NOT_ESTABLISHED: only ${confirm} distinct fresh pinned-witness confirmation(s), quorum q=${q} not met (fail-closed: silence/unreachable witnesses are 'unknown', never 'no contradiction')${fnote}`, confirm, stale, freshnessEnforced); }