diff --git a/API.md b/API.md index b20830ad..5b0c655f 100644 --- a/API.md +++ b/API.md @@ -74,9 +74,12 @@ const receipt = executeAccountableIntent(intent, { ``` ### `verifyExecutionReceipt(receipt)` -Recomputes receipt integrity and verifies bound runtime artifacts. A receipt -missing its audit stream or attention ledger, or carrying a non-array audit, -cannot verify even if its outer hash is recalculated. +Recomputes receipt integrity and verifies bound runtime artifacts. The audit +stream must contain five ordered events whose timestamps, types, and payloads +agree with the receipt's intent, profile, AML, simulations, selected decision, +attention ledger, and output hashes. `audit_receipt_binding_valid` reports this +semantic check. A missing, malformed, or contradictory stream cannot verify +even if its chain and outer receipt hash are recalculated. ### `signExecutionReceipt(receipt, privateKeyPem, options?)` Adds an Ed25519 attestation to a valid receipt. Rejects private keys of other algorithm types. diff --git a/compiler/accountablePipeline.js b/compiler/accountablePipeline.js index f8a8a15d..ddefae5d 100644 --- a/compiler/accountablePipeline.js +++ b/compiler/accountablePipeline.js @@ -2,6 +2,7 @@ // ĀML v1.3 — end-to-end accountable AI intent execution pipeline. import crypto from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; import { generateAMLFromIntent } from "./intentCompiler.js"; import { compileSource } from "./compiler.js"; import { simulatePolicies } from "./policySimulator.js"; @@ -50,6 +51,31 @@ function resolveInitialAttentionBudget(context) { return null; } +function verifyExecutionAuditBinding(receipt) { + const stream = receipt.runtime_audit_stream; + const entries = stream?.entries; + if (stream?.version !== "1.0" || stream.created_at !== receipt.timestamp || + !Array.isArray(entries) || entries.length !== 5) return false; + + const expected = [ + ["intent_received", { intent_sha256: receipt.intent_sha256, profile_id: receipt.profile?.id }], + ["aml_generated", { aml_sha256: receipt.aml_sha256 }], + ["policy_simulated", { simulation_sha256: receipt.simulation_sha256, policies: receipt.profile?.policies }], + ["policy_selected", { + policy_id: receipt.selected_render?.policy_id, + decision_sha256: receipt.decision_sha256, + attention_ledger_remaining: receipt.attention_ledger?.remaining, + attention_ledger_consumed: receipt.attention_ledger?.consumed + }], + ["output_rendered", { output_sha256: receipt.output_sha256 }] + ]; + return expected.every(([type, payload], index) => + Object.values(payload).every(value => value !== undefined) && + entries[index]?.timestamp === receipt.timestamp && + entries[index]?.event_type === type && + isDeepStrictEqual(entries[index]?.payload, payload)); +} + export function executeAccountableIntent(intent, options = {}) { const timestamp = options.timestamp ?? new Date().toISOString(); const profile = resolvePolicyProfile(options.profile || "calm_default"); @@ -153,7 +179,8 @@ export function verifyExecutionReceipt(receipt) { } } - const bindingsValid = intentHashValid && amlHashValid && simulationHashValid && decisionHashValid && outputHashValid && selectedRenderCountsValid && auditHashValid && audit.verified && runtimeAuditFlagValid && ledgerHashValid && ledgerStructureValid; + const auditReceiptBindingValid = audit.verified && verifyExecutionAuditBinding(receipt); + const bindingsValid = intentHashValid && amlHashValid && simulationHashValid && decisionHashValid && outputHashValid && selectedRenderCountsValid && auditHashValid && auditReceiptBindingValid && runtimeAuditFlagValid && ledgerHashValid && ledgerStructureValid; return { verified: receiptHashValid && bindingsValid, receipt_hash_valid: receiptHashValid, @@ -164,7 +191,8 @@ export function verifyExecutionReceipt(receipt) { decision_binding_valid: decisionHashValid, output_binding_valid: outputHashValid, selected_render_counts_valid: selectedRenderCountsValid, - audit_stream_valid: audit.verified && auditHashValid && runtimeAuditFlagValid, + audit_stream_valid: audit.verified && auditHashValid && auditReceiptBindingValid && runtimeAuditFlagValid, + audit_receipt_binding_valid: auditReceiptBindingValid, runtime_audit_flag_valid: runtimeAuditFlagValid, attention_ledger_valid: ledgerHashValid && ledgerStructureValid, attention_ledger_hash_valid: ledgerHashValid, diff --git a/docs/ACCOUNTABLE_AI_PIPELINE.md b/docs/ACCOUNTABLE_AI_PIPELINE.md index c7cd74fa..3c42b171 100644 --- a/docs/ACCOUNTABLE_AI_PIPELINE.md +++ b/docs/ACCOUNTABLE_AI_PIPELINE.md @@ -74,7 +74,7 @@ That makes policy differences inspectable instead of silently embedding them in ## Receipt integrity -`receipt_sha256` covers the unsigned execution receipt. A mutation to intent, context, simulations, decisions, AML source, or output causes `verifyExecutionReceipt()` to fail. Verification also requires the runtime audit stream and attention ledger and checks their hashes and structure. Recalculating an outer receipt hash cannot make missing evidence or a non-array audit pass. +`receipt_sha256` covers the unsigned execution receipt. A mutation to intent, context, simulations, decisions, AML source, or output causes `verifyExecutionReceipt()` to fail. Verification requires the runtime audit stream and attention ledger and checks their hashes and structure. It also checks that the five ordered audit events match the receipt's timestamp, intent and profile, generated AML, simulations, selected policy and decisions, ledger balances, and output. `audit_receipt_binding_valid` identifies that check. Recalculating the audit chain, outer receipt hash, or signature cannot make contradictory or missing evidence pass verification. The receipt can additionally be signed with Ed25519, providing a detached identity/attestation layer on top of content integrity. Signing rejects RSA and EC private keys, and verification requires an actual Ed25519 public key and one canonical 64-byte Ed25519 signature encoding. A trusted fingerprint still has to come from outside the receipt. diff --git a/docs/EVIDENCE_CAPSULE.md b/docs/EVIDENCE_CAPSULE.md index 0d4d49bb..60532c79 100644 --- a/docs/EVIDENCE_CAPSULE.md +++ b/docs/EVIDENCE_CAPSULE.md @@ -15,14 +15,14 @@ node scripts/evidence-capsule.mjs verify capsule.json Verification returns `verified: true` and exits 0 only when both capsule digests, the receipt's internal bindings, a present Ed25519 signature, and the derived summary all validate. A failed verification exits 1. An unsupported protocol, canonicalization, field, or malformed value fails closed. -A separate project-authored Python verifier checks capsules on the JSON subset for which its serializer reproduces the exact capsule digests. It also checks the receipt hash, intent/output/decision bindings, audit chain, ledger, counts, and derived summary. For signed v1.1 receipts it verifies Ed25519 and requires a separately supplied trusted receipt-key fingerprint; install `cryptography>=42,<51`: +A separate project-authored Python verifier checks capsules on the JSON subset for which its serializer reproduces the exact capsule digests. It also checks the receipt hash, intent/output/decision bindings, audit chain, the five audit events against their receipt fields (`audit_binding`), ledger, counts, and derived summary. For signed v1.1 receipts it verifies Ed25519 and requires a separately supplied trusted receipt-key fingerprint; install `cryptography>=42,<51`: ```bash python3 independent/python/verify_evidence_capsule.py capsule.json python3 independent/python/verify_evidence_capsule.py signed-capsule.json trusted-policy.json ``` -Unsigned capsules need one argument. Signed v1.1 capsules require a second JSON file with `trusted_receipt_fingerprints` (an array of SHA-256 SPKI DER fingerprints) and optional `revoked_receipt_fingerprints`. The embedded key is not a trust source. Legacy v1.0 signatures are unsupported in Python because their signer and signed time were not bound. The two implementations and their mutation cases are exercised with `node scripts/check-evidence-capsule-python.mjs` on CI, which publishes a JSON result artifact. Signed receipts with small and large exponents, boundary integers, the maximum finite JavaScript number, array-index-like keys, UTF-16-ordered keys, and escaped lone surrogates are checked there. A locally assembled, JavaScript-valid signed ledger also checks that three charges totaling beyond the safe-integer boundary use binary64 arithmetic. Boolean and negative-zero balance mutations must fail, while a negative-zero sequence spelling that JavaScript accepts must verify. Receipts with a missing audit stream, missing ledger, or non-array audit entries must fail even when a test signer recalculates the receipt signature and capsule digests. RSA and EC signatures mislabeled as Ed25519, noncanonical signature base64, changed numerical values, escaped duplicate keys, and non-finite numbers must fail. This is cross-runtime engineering evidence maintained by the project, not an independent external witness. +Unsigned capsules need one argument. Signed v1.1 capsules require a second JSON file with `trusted_receipt_fingerprints` (an array of SHA-256 SPKI DER fingerprints) and optional `revoked_receipt_fingerprints`. The embedded key is not a trust source. Legacy v1.0 signatures are unsupported in Python because their signer and signed time were not bound. The two implementations and their mutation cases are exercised with `node scripts/check-evidence-capsule-python.mjs` on CI, which publishes a JSON result artifact. Signed receipts with small and large exponents, boundary integers, the maximum finite JavaScript number, array-index-like keys, UTF-16-ordered keys, and escaped lone surrogates are checked there. A locally assembled, JavaScript-valid signed ledger also checks that three charges totaling beyond the safe-integer boundary use binary64 arithmetic. Boolean and negative-zero balance mutations must fail, while a negative-zero sequence spelling that JavaScript accepts must verify. Receipts with a missing audit stream, missing ledger, non-array audit entries, a stale intent event, or an audit event contradicting the ledger must fail even when a test signer recalculates the receipt signature and capsule digests. RSA and EC signatures mislabeled as Ed25519, noncanonical signature base64, changed numerical values, escaped duplicate keys, and non-finite numbers must fail. This is cross-runtime engineering evidence maintained by the project, not an independent external witness. For a saved response from the locked HTTP deployment endpoint, the [HTTP exchange pilot](../pilots/enterprise-http-gate/README.md#recheck-a-real-http-exchange-with-python) additionally checks host-owned expected inputs, nonce, HTTP envelope and effective decision in Python while reusing this signed capsule verifier. The capsule alone does not make those request and response claims. diff --git a/independent/python/verify_evidence_capsule.py b/independent/python/verify_evidence_capsule.py index 41c05133..a28e70ec 100644 --- a/independent/python/verify_evidence_capsule.py +++ b/independent/python/verify_evidence_capsule.py @@ -136,6 +136,29 @@ def js_integer(value): return type(value) is int or (type(value) is float and math.isfinite(value) and value.is_integer()) +def same_number(actual, expected, *, object_is=False): + # JS uses === for consumed values and Object.is for before/after/remaining. + # Python's True == 1 and -0.0 == 0.0 must not silently change that contract. + if actual is None or expected is None: + return actual is None and expected is None + if type(actual) not in (int, float) or type(expected) not in (int, float): + return False + if object_is and actual == expected == 0: + return math.copysign(1, actual) == math.copysign(1, expected) + return actual == expected + + +def same_json_value(actual, expected): + if type(actual) is dict and type(expected) is dict: + return (actual.keys() == expected.keys() and + all(same_json_value(actual[key], expected[key]) for key in actual)) + if type(actual) is list and type(expected) is list: + return len(actual) == len(expected) and all(same_json_value(a, b) for a, b in zip(actual, expected)) + if type(actual) in (int, float) or type(expected) in (int, float): + return same_number(actual, expected, object_is=True) + return type(actual) is type(expected) and actual == expected + + def verify_signature(receipt, policy): if not isinstance(policy, dict): raise ValueError("external_receipt_trust_required") @@ -183,17 +206,6 @@ def valid_ledger(ledger): if budget is not None and (type(budget) not in (int, float) or budget < 0): return False - def same_number(actual, expected, *, object_is=False): - # JS uses === for consumed values and Object.is for before/after/remaining. - # Python's True == 1 and -0.0 == 0.0 must not silently change that contract. - if actual is None or expected is None: - return actual is None and expected is None - if type(actual) not in (int, float) or type(expected) not in (int, float): - return False - if object_is and actual == expected == 0: - return math.copysign(1, actual) == math.copysign(1, expected) - return actual == expected - # JavaScript adds and subtracts binary64 Numbers, even when each input is a # safe integer. Python integer arithmetic would diverge after the sum crosses # 2**53, rejecting an otherwise valid signed receipt. @@ -228,6 +240,28 @@ def valid_audit(stream): return True +def valid_audit_receipt_binding(receipt): + stream = receipt["runtime_audit_stream"] + entries = stream["entries"] + if (stream.get("version") != "1.0" or stream.get("created_at") != receipt["timestamp"] or + not isinstance(entries, list) or len(entries) != 5): + return False + expected = [ + ("intent_received", {"intent_sha256": receipt["intent_sha256"], "profile_id": receipt["profile"]["id"]}), + ("aml_generated", {"aml_sha256": receipt["aml_sha256"]}), + ("policy_simulated", {"simulation_sha256": receipt["simulation_sha256"], + "policies": receipt["profile"]["policies"]}), + ("policy_selected", {"policy_id": receipt["selected_render"]["policy_id"], + "decision_sha256": receipt["decision_sha256"], + "attention_ledger_remaining": receipt["attention_ledger"]["remaining"], + "attention_ledger_consumed": receipt["attention_ledger"]["consumed"]}), + ("output_rendered", {"output_sha256": receipt["output_sha256"]}), + ] + return all(entry.get("timestamp") == receipt["timestamp"] and entry.get("event_type") == event_type and + same_json_value(entry.get("payload"), payload) + for entry, (event_type, payload) in zip(entries, expected)) + + def verify(capsule, policy=None): if set(capsule) != FIELDS or capsule["protocol"] != "aml-evidence-capsule/1" or capsule["canonicalization"] != "aml-sorted-json/1": raise ValueError("unsupported capsule contract") @@ -259,6 +293,7 @@ def verify(capsule, policy=None): and selected["suppressed"] == sum(item["render_allowed"] is not True for item in decisions), "audit": hash_value(receipt["runtime_audit_stream"]) == receipt["audit_stream_sha256"] and valid_audit(receipt["runtime_audit_stream"]) and receipt["runtime_audit_verified"] is True, + "audit_binding": valid_audit_receipt_binding(receipt), "ledger": hash_value(receipt["attention_ledger"]) == receipt["attention_ledger_sha256"] and valid_ledger(receipt["attention_ledger"]), "signature": not signed or verify_signature(receipt, policy), diff --git a/index.d.ts b/index.d.ts index 3af81c8a..4cc7eff2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -108,6 +108,9 @@ export interface ReceiptVerification { verified: boolean; receipt_hash_valid: boolean; bindings_valid: boolean; + audit_stream_valid: boolean; + /** Checks the five ordered audit events against their receipt fields and timestamps. */ + audit_receipt_binding_valid: boolean; expected_sha256: string; receipt_sha256: string; [key: string]: unknown; diff --git a/scripts/check-evidence-capsule-python.mjs b/scripts/check-evidence-capsule-python.mjs index a4139152..fa4be4d0 100644 --- a/scripts/check-evidence-capsule-python.mjs +++ b/scripts/check-evidence-capsule-python.mjs @@ -32,10 +32,29 @@ function hashValue(value) { return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); } +function rehashAudit(receipt) { + let previous = null; + for (const entry of receipt.runtime_audit_stream.entries) { + entry.previous_hash = previous; + const { entry_hash, ...core } = entry; + entry.entry_hash = hashValue(core); + previous = entry.entry_hash; + } + receipt.audit_stream_sha256 = hashValue(receipt.runtime_audit_stream); +} + +function rebindLedgerAudit(receipt) { + const selected = receipt.runtime_audit_stream.entries[3].payload; + selected.attention_ledger_remaining = receipt.attention_ledger.remaining; + selected.attention_ledger_consumed = receipt.attention_ledger.consumed; + rehashAudit(receipt); +} + function receiptWithLedger(base, ledger) { const updated = structuredClone(base); updated.attention_ledger = ledger; updated.attention_ledger_sha256 = hashValue(ledger); + rebindLedgerAudit(updated); const payload = { ...updated }; delete payload.receipt_sha256; updated.receipt_sha256 = hashValue(payload); @@ -55,6 +74,7 @@ function signModifiedReceipt(receipt, privateKey) { function resign(receipt, privateKey) { receipt.attention_ledger_sha256 = hashValue(receipt.attention_ledger); + rebindLedgerAudit(receipt); signModifiedReceipt(receipt, privateKey); } @@ -100,6 +120,25 @@ try { const signedPolicy = { trusted_receipt_fingerprints: [signed.signature.public_key_sha256] }; results.push(check("signed-external-trust-required", signedCapsule, false)); results.push(check("signed-valid", signedCapsule, true, signedPolicy)); + const changedIntent = structuredClone(signedCapsule); + changedIntent.receipt.intent.audit_probe = "different declaration"; + changedIntent.receipt.intent_sha256 = hashValue(changedIntent.receipt.intent); + signModifiedReceipt(changedIntent.receipt, privateKey); + changedIntent.summary.receipt_sha256 = changedIntent.receipt.receipt_sha256; + rehash(changedIntent); + if (verifyEvidenceCapsule(changedIntent).verified) throw new Error("JavaScript accepted a signed receipt with a stale audit intent"); + results.push(check("signed-audit-intent-binding-rejected", changedIntent, false, signedPolicy, + null, { receipt_hash: true, intent: true, audit: true, audit_binding: false, signature: true, summary: true })); + + const changedAudit = structuredClone(signedCapsule); + changedAudit.receipt.runtime_audit_stream.entries[3].payload.attention_ledger_consumed = 999; + rehashAudit(changedAudit.receipt); + signModifiedReceipt(changedAudit.receipt, privateKey); + changedAudit.summary.receipt_sha256 = changedAudit.receipt.receipt_sha256; + rehash(changedAudit); + if (verifyEvidenceCapsule(changedAudit).verified) throw new Error("JavaScript accepted a signed receipt with a conflicting audit event"); + results.push(check("signed-audit-ledger-binding-rejected", changedAudit, false, signedPolicy, + null, { receipt_hash: true, audit: true, audit_binding: false, ledger: true, signature: true, summary: true })); for (const field of ["runtime_audit_stream", "attention_ledger"]) { const omitted = structuredClone(signedCapsule); delete omitted.receipt[field]; diff --git a/test/accountable-pipeline.test.js b/test/accountable-pipeline.test.js index 10c02316..a67ed3aa 100644 --- a/test/accountable-pipeline.test.js +++ b/test/accountable-pipeline.test.js @@ -5,6 +5,7 @@ import crypto from "node:crypto"; import { executeAccountableIntent, verifyExecutionReceipt, + verifyAuditStream, signExecutionReceipt, verifySignedExecutionReceipt, listPolicyProfiles, @@ -48,6 +49,18 @@ function refreshOuterReceiptHash(receipt) { return receipt; } +function refreshAuditAndReceiptHashes(receipt) { + let previous = null; + for (const entry of receipt.runtime_audit_stream.entries) { + entry.previous_hash = previous; + const { entry_hash, ...core } = entry; + entry.entry_hash = sha256(core); + previous = entry.entry_hash; + } + receipt.audit_stream_sha256 = sha256(receipt.runtime_audit_stream); + return refreshOuterReceiptHash(receipt); +} + function freshReceipt() { return executeAccountableIntent(intent, { timestamp, @@ -228,6 +241,55 @@ test("receipt verification rejects a non-array audit even when its hashes are re assert.equal(verification.verified, false); }); +test("receipt verification and signing reject audit events that contradict a re-hashed receipt", () => { + const { privateKey } = crypto.generateKeyPairSync("ed25519"); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }); + for (const [stage, mutate] of [ + ["intent", receipt => { receipt.intent.audit_probe = "different declaration"; receipt.intent_sha256 = sha256(receipt.intent); }], + ["AML", receipt => { receipt.aml_source += "\n// different source"; receipt.aml_sha256 = sha256(receipt.aml_source); }], + ["simulation", receipt => { receipt.simulations.audit_probe = "different simulation"; receipt.simulation_sha256 = sha256(receipt.simulations); }], + ["decision", receipt => { receipt.decision_sha256 = "f".repeat(64); }], + ["output", receipt => { receipt.output_sha256 = "f".repeat(64); }], + ["profile", receipt => { receipt.profile.id = "another_profile"; }], + ["policy list", receipt => { receipt.profile.policies = [...receipt.profile.policies, "another_policy"]; }], + ["selected policy", receipt => { receipt.selected_render.policy_id = "another_policy"; }] + ]) { + const receipt = freshReceipt(); + mutate(receipt); + refreshOuterReceiptHash(receipt); + const verification = verifyExecutionReceipt(receipt); + assert.equal(verification.receipt_hash_valid, true, stage); + assert.equal(verifyAuditStream(receipt.runtime_audit_stream).verified, true, stage); + assert.equal(verification.audit_receipt_binding_valid, false, stage); + assert.equal(verification.audit_stream_valid, false, stage); + assert.equal(verification.verified, false, stage); + assert.throws(() => signExecutionReceipt(receipt, pem), /invalid ĀML execution receipt/, stage); + } +}); + +test("receipt verification rejects a re-hashed audit chain that contradicts its receipt", () => { + for (const [label, mutate] of [ + ["event payload", receipt => { receipt.runtime_audit_stream.entries[3].payload.attention_ledger_consumed = 999; }], + ["event type", receipt => { receipt.runtime_audit_stream.entries[4].event_type = "wrong_stage"; }], + ["event timestamp", receipt => { receipt.runtime_audit_stream.entries[2].timestamp = "2031-01-01T00:00:00.000Z"; }], + ["stream timestamp", receipt => { receipt.runtime_audit_stream.created_at = "2031-01-01T00:00:00.000Z"; }], + ["extra event", receipt => { + const extra = structuredClone(receipt.runtime_audit_stream.entries[4]); + extra.sequence = 5; + receipt.runtime_audit_stream.entries.push(extra); + }] + ]) { + const receipt = freshReceipt(); + mutate(receipt); + refreshAuditAndReceiptHashes(receipt); + assert.equal(verifyAuditStream(receipt.runtime_audit_stream).verified, true, label); + const verification = verifyExecutionReceipt(receipt); + assert.equal(verification.receipt_hash_valid, true, label); + assert.equal(verification.audit_receipt_binding_valid, false, label); + assert.equal(verification.verified, false, label); + } +}); + test("receipt verification rejects a structurally invalid ledger even when its hashes are refreshed", () => { const receipt = freshReceipt(); const entry = receipt.attention_ledger.entries[0];