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
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, which publishes a JSON result artifact. Signed receipts with small exponents, fractions, negative zero, the minimum subnormal, the maximum safe integer, array-index-like keys, UTF-16-ordered keys, and escaped lone surrogates are checked there; mutation, escaped duplicate-key, and unsafe-integer cases 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; 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.

## 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 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 and exponent notation up to JavaScript's maximum safe-integer magnitude, rendering the shortest round-trip digits at JavaScript's fixed/exponent boundary. It reproduces the tested UTF-16 key order, array-index enumeration, and JSON escaping of lone surrogates. It rejects integers outside the safe range and non-finite numbers. 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. 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?

Expand Down
4 changes: 2 additions & 2 deletions independent/python/verify_evidence_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

from verify_evidence_capsule import canonical, reject_constant, unique_pairs, verify as verify_capsule
from verify_evidence_capsule import canonical, parse_js_int, reject_constant, unique_pairs, verify as verify_capsule

PROTOCOL = "aml-evidence-archive/1"
ENCODING = "canonical-json-utf8-base64/1"
Expand All @@ -42,7 +42,7 @@ def sha3(data):


def strict_json(data):
return json.loads(data, object_pairs_hook=unique_pairs, parse_constant=reject_constant)
return json.loads(data, object_pairs_hook=unique_pairs, parse_constant=reject_constant, parse_int=parse_js_int)


def component(archive, name, digest_name):
Expand Down
31 changes: 23 additions & 8 deletions independent/python/verify_evidence_capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,30 @@ def reject_constant(value):
raise ValueError(f"unsupported JSON 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.
number = int(raw)
if abs(number) <= MAX_SAFE_INTEGER:
return number
rounded = float(raw)
if not math.isfinite(rounded):
raise ValueError("unsupported non-finite JSON number")
return rounded


def number_text(value):
# Match JSON.stringify's fixed/exponent boundary for the supported numeric subset.
# Match JSON.stringify's fixed/exponent boundary for finite binary64 numbers.
# 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 abs(value) <= MAX_SAFE_INTEGER:
return str(value)
try:
value = float(value)
except OverflowError as error:
raise ValueError("unsupported non-finite JSON number") from error
if not math.isfinite(value):
raise ValueError("unsupported non-finite JSON number")
if value == 0:
return "0"
sign = "-" if value < 0 else ""
Expand Down Expand Up @@ -238,9 +253,9 @@ def verify(capsule, policy=None):
if len(sys.argv) not in (2, 3):
raise ValueError("Usage: python3 independent/python/verify_evidence_capsule.py <capsule.json> [trusted-policy.json]")
capsule = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"),
object_pairs_hook=unique_pairs, parse_constant=reject_constant)
object_pairs_hook=unique_pairs, parse_constant=reject_constant, parse_int=parse_js_int)
policy = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"),
object_pairs_hook=unique_pairs, parse_constant=reject_constant) if len(sys.argv) == 3 else None
object_pairs_hook=unique_pairs, parse_constant=reject_constant, parse_int=parse_js_int) if len(sys.argv) == 3 else None
report = verify(capsule, policy)
except (ValueError, KeyError, TypeError, OSError, OverflowError, RecursionError) as error:
report = {"protocol": "aml-evidence-capsule-python-report/1", "verified": False, "reason": str(error)}
Expand Down
4 changes: 2 additions & 2 deletions independent/python/verify_http_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import re
import sys

from verify_evidence_capsule import canonical, unique_pairs, reject_constant, verify
from verify_evidence_capsule import canonical, unique_pairs, reject_constant, parse_js_int, verify

MAX_FILE_BYTES = 8 * 1024 * 1024
NONCE = re.compile(r"[0-9a-f]{64}\Z")
Expand All @@ -25,7 +25,7 @@ def read_json(filename):
if path.stat().st_size > MAX_FILE_BYTES:
raise ValueError("input_too_large")
return json.loads(path.read_text(encoding="utf-8"),
object_pairs_hook=unique_pairs, parse_constant=reject_constant)
object_pairs_hook=unique_pairs, parse_constant=reject_constant, parse_int=parse_js_int)


def check_exchange(exchange, capsule, expected, policy):
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, consent suppression, exponent-bearing intent, and unusual-key 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, small exponent, unusual-key, and large-number fixtures into a **new** directory:

```bash
node pilots/enterprise-http-gate/capture.mjs --out /tmp/aml-http-capture-example
Expand All @@ -74,8 +74,13 @@ python3 independent/python/verify_http_exchange.py \
/tmp/aml-http-capture-example/capsule-keys.json \
/tmp/aml-http-capture-example/host-expected-keys.json \
/tmp/aml-http-capture-example/trusted-policy.json
python3 independent/python/verify_http_exchange.py \
/tmp/aml-http-capture-example/exchange-large.json \
/tmp/aml-http-capture-example/capsule-large.json \
/tmp/aml-http-capture-example/host-expected-large.json \
/tmp/aml-http-capture-example/trusted-policy.json
```

The first, third, and fourth 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 fourth carries array-index and Unicode keys plus an escaped lone surrogate. 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, third, fourth, and fifth 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 fourth carries array-index and Unicode keys plus an escaped lone surrogate. The fifth carries `2**53` and `1e100`. 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 26 cross-runtime cases, including the numeric and unusual-key intents, altered host expectations, 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 28 cross-runtime cases, including small and large numeric and unusual-key intents, altered host expectations, 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.
7 changes: 6 additions & 1 deletion pilots/enterprise-http-gate/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,15 @@ try {
const results = [];
for (const [name, intentName, shouldAllow] of [
["allow", "intent-allowed.json", true], ["suppress", "intent-sensitive.json", false],
["numeric", "intent-allowed.json", true], ["keys", "intent-allowed.json", true]
["numeric", "intent-allowed.json", true], ["keys", "intent-allowed.json", true],
["large", "intent-allowed.json", true]
]) {
const intent = fixture(intentName);
if (name === "numeric") intent.numeric_probe = 1e-7;
if (name === "large") {
intent.numeric_probe = 2 ** 53;
intent.large_exponent_probe = 1e100;
}
if (name === "keys") intent.key_probe = {
"10": "ten", "2": "two", "\uE000": "private use", "\u{1F4A1}": "astral", lone: "\uD800"
};
Expand Down
3 changes: 2 additions & 1 deletion scripts/check-evidence-archive-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ try {
results.push(check("signed-archive-key-revoked", signedArchive,
{ ...signedPolicy, revoked_receipt_fingerprints: signedPolicy.trusted_receipt_fingerprints }, false, "invalid_or_unsupported_capsule"));
const keyedReceipt = signExecutionReceipt(executeAccountableIntent({ ...intent,
numeric_probe: 2 ** 53, large_exponent_probe: 1e100,
key_probe: { "10": "ten", "2": "two", "\uE000": "private use", "\u{1F4A1}": "astral", lone: "\uD800" }
}, { profile: "human_first", timestamp: "2026-10-10T00:00:00.000Z" }), keys[0],
{ signer: "synthetic-receipt", timestamp: "2026-10-10T00:30:00.000Z" });
Expand All @@ -117,7 +118,7 @@ try {
const keyedPolicy = { threshold: 1, trusted_fingerprints: [keyedWitness.public_key_fingerprint_sha256],
trusted_receipt_fingerprints: [keyedReceipt.signature.public_key_sha256] };
const keyedArchive = createEvidenceArchive(keyedCapsule, [{ record: keyedRecord, witnesses: [keyedWitness] }], keyedPolicy);
results.push(check("signed-archive-index-and-unicode-keys", keyedArchive, keyedPolicy, true));
results.push(check("signed-archive-complex-json-values", keyedArchive, keyedPolicy, true));

process.stdout.write(`${JSON.stringify({ protocol: "aml-archive-cross-runtime-check/1", passed: true, results }, null, 2)}\n`);
} finally {
Expand Down
32 changes: 26 additions & 6 deletions scripts/check-evidence-capsule-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ try {
["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]
["maximum-safe-integer", Number.MAX_SAFE_INTEGER],
["first-beyond-safe", 2 ** 53], ["rounded-large-integer", 1000000000000000100],
["large-fixed", 1e20], ["large-exponent-boundary", 1e21],
["large-exponent", 1e100], ["maximum-finite", Number.MAX_VALUE],
["negative-large", -1e250]
]) {
const numericReceipt = executeAccountableIntent({ ...intent, numeric_probe: value },
{ profile: "human_first", timestamp: "2026-10-10T00:00:00.000Z" });
Expand All @@ -77,13 +81,29 @@ try {
rehash(tampered);
results.push(check("signed-numeric-tampered", tampered, false, signedPolicy));
}
if (name === "first-beyond-safe") {
const tampered = structuredClone(numericCapsule);
tampered.receipt.intent.numeric_probe = 2 ** 53 + 2;
rehash(tampered);
results.push(check("signed-large-number-tampered", tampered, false, signedPolicy));
const serialized = JSON.stringify(numericCapsule);
const alias = serialized.replace('"numeric_probe":9007199254740992', '"numeric_probe":9007199254740993');
if (alias === serialized || JSON.parse(alias).receipt.intent.numeric_probe !== value) {
throw new Error("Numeric alias fixture does not preserve the JavaScript Number");
}
results.push(check("signed-large-number-same-value-alias", alias, true, signedPolicy));
const nonFinite = serialized.replace('"numeric_probe":9007199254740992', '"numeric_probe":1e400');
results.push(check("signed-nonfinite-json-rejected", nonFinite, false, signedPolicy,
"unsupported non-finite JSON number"));
}
}
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" }),
const largeBudgetContext = JSON.parse(fs.readFileSync("pilots/enterprise-30min/context-denied.json", "utf8"));
largeBudgetContext.attention_budget_remaining = 2 ** 53;
const largeBudgetReceipt = executeAccountableIntent(intent,
{ profile: "human_first", context: largeBudgetContext, timestamp: "2026-10-10T00:00:00.000Z" });
const largeBudgetSignature = signExecutionReceipt(largeBudgetReceipt, 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-large-attention-budget", createEvidenceCapsule(largeBudgetSignature), true, signedPolicy));
for (const [name, key_probe] of [
["array-index-keys", { "10": "ten", "2": "two", "01": "leading", "0": "zero",
"4294967294": "last index", "4294967295": "ordinary key" }],
Expand Down
Loading
Loading