PageSourceSearch

https://classaction.cv/shared/passkey-crypto.js

js classaction.cv collected 2026-10-07 13:18:15 UTC 16,416 bytes, 458 lines download raw bytes

1/**
2 * shared/passkey-crypto.js
3 *
4 * WebAuthn + PRF extension for zero-knowledge passkey-based workspace key unlock.
5 *
6 * Flow:
7 *   1. User registers a passkey (navigator.credentials.create) with PRF extension
8 *   2. PRF output → HKDF-SHA-256 → AES-256-GCM wrapping key
9 *   3. Wrapping key encrypts the raw workspace key → stored in localStorage
10 *   4. On cold start: navigator.credentials.get with PRF → re-derive wrapping key → unwrap
11 *
12 * This module never touches crypto.subtle for anything other than passkey-related
13 * key derivation and wrapping. All workspace key creation/PBKDF2 lives in workspace-crypto.js.
14 *
15 * Requirements: iOS 18.4+, macOS Sequoia + Safari/Chrome, or Chrome 116+ on desktop.
16 */
17
18import { PASSKEY_KEYS, PASSKEY_CRYPTO_CONFIG } from './constants.js';
19import { bytesToBase64, base64ToBytes, randomBytes } from './crypto-utils.js';
20
21// Fixed PRF salt used for both registration and authentication.
22// Must NEVER change — changing this invalidates all existing passkey-wrapped keys.
23const PRF_WRAPPING_SALT = 'classaction-workspace-key-wrap-v1';
24
25// ---------------------------------------------------------------------------
26// Feature detection
27// ---------------------------------------------------------------------------
28
29/**
30 * Returns true if this browser supports WebAuthn with the PRF extension.
31 * Does NOT guarantee the authenticator supports PRF — that's checked at
32 * registration time via getClientExtensionResults().
33 * @returns {boolean}
34 */
35export function isPasskeyPrfSupported() {
36  return !!(
37    window.PublicKeyCredential &&
38    typeof navigator.credentials?.create === 'function' &&
39    typeof navigator.credentials?.get === 'function' &&
40    window.crypto?.subtle
41  );
42}
43
44// ---------------------------------------------------------------------------
45// Registration
46// ---------------------------------------------------------------------------
47
48/**
49 * Registers a new passkey with the PRF extension.
50 *
51 * The server (Supabase) is NOT involved as a WebAuthn relying party — we use
52 * the browser's built-in authenticator directly. The challenge is random and
53 * discarded; we only care about:
54 *   - The credential ID (for future .get() calls)
55 *   - Whether PRF was enabled (getClientExtensionResults().prf.enabled)
56 *
57 * @param {string} userId       - Supabase user ID (used as WebAuthn user.id)
58 * @param {string} userEmail    - Display name / email for the credential
59 * @param {string} displayName  - Human-readable name
60 * @returns {Promise<{ credentialIdB64: string, prfEnabled: boolean, transports: string[] }>}
61 * @throws {Error} if registration fails or PRF is not supported by the authenticator
62 */
63export async function registerPasskey(userId, userEmail, displayName = 'Class Action') {
64  const challenge = randomBytes(32);
65
66  // Encode userId as UTF-8 bytes for user.id (WebAuthn requires BufferSource)
67  const userIdBytes = new TextEncoder().encode(userId);
68
69  // Use the REAL wrapping salt for the PRF eval during creation — NOT a test salt.
70  // This way, if the authenticator returns PRF results in the create response
71  // (iOS 18.4+, Chrome 116+), we can use them directly for key wrapping without
72  // triggering a second biometric prompt via navigator.credentials.get().
73  const prfSaltBytes = new TextEncoder().encode(PRF_WRAPPING_SALT);
74
75  /** @type {PublicKeyCredentialCreationOptions} */
76  const createOptions = {
77    challenge,
78    rp: {
79      name: 'Class Action',
80      id: window.location.hostname,
81    },
82    user: {
83      id: userIdBytes,
84      name: userEmail || '[email protected]',
85      displayName: displayName || 'Class Action User',
86    },
87    pubKeyCredParams: [
88      { alg: -7, type: 'public-key' },   // ES256
89      { alg: -257, type: 'public-key' },  // RS256
90    ],
91    authenticatorSelection: {
92      authenticatorAttachment: 'platform',
93      residentKey: 'required',
94      userVerification: 'required',
95    },
96    attestation: 'none',
97    timeout: 120000,
98    extensions: {
99      prf: {
100        eval: {
101          first: prfSaltBytes,
102        },
103      },
104    },
105  };
106
107  const credential = /** @type {PublicKeyCredential} */ (
108    await navigator.credentials.create({ publicKey: createOptions })
109  );
110
111  if (!credential) {
112    throw new Error('Passkey registration was cancelled.');
113  }
114
115  const extResults = credential.getClientExtensionResults();
116  const prfEnabled = !!(extResults?.prf?.enabled || extResults?.prf?.results?.first);
117
118  if (!prfEnabled) {
119    throw new Error('This device does not support the PRF extension needed for passkey unlock. Try updating your OS or browser.');
120  }
121
122  const credentialIdB64 = bytesToBase64(new Uint8Array(credential.rawId));
123  const transports = credential.response?.getTransports?.() || ['internal'];
124
125  // Return PRF output from create() if the authenticator provided it.
126  // This avoids a second biometric prompt — the caller can use it directly.
127  let prfOutput = null;
128  const prfResult = extResults?.prf?.results?.first;
129  if (prfResult && (prfResult instanceof ArrayBuffer || ArrayBuffer.isView(prfResult))) {
130    prfOutput = new Uint8Array(prfResult);
131  }
132
133  return { credentialIdB64, prfEnabled, transports, prfOutput };
134}
135
136// ---------------------------------------------------------------------------
137// PRF evaluation (authentication)
138// ---------------------------------------------------------------------------
139
140/**
141 * Authenticates with an existing passkey and evaluates the PRF extension.
142 * Returns the raw PRF output bytes (32 bytes from HMAC-SHA-256).
143 *
144 * @param {string} credentialIdB64  - base64-encoded credential ID from registration
145 * @returns {Promise<Uint8Array>}   - 32-byte PRF output
146 * @throws {Error} if authentication fails or PRF output is missing
147 */
148export async function evaluatePasskeyPrf(credentialIdB64) {
149  const challenge = randomBytes(32);
150  const credentialId = base64ToBytes(credentialIdB64);
151
152  /** @type {PublicKeyCredentialRequestOptions} */
153  const getOptions = {
154    challenge,
155    rpId: window.location.hostname,
156    allowCredentials: [{
157      id: credentialId,
158      type: 'public-key',
159      transports: ['internal', 'hybrid'],
160    }],
161    userVerification: 'required',
162    timeout: 120000,
163    extensions: {
164      prf: {
165        eval: {
166          first: new TextEncoder().encode(PRF_WRAPPING_SALT),
167        },
168      },
169    },
170  };
171
172  const assertion = /** @type {PublicKeyCredential} */ (
173    await navigator.credentials.get({ publicKey: getOptions })
174  );
175
176  if (!assertion) {
177    throw new Error('Passkey authentication was cancelled.');
178  }
179
180  const extResults = assertion.getClientExtensionResults();
181  const prfOutput = extResults?.prf?.results?.first;
182
183  if (!prfOutput || !(prfOutput instanceof ArrayBuffer || ArrayBuffer.isView(prfOutput))) {
184    throw new Error('PRF output not available. Your device may not support this feature.');
185  }
186
187  return new Uint8Array(prfOutput);
188}
189
190// ---------------------------------------------------------------------------
191// HKDF key derivation from PRF output
192// ---------------------------------------------------------------------------
193
194/**
195 * Derives an AES-256-GCM wrapping key from 32-byte PRF output using HKDF-SHA-256.
196 *
197 * @param {Uint8Array} prfOutput  - 32-byte PRF result
198 * @returns {Promise<CryptoKey>}  - AES-GCM wrapping key (encrypt + decrypt)
199 */
200async function deriveWrappingKeyFromPrf(prfOutput) {
201  const infoBytes = new TextEncoder().encode(PASSKEY_CRYPTO_CONFIG.hkdfInfo);
202
203  // Import PRF output as HKDF key material
204  const baseKey = await crypto.subtle.importKey(
205    'raw',
206    prfOutput,
207    { name: 'HKDF' },
208    false,
209    ['deriveKey']
210  );
211
212  return crypto.subtle.deriveKey(
213    {
214      name: 'HKDF',
215      hash: PASSKEY_CRYPTO_CONFIG.hkdfHash,
216      salt: new Uint8Array(0),  // empty salt — PRF output is already keyed
217      info: infoBytes,
218    },
219    baseKey,
220    { name: 'AES-GCM', length: PASSKEY_CRYPTO_CONFIG.keyLength },
221    false,
222    ['encrypt', 'decrypt']
223  );
224}
225
226// ---------------------------------------------------------------------------
227// Workspace key wrapping / unwrapping with PRF-derived key
228// ---------------------------------------------------------------------------
229
230/**
231 * Wraps the raw workspace key bytes with a PRF-derived AES-256-GCM key.
232 * Returns a JSON-serializable blob for localStorage.
233 *
234 * @param {Uint8Array} rawWorkspaceKey  - 32-byte raw workspace key
235 * @param {Uint8Array} prfOutput        - 32-byte PRF result
236 * @param {string} userId               - Supabase user ID (stored in blob for validation)
237 * @returns {Promise<object>}           - { userId, ivB64, ciphertextB64, version }
238 */
239export async function wrapWorkspaceKeyWithPrf(rawWorkspaceKey, prfOutput, userId) {
240  const wrappingKey = await deriveWrappingKeyFromPrf(prfOutput);
241  const iv = randomBytes(PASSKEY_CRYPTO_CONFIG.ivBytes);
242
243  const ciphertext = await crypto.subtle.encrypt(
244    { name: 'AES-GCM', iv },
245    wrappingKey,
246    rawWorkspaceKey
247  );
248
249  return {
250    userId,
251    ivB64: bytesToBase64(iv),
252    ciphertextB64: bytesToBase64(new Uint8Array(ciphertext)),
253    version: 1,
254  };
255}
256
257/**
258 * Unwraps the workspace key from a PRF-wrapped blob.
259 * Returns raw workspace key bytes (32 bytes).
260 *
261 * @param {object} wrappedBlob  - { userId, ivB64, ciphertextB64, version }
262 * @param {Uint8Array} prfOutput - 32-byte PRF result
263 * @returns {Promise<Uint8Array>} - 32-byte raw workspace key
264 * @throws {Error} if decryption fails (wrong passkey, tampered data)
265 */
266export async function unwrapWorkspaceKeyWithPrf(wrappedBlob, prfOutput) {
267  if (!wrappedBlob?.ivB64 || !wrappedBlob?.ciphertextB64) {
268    throw new Error('Invalid passkey-wrapped key blob.');
269  }
270
271  const wrappingKey = await deriveWrappingKeyFromPrf(prfOutput);
272  const iv = base64ToBytes(wrappedBlob.ivB64);
273  const ciphertext = base64ToBytes(wrappedBlob.ciphertextB64);
274
275  try {
276    const rawKey = await crypto.subtle.decrypt(
277      { name: 'AES-GCM', iv },
278      wrappingKey,
279      ciphertext
280    );
281    return new Uint8Array(rawKey);
282  } catch (_) {
283    throw new Error('Passkey could not unlock your workspace key. The credential may have changed.');
284  }
285}
286
287// ---------------------------------------------------------------------------
288// Local storage helpers — passkey credential metadata + wrapped key blob
289// ---------------------------------------------------------------------------
290
291/**
292 * Saves passkey registration data to localStorage.
293 *
294 * @param {string} userId
295 * @param {string} credentialIdB64
296 * @param {string[]} transports
297 * @param {string} [deviceLabel]
298 */
299export function savePasskeyRegistration(userId, credentialIdB64, transports = ['internal'], deviceLabel = '') {
300  const data = {
301    userId,
302    credentialIdB64,
303    transports,
304    deviceLabel: deviceLabel || _guessDeviceLabel(),
305    createdAt: Date.now(),
306  };
307  localStorage.setItem(PASSKEY_KEYS.localCredential, JSON.stringify(data));
308}
309
310/**
311 * Returns the stored passkey credential metadata, or null if not registered.
312 * Returns null if the stored credential belongs to a different user.
313 *
314 * @param {string} [userId]  - if provided, validates ownership
315 * @returns {{ userId: string, credentialIdB64: string, transports: string[], deviceLabel: string, createdAt: number } | null}
316 */
317export function getPasskeyRegistration(userId = '') {
318  const raw = localStorage.getItem(PASSKEY_KEYS.localCredential);
319  if (!raw) return null;
320  try {
321    const parsed = JSON.parse(raw);
322    if (!parsed?.credentialIdB64) return null;
323    if (userId && parsed.userId && parsed.userId !== userId) return null;
324    return parsed;
325  } catch {
326    return null;
327  }
328}
329
330/**
331 * Saves the PRF-wrapped workspace key blob to localStorage.
332 *
333 * @param {object} wrappedBlob  - from wrapWorkspaceKeyWithPrf()
334 */
335export function savePasskeyWrappedKey(wrappedBlob) {
336  localStorage.setItem(PASSKEY_KEYS.localWrappedWorkspaceKey, JSON.stringify(wrappedBlob));
337}
338
339/**
340 * Returns the stored PRF-wrapped workspace key blob, or null.
341 * Returns null if the blob belongs to a different user.
342 *
343 * @param {string} [userId]
344 * @returns {object|null}
345 */
346export function getPasskeyWrappedKey(userId = '') {
347  const raw = localStorage.getItem(PASSKEY_KEYS.localWrappedWorkspaceKey);
348  if (!raw) return null;
349  try {
350    const parsed = JSON.parse(raw);
351    if (!parsed?.ciphertextB64) return null;
352    if (userId && parsed.userId && parsed.userId !== userId) return null;
353    return parsed;
354  } catch {
355    return null;
356  }
357}
358
359/**
360 * Clears all passkey data from localStorage.
361 * Called on sign-out or when removing a passkey.
362 */
363export function clearPasskeyRegistration() {
364  localStorage.removeItem(PASSKEY_KEYS.localCredential);
365  localStorage.removeItem(PASSKEY_KEYS.localWrappedWorkspaceKey);
366}
367
368/**
369 * Returns true if this device has a passkey registered for the given user
370 * AND has a PRF-wrapped workspace key blob ready for unlock.
371 *
372 * @param {string} [userId]
373 * @returns {boolean}
374 */
375export function hasPasskeyUnlockReady(userId = '') {
376  return !!(getPasskeyRegistration(userId) && getPasskeyWrappedKey(userId));
377}
378
379// ---------------------------------------------------------------------------
380// Full passkey unlock flow (combines eval + unwrap)
381// ---------------------------------------------------------------------------
382
383/**
384 * Attempts to unlock the workspace key using the stored passkey.
385 * Returns the raw 32-byte workspace key, or throws on failure.
386 *
387 * @param {string} userId
388 * @returns {Promise<Uint8Array>}  - raw workspace key bytes
389 * @throws {Error} if no passkey registered, PRF eval fails, or unwrap fails
390 */
391export async function unlockWorkspaceKeyWithPasskey(userId) {
392  const registration = getPasskeyRegistration(userId);
393  if (!registration) {
394    throw new Error('No passkey registered on this device.');
395  }
396
397  const wrappedBlob = getPasskeyWrappedKey(userId);
398  if (!wrappedBlob) {
399    throw new Error('No passkey-wrapped workspace key found. Re-register your passkey.');
400  }
401
402  const prfOutput = await evaluatePasskeyPrf(registration.credentialIdB64);
403  return unwrapWorkspaceKeyWithPrf(wrappedBlob, prfOutput);
404}
405
406// ---------------------------------------------------------------------------
407// Full passkey registration flow (register + wrap current workspace key)
408// ---------------------------------------------------------------------------
409
410/**
411 * Registers a passkey and wraps the current workspace key with the PRF output.
412 * Call this after the user is authenticated and the workspace key is available.
413 *
414 * @param {string} userId
415 * @param {string} userEmail
416 * @param {Uint8Array} rawWorkspaceKey  - 32-byte raw workspace key (from export)
417 * @returns {Promise<{ credentialIdB64: string, deviceLabel: string }>}
418 * @throws {Error} if registration fails or PRF is unavailable
419 */
420export async function registerPasskeyAndWrapKey(userId, userEmail, rawWorkspaceKey) {
421  // Step 1: Register passkey — uses the real wrapping salt for PRF eval
422  const { credentialIdB64, transports, prfOutput: createPrfOutput } = await registerPasskey(userId, userEmail);
423
424  // Step 2: Use PRF output from create() if the authenticator provided it.
425  // Otherwise fall back to a separate get() call (second biometric prompt).
426  // Most modern authenticators (iOS 18.4+, Chrome 116+) return PRF in create().
427  const prfOutput = createPrfOutput || await evaluatePasskeyPrf(credentialIdB64);
428
429  // Step 3: Wrap the workspace key
430  const wrappedBlob = await wrapWorkspaceKeyWithPrf(rawWorkspaceKey, prfOutput, userId);
431
432  // Step 4: Persist everything locally
433  const deviceLabel = _guessDeviceLabel();
434  savePasskeyRegistration(userId, credentialIdB64, transports, deviceLabel);
435  savePasskeyWrappedKey(wrappedBlob);
436
437  return { credentialIdB64, deviceLabel };
438}
439
440// ---------------------------------------------------------------------------
441// Private helpers
442// ---------------------------------------------------------------------------
443
444/**
445 * Best-effort device label for the passkey credential.
446 * @returns {string}
447 */
448function _guessDeviceLabel() {
449  const ua = navigator.userAgent || '';
450  if (/iPhone/i.test(ua)) return 'iPhone';
451  if (/iPad/i.test(ua)) return 'iPad';
452  if (/Macintosh/i.test(ua)) return 'Mac';
453  if (/Windows/i.test(ua)) return 'Windows PC';
454  if (/Android/i.test(ua)) return 'Android';
455  if (/CrOS/i.test(ua)) return 'Chromebook';
456  if (/Linux/i.test(ua)) return 'Linux';
457  return 'Unknown device';
458}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.