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.

119 lines (118 loc) • 5.16 kB
import { RECEIPT_SPEC } from "./types.js"; import { receiptHashInput, checkpointHashInput } from "./canonicalize.js"; import { sha256Hex } from "./hash.js"; import { signEd25519 } from "./keys.js"; import { signingMessage, RECEIPT_SIG_DOMAIN, CHECKPOINT_SIG_DOMAIN } from "./signing.js"; import { validateReceiptShapeParsed } from "./schema.js"; import { nonNfcPaths, isNFC } from "./nfc.js"; import { isSha256Hash, isRfc3339Instant } from "./scan.js"; import { arrayPush, structuredCloneValue } from "./intrinsics.js"; export class BuilderError extends Error { errors; constructor(message, errors) { super(message); this.errors = errors; this.name = "BuilderError"; } } function buildDraft(input, prev, kid) { let cloned; try { cloned = structuredCloneValue({ id: input.id, ts: input.ts, scope: input.scope, agent: input.agent, action: input.action, governance: input.governance, }); } catch (e) { throw new BuilderError(`buildReceipt: input is not structured-cloneable (${e.message})`, []); } const nonNfc = [...nonNfcPaths(cloned), ...(isNFC(kid) ? [] : ["sig.kid"])]; if (nonNfc.length > 0) { throw new BuilderError(`buildReceipt: refusing to sign a payload with non-NFC strings (the profile requires producers to emit Unicode NFC): ${nonNfc.join(", ")}`, nonNfc); } const seq = prev ? prev.chain.seq + 1 : 0; const prevHash = prev ? prev.chain.hash : null; const draft = { spec: RECEIPT_SPEC, id: cloned.id, ts: cloned.ts, scope: cloned.scope, agent: cloned.agent, action: cloned.action, governance: cloned.governance, chain: { seq, prevHash, hash: "" }, sig: { alg: "ed25519", kid, value: "" }, }; const hashInput = receiptHashInput(draft); draft.chain.hash = "sha256:" + sha256Hex(hashInput); return { draft, hashInput }; } function finalizeReceipt(draft) { const shape = validateReceiptShapeParsed(draft); if (!shape.ok) { throw new BuilderError(`buildReceipt: refusing to return a signed receipt that fails its own verifier's structural check: ${shape.errors.join("; ")}`, shape.errors); } return draft; } export function buildReceipt(input, prev, signer) { const { draft, hashInput } = buildDraft(input, prev, signer.kid); draft.sig.value = signEd25519(signer.privateKey, signingMessage(RECEIPT_SIG_DOMAIN, hashInput)); return finalizeReceipt(draft); } function isRemoteSigner(signer) { return typeof signer.sign === "function"; } export async function buildReceiptAsync(input, prev, signer) { const { draft, hashInput } = buildDraft(input, prev, signer.kid); const message = signingMessage(RECEIPT_SIG_DOMAIN, hashInput); draft.sig.value = isRemoteSigner(signer) ? await signer.sign(message) : signEd25519(signer.privateKey, message); return finalizeReceipt(draft); } function checkpointDraftErrors(cp) { const errors = []; if (cp.spec !== "noa.checkpoint/0.1") arrayPush(errors, 'checkpoint.spec: must be "noa.checkpoint/0.1"'); if (typeof cp.chain !== "string" || cp.chain.length === 0) arrayPush(errors, "checkpoint.chain: non-empty string"); if (typeof cp.highestSeq !== "number" || !Number.isSafeInteger(cp.highestSeq) || cp.highestSeq < 0) arrayPush(errors, "checkpoint.highestSeq: non-negative safe integer"); if (typeof cp.headHash !== "string" || !isSha256Hash(cp.headHash)) arrayPush(errors, "checkpoint.headHash: sha256:<64 hex>"); if (typeof cp.ts !== "string" || !isRfc3339Instant(cp.ts)) arrayPush(errors, "checkpoint.ts: must be RFC 3339 UTC timestamp"); if (cp.sig.alg !== "ed25519") arrayPush(errors, 'checkpoint.sig.alg: must be "ed25519"'); if (typeof cp.sig.kid !== "string" || cp.sig.kid.length === 0) arrayPush(errors, "checkpoint.sig.kid: non-empty string"); if (typeof cp.sig.value !== "string" || cp.sig.value.length === 0) arrayPush(errors, "checkpoint.sig.value: non-empty string"); return errors; } export function buildCheckpoint(head, ts, signer) { let headSnap; try { headSnap = structuredCloneValue({ chain: head.scope.chain, seq: head.chain.seq, hash: head.chain.hash }); } catch (e) { throw new BuilderError(`buildCheckpoint: head is not structured-cloneable (${e.message})`, []); } const draft = { spec: "noa.checkpoint/0.1", chain: headSnap.chain, highestSeq: headSnap.seq, headHash: headSnap.hash, ts, sig: { alg: "ed25519", kid: signer.kid, value: "" }, }; const hashInput = checkpointHashInput(draft); draft.sig.value = signEd25519(signer.privateKey, signingMessage(CHECKPOINT_SIG_DOMAIN, hashInput)); const errors = checkpointDraftErrors(draft); if (errors.length > 0) { throw new BuilderError(`buildCheckpoint: refusing to return a signed checkpoint that fails its own verifier's structural check: ${errors.join("; ")}`, errors); } return draft; }