// - the client half of DPoP: the key, its fingerprint, and the per-request proof. // // THE WHOLE SECURITY PROPERTY IS `extractable: false`. The browser will let this page USE the private // key and will never let it READ the key out - there is no API to export it. Attacker JavaScript on the // practice's origin can ask the browser to sign with it; it cannot take it away. That is what turns a // stolen token from "usable anywhere" into "useless off this device". // // THIS IS NOT PASSKEYS, and Spencer ruled that out explicitly. The page generates a key for itself in // milliseconds, the browser does not prompt, and the user never learns it exists. If a change here // would make somebody DO something in order to sign in, it is the wrong change. // // THE THUMBPRINT MUST AGREE WITH THE SERVER BYTE FOR BYTE - `api/src/utils/dpopProof.ts`. The plugin // cannot import from infra, so nothing compiles that coupling; a one-character difference in // canonicalisation makes EVERY request fail its binding check, and the symptom reads like an outage // rather than like a bug. `__tests__/dpop.test.ts` pins it against a value derived from the server's own // implementation if that test fails, do NOT update the literal: something has drifted. const ALGORITHM = { name: 'ECDSA', namedCurve: 'P-256' } as const; const SIGN_PARAMS = { name: 'ECDSA', hash: 'SHA-256' } as const; export interface DpopKeyPair { publicKey: CryptoKey; privateKey: CryptoKey; } const b64url = (bytes: ArrayBuffer | Uint8Array): string => { const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let binary = ''; for (const byte of view) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }; const b64urlText = (text: string): string => btoa(unescape(encodeURIComponent(text))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); /** * Generate the key pair this browser will be known by. * * `extractable: false` is not a hardening option here, it is the feature. With it true, DPoP would be * obfuscation rather than a boundary: attacker JS would export the key and replay elsewhere. */ export async function generateDpopKeyPair(): Promise { const pair = await crypto.subtle.generateKey(ALGORITHM, false, ['sign', 'verify']); return { publicKey: pair.publicKey, privateKey: pair.privateKey }; } /** * RFC 7638 thumbprint - SHA-256 over ONLY the required members, lexicographically ordered, no whitespace. * * Only the required members. WebCrypto's export adds `key_ops` and `ext`, and libraries add `kid` and * `use`; including any of them would produce a value the server never computes, so the same key would * fail to match its own binding. */ export async function thumbprintFromJwk(jwk: JsonWebKey): Promise { let canonical: string; if (jwk.kty === 'EC') { canonical = JSON.stringify({ crv: jwk.crv, kty: 'EC', x: jwk.x, y: jwk.y }); } else if (jwk.kty === 'RSA') { canonical = JSON.stringify({ e: jwk.e, kty: 'RSA', n: jwk.n }); } else { // Never hash whatever is present: that yields a stable-looking value which is not an RFC 7638 // thumbprint, and the two sides would disagree in silence. throw new Error(`thumbprintFromJwk: unsupported key type ${String(jwk.kty)}`); } const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)); return b64url(digest); } /** The public key as the proof publishes it - the four required members and nothing else. */ async function publicJwkForProof(publicKey: CryptoKey): Promise> { const exported = await crypto.subtle.exportKey('jwk', publicKey); return { crv: exported.crv as string, kty: exported.kty as string, x: exported.x as string, y: exported.y as string, }; } /** This browser's own thumbprint - what travels on `InitiateAuth` and gets stamped into the token. */ export async function thumbprintForKeyPair(keyPair: DpopKeyPair): Promise { return thumbprintFromJwk(await publicJwkForProof(keyPair.publicKey)); } /** * One proof, for one request. * * `htu` drops the query and fragment, because that is what the server compares. Sending them would * fail every paginated request - and it would fail as a BINDING REFUSAL, which reads like an attack * rather than like a mistake. */ export async function createDpopProof(input: { keyPair: DpopKeyPair; method: string; url: string; now?: number; }): Promise { const now = input.now ?? Date.now(); const jwk = await publicJwkForProof(input.keyPair.publicKey); const parsed = new URL(input.url); const header = { typ: 'dpop+jwt', alg: 'ES256', jwk }; const payload = { htm: input.method.toUpperCase(), htu: `${parsed.origin}${parsed.pathname}`, // SECONDS. Milliseconds would put every proof ~55,000 years in the future and be refused as // stale, with nothing on either side naming the units. iat: Math.floor(now / 1000), // Its own per proof - the jti is what a replay cache keys on, and reusing one turns every // second request into a replay. jti: crypto.randomUUID(), }; const signingInput = `${b64urlText(JSON.stringify(header))}.${b64urlText(JSON.stringify(payload))}`; const signature = await crypto.subtle.sign( SIGN_PARAMS, input.keyPair.privateKey, new TextEncoder().encode(signingInput), ); return `${signingInput}.${b64url(signature)}`; }