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