jevstrudel.git / worker / test / authenticator.ts
1// A passkey authenticator in software, for the Worker's tests: an ES256
2// key pair that answers registration and sign-in options the way a
3// browser and platform authenticator would, as the JSON the page posts
4// (RegistrationResponseJSON / AuthenticationResponseJSON). So auth.ts is
5// tested against real signatures, verified by the real library, rather
6// than a stubbed verifier.
7//
8// It writes `none` attestation, user present and verified, and a signature
9// counter it increments per sign-in (0 would mean "does not count", as most
10// synced passkeys report). Only what the tests need of CBOR is encoded.
11const enc = new TextEncoder();
12
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));
30
31// 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}
48
49// 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}
61
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 };