From 241d132d260d31129553e45de4e1bbb1ebc6c5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Jacob=20Read=20IV=20=E2=80=94=20Creator=20of=20?= =?UTF-8?q?=C4=80ML=E2=84=A2?= Date: Thu, 15 Oct 2026 08:47:01 -0700 Subject: [PATCH] Match JavaScript attention ledger semantics in Python verifier --- .gitignore | 2 + docs/EVIDENCE_CAPSULE.md | 4 +- independent/python/verify_evidence_capsule.py | 46 ++++++++--- scripts/check-evidence-capsule-python.mjs | 80 ++++++++++++++++++- 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 480b05f4..41b449f8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ .env *.log coverage/ +__pycache__/ +*.pyc diff --git a/docs/EVIDENCE_CAPSULE.md b/docs/EVIDENCE_CAPSULE.md index f772035c..48260341 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; 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. 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. @@ -30,7 +30,7 @@ For a saved response from the locked HTTP deployment endpoint, the [HTTP exchang The capsule has exactly six top-level keys: `protocol`, `canonicalization`, `claim_boundary`, `summary`, `receipt`, and `digests`. The digest material consists of the first five keys, serialized with `canonicalJSONStringify` from `protocol/canonicalJson.js`: ordinary JSON values, object keys sorted by JavaScript UTF-16 code-unit order before `JSON.stringify`, no whitespace, JSON string escaping, and JSON number serialization. JavaScript enumerates array-index keys numerically ahead of other keys during that final serialization. The receipt's internal hashes use a separate, hand-built sorted JSON rule that keeps those keys in UTF-16 lexical order. The Python verifier reproduces both byte orders without changing either frozen JavaScript hash. `undefined`, non-finite numbers, executable values, and non-plain objects are rejected. Hash the capsule payload's UTF-8 bytes with SHA-256 and SHA-512 as lowercase hexadecimal. -The receipt's own v1.1 verification remains a separate contract. Both digest algorithms are required in this capsule version. New algorithms and formats should get a new version and test vectors; do not reinterpret old bytes. Do not overwrite an old capsule when migrating: retain it and record a new, separately verifiable link to its exact digest. The [renewal chain](EVIDENCE_RENEWAL.md) implements one such link with a verifier-owned trust policy. No verifier here establishes an external timestamp or independent validation. The Python implementation handles tested finite fractions, large integers and exponents through JavaScript's maximum finite number, rendering shortest round-trip digits at JavaScript's fixed/exponent boundary. JSON integer tokens above the safe-integer limit are rounded to binary64 before verification, as in JavaScript: distinct decimal spellings can denote the same signed Number. Represent precision-critical identifiers and amounts as strings with a separate validation rule. Non-finite numbers are rejected. The verifier also reproduces tested UTF-16 key order, array-index enumeration, and JSON escaping of lone surrogates. This is a tested interoperability subset, not a proof of parity for every JavaScript number or arbitrary source string. Supplied JSON with duplicate keys is rejected before verification. +The receipt's own v1.1 verification remains a separate contract. Both digest algorithms are required in this capsule version. New algorithms and formats should get a new version and test vectors; do not reinterpret old bytes. Do not overwrite an old capsule when migrating: retain it and record a new, separately verifiable link to its exact digest. The [renewal chain](EVIDENCE_RENEWAL.md) implements one such link with a verifier-owned trust policy. No verifier here establishes an external timestamp or independent validation. The Python implementation handles tested finite fractions, large integers and exponents through JavaScript's maximum finite number, rendering shortest round-trip digits at JavaScript's fixed/exponent boundary. JSON integer tokens above the safe-integer limit are rounded to binary64 before verification, as in JavaScript: distinct decimal spellings can denote the same signed Number. Attention-ledger arithmetic rounds after each binary64 operation; its before, after, and remaining balances use JavaScript `Object.is` semantics, including the sign of zero. Represent precision-critical identifiers and amounts as strings with a separate validation rule. Non-finite numbers are rejected. The verifier also reproduces tested UTF-16 key order, array-index enumeration, and JSON escaping of lone surrogates. This is a tested interoperability subset, not a proof of parity for every JavaScript number or arbitrary source string. Supplied JSON with duplicate keys is rejected before verification. ## Why preserve both views? diff --git a/independent/python/verify_evidence_capsule.py b/independent/python/verify_evidence_capsule.py index a1c60603..41c05133 100644 --- a/independent/python/verify_evidence_capsule.py +++ b/independent/python/verify_evidence_capsule.py @@ -40,8 +40,10 @@ def reject_constant(value): def parse_js_int(raw): - # JSON.parse represents every JSON number as binary64. Preserve small integers - # for Python structural checks; round larger decimal tokens to the same value. + # JSON.parse represents every JSON number as binary64. Preserve small positive + # and negative integers for structural checks, but retain the sign of -0. + if raw == "-0": + return -0.0 number = int(raw) if abs(number) <= MAX_SAFE_INTEGER: return number @@ -129,6 +131,11 @@ def hash_text(value): return hashlib.sha256(value.encode("utf-8")).hexdigest() +def js_integer(value): + # Number.isInteger(-0) is true; Python's int type check would reject it. + return type(value) is int or (type(value) is float and math.isfinite(value) and value.is_integer()) + + def verify_signature(receipt, policy): if not isinstance(policy, dict): raise ValueError("external_receipt_trust_required") @@ -175,21 +182,38 @@ def valid_ledger(ledger): budget = ledger.get("initial_budget") if budget is not None and (type(budget) not in (int, float) or budget < 0): return False - remaining, consumed = budget, 0 + + 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. + remaining, consumed = (None if budget is None else float(budget)), 0.0 for index, entry in enumerate(ledger["entries"]): amount = entry.get("amount_requested") if type(amount) not in (int, float) or amount < 0: return False allowed = budget is None or amount <= remaining - charged = amount if allowed else 0 - after = None if budget is None else max(0, remaining - charged) - if (type(entry.get("sequence")) is not int or entry.get("sequence") != index or entry.get("allowed") is not allowed or - entry.get("budget_before") != remaining or entry.get("amount_consumed") != charged or - entry.get("budget_after") != after): + charged = float(amount) if allowed else 0.0 + after = None if budget is None else max(0.0, remaining - charged) + if (not js_integer(entry.get("sequence")) or entry.get("sequence") != index or entry.get("allowed") is not allowed or + not same_number(entry.get("budget_before"), remaining, object_is=True) or + not same_number(entry.get("amount_consumed"), charged) or + not same_number(entry.get("budget_after"), after, object_is=True)): return False consumed += charged remaining = after - return ledger.get("consumed") == consumed and ledger.get("remaining") == remaining + return (same_number(ledger.get("consumed"), consumed) and + same_number(ledger.get("remaining"), remaining, object_is=True)) def valid_audit(stream): @@ -198,7 +222,7 @@ def valid_audit(stream): previous = None for index, entry in enumerate(stream["entries"]): core = {key: entry[key] for key in ("sequence", "timestamp", "event_type", "payload", "previous_hash")} - if type(entry["sequence"]) is not int or entry["sequence"] != index or entry["previous_hash"] != previous or entry["entry_hash"] != hash_value(core): + if not js_integer(entry["sequence"]) or entry["sequence"] != index or entry["previous_hash"] != previous or entry["entry_hash"] != hash_value(core): return False previous = entry["entry_hash"] return True @@ -230,7 +254,7 @@ def verify(capsule, policy=None): "simulation": hash_value(receipt["simulations"]) == receipt["simulation_sha256"], "decision": hash_value(decisions) == receipt["decision_sha256"], "output": hash_text(selected["html"]) == receipt["output_sha256"], - "counts": type(selected["allowed"]) is int and type(selected["suppressed"]) is int + "counts": js_integer(selected["allowed"]) and js_integer(selected["suppressed"]) and selected["allowed"] == sum(item["render_allowed"] is True for item in decisions) 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"] diff --git a/scripts/check-evidence-capsule-python.mjs b/scripts/check-evidence-capsule-python.mjs index eadf6742..a3043e7d 100644 --- a/scripts/check-evidence-capsule-python.mjs +++ b/scripts/check-evidence-capsule-python.mjs @@ -3,7 +3,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { executeAccountableIntent, signExecutionReceipt } from "../compiler/accountablePipeline.js"; +import { executeAccountableIntent, signExecutionReceipt, verifyExecutionReceipt } from "../compiler/accountablePipeline.js"; +import { createAttentionLedger, consumeAttention } from "../runtime/attentionLedger.js"; import { createEvidenceCapsule } from "../runtime/evidenceCapsule.js"; import { canonicalJSONStringify } from "../protocol/canonicalJson.js"; @@ -18,7 +19,42 @@ function rehash(capsule) { capsule.digests = Object.fromEntries(["sha256", "sha512"].map(algorithm => [algorithm, crypto.createHash(algorithm).update(bytes).digest("hex")])); } -function check(name, content, expected, policy = null, reasonIncludes = null) { +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().filter(key => value[key] !== undefined) + .map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function hashValue(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function receiptWithLedger(base, ledger) { + const updated = structuredClone(base); + updated.attention_ledger = ledger; + updated.attention_ledger_sha256 = hashValue(ledger); + const payload = { ...updated }; + delete payload.receipt_sha256; + updated.receipt_sha256 = hashValue(payload); + return updated; +} + +function resign(receipt, privateKey) { + receipt.attention_ledger_sha256 = hashValue(receipt.attention_ledger); + const payload = { ...receipt }; + delete payload.receipt_sha256; + delete payload.signature; + receipt.receipt_sha256 = hashValue(payload); + const { signature } = receipt; + const material = { protocol: "ĀML Execution Receipt Attestation", version: "1.1", algorithm: "Ed25519", + receipt_sha256: receipt.receipt_sha256, signer: signature.signer, signed_at: signature.signed_at }; + signature.signature_base64 = crypto.sign(null, Buffer.from(stableStringify(material)), privateKey).toString("base64"); +} + +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)); const args = [verifier, file]; @@ -32,6 +68,7 @@ function check(name, content, expected, policy = null, reasonIncludes = null) { const report = JSON.parse(result.stdout); if (report.verified !== expected || result.status !== (expected ? 0 : 1) || (reasonIncludes && !report.reason?.includes(reasonIncludes)) || + (expectedChecks && Object.entries(expectedChecks).some(([key, value]) => report.checks?.[key] !== value)) || (expected && policy && report.attribution_bound !== true)) { throw new Error(`${name}: unexpected Python result ${result.stdout} ${result.stderr}`); } @@ -104,6 +141,45 @@ try { const largeBudgetSignature = signExecutionReceipt(largeBudgetReceipt, privateKey.export({ type: "pkcs8", format: "pem" }), { signer: "synthetic", timestamp: "2026-10-10T01:00:00.000Z" }); results.push(check("signed-large-attention-budget", createEvidenceCapsule(largeBudgetSignature), true, signedPolicy)); + const roundingLedger = createAttentionLedger(null); + for (const amount of [Number.MAX_SAFE_INTEGER, 1, 1]) consumeAttention(roundingLedger, amount); + if (roundingLedger.consumed !== 2 ** 53) throw new Error("Ledger rounding fixture changed"); + const roundingReceipt = receiptWithLedger(receipt, roundingLedger); + if (!verifyExecutionReceipt(roundingReceipt).verified) throw new Error("JavaScript rejected the rounding fixture"); + const roundingSignature = signExecutionReceipt(roundingReceipt, privateKey.export({ type: "pkcs8", format: "pem" }), + { signer: "synthetic", timestamp: "2026-10-10T01:00:00.000Z" }); + results.push(check("signed-ledger-binary64-sum", createEvidenceCapsule(roundingSignature), true, signedPolicy)); + const roundingSource = JSON.stringify(createEvidenceCapsule(roundingSignature)); + const negativeZeroSequence = roundingSource.replace('"sequence":0', '"sequence":-0'); + if (negativeZeroSequence === roundingSource || + !verifyExecutionReceipt(JSON.parse(negativeZeroSequence).receipt).verified) { + throw new Error("Negative-zero sequence fixture changed JavaScript verification"); + } + results.push(check("signed-negative-zero-sequence", negativeZeroSequence, true, signedPolicy)); + + const oneLedger = createAttentionLedger(1); + const oneSignature = signExecutionReceipt(receiptWithLedger(receipt, oneLedger), + privateKey.export({ type: "pkcs8", format: "pem" }), + { signer: "synthetic", timestamp: "2026-10-10T01:00:00.000Z" }); + const forgedBalance = createEvidenceCapsule(oneSignature); + forgedBalance.receipt.attention_ledger.remaining = true; + resign(forgedBalance.receipt, privateKey); + forgedBalance.summary.receipt_sha256 = forgedBalance.receipt.receipt_sha256; + rehash(forgedBalance); + if (verifyExecutionReceipt(forgedBalance.receipt).verified) throw new Error("JavaScript accepted a boolean balance"); + results.push(check("signed-ledger-boolean-balance-rejected", forgedBalance, false, signedPolicy, + null, { receipt_hash: true, signature: true, ledger: false, summary: true })); + + const zeroSignature = signExecutionReceipt(receiptWithLedger(receipt, createAttentionLedger(0)), + privateKey.export({ type: "pkcs8", format: "pem" }), + { signer: "synthetic", timestamp: "2026-10-10T01:00:00.000Z" }); + const zeroSource = JSON.stringify(createEvidenceCapsule(zeroSignature)); + const negativeZero = zeroSource.replace('"remaining":0', '"remaining":-0'); + if (negativeZero === zeroSource || verifyExecutionReceipt(JSON.parse(negativeZero).receipt).verified) { + throw new Error("Negative-zero balance fixture does not diverge from JavaScript"); + } + results.push(check("signed-ledger-negative-zero-balance-rejected", negativeZero, false, signedPolicy, + null, { receipt_hash: true, signature: true, ledger: false, summary: true })); for (const [name, key_probe] of [ ["array-index-keys", { "10": "ten", "2": "two", "01": "leading", "0": "zero", "4294967294": "last index", "4294967295": "ordinary key" }],