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