diff --git a/docs/VERIFIER_PLURALITY.md b/docs/VERIFIER_PLURALITY.md index 0c794900..7e0eb093 100644 --- a/docs/VERIFIER_PLURALITY.md +++ b/docs/VERIFIER_PLURALITY.md @@ -57,6 +57,16 @@ objects. A later edit requires a new verification call. The CLI reads saved files; this behavior does not authenticate who supplied them or prevent a file from changing before it is read. +The browser receipt, evidence, session attestation, and witness bundle helpers +also copy ordinary JSON inputs before their first asynchronous check. Direct +verification results describe those call-time bytes. Evidence and bundle +creators embed detached copies of the inputs they checked, so later caller +edits cannot change the generated packet. Receipt sealing preserves its +published object identity and rejects a payload edited while hashing; it +does not attach a seal to stale bytes. Recheck any packet after changing it. +These rules prevent a live-object race, not a compromised page or false +claims in the supplied JSON. + From a checkout or installed preview package, the offline command is: ```bash diff --git a/docs/aml-browser-evidence.js b/docs/aml-browser-evidence.js index 81363b48..28b3f642 100644 --- a/docs/aml-browser-evidence.js +++ b/docs/aml-browser-evidence.js @@ -1,4 +1,5 @@ -import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser, verifyBrowserReceipt } from './aml-browser-integrity.js'; +import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser, + snapshotBrowserJSON, verifyBrowserReceipt } from './aml-browser-integrity.js'; let evidenceRevision = 0; @@ -20,6 +21,9 @@ export async function createBrowserEvidence( { url = globalThis.location?.href || 'about:blank' } = {} ) { if (!receipt?.integrity) throw new Error('AML_BROWSER_EVIDENCE_REQUIRES_SEALED_RECEIPT'); + try { receipt = snapshotBrowserJSON(receipt); } + catch { throw new Error('AML_BROWSER_EVIDENCE_RECEIPT_INVALID'); } + url = snapshotBrowserJSON(url); const receiptCheck = await verifyBrowserReceipt(receipt); if (!receiptCheck.valid) throw new Error('AML_BROWSER_EVIDENCE_RECEIPT_INVALID'); @@ -31,7 +35,7 @@ export async function createBrowserEvidence( url, generated_at: new Date().toISOString(), receipt, - zone_violations: currentViolations() + zone_violations: snapshotBrowserJSON(currentViolations()) }; evidence.integrity = { @@ -59,7 +63,7 @@ export async function createBrowserEvidence( export async function verifyBrowserEvidence(evidence) { try { - assertBrowserJSON(evidence); + evidence = snapshotBrowserJSON(evidence); if (!evidence?.integrity || evidence.integrity.schema !== 'aml-integrity/1') { return { valid: false, reason: 'AML_BROWSER_EVIDENCE_UNSEALED' }; } diff --git a/docs/aml-browser-integrity.js b/docs/aml-browser-integrity.js index c6cc6e18..daf36029 100644 --- a/docs/aml-browser-integrity.js +++ b/docs/aml-browser-integrity.js @@ -115,8 +115,14 @@ export function browserReceiptPayload(receipt) { } export async function sealBrowserReceipt(receipt) { - const payload = browserReceiptPayload(receipt); + const payload = snapshotBrowserJSON(browserReceiptPayload(receipt)); const value = await sha256Browser(payload); + // The live receipt is published before its digest resolves. Preserve that + // object identity, but never attach a seal for stale payload bytes. + if (canonicalJSONStringifyBrowser(browserReceiptPayload(receipt)) !== + canonicalJSONStringifyBrowser(payload)) { + throw new Error('AML_BROWSER_RECEIPT_CHANGED_DURING_SEAL'); + } receipt.integrity = { schema: 'aml-integrity/1', algorithm: 'SHA-256', @@ -128,7 +134,7 @@ export async function sealBrowserReceipt(receipt) { export async function verifyBrowserReceipt(receipt) { try { - assertBrowserJSON(receipt); + receipt = snapshotBrowserJSON(receipt); if (!receipt?.integrity || receipt.integrity.schema !== 'aml-integrity/1') { return { valid: false, reason: 'AML_BROWSER_RECEIPT_UNSEALED' }; } diff --git a/docs/aml-session-attestation.js b/docs/aml-session-attestation.js index b3dba1d3..354672eb 100644 --- a/docs/aml-session-attestation.js +++ b/docs/aml-session-attestation.js @@ -3,7 +3,8 @@ // This proves possession of the ephemeral session key and exact payload binding. // It does NOT prove human identity, organization identity, authorization, intent truth, safety, or policy quality. -import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser } from './aml-browser-integrity.js'; +import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser, + snapshotBrowserJSON } from './aml-browser-integrity.js'; import { verifyBrowserEvidence } from './aml-browser-evidence.js'; import { verifyVerificationChallenge } from './aml-verification-challenge.js'; @@ -56,6 +57,7 @@ export async function createSessionAttestation({ evidence = globalThis.__AML_EVIDENCE__, challenge } = {}) { + ({ evidence, challenge } = snapshotBrowserJSON({ evidence, challenge })); const evidenceCheck = await verifyBrowserEvidence(evidence); if (!evidenceCheck.valid) throw new Error('AML_SESSION_EVIDENCE_INVALID'); const challengeCheck = verifyVerificationChallenge(challenge); @@ -100,13 +102,29 @@ export async function verifySessionAttestation({ evidence, challenge, attestatio }; const fail = (reason, extra = {}) => ({ valid: false, reason, checks: { ...checks }, ...extra }); try { - const evidenceCheck = await verifyBrowserEvidence(evidence); + // Capture all three inputs before the first await, but retain the usual + // evidence -> challenge -> attestation error order for malformed inputs. + const capture = value => { + try { return { value: snapshotBrowserJSON(value) }; } + catch (error) { return { error }; } + }; + const evidenceInput = capture(evidence); + const challengeInput = capture(challenge); + const attestationInput = capture(attestation); + const evidenceCheck = evidenceInput.error + ? { valid: false, reason: evidenceInput.error?.message || 'AML_BROWSER_EVIDENCE_VERIFY_ERROR' } + : await verifyBrowserEvidence(evidenceInput.value); checks.receipt_integrity = evidenceCheck.receipt?.valid === true; if (!evidenceCheck.valid) return fail('AML_SESSION_EVIDENCE_INVALID', { evidence: evidenceCheck }); checks.evidence_integrity = true; + if (challengeInput.error) return fail(challengeInput.error?.message || 'AML_CHALLENGE_INVALID'); + challenge = challengeInput.value; + evidence = evidenceInput.value; const challengeCheck = verifyVerificationChallenge(challenge, { now }); if (!challengeCheck.valid) return fail(challengeCheck.reason); checks.challenge_freshness = true; + if (attestationInput.error) return fail(attestationInput.error?.message || 'AML_SESSION_VERIFY_ERROR'); + attestation = attestationInput.value; assertBrowserJSON(attestation); if (!attestation || attestation.schema !== 'aml-session-attestation/1') { return fail('AML_SESSION_ATTESTATION_INVALID'); diff --git a/docs/aml-witness-bundle.js b/docs/aml-witness-bundle.js index fa082775..6f82bc43 100644 --- a/docs/aml-witness-bundle.js +++ b/docs/aml-witness-bundle.js @@ -1,7 +1,8 @@ // ĀML witness bundle — SHIPPED reference prototype. // Packages evidence + verifier challenge + ephemeral-session attestation into one portable JSON object. -import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser } from './aml-browser-integrity.js'; +import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser, + snapshotBrowserJSON } from './aml-browser-integrity.js'; import { verifySessionAttestation } from './aml-session-attestation.js'; import { verifyVerificationChallenge } from './aml-verification-challenge.js'; @@ -41,6 +42,7 @@ function bundleTimeFailure(bundle, now) { } export async function createWitnessBundle({ evidence, challenge, attestation }) { + ({ evidence, challenge, attestation } = snapshotBrowserJSON({ evidence, challenge, attestation })); const attestationCheck = await verifySessionAttestation({ evidence, challenge, attestation }); if (!attestationCheck.valid) throw new Error(attestationCheck.reason); const createdNow = Date.now(); @@ -75,7 +77,12 @@ export async function verifyWitnessBundle(bundle, { now = Date.now(), expectedCh }; const fail = (reason, extra = {}) => ({ valid: false, reason, checks: { ...checks }, ...extra }); try { - assertBrowserJSON(bundle); + let heldInputError; + if (expectedChallenge !== undefined) { + try { expectedChallenge = snapshotBrowserJSON(expectedChallenge); } + catch (error) { heldInputError = error; } + } + bundle = snapshotBrowserJSON(bundle); if (!bundle?.integrity || bundle.integrity.schema !== 'aml-integrity/1') { return fail('AML_WITNESS_BUNDLE_UNSEALED'); } @@ -95,6 +102,7 @@ export async function verifyWitnessBundle(bundle, { now = Date.now(), expectedCh } checks.bundle_integrity = true; if (expectedChallenge !== undefined) { + if (heldInputError) return fail(heldInputError?.message || 'AML_CHALLENGE_INVALID'); const heldChallenge = verifyVerificationChallenge(expectedChallenge, { now }); if (!heldChallenge.valid) return fail(heldChallenge.reason); if (canonicalJSONStringifyBrowser(bundle.challenge) !== canonicalJSONStringifyBrowser(expectedChallenge)) { diff --git a/test/browser-packet-json-integrity.test.js b/test/browser-packet-json-integrity.test.js index 26b2b891..9a7272f2 100644 --- a/test/browser-packet-json-integrity.test.js +++ b/test/browser-packet-json-integrity.test.js @@ -99,6 +99,42 @@ test('browser evidence rejects receipt and packet changes invisible to their has assert.equal((await verifyBrowserEvidence(evidence)).reason, 'AML_BROWSER_EVIDENCE_INTEGRITY_INVALID'); delete evidence.integrity.audit_note; receipt.decisions.audit_note = 'added'; - assert.equal((await verifyBrowserEvidence(evidence)).valid, false); + // The evidence retains the sealed receipt bytes, not the caller's later edit. + assert.equal((await verifyBrowserEvidence(evidence)).valid, true); await assert.rejects(createBrowserEvidence(receipt), /AML_BROWSER_EVIDENCE_RECEIPT_INVALID/); }); + +test('receipt and evidence verification use their call-time bytes', async () => { + const receipt = await sealBrowserReceipt(makeReceipt()); + const receiptCheck = verifyBrowserReceipt(receipt); + receipt.decisions[0].id = 'changed'; + assert.equal((await receiptCheck).valid, true); + assert.equal((await verifyBrowserReceipt(receipt)).reason, 'AML_BROWSER_RECEIPT_HASH_MISMATCH'); + + const sealed = await sealBrowserReceipt(makeReceipt()); + const evidence = await createBrowserEvidence(sealed); + const evidenceCheck = verifyBrowserEvidence(evidence); + evidence.zone_violations.push({ reason: 'changed' }); + assert.equal((await evidenceCheck).valid, true); + assert.equal((await verifyBrowserEvidence(evidence)).reason, 'AML_BROWSER_EVIDENCE_HASH_MISMATCH'); +}); + +test('receipt sealing rejects a payload changed while its digest is pending', async () => { + const receipt = makeReceipt(); + const pending = sealBrowserReceipt(receipt); + receipt.decisions[0].id = 'changed'; + await assert.rejects(pending, /AML_BROWSER_RECEIPT_CHANGED_DURING_SEAL/); + assert.equal(receipt.integrity, undefined); +}); + +test('evidence creation detaches its receipt and zone violations', async () => { + const receipt = await sealBrowserReceipt(makeReceipt()); + globalThis.__AML_ZONE_VIOLATIONS__ = [{ reason: 'first' }]; + const pending = createBrowserEvidence(receipt); + receipt.decisions[0].id = 'changed'; + const evidence = await pending; + assert.equal(evidence.receipt.decisions[0].id, 'one'); + globalThis.__AML_ZONE_VIOLATIONS__[0].reason = 'second'; + assert.equal(evidence.zone_violations[0].reason, 'first'); + assert.equal((await verifyBrowserEvidence(evidence)).valid, true); +}); diff --git a/test/detached-witness.test.js b/test/detached-witness.test.js index ee8a11ec..8bb3ab41 100644 --- a/test/detached-witness.test.js +++ b/test/detached-witness.test.js @@ -63,6 +63,75 @@ test('detached witness bundle verifies under Node WebCrypto', async () => { assert.equal(bundleCheck.reason, 'AML_WITNESS_BUNDLE_VALID'); }); +test('direct session and bundle verification bind all inputs before Web Crypto yields', async () => { + const source = await createFixture(); + const evidence = structuredClone(source.evidence); + const challenge = structuredClone(source.challenge); + const attestation = structuredClone(source.attestation); + const sessionCheck = verifySessionAttestation({ evidence, challenge, attestation }); + evidence.receipt.decisions[0].purpose = 'changed after call'; + challenge.nonce = 'A'.repeat(43); + attestation.signature = 'A'.repeat(86); + const session = await sessionCheck; + assert.equal(session.valid, true); + assert.equal(session.evidence_hash, source.evidence.integrity.value); + assert.equal((await verifySessionAttestation({ evidence, challenge, attestation })).valid, false); + + const bundle = structuredClone(source.bundle); + const heldChallenge = structuredClone(source.challenge); + const pending = verifyWitnessBundle(bundle, { expectedChallenge: heldChallenge }); + bundle.evidence.receipt.decisions[0].purpose = 'changed after call'; + bundle.attestation.signature = 'A'.repeat(86); + heldChallenge.issued_at = '2030-01-01T00:00:00.000Z'; + const replay = await pending; + assert.equal(replay.valid, true); + assert.equal(replay.checks.verifier_held_challenge, true); + assert.equal((await verifyWitnessBundle(bundle, { expectedChallenge: heldChallenge })).valid, false); +}); + +test('witness creators package the inputs checked at call time', async () => { + const source = await createFixture(); + const evidence = structuredClone(source.evidence); + const challenge = structuredClone(source.challenge); + const pendingAttestation = createSessionAttestation({ evidence, challenge }); + evidence.receipt.decisions[0].purpose = 'changed after call'; + challenge.nonce = 'A'.repeat(43); + const attestation = await pendingAttestation; + assert.equal(attestation.challenge_nonce, source.challenge.nonce); + assert.equal((await verifySessionAttestation({ evidence: source.evidence, + challenge: source.challenge, attestation })).valid, true); + + const bundleEvidence = structuredClone(source.evidence); + const bundleChallenge = structuredClone(source.challenge); + const bundleAttestation = structuredClone(attestation); + const pendingBundle = createWitnessBundle({ evidence: bundleEvidence, + challenge: bundleChallenge, attestation: bundleAttestation }); + bundleEvidence.receipt.decisions[0].purpose = 'changed after call'; + bundleChallenge.nonce = 'A'.repeat(43); + bundleAttestation.signature = 'A'.repeat(86); + const bundle = await pendingBundle; + assert.equal(bundle.challenge.nonce, source.challenge.nonce); + assert.equal((await verifyWitnessBundle(bundle, { expectedChallenge: source.challenge })).valid, true); +}); + +test('hidden call-time inputs cannot become valid by deletion during replay', async () => { + const source = await createFixture(); + const held = structuredClone(source.challenge); + Object.defineProperty(held, 'unbound', { value: true, configurable: true }); + const pending = verifyWitnessBundle(source.bundle, { expectedChallenge: held }); + delete held.unbound; + assert.equal((await pending).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT'); + assert.equal((await verifyWitnessBundle(source.bundle, { expectedChallenge: held })).valid, true); + + const attestation = structuredClone(source.attestation); + Object.defineProperty(attestation.session_public_key_jwk, 'unbound', + { value: true, configurable: true }); + const pendingSession = verifySessionAttestation({ evidence: source.evidence, + challenge: source.challenge, attestation }); + delete attestation.session_public_key_jwk.unbound; + assert.equal((await pendingSession).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT'); +}); + test('sequential and concurrent attestations use isolated keys and remain replayable', async () => { const { evidence, challenge, attestation: first } = await createFixture(); const second = await createSessionAttestation({ evidence, challenge }); @@ -385,6 +454,8 @@ test('a hidden challenge field fails direct challenge, signature, and bundle che assert.equal(canonicalJSONStringifyBrowser(bundle), canonical); assert.equal(verifyVerificationChallenge(challenge).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT'); assert.equal((await verifySessionAttestation({ evidence, challenge, attestation })).valid, false); + assert.equal((await verifyWitnessBundle(bundle)).valid, true); + Object.defineProperty(bundle.challenge, 'unbound_claim', { value: 'added after signing' }); assert.equal((await verifyWitnessBundle(bundle)).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT'); }); @@ -395,6 +466,8 @@ test('an unsigned hidden attestation field fails direct signature and bundle che assert.equal(canonicalJSONStringifyBrowser(bundle), canonical); assert.equal((await verifySessionAttestation({ evidence, challenge, attestation })).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT'); + assert.equal((await verifyWitnessBundle(bundle)).valid, true); + Object.defineProperty(bundle.attestation.session_public_key_jwk, 'unbound_claim', { value: 'added' }); assert.equal((await verifyWitnessBundle(bundle)).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT'); await assert.rejects(createWitnessBundle({ evidence, challenge, attestation }), /AML_CANONICAL_JSON_NON_JSON_OBJECT/); @@ -427,7 +500,7 @@ test('verifier-held challenge detects a changed issued_at even after the bundle const { bundle, challenge } = await createFixture(); const heldChallenge = structuredClone(challenge); assert.equal((await verifyWitnessBundle(bundle, { expectedChallenge: heldChallenge })).valid, true); - challenge.issued_at = new Date(Date.parse(challenge.issued_at) - 1000).toISOString(); + bundle.challenge.issued_at = new Date(Date.parse(challenge.issued_at) - 1000).toISOString(); bundle.integrity.value = await sha256Browser(witnessBundlePayload(bundle)); // The ephemeral signature binds the nonce and expiration, not issued_at. assert.equal((await verifyWitnessBundle(bundle)).valid, true);