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
5 changes: 5 additions & 0 deletions docs/DETACHED_VERIFIER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/SESSION_ATTESTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 13 additions & 17 deletions docs/aml-session-attestation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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',
Expand All @@ -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));
Expand Down Expand Up @@ -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;
}
14 changes: 14 additions & 0 deletions scripts/check-signed-quorum-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down
20 changes: 20 additions & 0 deletions test/detached-witness.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading