/** * @module identity/passkey-helper * * WebAuthn Level 3 PRF (Pseudo-Random Function) Passkey helper service. * Derives deterministic 256-bit symmetric secrets from hardware biometrics * (Touch ID, Windows Hello, Android Biometrics, YubiKey 5Ci) and creates * passkey device sync delegates. */ import { extractErrorMsg, getSaferSubstring } from '@ibgib/helper-gib/dist/helpers/utils-helper.mjs'; import { deriveDelegateSecret, createSyncDelegateKeystone } from '@ibgib/core-gib/dist/keystone/delegate/delegate-helpers.mjs'; import { getTjpAddr } from '@ibgib/core-gib/dist/common/other/ibgib-helper.mjs'; import { getIbGibAddr } from '@ibgib/ts-gib/dist/helper.mjs'; import { AuthenticatorTransport, KeystonePasskeyEntry } from '@ibgib/core-gib/dist/keystone/keystone-types.mjs'; import { generateDefaultDeviceTag, setDeviceDelegateSecret } from './device-delegate-helper.mjs'; /** * Checks if the browser and underlying platform authenticator support WebAuthn PRF. */ export async function isPasskeyPrfSupported(): Promise { try { if (typeof window === 'undefined' || !window.PublicKeyCredential) { return false; } if (typeof (window.PublicKeyCredential as any).getClientCapabilities === 'function') { const caps = await (window.PublicKeyCredential as any).getClientCapabilities(); if (caps && caps['prf']) { return true; } } // Fallback: check if PublicKeyCredential exists and platform authenticator is available if (typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') { const isPlatformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); return isPlatformAvailable; } return false; } catch { return false; } } /** * Helper to convert an ArrayBuffer to a lowercase hex string. */ export function bufferToHex(buffer: ArrayBuffer | ArrayBufferLike): string { return Array.from(new Uint8Array(buffer as ArrayBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } /** * Helper to convert a hex string to a Uint8Array. */ export function hexToUint8Array(hex: string): Uint8Array { const cleanHex = hex.replace(/[^0-9a-fA-F]/g, ''); const bytes = new Uint8Array(Math.floor(cleanHex.length / 2)); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16); } return bytes; } /** * Helper to normalize a salt string or buffer into a 32-byte Uint8Array for PRF evaluation. */ export function normalizeSaltTo32Bytes(salt: string | Uint8Array): Uint8Array { if (salt instanceof Uint8Array) { if (salt.length === 32) return salt; const result = new Uint8Array(32); result.set(salt.subarray(0, 32)); return result; } // If hex string of length 64 (32 bytes) if (/^[0-9a-fA-F]{64}$/.test(salt)) { return hexToUint8Array(salt); } // Text string: encode and pad/slice to 32 bytes const encoded = new TextEncoder().encode(salt); const result = new Uint8Array(32); result.set(encoded.subarray(0, 32)); return result; } /** * Creates and registers a new WebAuthn resident passkey credential with PRF enabled. */ /** * Creates and registers a new WebAuthn resident passkey credential with PRF enabled. */ export async function registerPasskeyCredential({ username, displayName = username, rpName = 'ibgib', rpId, salt, userVerification = 'preferred', authenticatorAttachment, hints = ['security-key', 'client-device', 'hybrid'], }: { username: string; displayName?: string; rpName?: string; rpId?: string; salt?: string | Uint8Array; userVerification?: UserVerificationRequirement; authenticatorAttachment?: AuthenticatorAttachment; hints?: ('security-key' | 'client-device' | 'hybrid')[]; }): Promise<{ credentialId: string; rawId: ArrayBuffer; transports: AuthenticatorTransport[]; passkeySecret?: string; }> { const lc = `[registerPasskeyCredential]`; try { if (typeof window === 'undefined' || !navigator.credentials?.create) { throw new Error('WebAuthn is not supported in this browser environment.'); } const effectiveRpId = rpId || (typeof window !== 'undefined' ? window.location.hostname : undefined); const challenge = crypto.getRandomValues(new Uint8Array(32)); const userId = new TextEncoder().encode(username.slice(0, 64)); const saltBuffer = salt ? normalizeSaltTo32Bytes(salt) : undefined; const prfExtension = saltBuffer ? { eval: { first: saltBuffer } } : {}; const credential = await navigator.credentials.create({ publicKey: { challenge, rp: { name: rpName, ...(effectiveRpId ? { id: effectiveRpId } : {}) }, user: { id: userId, name: username, displayName }, pubKeyCredParams: [ { alg: -7, type: 'public-key' }, // ES256 (ECDSA P-256) { alg: -257, type: 'public-key' }, // RS256 (RSA) { alg: -8, type: 'public-key' }, // Ed25519 ], authenticatorSelection: { residentKey: 'required', userVerification, ...(authenticatorAttachment ? { authenticatorAttachment } : {}) }, ...(hints && hints.length > 0 ? { hints } : {}), extensions: { prf: prfExtension } as any } }) as PublicKeyCredential; if (!credential) { throw new Error('Passkey creation was cancelled or failed.'); } const credentialId = bufferToHex(credential.rawId); const response: any = credential.response; const transports: AuthenticatorTransport[] = (typeof response.getTransports === 'function') ? response.getTransports() : []; const extResults: any = credential.getClientExtensionResults?.() || {}; console.log(`${lc} WebAuthn registration clientExtensionResults:`, extResults); let passkeySecret: string | undefined; if (extResults.prf?.results?.first) { passkeySecret = bufferToHex(extResults.prf.results.first); console.log(`${lc} Evaluated PRF directly during create!`); } return { credentialId, rawId: credential.rawId, transports, passkeySecret }; } catch (error) { console.error(`${lc} ${extractErrorMsg(error)}`); throw error; } } /** * Evaluates the PRF extension on a passkey assertion to derive a deterministic 256-bit symmetric secret. */ export async function evaluatePasskeyPrf({ salt, credentialId, transports, rpId, userVerification = 'preferred', hints = ['security-key', 'client-device', 'hybrid'], }: { salt: string | Uint8Array; credentialId?: string; transports?: AuthenticatorTransport[]; rpId?: string; userVerification?: UserVerificationRequirement; hints?: ('security-key' | 'client-device' | 'hybrid')[]; }): Promise<{ passkeySecret: string; credentialId: string; }> { const lc = `[evaluatePasskeyPrf]`; try { if (typeof window === 'undefined' || !navigator.credentials?.get) { throw new Error('WebAuthn is not supported in this browser environment.'); } const effectiveRpId = rpId || (typeof window !== 'undefined' ? window.location.hostname : undefined); const challenge = crypto.getRandomValues(new Uint8Array(32)); const saltBuffer = normalizeSaltTo32Bytes(salt); let credentialIdBase64Url: string | undefined = undefined; let allowCredentials: PublicKeyCredentialDescriptor[] | undefined = undefined; if (credentialId) { const rawBytes = hexToUint8Array(credentialId); allowCredentials = [{ id: rawBytes.buffer as ArrayBuffer, type: 'public-key' as const, ...(transports && transports.length > 0 ? { transports } : {}) }]; let binary = ''; for (let i = 0; i < rawBytes.byteLength; i++) { binary += String.fromCharCode(rawBytes[i]); } credentialIdBase64Url = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } const prfExtension: any = { eval: { first: saltBuffer } }; if (credentialIdBase64Url) { prfExtension.evalByCredential = { [credentialIdBase64Url]: { first: saltBuffer } }; } const assertion = await navigator.credentials.get({ publicKey: { challenge, ...(effectiveRpId ? { rpId: effectiveRpId } : {}), allowCredentials, userVerification, ...(hints && hints.length > 0 ? { hints } : {}), extensions: { prf: prfExtension } as any } }) as PublicKeyCredential; if (!assertion) { throw new Error('Passkey authentication was cancelled or failed.'); } const resolvedCredentialId = bufferToHex(assertion.rawId); const extResults: any = assertion.getClientExtensionResults?.() || {}; console.log(`${lc} WebAuthn assertion clientExtensionResults:`, JSON.stringify(extResults), extResults); let prfResult: ArrayBuffer | undefined = extResults.prf?.results?.first; if (!prfResult && credentialIdBase64Url && extResults.prf?.resultsByCredential?.[credentialIdBase64Url]?.first) { prfResult = extResults.prf.resultsByCredential[credentialIdBase64Url].first; console.log(`${lc} Extracted PRF result from resultsByCredential!`); } if (!prfResult) { console.error(`${lc} Extension results did not contain PRF evaluation:`, JSON.stringify(extResults), extResults); throw new Error('Authenticator did not return a PRF evaluation. PRF extension may not be supported on this authenticator.'); } const passkeySecret = bufferToHex(prfResult); return { passkeySecret, credentialId: resolvedCredentialId }; } catch (error) { console.error(`${lc} ${extractErrorMsg(error)}`); throw error; } } /** * Creates a Passkey sovereign sync delegate for this device. * Standardized format: `passkey-${safeUsername}-${defaultDeviceTag}` */ export async function createPasskeyDeviceSyncDelegate({ username, primaryTjpAddr, passkeySecret, transports, metaspace, space, }: { username: string; primaryTjpAddr: string; passkeySecret: string; transports?: AuthenticatorTransport[]; metaspace?: any; space?: any; }): Promise<{ delegateKeystone: any; deviceTag: string; delegateSecret: string }> { const delegateSecret = await deriveDelegateSecret({ masterSecret: passkeySecret }); const safeUser = getSaferSubstring({ text: username, length: 8, keepLiterals: ['-'] }) || 'user'; const defaultDeviceTag = generateDefaultDeviceTag(); const deviceTag = `passkey-${safeUser}-${defaultDeviceTag}`; const sovereignSyncProfileName = process.env.KEYSTONE_PROFILE_SOVEREIGN_SYNC; if (!sovereignSyncProfileName || !sovereignSyncProfileName.trim()) { throw new Error('KEYSTONE_PROFILE_SOVEREIGN_SYNC environment variable is missing.'); } const delegateKeystone = await createSyncDelegateKeystone({ profileName: sovereignSyncProfileName.trim() as any, username, deviceTag, primaryTjpAddr, delegateSecret, additionalFrameDetails: { client: 'space-gib-web', role: 'passkey-sync', ...(transports && transports.length > 0 ? { transports } : {}) }, metaspace, space, }); const delegateTjpAddr = getTjpAddr({ ibGib: delegateKeystone }) || getIbGibAddr({ ibGib: delegateKeystone }); await setDeviceDelegateSecret({ delegateTjpAddr, delegateSecret }); return { delegateKeystone, deviceTag, delegateSecret }; }