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