From 407901b83316ab2ae82077e3f3fb0d462fdd0e54 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:15:24 -0700 Subject: [PATCH] Align wire envelope validation across JavaScript and Python --- .github/workflows/python-wire-runtime.yml | 13 +++++- API.md | 1 + README.md | 2 + docs/INTEROPERABILITY_STANDARD.md | 2 + independent/python/WIRE_RUNTIME.md | 4 +- independent/python/wire_runtime.py | 15 +++++-- protocol/wireProtocol.js | 50 +++++++++++++++++++++-- schema/wire-envelope.schema.json | 4 +- scripts/check-python-wire-runtime.mjs | 43 +++++++++++++++++-- 9 files changed, 119 insertions(+), 15 deletions(-) diff --git a/.github/workflows/python-wire-runtime.yml b/.github/workflows/python-wire-runtime.yml index cb161457..4575b855 100644 --- a/.github/workflows/python-wire-runtime.yml +++ b/.github/workflows/python-wire-runtime.yml @@ -7,6 +7,8 @@ on: - "independent/python/wire_runtime.py" - "independent/python/WIRE_RUNTIME.md" - "scripts/check-python-wire-runtime.mjs" + - "protocol/wireProtocol.js" + - "schema/wire-envelope.schema.json" - "independent/python/verify_evidence_capsule.py" - "scripts/check-evidence-capsule-python.mjs" - "independent/python/verify_evidence_archive.py" @@ -61,6 +63,8 @@ on: - "independent/python/wire_runtime.py" - "independent/python/WIRE_RUNTIME.md" - "scripts/check-python-wire-runtime.mjs" + - "protocol/wireProtocol.js" + - "schema/wire-envelope.schema.json" - "independent/python/verify_evidence_capsule.py" - "scripts/check-evidence-capsule-python.mjs" - "independent/python/verify_evidence_archive.py" @@ -125,7 +129,14 @@ jobs: with: python-version: "3.12" - name: Verify reference-produced artifacts with standalone Python - run: node scripts/check-python-wire-runtime.mjs + run: node scripts/check-python-wire-runtime.mjs > "${RUNNER_TEMP}/aml-wire-envelope-cross-runtime.json" + - name: Upload wire envelope comparison + uses: actions/upload-artifact@v4 + with: + name: aml-wire-envelope-cross-runtime-${{ github.sha }} + path: ${{ runner.temp }}/aml-wire-envelope-cross-runtime.json + if-no-files-found: error + retention-days: 30 - name: Install Python Ed25519 verifier dependency run: python -m pip install 'cryptography>=42,<51' - name: Verify evidence capsules across JavaScript and Python diff --git a/API.md b/API.md index 2eaaeb0a..0d5a4f31 100644 --- a/API.md +++ b/API.md @@ -320,6 +320,7 @@ The public trust registry is `BRAND_TRUST_ROOTS.json`. Production private key ma - `acceptWireEnvelope(guard, envelope, options?)` Canonical serialization and protocol vectors help independent implementations converge on the same hashes and signatures. +`validateWireEnvelope` rejects missing or unknown fields, repeated or malformed capabilities, invalid optional metadata, and malformed or expired timestamps. The portable timestamp profile requires a calendar-valid ISO date-time with an explicit timezone and at most millisecond precision. Callers that previously relied on permissive acceptance of other shapes should normalize their envelopes before upgrading. The [JavaScript/Python comparison](independent/python/WIRE_RUNTIME.md) records the selected shared cases. ## HTTP service diff --git a/README.md b/README.md index dca8934f..384c75e4 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ **A counterexample made the contract stronger:** [Inspect the key-order lab](https://aruintelligence.github.io/aml-core/key-order-lab.html?case=two-numeric-keys). Numeric-looking JSON keys exposed a real proposal-hash disagreement between the project's JavaScript and Python code paths. The Python wire order is fixed without changing existing JavaScript digests. A deterministic, deliberately adversarial 519-case regression records 439 mismatches under the prior Python ordering and zero after the fix; the seven selected examples show exact old and new bytes. This remains project-maintained evidence for a restricted JSON subset. +The [wire-envelope cross-runtime check](independent/python/WIRE_RUNTIME.md) now compares 19 selected valid and malformed cases in JavaScript and Python. It catches extra fields, duplicate capabilities, and ambiguous timestamps before applications accept a session; CI publishes the compact comparison report. The numeric canonicalization limits described there still apply. + [![One action produced two hashes when Python sorted numeric-looking keys differently from JavaScript; the fixed Python path now matches.](docs/key-order-counterexample.svg)](https://aruintelligence.github.io/aml-core/key-order-lab.html) **A spent grant must stay spent:** [Watch the local replay counterexample](https://aruintelligence.github.io/aml-core/grant-directory-lab.html?scenario=writable-root). A misconfigured writable directory let the former one-use file store accept the same test grant twice after its claim directory was replaced. The store now rejects weak ownership, permissions, and symlinks before issuance or consumption. Four before/after scenarios run the pinned historical module and corrected module in CI. This project-authored POSIX simulation does not involve a second operating-system user or real tool side effect. diff --git a/docs/INTEROPERABILITY_STANDARD.md b/docs/INTEROPERABILITY_STANDARD.md index 72a74224..35e8a94f 100644 --- a/docs/INTEROPERABILITY_STANDARD.md +++ b/docs/INTEROPERABILITY_STANDARD.md @@ -30,6 +30,8 @@ The current mechanism is a hash commitment/inclusion proof. It is deliberately * `createWireEnvelope()` wraps a typed payload in an explicit protocol/version/capability header. This creates a stable boundary for future cross-runtime transport. +`validateWireEnvelope()` now applies the required fields, unknown-field, capability uniqueness, and optional metadata checks used by the [Python wire verifier](../independent/python/WIRE_RUNTIME.md). Their 19-case mutation report is saved by CI. Both apply a calendar-valid timestamp subset with an explicit timezone and up to millisecond precision; callers with looser timestamps or extra properties must normalize them before validation. The cross-runtime result covers those fixtures, not arbitrary JSON numeric canonicalization. + ## Why this matters A standard becomes much more credible when two independently written systems can: diff --git a/independent/python/WIRE_RUNTIME.md b/independent/python/WIRE_RUNTIME.md index 09f419f4..825e2782 100644 --- a/independent/python/WIRE_RUNTIME.md +++ b/independent/python/WIRE_RUNTIME.md @@ -10,12 +10,12 @@ python3 independent/python/wire_runtime.py negotiate protocol/discovery.json pro python3 independent/python/wire_runtime.py verify passport path/to/passport.json --now 2030-01-01T12:00:00Z ``` -The first command creates artifacts with the JavaScript runtime, checks the same artifacts and mutations with Python, and removes temporary vectors. The Python module itself is standalone. Its CLI exits 0 for a valid artifact or accepted session and 1 for an invalid artifact or rejected session, with a JSON report on stdout. +The first command creates artifacts with the JavaScript runtime, compares 19 selected envelope cases against Python, checks five other artifact classes and mutations, prints a compact JSON report, and removes temporary vectors. CI uploads that report as `aml-wire-envelope-cross-runtime-`. The Python module itself is standalone. Its CLI exits 0 for a valid artifact or accepted session and 1 for an invalid artifact or rejected session, with a JSON report on stdout. ## Interoperability boundaries found - RFC 0005 and 0006 describe object-key sorting but do not define numeric JSON serialization or the locale used for selective-disclosure claim ordering. This implementation accepts only safe integers in hashed JSON and lowercase ASCII-letter disclosure keys; other values fail explicitly. This is a documented subset, not a complete cross-language canonicalization contract. -- `aml-wire/1` JSON Schema requires a unique capability list, payload property, and no unknown fields. The JavaScript `validateWireEnvelope` helper checks only the protocol, header, allowed kind, and expiry. Python applies the stricter schema-shaped checks; this can reject an envelope the helper accepts. +- JavaScript and Python now agree on the selected envelope structure, capability, metadata, kind, and time mutation cases in `scripts/check-python-wire-runtime.mjs`. Both reject unknown fields and duplicate capabilities. The portable timestamp profile accepts a calendar-valid ISO date-time with a timezone and at most three fractional second digits. The published schema describes this subset; the validators should still be checked on any newly supported data shapes. - The passport schema excludes extra fields, while the JavaScript verifier hashes whatever additional fields appear. Python rejects extra fields. Both implementations reject modified hash-bound preferences and expired passports on the tested subset. - A disclosure commitment proves that the revealed values and hidden leaf hashes fit one root. It does not prove the hidden values, the holder's identity, or the truth of the claims. - Causal graph verification proves the integrity of declared parent links and topology; it does not prove that the declared causal relationship is true. diff --git a/independent/python/wire_runtime.py b/independent/python/wire_runtime.py index a4a5f844..abe5de92 100644 --- a/independent/python/wire_runtime.py +++ b/independent/python/wire_runtime.py @@ -13,6 +13,7 @@ HEX = re.compile(r"^[0-9a-f]{64}$") LOWER_KEY = re.compile(r"^[a-z]+$") VERSION = re.compile(r"^[0-9]+(?:\.[0-9]+)*$") +WIRE_TIME = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,3})?(?:Z|[+-][0-9]{2}:[0-9]{2})\Z") MAX_SAFE_INTEGER = 2**53 - 1 @@ -73,6 +74,14 @@ def parse_time(value): return None +def parse_wire_time(value): + # A shared millisecond ISO subset avoids Date.parse accepting normalized + # calendar dates or Python accepting fractions JavaScript would truncate. + if not isinstance(value, str) or not WIRE_TIME.fullmatch(value): + return None + return parse_time(value) + + def compare_versions(left, right): if left == right: return 0 @@ -124,13 +133,13 @@ def verify_envelope(envelope, *, now=None, allowed_kinds=()): if envelope.get(key) is not None and not isinstance(envelope[key], str): return failure("invalid_" + key) for key in ("issued_at", "expires_at"): - if envelope.get(key) is not None and parse_time(envelope[key]) is None: + if envelope.get(key) is not None and parse_wire_time(envelope[key]) is None: return failure("invalid_" + key) if now is not None: - current = parse_time(now) + current = parse_wire_time(now) if current is None: return failure("invalid_now") - expiry = parse_time(envelope.get("expires_at")) + expiry = parse_wire_time(envelope.get("expires_at")) if expiry is not None and current >= expiry: return failure("expired") return {"valid": True, "reason": None, "kind": envelope["kind"], "version": envelope["version"]} diff --git a/protocol/wireProtocol.js b/protocol/wireProtocol.js index e86a5a6f..3014fab2 100644 --- a/protocol/wireProtocol.js +++ b/protocol/wireProtocol.js @@ -1,5 +1,25 @@ import { highestCommonProtocolVersion } from "./versionOrder.js"; +const REQUIRED_ENVELOPE_FIELDS = ["protocol", "version", "kind", "capabilities", "payload"]; +const OPTIONAL_ENVELOPE_FIELDS = ["session_id", "nonce", "issued_at", "expires_at"]; +const ENVELOPE_FIELDS = new Set([...REQUIRED_ENVELOPE_FIELDS, ...OPTIONAL_ENVELOPE_FIELDS]); +const WIRE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-](\d{2}):(\d{2}))$/; + +function wireTimestamp(value) { + if (typeof value !== "string") return null; + const match = WIRE_TIME.exec(value); + if (!match) return null; + const [, year, month, day, hour, minute, second, , , zoneHour, zoneMinute] = match; + const y = Number(year), m = Number(month), d = Number(day); + const leap = y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (y < 1 || m < 1 || m > 12 || d < 1 || d > days[m - 1] || Number(hour) > 23 || + Number(minute) > 59 || Number(second) > 59 || + (zoneHour !== undefined && (Number(zoneHour) > 23 || Number(zoneMinute) > 59))) return null; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; +} + export function createWireEnvelope({ kind, payload, @@ -25,10 +45,34 @@ export function createWireEnvelope({ } export function validateWireEnvelope(envelope, { allowedKinds = [], now = null } = {}) { - if (!envelope || envelope.protocol !== "aml-wire/1") return { valid: false, reason: "invalid_protocol" }; - if (!envelope.version || !envelope.kind) return { valid: false, reason: "missing_header" }; + if (!envelope || typeof envelope !== "object" || Array.isArray(envelope) || + envelope.protocol !== "aml-wire/1") return { valid: false, reason: "invalid_protocol" }; + if (REQUIRED_ENVELOPE_FIELDS.some(field => !Object.hasOwn(envelope, field) || envelope[field] === undefined) || + Object.keys(envelope).some(field => !ENVELOPE_FIELDS.has(field))) { + return { valid: false, reason: "invalid_structure" }; + } + if (typeof envelope.version !== "string" || !envelope.version || + typeof envelope.kind !== "string" || !envelope.kind) return { valid: false, reason: "missing_header" }; + const caps = envelope.capabilities; + if (!Array.isArray(caps) || caps.some(cap => typeof cap !== "string") || + caps.length !== new Set(caps).size) return { valid: false, reason: "invalid_capabilities" }; if (allowedKinds.length && !allowedKinds.includes(envelope.kind)) return { valid: false, reason: "unsupported_kind" }; - if (now && envelope.expires_at && new Date(now) >= new Date(envelope.expires_at)) return { valid: false, reason: "expired" }; + for (const field of ["session_id", "nonce"]) { + if (envelope[field] != null && typeof envelope[field] !== "string") { + return { valid: false, reason: `invalid_${field}` }; + } + } + for (const field of ["issued_at", "expires_at"]) { + if (envelope[field] != null && wireTimestamp(envelope[field]) === null) { + return { valid: false, reason: `invalid_${field}` }; + } + } + if (now !== null && now !== undefined) { + const current = wireTimestamp(now); + if (current === null) return { valid: false, reason: "invalid_now" }; + const expiry = envelope.expires_at == null ? null : wireTimestamp(envelope.expires_at); + if (expiry !== null && current >= expiry) return { valid: false, reason: "expired" }; + } return { valid: true, reason: null, kind: envelope.kind, version: envelope.version }; } diff --git a/schema/wire-envelope.schema.json b/schema/wire-envelope.schema.json index da461bc9..3928c870 100644 --- a/schema/wire-envelope.schema.json +++ b/schema/wire-envelope.schema.json @@ -11,8 +11,8 @@ "capabilities": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "session_id": { "type": ["string", "null"] }, "nonce": { "type": ["string", "null"] }, - "issued_at": { "type": ["string", "null"], "format": "date-time" }, - "expires_at": { "type": ["string", "null"], "format": "date-time" }, + "issued_at": { "type": ["string", "null"], "format": "date-time", "description": "Portable verifier profile: ISO time with timezone and at most millisecond precision; validators also check calendar validity.", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?(?:Z|[+-]\\d{2}:\\d{2})$" }, + "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "Portable verifier profile: ISO time with timezone and at most millisecond precision; validators also check calendar validity.", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?(?:Z|[+-]\\d{2}:\\d{2})$" }, "payload": {} }, "additionalProperties": false diff --git a/scripts/check-python-wire-runtime.mjs b/scripts/check-python-wire-runtime.mjs index 1ad05311..dbc38b05 100644 --- a/scripts/check-python-wire-runtime.mjs +++ b/scripts/check-python-wire-runtime.mjs @@ -57,9 +57,41 @@ try { assert.equal(validateWireEnvelope(badEnvelope).valid, false); verify("envelope", "bad-envelope", badEnvelope, false); verify("envelope", "expired-envelope", envelope, false, ["--now", "2030-01-02T00:00:00Z"]); - const extraEnvelope = { ...envelope, unregistered_field: true }; - assert.equal(validateWireEnvelope(extraEnvelope).valid, true); - assert.equal(verify("envelope", "extra-envelope", extraEnvelope, false).reason, "invalid_structure"); + const envelopeCases = [ + ["valid-offset", {}, null, { now: "2030-01-01T11:00:00-01:00" }], + ["valid-empty-caps", { capabilities: [] }, null], + ["extra-field", { unregistered_field: true }, "invalid_structure"], + ["missing-payload", { payload: undefined }, "invalid_structure"], + ["missing-caps", { capabilities: undefined }, "invalid_structure"], + ["duplicate-capability", { capabilities: ["policy-passports", "policy-passports"] }, "invalid_capabilities"], + ["numeric-capability", { capabilities: [1] }, "invalid_capabilities"], + ["non-array-capabilities", { capabilities: "policy-passports" }, "invalid_capabilities"], + ["numeric-kind", { kind: 1 }, "missing_header"], + ["numeric-version", { version: 1 }, "missing_header"], + ["numeric-nonce", { nonce: 1 }, "invalid_nonce"], + ["numeric-session", { session_id: 1 }, "invalid_session_id"], + ["invalid-issued", { issued_at: "not-a-date" }, "invalid_issued_at"], + ["invalid-calendar-day", { issued_at: "2030-02-30T00:00:00Z" }, "invalid_issued_at"], + ["submillisecond-expiry", { expires_at: "2030-01-02T00:00:00.0001Z" }, "invalid_expires_at"], + ["invalid-zone", { expires_at: "2030-01-02T00:00:00+24:00" }, "invalid_expires_at"], + ["invalid-now", {}, "invalid_now", { now: "not-a-date" }], + ["expired", {}, "expired", { now: "2030-01-02T00:00:00Z" }], + ["unsupported-kind", {}, "unsupported_kind", { allowedKinds: ["other-kind"] }] + ]; + const envelopeResults = []; + for (const [name, changes, expectedReason, options = {}] of envelopeCases) { + const candidate = { ...envelope, ...changes }; + const js = validateWireEnvelope(candidate, options); + const extra = [ + ...(options.now ? ["--now", options.now] : []), + ...(options.allowedKinds || []).flatMap(value => ["--allowed-kind", value]) + ]; + const python = verify("envelope", name, candidate, expectedReason === null, extra); + assert.deepEqual({ valid: js.valid, reason: js.reason }, + { valid: python.valid, reason: python.reason }, name); + assert.equal(js.reason, expectedReason, name); + envelopeResults.push({ id: name, valid: js.valid, reason: js.reason, runtimes_agree: true }); + } const passport = createPolicyPassport({ subject: "example", profile: "calm_default", preferences: { reduced_motion: true, attention_budget: 3 }, @@ -108,7 +140,10 @@ try { assert.equal(verifyCausalExecutionGraph(orphanGraph).valid, false); assert.equal(verify("graph", "orphan-graph", orphanGraph, false).reason, "missing_parent"); - console.log("Python wire runtime: negotiation, five artifact classes, and tamper cases passed"); + console.log(JSON.stringify({ protocol: "aml-wire-envelope-cross-runtime/1", passed: true, + envelope_case_count: envelopeResults.length, envelope_cases: envelopeResults, + other_checks: "negotiation, five artifact classes, and tamper cases passed", + claim_boundary: "Project-authored JavaScript/Python comparison of selected wire fixtures; not complete JSON interoperability or external validation." }, null, 2)); } finally { fs.rmSync(temp, { recursive: true, force: true }); }