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.