From 6dbd50fb559a8b3fed764c1a120c801e176d0468 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: Wed, 14 Oct 2026 23:45:38 -0700 Subject: [PATCH] Verify fractional signed receipts across runtimes --- .github/workflows/python-wire-runtime.yml | 9 ++++- docs/EVIDENCE_CAPSULE.md | 4 +- independent/python/verify_evidence_capsule.py | 39 ++++++++++++++++++- pilots/enterprise-http-gate/README.md | 11 ++++-- pilots/enterprise-http-gate/capture.mjs | 6 ++- scripts/check-evidence-capsule-python.mjs | 31 ++++++++++++++- scripts/check-http-exchange-python.mjs | 8 +++- 7 files changed, 95 insertions(+), 13 deletions(-) diff --git a/.github/workflows/python-wire-runtime.yml b/.github/workflows/python-wire-runtime.yml index 4575b855..66ce90cb 100644 --- a/.github/workflows/python-wire-runtime.yml +++ b/.github/workflows/python-wire-runtime.yml @@ -140,7 +140,14 @@ jobs: - name: Install Python Ed25519 verifier dependency run: python -m pip install 'cryptography>=42,<51' - name: Verify evidence capsules across JavaScript and Python - run: node scripts/check-evidence-capsule-python.mjs + run: node scripts/check-evidence-capsule-python.mjs > "${RUNNER_TEMP}/aml-capsule-cross-runtime.json" + - name: Upload capsule comparison + uses: actions/upload-artifact@v4 + with: + name: aml-capsule-cross-runtime-${{ github.sha }} + path: ${{ runner.temp }}/aml-capsule-cross-runtime.json + if-no-files-found: error + retention-days: 30 - name: Check fixed archive wire bytes and digests run: node scripts/check-evidence-archive-vector.mjs - name: Verify archives and renewal witnesses across JavaScript and Python diff --git a/docs/EVIDENCE_CAPSULE.md b/docs/EVIDENCE_CAPSULE.md index d6a810fc..5ab069a8 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. 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 exponents, fractions, negative zero, the minimum subnormal, and the maximum safe integer are checked there; mutation and unsafe-integer cases 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, no whitespace, JSON string escaping, and JSON number serialization. `undefined`, non-finite numbers, executable values, and non-plain objects are rejected. Hash the resulting 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 checks a documented subset, not every possible JavaScript number or string encoding. JSON parsers that accept duplicate keys should reject such input before parsing if the original bytes are supplied by an adversary. +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 and exponent notation up to JavaScript's maximum safe-integer magnitude, rendering the shortest round-trip digits at JavaScript's fixed/exponent boundary. It rejects integers outside the safe range and non-finite numbers. It does not claim complete JavaScript serialization parity: unusual Unicode strings and object keys (including array-index-like keys) remain outside its documented interoperability subset. 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 984ae46f..f3f34634 100644 --- a/independent/python/verify_evidence_capsule.py +++ b/independent/python/verify_evidence_capsule.py @@ -8,9 +8,11 @@ import binascii import hashlib import json +import math import pathlib import re import sys +from decimal import Decimal from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization @@ -21,6 +23,7 @@ SIGNATURE_FIELDS = {"protocol", "version", "algorithm", "signer", "signed_at", "public_key_pem", "public_key_sha256", "signature_base64"} FINGERPRINT = re.compile(r"[a-f0-9]{64}\Z") UTC = re.compile(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z\Z") +MAX_SAFE_INTEGER = 2**53 - 1 def unique_pairs(pairs): @@ -36,8 +39,42 @@ def reject_constant(value): raise ValueError(f"unsupported JSON constant: {value}") +def number_text(value): + # Match JSON.stringify's fixed/exponent boundary for the supported numeric subset. + # repr(float) supplies the shortest round-trip digits; Decimal only places the point. + if type(value) is int: + if abs(value) > MAX_SAFE_INTEGER: + raise ValueError("unsupported JSON integer outside JavaScript safe range") + return str(value) + if not math.isfinite(value) or abs(value) > MAX_SAFE_INTEGER: + raise ValueError("unsupported JSON number outside finite safe-magnitude subset") + if value == 0: + return "0" + sign = "-" if value < 0 else "" + decimal = Decimal(repr(abs(value))).normalize() + exponent = decimal.adjusted() + if -6 <= exponent < 21: + return sign + format(decimal, "f") + digits = "".join(str(digit) for digit in decimal.as_tuple().digits) + coefficient = digits[0] + ("." + digits[1:] if len(digits) > 1 else "") + return sign + coefficient + "e" + ("+" if exponent >= 0 else "") + str(exponent) + + def canonical(value): - return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8") + def serialize(item): + if isinstance(item, dict): + if any(not isinstance(key, str) for key in item): + raise ValueError("non-string JSON object key") + return "{" + ",".join(serialize(key) + ":" + serialize(item[key]) for key in sorted(item)) + "}" + if isinstance(item, list): + return "[" + ",".join(serialize(child) for child in item) + "]" + if item is None or type(item) in (str, bool): + return json.dumps(item, ensure_ascii=False, allow_nan=False) + if type(item) in (int, float): + return number_text(item) + raise ValueError("unsupported JSON value") + + return serialize(value).encode("utf-8") def hash_value(value): diff --git a/pilots/enterprise-http-gate/README.md b/pilots/enterprise-http-gate/README.md index 101026e4..57e012ce 100644 --- a/pilots/enterprise-http-gate/README.md +++ b/pilots/enterprise-http-gate/README.md @@ -50,7 +50,7 @@ Run `node --test test/enterprise-http-gate.test.js` or inspect the `aml-signed-g ## Recheck a real HTTP exchange with Python -The project also supplies a separate Python verifier for saved, parsed responses from this locked HTTP service. Install Python 3.12 and `cryptography>=42,<51`, then capture the repository's synthetic allow and consent suppression fixtures into a **new** directory: +The project also supplies a separate Python verifier for saved, parsed responses from this locked HTTP service. Install Python 3.12 and `cryptography>=42,<51`, then capture the repository's synthetic allow, consent suppression, and exponent-bearing intent fixtures into a **new** directory: ```bash node pilots/enterprise-http-gate/capture.mjs --out /tmp/aml-http-capture-example @@ -64,8 +64,13 @@ python3 independent/python/verify_http_exchange.py \ /tmp/aml-http-capture-example/capsule-suppress.json \ /tmp/aml-http-capture-example/host-expected-suppress.json \ /tmp/aml-http-capture-example/trusted-policy.json +python3 independent/python/verify_http_exchange.py \ + /tmp/aml-http-capture-example/exchange-numeric.json \ + /tmp/aml-http-capture-example/capsule-numeric.json \ + /tmp/aml-http-capture-example/host-expected-numeric.json \ + /tmp/aml-http-capture-example/trusted-policy.json ``` -The first report has `verified: true`, `status: "allow"`; the second has `verified: true`, `status: "suppressed"`, and `render_eligible: false`. The verifier compares each response receipt to its evidence capsule, checks the capsule's internal hashes, audit chain, ledger, Ed25519 signature and **separately supplied** fingerprint policy, then binds the signed nonce, intent, profile and context to the host's saved expectation. It checks the HTTP status, server-owned enforcement settings, and effective decision. It reports the signed output hash; it never executes the HTML or trusts the outer `selected_render.html` as output. It exits nonzero on rejected evidence. +The first and third reports have `verified: true`, `status: "allow"`; the second has `verified: true`, `status: "suppressed"`, and `render_eligible: false`. The third intent carries `1e-7` through a real signed HTTP response and the Python verifier. The verifier compares each response receipt to its evidence capsule, checks the capsule's internal hashes, audit chain, ledger, Ed25519 signature and **separately supplied** fingerprint policy, then binds the signed nonce, intent, profile and context to the host's saved expectation. It checks the HTTP status, server-owned enforcement settings, and effective decision. It reports the signed output hash; it never executes the HTML or trusts the outer `selected_render.html` as output. It exits nonzero on rejected evidence. -Run `node scripts/check-http-exchange-python.mjs` to generate fresh loopback exchanges and exercise 22 cross-runtime cases, including replay, foreign keys, changed receipt content, altered decision, changed context, revocation, and an outer HTML swap. Enterprise Readiness CI uploads the compact `aml-locked-http-python-` report. The capture creates its own ephemeral key and writes the corresponding fingerprint to a separate file for this local fixture; a real host must provision and review that trust pin independently. Only the project fixtures are used here. The full saved exchanges may contain declared intent and context, so review them before sharing. Neither this project-authored Python implementation nor its passing examples establish an external witness, truthful inputs, live network identity, browser paint, or production fitness. +Run `node scripts/check-http-exchange-python.mjs` to generate fresh loopback exchanges and exercise 24 cross-runtime cases, including the numeric intent, altered host expectation, replay, foreign keys, changed receipt content, altered decision, changed context, revocation, and an outer HTML swap. Enterprise Readiness CI uploads the compact `aml-locked-http-python-` report. The capture creates its own ephemeral key and writes the corresponding fingerprint to a separate file for this local fixture; a real host must provision and review that trust pin independently. Only the project fixtures are used here. The full saved exchanges may contain declared intent and context, so review them before sharing. Neither this project-authored Python implementation nor its passing examples establish an external witness, truthful inputs, live network identity, browser paint, or production fitness. diff --git a/pilots/enterprise-http-gate/capture.mjs b/pilots/enterprise-http-gate/capture.mjs index 2ea60d82..7277c5b7 100644 --- a/pilots/enterprise-http-gate/capture.mjs +++ b/pilots/enterprise-http-gate/capture.mjs @@ -1,4 +1,4 @@ -// Save two real loopback HTTP evaluations for verification without the JS gate. +// Save real loopback HTTP evaluations for verification without the JS gate. // Only synthetic repository fixtures are used. The private key and token stay in memory. import assert from "node:assert/strict"; import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; @@ -32,9 +32,11 @@ try { const url = `http://127.0.0.1:${server.address().port}/v1/deployment/evaluate`; const results = []; for (const [name, intentName, shouldAllow] of [ - ["allow", "intent-allowed.json", true], ["suppress", "intent-sensitive.json", false] + ["allow", "intent-allowed.json", true], ["suppress", "intent-sensitive.json", false], + ["numeric", "intent-allowed.json", true] ]) { const intent = fixture(intentName); + if (name === "numeric") intent.numeric_probe = 1e-7; const request_nonce = randomBytes(32).toString("hex"); const expected = { protocol: "aml-locked-http-host-expected/1", intent, profile, context, request_nonce }; save(`host-expected-${name}.json`, expected); diff --git a/scripts/check-evidence-capsule-python.mjs b/scripts/check-evidence-capsule-python.mjs index 8d8293fe..18dc13b9 100644 --- a/scripts/check-evidence-capsule-python.mjs +++ b/scripts/check-evidence-capsule-python.mjs @@ -18,7 +18,7 @@ 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) { +function check(name, content, expected, policy = null, reasonIncludes = null) { const file = path.join(directory, `${name}.json`); fs.writeFileSync(file, typeof content === "string" ? content : JSON.stringify(content)); const args = [verifier, file]; @@ -30,7 +30,9 @@ function check(name, content, expected, policy = null) { const result = spawnSync("python3", args, { encoding: "utf8" }); if (result.error) throw result.error; const report = JSON.parse(result.stdout); - if (report.verified !== expected || result.status !== (expected ? 0 : 1)) { + if (report.verified !== expected || result.status !== (expected ? 0 : 1) || + (reasonIncludes && !report.reason?.includes(reasonIncludes)) || + (expected && policy && report.attribution_bound !== true)) { throw new Error(`${name}: unexpected Python result ${result.stdout} ${result.stderr}`); } return { name, verified: report.verified }; @@ -57,6 +59,31 @@ 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 [name, value] of [ + ["small-exponent", 1e-7], ["fixed-boundary", 1e-6], + ["negative-fraction", -0.0000015], ["negative-zero", -0], + ["minimum-subnormal", Number.MIN_VALUE], + ["maximum-safe-integer", Number.MAX_SAFE_INTEGER] + ]) { + const numericReceipt = executeAccountableIntent({ ...intent, numeric_probe: value }, + { profile: "human_first", timestamp: "2026-10-10T00:00:00.000Z" }); + const numericSignature = signExecutionReceipt(numericReceipt, privateKey.export({ type: "pkcs8", format: "pem" }), + { signer: "synthetic", timestamp: "2026-10-10T01:00:00.000Z" }); + const numericCapsule = createEvidenceCapsule(numericSignature); + results.push(check(`signed-numeric-${name}`, numericCapsule, true, signedPolicy)); + if (name === "small-exponent") { + const tampered = structuredClone(numericCapsule); + tampered.receipt.intent.numeric_probe = 2e-7; + rehash(tampered); + results.push(check("signed-numeric-tampered", tampered, false, signedPolicy)); + } + } + const unsafeReceipt = executeAccountableIntent({ ...intent, numeric_probe: 2 ** 53 }, + { profile: "human_first", timestamp: "2026-10-10T00:00:00.000Z" }); + const unsafeSignature = signExecutionReceipt(unsafeReceipt, privateKey.export({ type: "pkcs8", format: "pem" }), + { signer: "synthetic", timestamp: "2026-10-10T01:00:00.000Z" }); + results.push(check("signed-unsafe-integer-rejected", createEvidenceCapsule(unsafeSignature), false, signedPolicy, + "outside JavaScript safe range")); results.push(check("signed-wrong-trust", signedCapsule, false, { trusted_receipt_fingerprints: ["f".repeat(64)] })); results.push(check("signed-revoked", signedCapsule, false, { ...signedPolicy, revoked_receipt_fingerprints: signedPolicy.trusted_receipt_fingerprints })); const forgedSigner = structuredClone(signedCapsule); diff --git a/scripts/check-http-exchange-python.mjs b/scripts/check-http-exchange-python.mjs index e500a82c..c5af82ac 100644 --- a/scripts/check-http-exchange-python.mjs +++ b/scripts/check-http-exchange-python.mjs @@ -31,8 +31,8 @@ try { ["pilots/enterprise-http-gate/capture.mjs", "--out", captureDir], { encoding: "utf8" }); assert.equal(capture.status, 0, capture.stderr); const captured = JSON.parse(capture.stdout); - assert.equal(captured.cases.length, 2); - const original = Object.fromEntries(["allow", "suppress"].map(name => [name, { + assert.equal(captured.cases.length, 3); + const original = Object.fromEntries(["allow", "suppress", "numeric"].map(name => [name, { exchange: read(`exchange-${name}.json`), capsule: read(`capsule-${name}.json`), expected: read(`host-expected-${name}.json`) }])); @@ -61,6 +61,10 @@ try { } const allowed = check("valid_allow", "allow", "allow", "verified_allow"); check("valid_suppression", "suppress", "suppressed", "policy_suppressed"); + check("valid_numeric_exponent", "numeric", "allow", "verified_allow"); + check("numeric_host_expectation_changed", "numeric", "blocked", "receipt_binding_mismatch", sample => { + sample.expected.intent.numeric_probe = 2e-7; + }); const swapped = check("outer_html_swap", "allow", "allow", "verified_allow", sample => { sample.exchange.body.selected_render.html = "

Unreviewed replacement

"; });