Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/python-wire-runtime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/EVIDENCE_CAPSULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ 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.

## Frozen v1 contract

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?

Expand Down
39 changes: 38 additions & 1 deletion independent/python/verify_evidence_capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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):
Expand Down
11 changes: 8 additions & 3 deletions pilots/enterprise-http-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-<commit>` 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-<commit>` 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.
6 changes: 4 additions & 2 deletions pilots/enterprise-http-gate/capture.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 29 additions & 2 deletions scripts/check-evidence-capsule-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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 };
Expand All @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions scripts/check-http-exchange-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}]));
Expand Down Expand Up @@ -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 = "<h2>Unreviewed replacement</h2>";
});
Expand Down
Loading