/** * EncryptID WebAuthn Module * * Handles passkey registration, authentication, and PRF extension * for key derivation. This is the foundation layer of EncryptID. */ // ============================================================================ // TYPES // ============================================================================ export interface EncryptIDCredential { credentialId: string; publicKey: ArrayBuffer; userId: string; username: string; createdAt: number; prfSupported: boolean; transports?: AuthenticatorTransport[]; } export interface AuthenticationResult { credentialId: string; userId: string; prfOutput?: ArrayBuffer; // Only if PRF extension supported signature: ArrayBuffer; authenticatorData: ArrayBuffer; } export interface EncryptIDConfig { rpId: string; rpName: string; origin: string; userVerification: UserVerificationRequirement; timeout: number; } // Default configuration for EncryptID const DEFAULT_CONFIG: EncryptIDConfig = { rpId: 'rspace.online', // Root domain — all *.rspace.online subdomains are valid rpName: 'EncryptID', origin: typeof window !== 'undefined' ? window.location.origin : '', userVerification: 'required', timeout: 60000, }; // Global abort controller for conditional UI let conditionalUIAbortController: AbortController | null = null; /** * Abort any pending conditional UI request * Call this before starting registration or authentication */ export function abortConditionalUI(): void { if (conditionalUIAbortController) { conditionalUIAbortController.abort(); conditionalUIAbortController = null; console.log('EncryptID: Conditional UI aborted'); } } // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ /** * Convert ArrayBuffer to base64url string */ export function bufferToBase64url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } /** * Convert base64url string to ArrayBuffer */ export function base64urlToBuffer(base64url: string): ArrayBuffer { const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); const padding = '='.repeat((4 - (base64.length % 4)) % 4); const binary = atob(base64 + padding); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } /** * Generate a random challenge */ export function generateChallenge(): ArrayBuffer { return crypto.getRandomValues(new Uint8Array(32)).buffer; } /** * Generate a deterministic salt for PRF extension */ export async function generatePRFSalt(purpose: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(`encryptid-prf-salt-${purpose}-v1`); return crypto.subtle.digest('SHA-256', data); } // ============================================================================ // WEBAUTHN REGISTRATION // ============================================================================ /** * Register a new EncryptID passkey * * This creates a discoverable credential (passkey) that can be used * for authentication across all r-ecosystem apps. */ export async function registerPasskey( username: string, displayName: string, config: Partial = {} ): Promise { // Abort any pending conditional UI to prevent "request already pending" error abortConditionalUI(); const cfg = { ...DEFAULT_CONFIG, ...config }; // Check WebAuthn support if (!window.PublicKeyCredential) { throw new Error('WebAuthn is not supported in this browser'); } // Check platform authenticator availability const platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); // Generate user ID (random bytes, not PII) const userId = crypto.getRandomValues(new Uint8Array(32)); // Generate challenge (in production, this comes from server) const challenge = generateChallenge(); // Generate PRF salt for key derivation const prfSalt = await generatePRFSalt('master-key'); // Build credential creation options const createOptions: CredentialCreationOptions = { publicKey: { challenge: new Uint8Array(challenge), // Relying Party information rp: { id: cfg.rpId, name: cfg.rpName, }, // User information user: { id: userId, name: username, displayName: displayName, }, // Supported algorithms (prefer ES256) pubKeyCredParams: [ { alg: -7, type: 'public-key' }, // ES256 (P-256) { alg: -257, type: 'public-key' }, // RS256 ], // Authenticator requirements authenticatorSelection: { // Require discoverable credential (passkey) residentKey: 'required', requireResidentKey: true, // Require user verification (biometric/PIN) userVerification: cfg.userVerification, // Prefer platform authenticator but allow cross-platform authenticatorAttachment: platformAvailable ? 'platform' : undefined, }, // Don't request attestation (privacy) attestation: 'none', // Timeout timeout: cfg.timeout, // Extensions extensions: { // Request PRF extension for key derivation // @ts-ignore - PRF extension not in standard types yet prf: { eval: { first: new Uint8Array(prfSalt), }, }, // Request credential properties credProps: true, }, }, }; // Create the credential const credential = await navigator.credentials.create(createOptions) as PublicKeyCredential; if (!credential) { throw new Error('Failed to create credential'); } const response = credential.response as AuthenticatorAttestationResponse; // Check if PRF is supported // @ts-ignore const prfSupported = credential.getClientExtensionResults()?.prf?.enabled === true; // Extract public key from attestation const publicKey = response.getPublicKey(); if (!publicKey) { throw new Error('Failed to get public key from credential'); } // Build credential object const encryptIDCredential: EncryptIDCredential = { credentialId: bufferToBase64url(credential.rawId), publicKey: publicKey, userId: bufferToBase64url(userId.buffer), username: username, createdAt: Date.now(), prfSupported: prfSupported, transports: response.getTransports?.() as AuthenticatorTransport[], }; console.log('EncryptID: Passkey registered', { credentialId: encryptIDCredential.credentialId.slice(0, 20) + '...', prfSupported, }); return encryptIDCredential; } // ============================================================================ // WEBAUTHN AUTHENTICATION // ============================================================================ /** * Authenticate with an existing EncryptID passkey * * Returns the authentication result including PRF output for key derivation * (if the authenticator supports PRF). */ export async function authenticatePasskey( credentialId?: string, // Optional: specify credential, or let user choose config: Partial = {} ): Promise { // Abort any pending conditional UI to prevent "request already pending" error abortConditionalUI(); const cfg = { ...DEFAULT_CONFIG, ...config }; // Check WebAuthn support if (!window.PublicKeyCredential) { throw new Error('WebAuthn is not supported in this browser'); } // Generate challenge (in production, this comes from server) const challenge = generateChallenge(); // Generate PRF salt for key derivation const prfSalt = await generatePRFSalt('master-key'); // Build allowed credentials list const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = credentialId ? [{ type: 'public-key', id: new Uint8Array(base64urlToBuffer(credentialId)), }] : undefined; // undefined = let user choose from available passkeys // Build authentication options const getOptions: CredentialRequestOptions = { publicKey: { challenge: new Uint8Array(challenge), // Relying Party ID rpId: cfg.rpId, // Allowed credentials (or undefined for discoverable) allowCredentials: allowCredentials, // Require user verification userVerification: cfg.userVerification, // Timeout timeout: cfg.timeout, // Extensions extensions: { // Request PRF evaluation for key derivation // @ts-ignore - PRF extension not in standard types yet prf: { eval: { first: new Uint8Array(prfSalt), }, }, }, }, }; // Perform authentication const credential = await navigator.credentials.get(getOptions) as PublicKeyCredential; if (!credential) { throw new Error('Authentication failed'); } const response = credential.response as AuthenticatorAssertionResponse; // Extract PRF output if available // @ts-ignore const prfResults = credential.getClientExtensionResults()?.prf?.results; const prfOutput = prfResults?.first; // Build result const result: AuthenticationResult = { credentialId: bufferToBase64url(credential.rawId), userId: response.userHandle ? bufferToBase64url(response.userHandle) : '', prfOutput: prfOutput as ArrayBuffer | undefined, signature: response.signature, authenticatorData: response.authenticatorData, }; console.log('EncryptID: Authentication successful', { credentialId: result.credentialId.slice(0, 20) + '...', prfAvailable: !!prfOutput, }); return result; } // ============================================================================ // CONDITIONAL UI (AUTOFILL) // ============================================================================ /** * Check if conditional mediation (passkey autofill) is available */ export async function isConditionalMediationAvailable(): Promise { if (!window.PublicKeyCredential) { return false; } // @ts-ignore if (typeof PublicKeyCredential.isConditionalMediationAvailable === 'function') { // @ts-ignore return PublicKeyCredential.isConditionalMediationAvailable(); } return false; } /** * Start conditional UI authentication (passkey autofill in input fields) * * Call this on page load to enable passkey suggestions in username fields. * The input field needs `autocomplete="username webauthn"` attribute. */ export async function startConditionalUI( config: Partial = {} ): Promise { const available = await isConditionalMediationAvailable(); if (!available) { console.log('EncryptID: Conditional mediation not available'); return null; } // Abort any existing conditional UI request abortConditionalUI(); // Create new abort controller for this request conditionalUIAbortController = new AbortController(); const cfg = { ...DEFAULT_CONFIG, ...config }; const challenge = generateChallenge(); const prfSalt = await generatePRFSalt('master-key'); try { const credential = await navigator.credentials.get({ publicKey: { challenge: new Uint8Array(challenge), rpId: cfg.rpId, userVerification: cfg.userVerification, timeout: cfg.timeout, extensions: { // @ts-ignore prf: { eval: { first: new Uint8Array(prfSalt), }, }, }, }, // @ts-ignore - conditional mediation mediation: 'conditional', signal: conditionalUIAbortController.signal, }) as PublicKeyCredential; // Clear abort controller on success conditionalUIAbortController = null; if (!credential) { return null; } const response = credential.response as AuthenticatorAssertionResponse; // @ts-ignore const prfResults = credential.getClientExtensionResults()?.prf?.results; return { credentialId: bufferToBase64url(credential.rawId), userId: response.userHandle ? bufferToBase64url(response.userHandle) : '', prfOutput: prfResults?.first as ArrayBuffer | undefined, signature: response.signature, authenticatorData: response.authenticatorData, }; } catch (error) { // Conditional UI was cancelled or failed console.log('EncryptID: Conditional UI cancelled or failed', error); return null; } } // ============================================================================ // FEATURE DETECTION // ============================================================================ export interface WebAuthnCapabilities { webauthn: boolean; platformAuthenticator: boolean; conditionalUI: boolean; prfExtension: boolean; // Note: Can only be confirmed after credential creation } /** * Detect WebAuthn capabilities of the current browser/device */ export async function detectCapabilities(): Promise { const capabilities: WebAuthnCapabilities = { webauthn: false, platformAuthenticator: false, conditionalUI: false, prfExtension: false, }; // Check basic WebAuthn support if (!window.PublicKeyCredential) { return capabilities; } capabilities.webauthn = true; // Check platform authenticator try { capabilities.platformAuthenticator = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } catch { capabilities.platformAuthenticator = false; } // Check conditional UI capabilities.conditionalUI = await isConditionalMediationAvailable(); // PRF support can only be confirmed after credential creation // We assume true for modern browsers and verify during registration capabilities.prfExtension = true; // Optimistic, verified at registration return capabilities; }