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.

129 lines (128 loc) • 6.01 kB
import { signEd25519 } from "../keys.js"; import { verifyChain } from "../verify.js"; import { jsonStringify } from "../intrinsics.js"; import { arrayLength, arrayPush, arrayJoin, isSafeInteger, isArray } from "../intrinsics.js"; import { safeParse } from "../safe-json.js"; import { isSha256Hash, isRfc3339Instant } from "../scan.js"; import { anchorSigningInput } from "./acceptance.js"; export class AnchorError extends Error { errors; constructor(message, errors) { super(message); this.errors = errors; this.name = "AnchorError"; } } function frontierInputErrors(frontier, signer) { const errors = []; if (typeof frontier !== "object" || frontier === null) { return ["frontier must be an object { chain, highestSeq, headHash, ts }"]; } if (typeof frontier.chain !== "string" || frontier.chain.length === 0) { arrayPush(errors, "frontier.chain must be a non-empty string"); } if (typeof frontier.highestSeq !== "number" || !isSafeInteger(frontier.highestSeq) || frontier.highestSeq < 0) { arrayPush(errors, "frontier.highestSeq must be a non-negative safe integer"); } if (typeof frontier.headHash !== "string" || !isSha256Hash(frontier.headHash)) { arrayPush(errors, "frontier.headHash must be sha256:<64 hex>"); } if (typeof frontier.ts !== "string" || !isRfc3339Instant(frontier.ts)) { arrayPush(errors, "frontier.ts must be an RFC 3339 UTC timestamp"); } if (typeof signer !== "object" || signer === null) { arrayPush(errors, "signer must be an object { kid, privateKey }"); } else { if (typeof signer.kid !== "string" || signer.kid.length === 0) arrayPush(errors, "signer.kid must be a non-empty string"); if (typeof signer.privateKey !== "string" || signer.privateKey.length === 0) { arrayPush(errors, "signer.privateKey must be a non-empty base64 PKCS8 string"); } } return errors; } function anchorDraftErrors(a) { const errors = []; if (typeof a.chain !== "string" || a.chain.length === 0) arrayPush(errors, "anchor.chain must be a non-empty string"); if (typeof a.highestSeq !== "number" || !isSafeInteger(a.highestSeq) || a.highestSeq < 0) { arrayPush(errors, "anchor.highestSeq must be a non-negative safe integer"); } if (typeof a.headHash !== "string" || !isSha256Hash(a.headHash)) arrayPush(errors, "anchor.headHash must be sha256:<64 hex>"); if (typeof a.ts !== "string" || !isRfc3339Instant(a.ts)) arrayPush(errors, "anchor.ts must be an RFC 3339 UTC timestamp"); if (a.sig.alg !== "ed25519") arrayPush(errors, 'anchor.sig.alg must be "ed25519"'); if (typeof a.sig.kid !== "string" || a.sig.kid.length === 0) arrayPush(errors, "anchor.sig.kid must be a non-empty string"); if (typeof a.sig.value !== "string" || a.sig.value.length === 0) arrayPush(errors, "anchor.sig.value must be a non-empty string"); return errors; } export function buildAnchor(frontier, signer) { const surface = { chain: frontier?.chain, highestSeq: frontier?.highestSeq, headHash: frontier?.headHash, ts: frontier?.ts, }; const signerKid = signer?.kid; const signerKey = signer?.privateKey; if (typeof frontier !== "object" || frontier === null) { throw new AnchorError("buildAnchor: invalid frontier/signer input: frontier must be an object { chain, highestSeq, headHash, ts }", ["frontier must be an object { chain, highestSeq, headHash, ts }"]); } const inErrors = frontierInputErrors(surface, { kid: signerKid, privateKey: signerKey }); if (inErrors.length > 0) { throw new AnchorError(`buildAnchor: invalid frontier/signer input: ${arrayJoin(inErrors, "; ")}`, inErrors); } const draft = { ...surface, sig: { alg: "ed25519", kid: signerKid, value: "" } }; draft.sig.value = signEd25519(signerKey, anchorSigningInput(surface)); const errors = anchorDraftErrors(draft); if (errors.length > 0) { throw new AnchorError(`buildAnchor: refusing to return a signed anchor that fails the acceptance verifier's structural check: ${arrayJoin(errors, "; ")}`, errors); } return draft; } export function anchorForChainHead(receipts, signer, opts) { if (typeof opts !== "object" || opts === null || typeof opts.ts !== "string") { throw new AnchorError("anchorForChainHead: opts.ts must be an RFC 3339 UTC timestamp string", []); } const ts = opts.ts; let receiptsJson; try { receiptsJson = jsonStringify(receipts); } catch { throw new AnchorError("anchorForChainHead: receipts are not serializable and cannot be anchored", []); } let snap; try { const parsed = safeParse(receiptsJson); if (!isArray(parsed)) throw new Error("not an array"); snap = parsed; } catch { throw new AnchorError("anchorForChainHead: receipts are not a parseable receipt array", []); } const res = verifyChain(receiptsJson); if (res.status !== "VALID" && res.status !== "UNVERIFIED") { throw new AnchorError(`anchorForChainHead: refusing to anchor a chain that does not verify (${res.status}: ${res.reason ?? "no reason"})`, res.reason ? [res.reason] : []); } let head; const wantSeq = res.count - 1; for (let i = 0; i < arrayLength(snap); i++) { const cand = snap[i]; if (cand !== undefined && typeof cand === "object" && cand !== null && typeof cand.chain === "object" && cand.chain !== null && cand.chain.seq === wantSeq) { head = cand; break; } } if (head === undefined) { throw new AnchorError(`anchorForChainHead: could not locate the chain head at seq ${res.count - 1}`, []); } return buildAnchor({ chain: head.scope.chain, highestSeq: head.chain.seq, headHash: head.chain.hash, ts }, signer); }