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.
295 lines (294 loc) • 13.7 kB
JavaScript
import { canonicalize } from "./jcs.js";
import { sha256Prefixed } from "./hash.js";
import { signingMessage } from "./signing.js";
import { receiptHashInput } from "./canonicalize.js";
import { parseDocument } from "./bytes.js";
import { validateReceiptShapeParsed } from "./schema.js";
import { isSha256Hash, isParamsHash, isRfc3339Instant, isHex64 } from "./scan.js";
import { verifyChain } from "./verify.js";
import { resolveVerificationKey } from "./verification-keyring.js";
import { verifyEd25519 } from "./keys.js";
import { frozenTable } from "./inert.js";
import { arrayIncludes, arrayJoin, hasOwn, isArray, jsonStringify, objectCreateNull, objectGetOwnPropertyNames, strCodePointCount, strTrim } from "./intrinsics.js";
export const ACTION_DIGEST_SPEC = "noa.action-digest/0.1";
export const ACTION_DIGEST_DOMAIN = "NOA-ActionDigest-v0.1-dig";
const GRANT_SPEC = "noa.execution-grant/0.1";
const GRANT_KEYS = frozenTable([
"spec",
"grantId",
"holdId",
"paramsHash",
"holdEnvelopeHash",
"approvalReceiptHash",
"issuedAt",
"expiresAt",
"maxUses",
"nonce",
"sig",
]);
const GRANT_SIG_KEYS = frozenTable(["alg", "kid", "value"]);
const GRANT_SIG_DOMAIN = "NOA-ExecGrant-v0.1-sig";
const AUTHORIZING_VERDICT = "ALLOWED";
function fail(reason) {
return { ok: false, reason };
}
function isObject(v) {
return typeof v === "object" && v !== null && !isArray(v);
}
function boundedString(v, max) {
if (typeof v !== "string")
return false;
if (strTrim(v).length === 0)
return false;
return strCodePointCount(v) <= max;
}
function scopeIdentifier(v, max) {
if (typeof v !== "string" || v.length === 0)
return "absent";
if (strTrim(v).length === 0)
return "blank";
if (strTrim(v) !== v)
return "padded";
if (strCodePointCount(v) > max)
return "too-long";
return "ok";
}
function scopeReason(field, verdict, max) {
if (verdict === "absent")
return `${field}: absent or not a string`;
if (verdict === "blank") {
return `${field}: blank — whitespace is not an identifier, and a digest whose scope is the semantic "unknown" is replayable across tenants`;
}
if (verdict === "padded") {
return `${field}: has leading or trailing whitespace — it would alias to a different scope anywhere that trims`;
}
return `${field}: longer than ${max} code points`;
}
function checkGrant(grant) {
if (!isObject(grant))
return fail("grant: not a JSON object");
const keys = objectGetOwnPropertyNames(grant);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!arrayIncludes(GRANT_KEYS, k)) {
return fail(`grant: unknown property "${k}" (the grant schema is closed; an extra field moves executionGrantHash)`);
}
}
for (let i = 0; i < GRANT_KEYS.length; i++) {
const k = GRANT_KEYS[i];
if (!hasOwn(grant, k))
return fail(`grant: missing required property "${k}"`);
}
if (grant["spec"] !== GRANT_SPEC)
return fail(`grant.spec: must be "${GRANT_SPEC}"`);
if (!boundedString(grant["grantId"], 128))
return fail("grant.grantId: non-empty string ≤128 chars");
if (!boundedString(grant["holdId"], 128))
return fail("grant.holdId: non-empty string ≤128 chars");
if (typeof grant["paramsHash"] !== "string" || !isParamsHash(grant["paramsHash"])) {
return fail("grant.paramsHash: must be (sha256|hmac-sha256):<64 hex>");
}
if (typeof grant["holdEnvelopeHash"] !== "string" || !isSha256Hash(grant["holdEnvelopeHash"])) {
return fail("grant.holdEnvelopeHash: must be sha256:<64 hex>");
}
if (typeof grant["approvalReceiptHash"] !== "string" || !isSha256Hash(grant["approvalReceiptHash"])) {
return fail("grant.approvalReceiptHash: must be sha256:<64 hex>");
}
if (typeof grant["issuedAt"] !== "string" || !isRfc3339Instant(grant["issuedAt"])) {
return fail("grant.issuedAt: must be an RFC 3339 timestamp");
}
if (typeof grant["expiresAt"] !== "string" || !isRfc3339Instant(grant["expiresAt"])) {
return fail("grant.expiresAt: must be an RFC 3339 timestamp");
}
if (grant["maxUses"] !== 1)
return fail("grant.maxUses: must be exactly 1 (the digest binds a single-use grant)");
if (!isHex64(grant["nonce"]))
return fail("grant.nonce: must be exactly 64 lowercase hex characters (32 bytes — the D7 correlation seed)");
const sig = grant["sig"];
if (!isObject(sig))
return fail("grant.sig: not an object");
const sigKeys = objectGetOwnPropertyNames(sig);
for (let i = 0; i < sigKeys.length; i++) {
const k = sigKeys[i];
if (!arrayIncludes(GRANT_SIG_KEYS, k)) {
return fail(`grant.sig: unknown property "${k}" (the sig object is closed; an extra field moves executionGrantHash)`);
}
}
if (sig["alg"] !== "ed25519")
return fail('grant.sig.alg: must be "ed25519"');
if (!boundedString(sig["kid"], 128))
return fail("grant.sig.kid: non-empty string ≤128 chars");
if (!boundedString(sig["value"], 512))
return fail("grant.sig.value: non-empty string ≤512 chars");
return { ok: true, value: grant };
}
export function buildActionDigest(receiptBytes, grantBytes) {
const parsedReceipt = parseDocument(receiptBytes, "receipt");
if (!parsedReceipt.ok)
return fail(parsedReceipt.reason);
const parsedGrant = parseDocument(grantBytes, "grant");
if (!parsedGrant.ok)
return fail(parsedGrant.reason);
const shape = validateReceiptShapeParsed(parsedReceipt.value);
if (!shape.ok)
return fail(`receipt: ${arrayJoin(shape.errors, "; ")}`);
const receipt = parsedReceipt.value;
const grantCheck = checkGrant(parsedGrant.value);
if (!grantCheck.ok)
return fail(grantCheck.reason);
const grant = grantCheck.value;
const scope = receipt["scope"];
const action = receipt["action"];
const chainField = receipt["chain"];
const tenantVerdict = scopeIdentifier(scope["tenant"], 128);
if (tenantVerdict !== "ok")
return fail(scopeReason("receipt.scope.tenant", tenantVerdict, 128));
const tenant = scope["tenant"];
const chainVerdict = scopeIdentifier(scope["chain"], 128);
if (chainVerdict !== "ok")
return fail(scopeReason("receipt.scope.chain", chainVerdict, 128));
const chain = scope["chain"];
const governance = receipt["governance"];
if (governance["verdict"] !== AUTHORIZING_VERDICT) {
return fail(`receipt.governance.verdict: is ${jsonStringify(governance["verdict"])}, must be "${AUTHORIZING_VERDICT}" — ` +
"a grant descends from an ALLOWED decision, and a signature proves who wrote a decision, never which decision they wrote");
}
const recomputed = sha256Prefixed(receiptHashInput(parsedReceipt.value));
const committed = chainField["hash"];
if (recomputed !== committed) {
return fail(`receipt.chain.hash: recomputed ${recomputed} does not equal the committed ${jsonStringify(committed)} ` +
"(the receipt body was altered after it was hashed)");
}
if (grant["approvalReceiptHash"] !== recomputed) {
return fail(`grant.approvalReceiptHash: ${jsonStringify(grant["approvalReceiptHash"])} does not reference this receipt (${recomputed})`);
}
if (grant["paramsHash"] !== action["paramsHash"]) {
return fail(`grant.paramsHash ${jsonStringify(grant["paramsHash"])} does not equal receipt.action.paramsHash ${jsonStringify(action["paramsHash"])}`);
}
const projection = {
spec: ACTION_DIGEST_SPEC,
authorizationReceiptHash: recomputed,
tenant,
chain,
actionId: action["id"],
actionCanonical: action["canonical"],
actionParamsHash: action["paramsHash"],
executionGrantId: grant["grantId"],
executionGrantHash: sha256Prefixed(canonicalize(grant)),
executionNonce: grant["nonce"],
};
return { ok: true, digest: sha256Prefixed(signingMessage(ACTION_DIGEST_DOMAIN, canonicalize(projection))), projection };
}
export function verifyActionDigest(claimBytes, contextBytes) {
const parsedClaim = parseDocument(claimBytes, "claim");
if (!parsedClaim.ok)
return fail(parsedClaim.reason);
const parsedCtx = parseDocument(contextBytes, "context");
if (!parsedCtx.ok)
return fail(parsedCtx.reason);
const claim = parsedClaim.value;
if (!isObject(claim))
return fail("claim: not a JSON object");
const claimKeys = objectGetOwnPropertyNames(claim);
if (claimKeys.length !== 2 || !hasOwn(claim, "spec") || !hasOwn(claim, "digest")) {
return fail(`claim: must carry exactly {spec, digest} (got [${arrayJoin(claimKeys, ",")}])`);
}
if (claim["spec"] !== ACTION_DIGEST_SPEC) {
return fail(`claim.spec: must be "${ACTION_DIGEST_SPEC}" (got ${jsonStringify(claim["spec"])})`);
}
const claimed = claim["digest"];
if (typeof claimed !== "string" || !isSha256Hash(claimed)) {
return fail(`claim.digest: must be sha256:<64 lowercase hex> (got ${jsonStringify(claimed)})`);
}
const ctx = parsedCtx.value;
if (!isObject(ctx))
return fail("context: not a JSON object");
const chainDocs = ctx["chain"];
if (!isArray(chainDocs) || chainDocs.length === 0) {
return fail("context.chain: absent or empty — supply the receipt chain containing the authorization");
}
if (!hasOwn(ctx, "grant"))
return fail("context.grant: absent");
if (!hasOwn(ctx, "keyring")) {
return fail("context.keyring: absent — this verifier authenticates its own inputs and cannot do so without a trust root");
}
const expect = ctx["expect"];
if (!isObject(expect)) {
return fail("context.expect: absent — a verifier that cannot state its own tenant and chain cannot detect a replay");
}
const expTenant = scopeIdentifier(expect["tenant"], 128);
if (expTenant !== "ok")
return fail(scopeReason("context.expect.tenant", expTenant, 128));
const expChain = scopeIdentifier(expect["chain"], 128);
if (expChain !== "ok")
return fail(scopeReason("context.expect.chain", expChain, 128));
const keyringBytes = jsonStringify(ctx["keyring"]);
const chainVerdict = verifyChain(jsonStringify(chainDocs), {
keyring: keyringBytes,
...(hasOwn(ctx, "identityManifest") ? { identityManifest: jsonStringify(ctx["identityManifest"]) } : {}),
});
if (chainVerdict.status !== "VALID" || chainVerdict.signaturesVerified !== true) {
return fail(`context.chain: not authentic — verifyChain returned ${chainVerdict.status}` +
`${chainVerdict.reason ? ` (${chainVerdict.reason})` : ""}, signaturesVerified=${chainVerdict.signaturesVerified}`);
}
const grantDoc = ctx["grant"];
if (!isObject(grantDoc))
return fail("context.grant: not a JSON object");
const grantSig = grantDoc["sig"];
if (!isObject(grantSig) || typeof grantSig["kid"] !== "string" || typeof grantSig["value"] !== "string") {
return fail("context.grant.sig: missing kid/value");
}
const grantKey = resolveVerificationKey(keyringBytes, grantSig["kid"]);
if (!grantKey.ok)
return fail(`context.grant: ${grantKey.reason}`);
const grantWithoutSig = objectCreateNull();
const grantNames = objectGetOwnPropertyNames(grantDoc);
for (let i = 0; i < grantNames.length; i++) {
const k = grantNames[i];
if (k !== "sig")
grantWithoutSig[k] = grantDoc[k];
}
if (!verifyEd25519(grantKey.publicKey, signingMessage(GRANT_SIG_DOMAIN, canonicalize(grantWithoutSig)), grantSig["value"])) {
return fail(`context.grant: invalid signature (kid ${grantSig["kid"]})`);
}
const wanted = grantDoc["approvalReceiptHash"];
let authorization = undefined;
let matches = 0;
for (let i = 0; i < chainDocs.length; i++) {
const candidate = chainDocs[i];
if (!isObject(candidate))
continue;
const shapeOk = validateReceiptShapeParsed(candidate);
if (!shapeOk.ok)
continue;
if (sha256Prefixed(receiptHashInput(candidate)) === wanted) {
matches++;
authorization = candidate;
}
}
if (matches === 0) {
return fail(`context.chain: contains no receipt matching grant.approvalReceiptHash ${jsonStringify(wanted)} — ` +
"the grant does not descend from any authorization in the supplied chain");
}
if (matches > 1) {
return fail(`context.chain: ${matches} receipts match grant.approvalReceiptHash — the authorization is ambiguous`);
}
const built = buildActionDigest(jsonStringify(authorization), jsonStringify(grantDoc));
if (!built.ok)
return fail(built.reason);
if (built.projection.tenant !== expect["tenant"]) {
return fail(`tenant mismatch: the documents are for ${jsonStringify(built.projection.tenant)}, the verifier expects ${jsonStringify(expect["tenant"])}`);
}
if (built.projection.chain !== expect["chain"]) {
return fail(`chain mismatch: the documents are for ${jsonStringify(built.projection.chain)}, the verifier expects ${jsonStringify(expect["chain"])}`);
}
if (built.digest !== claimed) {
return fail(`action digest mismatch: claimed ${claimed}, recomputed ${built.digest}`);
}
return {
ok: true,
digest: built.digest,
projection: built.projection,
classification: "ACTION_DIGEST_LINKAGE_MATCHED",
};
}