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
JavaScript
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);
}