diff --git a/API.md b/API.md index 37dd51b2..b20830ad 100644 --- a/API.md +++ b/API.md @@ -74,7 +74,9 @@ const receipt = executeAccountableIntent(intent, { ``` ### `verifyExecutionReceipt(receipt)` -Recomputes receipt integrity and verifies bound runtime artifacts. +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. ### `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 dbbb3339..f8a8a15d 100644 --- a/compiler/accountablePipeline.js +++ b/compiler/accountablePipeline.js @@ -126,9 +126,9 @@ export function verifyExecutionReceipt(receipt) { const outputHashValid = typeof receipt.selected_render?.html === "string" && sha256(receipt.selected_render.html) === receipt.output_sha256; const selectedRenderCountsValid = decisions !== null && Number.isInteger(receipt.selected_render?.allowed) && Number.isInteger(receipt.selected_render?.suppressed) && receipt.selected_render.allowed === decisions.filter(item => item?.render_allowed).length && receipt.selected_render.suppressed === decisions.filter(item => !item?.render_allowed).length; - let audit = { verified: true }; - let auditHashValid = true; - let runtimeAuditFlagValid = true; + let audit = { verified: false }; + let auditHashValid = false; + let runtimeAuditFlagValid = false; if (receipt.runtime_audit_stream) { try { audit = verifyAuditStream(receipt.runtime_audit_stream); @@ -141,8 +141,8 @@ export function verifyExecutionReceipt(receipt) { } } - let ledgerHashValid = true; - let ledgerStructureValid = true; + let ledgerHashValid = false; + let ledgerStructureValid = false; if (receipt.attention_ledger) { try { ledgerHashValid = sha256(receipt.attention_ledger) === receipt.attention_ledger_sha256; diff --git a/docs/ACCOUNTABLE_AI_PIPELINE.md b/docs/ACCOUNTABLE_AI_PIPELINE.md index 70bc6fe1..c7cd74fa 100644 --- a/docs/ACCOUNTABLE_AI_PIPELINE.md +++ b/docs/ACCOUNTABLE_AI_PIPELINE.md @@ -35,6 +35,7 @@ The resulting execution receipt binds together: - counterfactual policy simulations - selected composed-policy decision - final browser output +- hash-chained runtime audit stream and cumulative attention ledger - SHA-256 hashes for each major stage - optional Ed25519 signature @@ -73,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. +`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. 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 e7df938f..0d4d49bb 100644 --- a/docs/EVIDENCE_CAPSULE.md +++ b/docs/EVIDENCE_CAPSULE.md @@ -22,7 +22,7 @@ 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. 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, 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. 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/index.d.ts b/index.d.ts index 3e171801..3af81c8a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -63,9 +63,14 @@ export interface ExecutionReceipt { context: AmlContext; intent_sha256: string; aml_sha256: string; + audit_stream_sha256: string; + attention_ledger_sha256: string; output_sha256: string; receipt_sha256: string; intent: AmlIntent; + runtime_audit_stream: { protocol: "ĀML Runtime Audit Stream"; entries: unknown[]; [key: string]: unknown }; + runtime_audit_verified: boolean; + attention_ledger: { protocol: "ĀML Attention Ledger"; entries: unknown[]; [key: string]: unknown }; selected_render: { policy_id: string; allowed: number; diff --git a/runtime/auditStream.js b/runtime/auditStream.js index e785fa4b..85d5b823 100644 --- a/runtime/auditStream.js +++ b/runtime/auditStream.js @@ -44,6 +44,7 @@ export function appendAuditEvent(stream, event, options = {}) { export function verifyAuditStream(stream) { if (!stream || stream.protocol !== "ĀML Runtime Audit Stream") throw new Error("Invalid ĀML runtime audit stream."); + if (!Array.isArray(stream.entries)) throw new Error("ĀML runtime audit entries must be an array."); const checks = []; let previous = null; for (let i = 0; i < stream.entries.length; i++) { diff --git a/schema/execution-receipt.schema.json b/schema/execution-receipt.schema.json index 303c0510..5f1f4e29 100644 --- a/schema/execution-receipt.schema.json +++ b/schema/execution-receipt.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/aruintelligence/aml-core/blob/main/schema/execution-receipt.schema.json", "title": "ĀML Accountable Execution Receipt", - "description": "Machine-readable contract for intent-to-render accountability receipts emitted by the ĀML v1.2 research pipeline.", + "description": "Machine-readable contract for intent-to-render accountability receipts emitted by the ĀML pipeline. Runtime audit and attention evidence must be present for verification.", "type": "object", "additionalProperties": true, "required": [ @@ -14,11 +14,16 @@ "intent_sha256", "aml_sha256", "simulation_sha256", + "audit_stream_sha256", + "attention_ledger_sha256", "output_sha256", "decision_sha256", "intent", "aml_source", "simulations", + "runtime_audit_stream", + "runtime_audit_verified", + "attention_ledger", "selected_render", "receipt_sha256" ], @@ -39,12 +44,31 @@ "intent_sha256": { "$ref": "#/$defs/sha256" }, "aml_sha256": { "$ref": "#/$defs/sha256" }, "simulation_sha256": { "$ref": "#/$defs/sha256" }, + "audit_stream_sha256": { "$ref": "#/$defs/sha256" }, + "attention_ledger_sha256": { "$ref": "#/$defs/sha256" }, "output_sha256": { "$ref": "#/$defs/sha256" }, "decision_sha256": { "$ref": "#/$defs/sha256" }, "receipt_sha256": { "$ref": "#/$defs/sha256" }, "intent": { "type": "object" }, "aml_source": { "type": "string" }, "simulations": { "type": "object" }, + "runtime_audit_stream": { + "type": "object", + "required": ["protocol", "entries"], + "properties": { + "protocol": { "const": "ĀML Runtime Audit Stream" }, + "entries": { "type": "array" } + } + }, + "runtime_audit_verified": { "type": "boolean" }, + "attention_ledger": { + "type": "object", + "required": ["protocol", "entries"], + "properties": { + "protocol": { "const": "ĀML Attention Ledger" }, + "entries": { "type": "array" } + } + }, "selected_render": { "type": "object", "required": ["policy_id", "allowed", "suppressed", "decisions", "html"], diff --git a/scripts/check-evidence-capsule-python.mjs b/scripts/check-evidence-capsule-python.mjs index 37391774..a4139152 100644 --- a/scripts/check-evidence-capsule-python.mjs +++ b/scripts/check-evidence-capsule-python.mjs @@ -42,8 +42,7 @@ function receiptWithLedger(base, ledger) { return updated; } -function resign(receipt, privateKey) { - receipt.attention_ledger_sha256 = hashValue(receipt.attention_ledger); +function signModifiedReceipt(receipt, privateKey) { const payload = { ...receipt }; delete payload.receipt_sha256; delete payload.signature; @@ -54,6 +53,11 @@ function resign(receipt, privateKey) { signature.signature_base64 = crypto.sign(null, Buffer.from(stableStringify(material)), privateKey).toString("base64"); } +function resign(receipt, privateKey) { + receipt.attention_ledger_sha256 = hashValue(receipt.attention_ledger); + signModifiedReceipt(receipt, privateKey); +} + function check(name, content, expected, policy = null, reasonIncludes = null, expectedChecks = null) { const file = path.join(directory, `${name}.json`); fs.writeFileSync(file, typeof content === "string" ? content : JSON.stringify(content)); @@ -96,6 +100,24 @@ 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)); + for (const field of ["runtime_audit_stream", "attention_ledger"]) { + const omitted = structuredClone(signedCapsule); + delete omitted.receipt[field]; + signModifiedReceipt(omitted.receipt, privateKey); + omitted.summary.receipt_sha256 = omitted.receipt.receipt_sha256; + rehash(omitted); + if (verifyEvidenceCapsule(omitted).verified) throw new Error(`JavaScript accepted a receipt missing ${field}`); + results.push(check(`signed-missing-${field}-rejected`, omitted, false, signedPolicy, field)); + } + const nonArrayAudit = structuredClone(signedCapsule); + nonArrayAudit.receipt.runtime_audit_stream.entries = ""; + nonArrayAudit.receipt.audit_stream_sha256 = hashValue(nonArrayAudit.receipt.runtime_audit_stream); + signModifiedReceipt(nonArrayAudit.receipt, privateKey); + nonArrayAudit.summary.receipt_sha256 = nonArrayAudit.receipt.receipt_sha256; + rehash(nonArrayAudit); + if (verifyEvidenceCapsule(nonArrayAudit).verified) throw new Error("JavaScript accepted non-array audit entries"); + results.push(check("signed-non-array-audit-rejected", nonArrayAudit, false, signedPolicy, + null, { receipt_hash: true, audit: false, signature: true, summary: true })); const alteredEncoding = structuredClone(signedCapsule); alteredEncoding.receipt.signature.signature_base64 += "!!!!"; rehash(alteredEncoding); diff --git a/test/accountable-pipeline.test.js b/test/accountable-pipeline.test.js index 44aa9035..10c02316 100644 --- a/test/accountable-pipeline.test.js +++ b/test/accountable-pipeline.test.js @@ -198,6 +198,36 @@ test("receipt verification rejects re-hashed audit verification flag mismatch", assert.equal(verification.verified, false); }); +test("receipt verification and signing require present audit and attention evidence", () => { + const { privateKey } = crypto.generateKeyPairSync("ed25519"); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }); + for (const [field, validity] of [ + ["runtime_audit_stream", "audit_stream_valid"], + ["attention_ledger", "attention_ledger_valid"] + ]) { + const receipt = freshReceipt(); + delete receipt[field]; + refreshOuterReceiptHash(receipt); + const verification = verifyExecutionReceipt(receipt); + assert.equal(verification.receipt_hash_valid, true); + assert.equal(verification[validity], false); + assert.equal(verification.verified, false); + assert.throws(() => signExecutionReceipt(receipt, pem), /invalid ĀML execution receipt/); + } +}); + +test("receipt verification rejects a non-array audit even when its hashes are refreshed", () => { + const receipt = freshReceipt(); + receipt.runtime_audit_stream.entries = ""; + receipt.audit_stream_sha256 = sha256(receipt.runtime_audit_stream); + refreshOuterReceiptHash(receipt); + + const verification = verifyExecutionReceipt(receipt); + assert.equal(verification.receipt_hash_valid, true); + assert.equal(verification.audit_stream_valid, false); + assert.equal(verification.verified, false); +}); + test("receipt verification rejects a structurally invalid ledger even when its hashes are refreshed", () => { const receipt = freshReceipt(); const entry = receipt.attention_ledger.entries[0];