A passkey authenticator in software, for the Worker's tests: an ES256 key pair that answers registration and sign-in options the way a browser and platform authenticator would, as the JSON the page posts (RegistrationResponseJSON / AuthenticationResponseJSON). So auth.ts is tested against real signatures, verified by the real library, rather than a stubbed verifier.
It writes none attestation, user present and verified, and a signature
counter it increments per sign-in (0 would mean "does not count", as most
synced passkeys report). Only what the tests need of CBOR is encoded.
11const enc = new TextEncoder();
13export const b64url = (bytes: Uint8Array) => 14 btoa(String.fromCharCode(...bytes)) 15 .replaceAll('+', '-') 16 .replaceAll('/', '_') 17 .replace(/=+$/, ''); 18const fromB64url = (s: string) => 19 Uint8Array.from(atob(s.replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0)); 20const concat = (...parts: Uint8Array[]) => { 21 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); 22 let at = 0; 23 for (const p of parts) { 24 out.set(p, at); 25 at += p.length; 26 } 27 return out; 28}; 29const sha256 = async (data: Uint8Array) => new Uint8Array(await crypto.subtle.digest('SHA-256', data));
CBOR: unsigned and negative integers, byte and text strings, maps.
32type Cbor = number | string | Uint8Array | Map<Cbor, Cbor>; 33function head(major: number, n: number): Uint8Array { 34 if (n < 24) return Uint8Array.of((major << 5) | n); 35 if (n < 256) return Uint8Array.of((major << 5) | 24, n); 36 if (n < 65536) return Uint8Array.of((major << 5) | 25, n >> 8, n & 255); 37 throw new Error('cbor: too long'); 38} 39export function cbor(value: Cbor): Uint8Array { 40 if (typeof value === 'number') return value >= 0 ? head(0, value) : head(1, -1 - value); 41 if (typeof value === 'string') { 42 const bytes = enc.encode(value); 43 return concat(head(3, bytes.length), bytes); 44 } 45 if (value instanceof Uint8Array) return concat(head(2, value.length), value); 46 return concat(head(5, value.size), ...[...value].flatMap(([k, v]) => [cbor(k), cbor(v)])); 47}
Web Crypto's ECDSA signature is r‖s; WebAuthn's ES256 is DER.
50function der(raw: Uint8Array): Uint8Array { 51 const int = (b: Uint8Array) => { 52 let i = 0; 53 while (i < b.length - 1 && b[i] === 0) i++; 54 let v = b.slice(i); 55 if (v[0] & 0x80) v = concat(Uint8Array.of(0), v); 56 return concat(Uint8Array.of(0x02, v.length), v); 57 }; 58 const body = concat(int(raw.slice(0, 32)), int(raw.slice(32))); 59 return concat(Uint8Array.of(0x30, body.length), body); 60}
62const UP = 0x01; 63const UV = 0x04; 64const AT = 0x40; 65 66type Options = { challenge: string; rp?: { id?: string }; rpId?: string; user?: { id: string } }; 67 68export async function authenticator() { 69 const keys = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ 70 'sign', 71 'verify', 72 ])) as CryptoKeyPair; 73 const jwk = await crypto.subtle.exportKey('jwk', keys.publicKey); 74 const credentialId = crypto.getRandomValues(new Uint8Array(32)); 75 const id = b64url(credentialId); 76 let counter = 0; 77 let userHandle: string | undefined; 78 79 const clientData = (type: string, challenge: string, origin: string) => 80 enc.encode(JSON.stringify({ type, challenge, origin, crossOrigin: false })); 81 const authData = async (rpId: string, flags: number, count: number, attested?: Uint8Array) => { 82 const c = Uint8Array.of(count >>> 24, (count >> 16) & 255, (count >> 8) & 255, count & 255); 83 return concat(await sha256(enc.encode(rpId)), Uint8Array.of(flags), c, attested ?? new Uint8Array()); 84 }; 85 86 return { 87 id, 88 // The response navigator.credentials.create(options) would give on `origin`. 89 async create(options: Options, origin: string, { flags = UP | UV | AT, rpId }: { flags?: number; rpId?: string } = {}) { 90 userHandle = options.user?.id; 91 const cose = cbor( 92 new Map<Cbor, Cbor>([ 93 [1, 2], 94 [3, -7], 95 [-1, 1], 96 [-2, fromB64url(jwk.x!)], 97 [-3, fromB64url(jwk.y!)], 98 ]), 99 ); 100 const attested = concat(new Uint8Array(16), Uint8Array.of(0, credentialId.length), credentialId, cose); 101 const data = await authData(rpId ?? options.rp?.id ?? '', flags, counter, attested); 102 const attestationObject = cbor( 103 new Map<Cbor, Cbor>([ 104 ['fmt', 'none'], 105 ['attStmt', new Map()], 106 ['authData', data], 107 ]), 108 ); 109 return { 110 id, 111 rawId: id, 112 type: 'public-key', 113 clientExtensionResults: {}, 114 response: { 115 clientDataJSON: b64url(clientData('webauthn.create', options.challenge, origin)), 116 attestationObject: b64url(attestationObject), 117 transports: ['internal', 'hybrid'], 118 }, 119 }; 120 }, 121 // The response navigator.credentials.get(options) would give on `origin`. 122 async get(options: Options, origin: string, { flags = UP | UV, handle = userHandle }: { flags?: number; handle?: string } = {}) { 123 counter += 1; 124 const data = await authData(options.rpId ?? '', flags, counter); 125 const client = clientData('webauthn.get', options.challenge, origin); 126 const signed = concat(data, await sha256(client)); 127 const raw = new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keys.privateKey, signed)); 128 return { 129 id, 130 rawId: id, 131 type: 'public-key', 132 clientExtensionResults: {}, 133 response: { 134 clientDataJSON: b64url(client), 135 authenticatorData: b64url(data), 136 signature: b64url(der(raw)), 137 ...(handle ? { userHandle: handle } : {}), 138 }, 139 }; 140 }, 141 }; 142} 143export const FLAGS = { UP, UV, AT };