From e87ce9a25db1f9be00de51e6c28a0562536ddd72 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: Fri, 16 Oct 2026 19:10:49 -0700 Subject: [PATCH] Check receipt and evidence times in witness replay --- docs/DETACHED_VERIFIER.md | 14 ++++--- docs/VERIFIER_PLURALITY.md | 8 ++-- docs/aml-witness-bundle.js | 9 ++++- independent/python/README.md | 5 ++- independent/python/verify_witness.py | 5 ++- scripts/check-signed-quorum-python.mjs | 51 +++++++++++++++++++++++++- test/detached-witness.test.js | 40 +++++++++++++++++++- 7 files changed, 117 insertions(+), 15 deletions(-) diff --git a/docs/DETACHED_VERIFIER.md b/docs/DETACHED_VERIFIER.md index de67b9d8..88eb6881 100644 --- a/docs/DETACHED_VERIFIER.md +++ b/docs/DETACHED_VERIFIER.md @@ -99,12 +99,14 @@ command is in the current preview checkout, not the pinned `v1.3.0` stable package. After the signature and hashes pass, witness replay also checks the claimed -timeline: the attestation issue time and bundle creation time must use exact -UTC milliseconds; the attestation cannot predate the challenge issue, and -bundle creation must follow the attestation, precede challenge expiry, and -have occurred by the verifier's replay clock. These fields are supplied -claims, not trusted timestamps. A resealed bundle with an impossible -creation time is rejected even when its signature remains valid. +timeline: evidence generation, attestation issue, and bundle creation must +use exact UTC milliseconds. A receipt's generation time must also use that +spelling when present. The receipt cannot postdate its evidence; evidence +cannot postdate its signed attestation; the attestation cannot predate the +challenge issue; and bundle creation must follow the attestation, precede +challenge expiry, and have occurred by the verifier's replay clock. These +fields are supplied claims, not trusted timestamps. A resealed bundle with +an impossible timeline is rejected even when its signature remains valid. ## Claim boundary diff --git a/docs/VERIFIER_PLURALITY.md b/docs/VERIFIER_PLURALITY.md index 49df172c..7ce0cd45 100644 --- a/docs/VERIFIER_PLURALITY.md +++ b/docs/VERIFIER_PLURALITY.md @@ -420,9 +420,11 @@ challenge freshness and binding, and session signature. An explicit challenge's inclusive issue-to-expiry interval, and cannot precede the bundle's or attestation's canonical creation time or exceed the single local replay instant. The underlying bundle replay also rejects malformed UTC -millisecond creation times, an attestation before challenge issue, a bundle -before attestation or after challenge expiry, and a bundle created after the -replay instant. A malformed, premature, or future claimed check time is a +millisecond evidence, attestation, or bundle times; an optional receipt time +with malformed spelling; a receipt after its evidence; evidence after +attestation; an attestation before challenge issue; a bundle before +attestation or after challenge expiry; and a bundle created after the replay +instant. A malformed, premature, or future claimed check time is a reported conflict. These times are artifact and signer assertions. The comparison cannot prove the signer actually performed the check at that time; a trusted time source would require separate evidence. The result includes diff --git a/docs/aml-witness-bundle.js b/docs/aml-witness-bundle.js index 32e62576..fa082775 100644 --- a/docs/aml-witness-bundle.js +++ b/docs/aml-witness-bundle.js @@ -25,7 +25,14 @@ function canonicalTime(value) { function bundleTimeFailure(bundle, now) { const attested = canonicalTime(bundle.attestation.issued_at); const created = canonicalTime(bundle.created_at); - if (attested === null || created === null) return 'AML_WITNESS_BUNDLE_TIME_INVALID'; + const evidenced = canonicalTime(bundle.evidence.generated_at); + const hasReceiptTime = Object.hasOwn(bundle.evidence.receipt, 'generated_at'); + const receipted = hasReceiptTime ? canonicalTime(bundle.evidence.receipt.generated_at) : null; + if (attested === null || created === null || evidenced === null || + (hasReceiptTime && receipted === null)) return 'AML_WITNESS_BUNDLE_TIME_INVALID'; + if ((hasReceiptTime && receipted > evidenced) || evidenced > attested) { + return 'AML_WITNESS_BUNDLE_TIME_ORDER_INVALID'; + } if (attested < Date.parse(bundle.challenge.issued_at) || created < attested || created > Date.parse(bundle.challenge.expires_at)) { return 'AML_WITNESS_BUNDLE_TIME_ORDER_INVALID'; diff --git a/independent/python/README.md b/independent/python/README.md index cae62276..b7b56f32 100644 --- a/independent/python/README.md +++ b/independent/python/README.md @@ -152,8 +152,9 @@ test fixtures only; a historical override is not a live freshness check. - challenge schema, nonce, issue time and expiry; - equality with a separately retained, fresh challenge when supplied; - challenge/evidence equality inside the session attestation; -- canonical UTC millisecond attestation/bundle times and their claimed order - from challenge issue through local replay and challenge expiry; +- canonical UTC millisecond evidence, attestation, and bundle times (and a + receipt time if present), with claimed order from receipt through evidence, + attestation, bundle creation, and the local replay clock; - P-256 public key is on curve; - public-JWK SHA-256 fingerprint; - raw 64-byte P-256 ECDSA signature over the canonical attestation payload. diff --git a/independent/python/verify_witness.py b/independent/python/verify_witness.py index 1fd9a753..c52c9ae3 100644 --- a/independent/python/verify_witness.py +++ b/independent/python/verify_witness.py @@ -286,7 +286,10 @@ def canonical_utc_millis(value): attested = canonical_utc_millis(attestation.get("issued_at")) created = canonical_utc_millis(bundle.get("created_at")) - if attested < parse_time(challenge["issued_at"]) or created < attested or \ + evidenced = canonical_utc_millis(evidence.get("generated_at")) + receipted = canonical_utc_millis(receipt["generated_at"]) if "generated_at" in receipt else None + if (receipted is not None and receipted > evidenced) or evidenced > attested or \ + attested < parse_time(challenge["issued_at"]) or created < attested or \ created > parse_time(challenge["expires_at"]): return False, "AML_PY_BUNDLE_TIME_ORDER_INVALID" if created > now: diff --git a/scripts/check-signed-quorum-python.mjs b/scripts/check-signed-quorum-python.mjs index 8702e6c4..9b4aa06f 100644 --- a/scripts/check-signed-quorum-python.mjs +++ b/scripts/check-signed-quorum-python.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { webcrypto } from 'node:crypto'; import { canonicalJSONStringifyBrowser, sealBrowserReceipt, sha256Browser } from '../docs/aml-browser-integrity.js'; -import { createBrowserEvidence } from '../docs/aml-browser-evidence.js'; +import { browserEvidencePayload, createBrowserEvidence } from '../docs/aml-browser-evidence.js'; import { createVerificationChallenge } from '../docs/aml-verification-challenge.js'; import { createSessionAttestation } from '../docs/aml-session-attestation.js'; import { createWitnessBundle, verifyWitnessBundle, witnessBundlePayload } from '../docs/aml-witness-bundle.js'; @@ -638,6 +638,53 @@ try { assert.equal(jsResult.reason, jsReason, name); } + // Sign altered evidence afresh so both runtimes reach the chronology check + // after all hashes and the session signature pass. + for (const [name, receiptTime, evidenceTime, jsReason, pyReason] of [ + ['receipt-after-evidence', 'future', 'current', + 'AML_WITNESS_BUNDLE_TIME_ORDER_INVALID', 'AML_PY_BUNDLE_TIME_ORDER_INVALID'], + ['evidence-after-attestation', 'past', 'future', + 'AML_WITNESS_BUNDLE_TIME_ORDER_INVALID', 'AML_PY_BUNDLE_TIME_ORDER_INVALID'], + ['malformed-receipt-time', 'malformed', 'current', + 'AML_WITNESS_BUNDLE_TIME_INVALID', 'AML_PY_BUNDLE_TIME_INVALID'], + ['missing-evidence-time', 'past', 'missing', + 'AML_WITNESS_BUNDLE_TIME_INVALID', 'AML_PY_BUNDLE_TIME_INVALID'] + ]) { + const receipt = await sealBrowserReceipt({ schema: 'aml-dom-receipt/1', decisions: [], + generated_at: receiptTime === 'future' ? new Date(Date.now() + 30000).toISOString() : + receiptTime === 'malformed' ? '2030-02-30T00:00:00.000Z' : + new Date(Date.now() - 1000).toISOString() }); + const candidateEvidence = await createBrowserEvidence(receipt, { url: 'https://example.test/' }); + if (evidenceTime === 'future') { + candidateEvidence.generated_at = new Date(Date.now() + 30000).toISOString(); + } else if (evidenceTime === 'missing') { + delete candidateEvidence.generated_at; + } + candidateEvidence.integrity.value = await sha256Browser(browserEvidencePayload(candidateEvidence)); + const candidateChallenge = createVerificationChallenge({ ttl_ms: 120000 }); + const candidateAttestation = await createSessionAttestation({ + evidence: candidateEvidence, challenge: candidateChallenge + }); + const candidateBundle = { + schema: 'aml-witness-bundle/1', prototype: true, created_at: new Date().toISOString(), + evidence: candidateEvidence, challenge: candidateChallenge, attestation: candidateAttestation + }; + candidateBundle.integrity = { schema: 'aml-integrity/1', algorithm: 'SHA-256', + canonicalization: 'sorted-json-v1', + value: await sha256Browser(witnessBundlePayload(candidateBundle)) }; + const jsResult = await verifyWitnessBundle(candidateBundle, { + expectedChallenge: candidateChallenge + }); + assert.equal(jsResult.reason, jsReason, name); + assert.equal(jsResult.checks.session_signature, true, name); + const pyRun = spawnSync('python3', [witnessPython, + save(`${name}-bundle.json`, candidateBundle), '--challenge', + save(`${name}-challenge.json`, candidateChallenge)], + { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } }); + assert.equal(pyRun.status, 1, `${name}: ${pyRun.stderr}`); + assert.equal(JSON.parse(pyRun.stdout).reason, pyReason, name); + } + console.log(JSON.stringify({ protocol: 'aml-signed-quorum-python-cross-runtime/1', passed: true, cases: checks, @@ -652,6 +699,8 @@ try { 'fixed_vector_bundle_calendar', 'fixed_vector_bundle_before_attestation', 'fixed_vector_bundle_after_expiry', 'fixed_vector_bundle_after_replay', 'fixed_vector_attestation_before_challenge', + 'signed_receipt_after_evidence', 'signed_evidence_after_attestation', + 'signed_malformed_receipt_time', 'signed_missing_evidence_time', 'null_input_expectations_rejected', 'null_held_policy_rejected'], claim_boundary: 'Project-authored Python and JavaScript implementations agree on these saved quorum, held-policy, and live witness-replay cases. This is not an external witness, signer identity, or independent certification.' }, null, 2)); diff --git a/test/detached-witness.test.js b/test/detached-witness.test.js index 37e88637..9b669e02 100644 --- a/test/detached-witness.test.js +++ b/test/detached-witness.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { canonicalJSONStringifyBrowser, sealBrowserReceipt, sha256Browser } from '../docs/aml-browser-integrity.js'; -import { createBrowserEvidence } from '../docs/aml-browser-evidence.js'; +import { browserEvidencePayload, createBrowserEvidence } from '../docs/aml-browser-evidence.js'; import { createVerificationChallenge, verifyVerificationChallenge } from '../docs/aml-verification-challenge.js'; import { createSessionAttestation, @@ -184,6 +184,44 @@ test('bundle creation refuses an attestation dated before challenge issue', asyn /AML_WITNESS_BUNDLE_TIME_ORDER_INVALID/); }); +test('receipt and evidence time claims cannot postdate their signed attestation', async () => { + for (const [name, mutateReceipt, mutateEvidence, reason] of [ + ['receipt-after-evidence', receipt => { + receipt.generated_at = new Date(Date.now() + 30000).toISOString(); + }, () => {}, 'AML_WITNESS_BUNDLE_TIME_ORDER_INVALID'], + ['evidence-after-attestation', () => {}, evidence => { + evidence.generated_at = new Date(Date.now() + 30000).toISOString(); + }, 'AML_WITNESS_BUNDLE_TIME_ORDER_INVALID'], + ['invalid-receipt-date', receipt => { + receipt.generated_at = '2030-02-30T00:00:00.000Z'; + }, () => {}, 'AML_WITNESS_BUNDLE_TIME_INVALID'], + ['missing-evidence-time', () => {}, evidence => { + delete evidence.generated_at; + }, 'AML_WITNESS_BUNDLE_TIME_INVALID'] + ]) { + const source = await createFixture(); + const receipt = structuredClone(source.receipt); + mutateReceipt(receipt); + await sealBrowserReceipt(receipt); + const evidence = await createBrowserEvidence(receipt, { url: 'https://example.test/' }); + mutateEvidence(evidence); + evidence.integrity.value = await sha256Browser(browserEvidencePayload(evidence)); + const challenge = createVerificationChallenge({ ttl_ms: 120000 }); + const attestation = await createSessionAttestation({ evidence, challenge }); + await assert.rejects(createWitnessBundle({ evidence, challenge, attestation }), + new RegExp(reason), name); + const bundle = { + schema: 'aml-witness-bundle/1', prototype: true, created_at: new Date().toISOString(), + evidence, challenge, attestation + }; + bundle.integrity = { schema: 'aml-integrity/1', algorithm: 'SHA-256', + canonicalization: 'sorted-json-v1', value: await sha256Browser(witnessBundlePayload(bundle)) }; + const result = await verifyWitnessBundle(bundle, { expectedChallenge: challenge }); + assert.equal(result.reason, reason, name); + assert.equal(result.checks.session_signature, true, name); + } +}); + test('signed witness cannot pass before its challenge issue time', async () => { const { bundle, challenge } = await createFixture(); const beforeIssue = Date.parse(challenge.issued_at) - 1;