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.

295 lines (294 loc) • 13.7 kB
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", }; }