From d496420467fd251de7ac3b691432bea73f2df494 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:57:33 -0700 Subject: [PATCH] Isolate browser attestation keys per challenge response --- docs/DETACHED_VERIFIER.md | 5 +++++ docs/SESSION_ATTESTATION.md | 10 +++++++++ docs/aml-session-attestation.js | 30 +++++++++++--------------- scripts/check-signed-quorum-python.mjs | 14 ++++++++++++ test/detached-witness.test.js | 20 +++++++++++++++++ 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/docs/DETACHED_VERIFIER.md b/docs/DETACHED_VERIFIER.md index b5663dc7..fedb5318 100644 --- a/docs/DETACHED_VERIFIER.md +++ b/docs/DETACHED_VERIFIER.md @@ -39,6 +39,11 @@ expired challenge -> FAIL - ECDSA P-256 / SHA-256 signature; - whole `aml-witness-bundle/1` integrity. +The browser producer uses a fresh, nonextractable private key per attestation +and exports only its public key. The fingerprint identifies that one signed +attestation's key; distinct fingerprints do not establish distinct people or +independent sessions. + The session signature must be exactly 64 raw bytes encoded as 86 unpadded base64url characters. Each public-key coordinate must be exactly 32 bytes encoded as 43 unpadded base64url characters. Verification decodes and diff --git a/docs/SESSION_ATTESTATION.md b/docs/SESSION_ATTESTATION.md index 3fa03403..84d74be1 100644 --- a/docs/SESSION_ATTESTATION.md +++ b/docs/SESSION_ATTESTATION.md @@ -41,6 +41,16 @@ import { createWitnessBundle, verifyWitnessBundle } from './aml-witness-bundle.j A valid `aml-session-attestation/1` proves that the holder of an ephemeral browser session private key signed the exact bound evidence hash and verifier challenge. +The producer generates a fresh P-256 key for each attestation and requests a +nonextractable private key from Web Crypto. Its public key remains exportable +for offline verification. Sequential and overlapping requests therefore do +not reuse a page-wide fingerprint or compete over a mutable signing key. +`resetAMLSessionKey()` still clears the last published attestation for callers +using that helper; there is no cached signing key to reset. Existing consumers +that grouped multiple attestations by one page-wide session fingerprint must +use an explicit, separately justified correlation mechanism instead. +Different fingerprints do not prove different people, browsers, or sessions. + It does **not** prove: - who the human is; diff --git a/docs/aml-session-attestation.js b/docs/aml-session-attestation.js index a43ca97b..b3dba1d3 100644 --- a/docs/aml-session-attestation.js +++ b/docs/aml-session-attestation.js @@ -7,10 +7,6 @@ import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser } from import { verifyBrowserEvidence } from './aml-browser-evidence.js'; import { verifyVerificationChallenge } from './aml-verification-challenge.js'; -let sessionKeyPair = null; -let sessionPublicJwk = null; -let sessionKeyFingerprint = null; - function bytesToBase64Url(bytes) { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); @@ -34,16 +30,17 @@ function canonicalBytes(value, length, encodedLength, reason) { return bytes; } -async function ensureSessionKey() { - if (sessionKeyPair) return; +async function createAttestationKey() { if (!globalThis.crypto?.subtle) throw new Error('AML_SESSION_CRYPTO_UNAVAILABLE'); - sessionKeyPair = await crypto.subtle.generateKey( + // Keep the private key local to one attestation. Reusing a page-wide key + // links unrelated challenges and races when two attestations overlap. + const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, - true, + false, ['sign', 'verify'] ); - sessionPublicJwk = await crypto.subtle.exportKey('jwk', sessionKeyPair.publicKey); - sessionKeyFingerprint = await sha256Browser(sessionPublicJwk); + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + return { keyPair, publicJwk, fingerprint: await sha256Browser(publicJwk) }; } export function sessionAttestationPayload(attestation) { @@ -64,7 +61,7 @@ export async function createSessionAttestation({ const challengeCheck = verifyVerificationChallenge(challenge); if (!challengeCheck.valid) throw new Error(challengeCheck.reason); - await ensureSessionKey(); + const { keyPair, publicJwk, fingerprint } = await createAttestationKey(); const issuedAt = new Date().toISOString(); const attestation = { schema: 'aml-session-attestation/1', @@ -74,14 +71,14 @@ export async function createSessionAttestation({ challenge_nonce: challenge.nonce, challenge_expires_at: challenge.expires_at, issued_at: issuedAt, - session_public_key_jwk: sessionPublicJwk, - session_key_fingerprint: sessionKeyFingerprint + session_public_key_jwk: publicJwk, + session_key_fingerprint: fingerprint }; const bytes = new TextEncoder().encode(canonicalJSONStringifyBrowser(sessionAttestationPayload({ ...attestation, signature: '' }))); const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, - sessionKeyPair.privateKey, + keyPair.privateKey, bytes ); attestation.signature = bytesToBase64Url(new Uint8Array(signature)); @@ -164,8 +161,7 @@ export async function verifySessionAttestation({ evidence, challenge, attestatio } export function resetAMLSessionKey() { - sessionKeyPair = null; - sessionPublicJwk = null; - sessionKeyFingerprint = null; + // Retained for callers that clear the last published attestation. Signing + // keys are already local to each createSessionAttestation invocation. globalThis.__AML_SESSION_ATTESTATION__ = null; } diff --git a/scripts/check-signed-quorum-python.mjs b/scripts/check-signed-quorum-python.mjs index ac82f123..75112f9d 100644 --- a/scripts/check-signed-quorum-python.mjs +++ b/scripts/check-signed-quorum-python.mjs @@ -264,6 +264,19 @@ try { assert.ok(Date.parse(bundle.created_at) > Date.parse(attestation.issued_at)); const witness = await verifyWitnessBundle(bundle, { expectedChallenge: challenge }); assert.equal(witness.valid, true); + const secondAttestation = await createSessionAttestation({ evidence, challenge }); + assert.notEqual(secondAttestation.session_key_fingerprint, + attestation.session_key_fingerprint); + const secondBundle = await createWitnessBundle({ evidence, challenge, + attestation: secondAttestation }); + assert.equal((await verifyWitnessBundle(secondBundle, { expectedChallenge: challenge })).valid, + true); + const secondPython = spawnSync('python3', [path.resolve('independent/python/verify_witness.py'), + save('second-key-bundle.json', secondBundle), '--challenge', + save('second-key-challenge.json', challenge)], + { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } }); + assert.equal(secondPython.status, 0, secondPython.stderr); + assert.equal(JSON.parse(secondPython.stdout).valid, true); const witnessKeyA = await createVerifierKeyPair(); const witnessKeyB = await createVerifierKeyPair(); const witnessA = await signVerificationReport( @@ -800,6 +813,7 @@ try { 'malformed_witness_signature', 'malformed_witness_attestation', 'witness_unused_signature_bits', 'witness_padded_signature', 'witness_alternate_key_coordinate', + 'fresh_second_attestation_key_python_replay', 'challenge_calendar_rollover', 'challenge_offset_alias', 'challenge_submillisecond', 'challenge_missing_milliseconds', 'before_challenge_issue', 'after_challenge_expiry', diff --git a/test/detached-witness.test.js b/test/detached-witness.test.js index 907f7129..61478f41 100644 --- a/test/detached-witness.test.js +++ b/test/detached-witness.test.js @@ -63,6 +63,26 @@ test('detached witness bundle verifies under Node WebCrypto', async () => { assert.equal(bundleCheck.reason, 'AML_WITNESS_BUNDLE_VALID'); }); +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 }); + const challenges = [createVerificationChallenge(), createVerificationChallenge()]; + const concurrent = await Promise.all(challenges.map(next => + createSessionAttestation({ evidence, challenge: next }))); + const attestations = [first, second, ...concurrent]; + assert.equal(new Set(attestations.map(item => item.session_key_fingerprint)).size, 4); + resetAMLSessionKey(); + assert.equal(globalThis.__AML_SESSION_ATTESTATION__, null); + for (const [index, attestation] of attestations.entries()) { + const retained = index < 2 ? challenge : challenges[index - 2]; + assert.equal((await verifySessionAttestation({ evidence, challenge: retained, + attestation })).valid, true, `attestation ${index}`); + const bundle = await createWitnessBundle({ evidence, challenge: retained, attestation }); + assert.equal((await verifyWitnessBundle(bundle, { expectedChallenge: retained })).valid, + true, `bundle ${index}`); + } +}); + test('detached witness bundle rejects evidence mutation', async () => { const { bundle } = await createFixture(); const mutated = structuredClone(bundle);