diff --git a/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts b/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts index f8ba559ec..ebe83404a 100644 --- a/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts @@ -53,7 +53,7 @@ export async function handleWebAuthnCreate(data: any): Promise { pendingRequestData.set(requestId, requestData); // Create popup using main popup with hash navigation - only pass requestId - const popupUrl = browser.runtime.getURL('/popup.html') + '#/passkeys/create?' + new URLSearchParams({ + const popupUrl = browser.runtime.getURL('/popup.html') + '#/credentials/passkeys/create?' + new URLSearchParams({ requestId }).toString(); @@ -112,7 +112,7 @@ export async function handleWebAuthnGet(data: any): Promise { pendingRequestData.set(requestId, requestData); // Create popup using main popup with hash navigation - only pass requestId - const popupUrl = browser.runtime.getURL('/popup.html') + '#/passkeys/authenticate?' + new URLSearchParams({ + const popupUrl = browser.runtime.getURL('/popup.html') + '#/credentials/passkeys/authenticate?' + new URLSearchParams({ requestId }).toString(); diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx index f1a2bc4dd..7dc49171d 100644 --- a/apps/browser-extension/src/entrypoints/popup/App.tsx +++ b/apps/browser-extension/src/entrypoints/popup/App.tsx @@ -25,6 +25,7 @@ import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList'; import Index from '@/entrypoints/popup/pages/Index'; import PasskeyAuthenticate from '@/entrypoints/popup/pages/passkeys/PasskeyAuthenticate'; import PasskeyCreate from '@/entrypoints/popup/pages/passkeys/PasskeyCreate'; +import PasskeysList from '@/entrypoints/popup/pages/passkeys/PasskeysList'; import Reinitialize from '@/entrypoints/popup/pages/Reinitialize'; import AutofillSettings from '@/entrypoints/popup/pages/settings/AutofillSettings'; import AutoLockSettings from '@/entrypoints/popup/pages/settings/AutoLockSettings'; @@ -71,6 +72,9 @@ const App: React.FC = () => { { path: '/credentials/add', element: , showBackButton: true, title: t('credentials.addCredential') }, { path: '/credentials/:id', element: , showBackButton: true, title: t('credentials.credentialDetails') }, { path: '/credentials/:id/edit', element: , showBackButton: true, title: t('credentials.editCredential') }, + { path: '/credentials/passkeys', element: , showBackButton: false }, + { path: '/credentials/passkeys/create', element: , showBackButton: true, title: 'Create Passkey' }, + { path: '/credentials/passkeys/authenticate', element: , showBackButton: true, title: 'Sign in with Passkey' }, { path: '/emails', element: , showBackButton: false }, { path: '/emails/:id', element: , showBackButton: true, title: t('emails.title') }, { path: '/settings', element: , showBackButton: false }, @@ -80,8 +84,6 @@ const App: React.FC = () => { { path: '/settings/language', element: , showBackButton: true, title: t('settings.language') }, { path: '/settings/auto-lock', element: , showBackButton: true, title: t('settings.autoLockTimeout') }, { path: '/logout', element: , showBackButton: false }, - { path: '/passkeys/create', element: , showBackButton: true, title: 'Create Passkey' }, - { path: '/passkeys/authenticate', element: , showBackButton: true, title: 'Sign in with Passkey' }, ], [t]); useEffect(() => { diff --git a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx index ea7acfc0f..69a5c9e64 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx @@ -42,7 +42,7 @@ const PasskeyAuthenticate: React.FC = () => { // Vault is locked, redirect to unlock const params = new URLSearchParams(location.search); const requestId = params.get('requestId'); - navigate(`/unlock?redirect=/passkeys/authenticate&requestId=${requestId}`); + navigate(`/unlock?redirect=/credentials/passkeys/authenticate&requestId=${requestId}`); return; } @@ -145,7 +145,7 @@ const PasskeyAuthenticate: React.FC = () => { // Get the assertion using the static method const credential: PasskeyGetCredentialResponse = await AliasVaultPasskeyProvider.getAssertion(getRequest, storedRecord, { - uvPerformed: true, + uvPerformed: true, // TODO: implement explicit user verification check if requested includeBEBS: true // Backup eligible/state - defaults to true }); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx index c318420b6..83f501278 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx @@ -41,7 +41,7 @@ const PasskeyCreate: React.FC = () => { // Vault is locked, redirect to unlock const params = new URLSearchParams(location.search); const requestId = params.get('requestId'); - navigate(`/unlock?redirect=/passkeys/create&requestId=${requestId}`); + navigate(`/unlock?redirect=/credentials/passkeys/create&requestId=${requestId}`); return; } diff --git a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeysList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeysList.tsx new file mode 100644 index 000000000..55d981d65 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeysList.tsx @@ -0,0 +1,200 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import CredentialCard from '@/entrypoints/popup/components/CredentialCard'; +import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; +import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; +import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; +import ReloadButton from '@/entrypoints/popup/components/ReloadButton'; +import { useApp } from '@/entrypoints/popup/context/AppContext'; +import { useDb } from '@/entrypoints/popup/context/DbContext'; +import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; +import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; +import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync'; +import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility'; + +import type { Credential } from '@/utils/dist/shared/models/vault'; + +import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; + +/** + * Passkeys list page - shows credentials that have passkeys. + */ +const PasskeysList: React.FC = () => { + const { t } = useTranslation(); + const dbContext = useDb(); + const app = useApp(); + const { syncVault } = useVaultSync(); + const { setHeaderButtons } = useHeaderButtons(); + const [credentials, setCredentials] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const { setIsInitialLoading } = useLoading(); + + /** + * Loading state with minimum duration for more fluid UX. + */ + const [isLoading, setIsLoading] = useMinDurationLoading(true, 100); + + /** + * Retrieve latest vault and refresh the credentials list. + */ + const onRefresh = useCallback(async (): Promise => { + if (!dbContext?.sqliteClient) { + return; + } + + try { + // Sync vault and load credentials + await syncVault({ + /** + * On success. + */ + onSuccess: async (_hasNewVault) => { + // Credentials list is refreshed automatically when the (new) sqlite client is available via useEffect hook below. + }, + /** + * On offline. + */ + _onOffline: () => { + // Not implemented for browser extension yet. + }, + /** + * On error. + */ + onError: async (error) => { + console.error('Error syncing vault:', error); + }, + }); + } catch (err) { + console.error('Error refreshing passkeys:', err); + await app.logout('Error while syncing vault, please re-authenticate.'); + } + }, [dbContext, app, syncVault]); + + /** + * Get latest vault from server and refresh the credentials list. + */ + const syncVaultAndRefresh = useCallback(async (): Promise => { + setIsLoading(true); + await onRefresh(); + setIsLoading(false); + }, [onRefresh, setIsLoading]); + + // Set header buttons on mount and clear on unmount + useEffect((): (() => void) => { + const headerButtonsJSX = ( +
+ {!PopoutUtility.isPopup() && ( + PopoutUtility.openInNewPopup()} + title="Open in new window" + iconType={HeaderIconType.EXPAND} + /> + )} +
+ ); + + setHeaderButtons(headerButtonsJSX); + return () => setHeaderButtons(null); + }, [setHeaderButtons]); + + /** + * Load credentials with passkeys on mount and on sqlite client change. + */ + useEffect(() => { + /** + * Refresh credentials list when a (new) sqlite client is available. + */ + const refreshCredentials = async (): Promise => { + if (dbContext?.sqliteClient) { + setIsLoading(true); + const allCredentials = dbContext.sqliteClient?.getAllCredentials() ?? []; + + // Filter to only credentials that have passkeys + const credentialsWithPasskeys = allCredentials.filter(credential => { + const passkeys = dbContext.sqliteClient!.getPasskeysByCredentialId(credential.Id); + return passkeys.length > 0; + }); + + setCredentials(credentialsWithPasskeys); + setIsLoading(false); + setIsInitialLoading(false); + } + }; + + refreshCredentials(); + }, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]); + + const filteredCredentials = credentials.filter(credential => { + const searchLower = searchTerm.toLowerCase(); + + /** + * We filter credentials by searching in the following fields: + * - Service name + * - Username + * - Alias email + * - Service URL + * - Notes + */ + const searchableFields = [ + credential.ServiceName?.toLowerCase(), + credential.Username?.toLowerCase(), + credential.Alias?.Email?.toLowerCase(), + credential.ServiceUrl?.toLowerCase(), + credential.Notes?.toLowerCase(), + ]; + return searchableFields.some(field => field?.includes(searchLower)); + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

{t('passkeys.title')}

+ +
+ + {credentials.length > 0 ? ( +
+ setSearchTerm(e.target.value)} + placeholder={`${t('content.searchVault')}`} + autoFocus + className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-blue-500 focus:border-blue-500" + /> +
+ ) : ( + <> + )} + + {credentials.length === 0 ? ( +
+

+ {t('passkeys.welcomeTitle')} +

+

+ {t('passkeys.welcomeDescription')} +

+
+ ) : ( +
    + {filteredCredentials.map(cred => ( + + ))} +
+ )} +
+ ); +}; + +export default PasskeysList; diff --git a/apps/browser-extension/src/entrypoints/webauthn.ts b/apps/browser-extension/src/entrypoints/webauthn.ts index 680030fc9..b97533e61 100644 --- a/apps/browser-extension/src/entrypoints/webauthn.ts +++ b/apps/browser-extension/src/entrypoints/webauthn.ts @@ -64,6 +64,8 @@ export default defineUnlistedScript(() => { return originalCreate(options); } + console.log('[AliasVault] Page: Received credential CREATE request with options:', options); + // Send event to content script const requestId = Math.random().toString(36).substr(2, 9); const eventDetail: WebAuthnCreateEventDetail = { @@ -162,7 +164,7 @@ export default defineUnlistedScript(() => { id: cred.id, type: 'public-key', rawId: base64ToBuffer(cred.rawId), - authenticatorAttachment: 'platform', + authenticatorAttachment: 'cross-platform', response: { clientDataJSON: base64ToBuffer(cred.clientDataJSON), attestationObject: attestationObjectBuffer, @@ -198,6 +200,7 @@ export default defineUnlistedScript(() => { return {}; } }; + console.log('[AliasVault] Page: Returned created credential object:', credential); resolve(credential as any); } catch (error) { console.error('[AliasVault] Page: Error creating credential object:', error); @@ -221,6 +224,8 @@ export default defineUnlistedScript(() => { return originalGet(options); } + console.log('[AliasVault] Page: Received credential GET request with options:', options); + // Send event to content script const requestId = Math.random().toString(36).substr(2, 9); const eventDetail: WebAuthnGetEventDetail = { @@ -274,11 +279,12 @@ export default defineUnlistedScript(() => { } else if (e.detail.credential) { // Create a proper credential object with required methods const cred: ProviderGetCredential = e.detail.credential; + console.log('[AliasVault] Page: Returned GET credential readable object:', cred); const credential = { id: cred.id, type: 'public-key', rawId: base64ToBuffer(cred.rawId), - authenticatorAttachment: 'platform', + authenticatorAttachment: 'cross-platform', response: { clientDataJSON: base64ToBuffer(cred.clientDataJSON), authenticatorData: base64ToBuffer(cred.authenticatorData), @@ -292,6 +298,7 @@ export default defineUnlistedScript(() => { return {}; } }; + console.log('[AliasVault] Page: Returned GET credential raw object:', credential); resolve(credential as any); } else { // Cancelled diff --git a/apps/browser-extension/src/utils/SqliteClient.ts b/apps/browser-extension/src/utils/SqliteClient.ts index ea5a8e8e5..7cd57ed94 100644 --- a/apps/browser-extension/src/utils/SqliteClient.ts +++ b/apps/browser-extension/src/utils/SqliteClient.ts @@ -1217,6 +1217,52 @@ export class SqliteClient { }; } + /** + * Get all passkeys for a specific credential + * @param credentialId - The credential ID + * @returns Array of passkey objects + */ + public getPasskeysByCredentialId(credentialId: string): Passkey[] { + if (!this.db) { + throw new Error('Database not initialized'); + } + + const query = ` + SELECT + p.Id, + p.CredentialId, + p.RpId, + p.UserId, + p.PublicKey, + p.PrivateKey, + p.DisplayName, + p.AdditionalData, + p.CreatedAt, + p.UpdatedAt, + p.IsDeleted + FROM Passkeys p + WHERE p.CredentialId = ? AND p.IsDeleted = 0 + ORDER BY p.CreatedAt DESC + `; + + const results = this.executeQuery(query, [credentialId]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return results.map((row: any) => ({ + Id: row.Id, + CredentialId: row.CredentialId, + RpId: row.RpId, + UserId: row.UserId, + PublicKey: row.PublicKey, + PrivateKey: row.PrivateKey, + DisplayName: row.DisplayName, + AdditionalData: row.AdditionalData, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + IsDeleted: row.IsDeleted + })); + } + /** * Create a new passkey linked to a credential * @param passkey - The passkey object to create diff --git a/apps/browser-extension/src/utils/passkey/AliasVaultPasskeyProvider.ts b/apps/browser-extension/src/utils/passkey/AliasVaultPasskeyProvider.ts index 94a01a950..153380e78 100644 --- a/apps/browser-extension/src/utils/passkey/AliasVaultPasskeyProvider.ts +++ b/apps/browser-extension/src/utils/passkey/AliasVaultPasskeyProvider.ts @@ -143,13 +143,13 @@ export class AliasVaultPasskeyProvider { lastUsedAt: Date.now() }; - // 12) Return a credential-like object (base64-encoded fields for transport) + // 12) Return a credential-like object (base64url-encoded fields for transport per RFC 4648 §5) const credential = { id: credentialIdB64u, rawId: credentialIdB64u, response: { - clientDataJSON: AliasVaultPasskeyProvider.toB64(clientDataJSONBytes), - attestationObject: AliasVaultPasskeyProvider.toB64(attObjBytes) + clientDataJSON: AliasVaultPasskeyProvider.toB64u(clientDataJSONBytes), + attestationObject: AliasVaultPasskeyProvider.toB64u(attObjBytes) }, type: 'public-key' as const }; @@ -234,26 +234,26 @@ export class AliasVaultPasskeyProvider { const derSig = AliasVaultPasskeyProvider.ecdsaRawToDer(rawSig); /* - * 8) Return userHandle (userId) as-is + * 8) Return userHandle (userId) - convert to base64url if present * This is required for discoverable credentials (resident keys) where the RP doesn't ask for a username first - * userId is already stored as standard base64 (from injection script), return as-is + * userId is stored as standard base64, convert to base64url for RFC 4648 §5 compliance */ - let userHandleB64: string | null = null; + let userHandleB64u: string | null = null; if (rec.userId) { - // Return as-is - already in standard base64 format - userHandleB64 = rec.userId; + // Convert standard base64 to base64url (remove padding, replace chars) + userHandleB64u = rec.userId.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } else { console.warn('AliasVaultPasskeyProvider.getAssertion: No userId found in stored passkey record'); } - // 9) Return object in the flat shape (base64 strings), as your client example expects + // 9) Return object in the flat shape (base64url strings per RFC 4648 §5) return { id: rec.credentialId, rawId: rec.credentialId, - clientDataJSON: AliasVaultPasskeyProvider.toB64(clientDataJSONBytes), - authenticatorData: AliasVaultPasskeyProvider.toB64(authenticatorData), - signature: AliasVaultPasskeyProvider.toB64(derSig), - userHandle: userHandleB64 + clientDataJSON: AliasVaultPasskeyProvider.toB64u(clientDataJSONBytes), + authenticatorData: AliasVaultPasskeyProvider.toB64u(authenticatorData), + signature: AliasVaultPasskeyProvider.toB64u(derSig), + userHandle: userHandleB64u }; } diff --git a/apps/browser-extension/src/utils/passkey/__tests__/AliasVaultPasskeyProvider.test.ts b/apps/browser-extension/src/utils/passkey/__tests__/AliasVaultPasskeyProvider.test.ts index 404ac774a..62810f2b8 100644 --- a/apps/browser-extension/src/utils/passkey/__tests__/AliasVaultPasskeyProvider.test.ts +++ b/apps/browser-extension/src/utils/passkey/__tests__/AliasVaultPasskeyProvider.test.ts @@ -4,6 +4,19 @@ import { AliasVaultPasskeyProvider } from '../AliasVaultPasskeyProvider'; import type { CreateRequest, GetRequest, StoredPasskeyRecord } from '../types'; +/** + * Helper function to decode base64url strings + */ +function fromBase64url(base64url: string): string { + // Convert base64url to base64 + let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + // Add padding if needed + while (base64.length % 4) { + base64 += '='; + } + return atob(base64); +} + describe('AliasVaultPasskeyProvider', () => { let storedPasskeys: Map; @@ -30,7 +43,7 @@ describe('AliasVaultPasskeyProvider', () => { userVerification: 'preferred', requireResidentKey: true, residentKey: 'required', - authenticatorAttachment: 'platform' + authenticatorAttachment: 'cross-platform' } } }; @@ -84,8 +97,8 @@ describe('AliasVaultPasskeyProvider', () => { const credentialIdBytes = crypto.getRandomValues(new Uint8Array(16)); const result = await AliasVaultPasskeyProvider.createPasskey(credentialIdBytes, createRequest); - // Decode and verify clientDataJSON - const clientDataJSON = atob(result.credential.response.clientDataJSON); + // Decode and verify clientDataJSON (base64url) + const clientDataJSON = fromBase64url(result.credential.response.clientDataJSON); const clientData = JSON.parse(clientDataJSON); expect(clientData.type).toBe('webauthn.create'); @@ -123,7 +136,7 @@ describe('AliasVaultPasskeyProvider', () => { const result = await AliasVaultPasskeyProvider.createPasskey(credentialIdBytes, createRequest); // Decode clientDataJSON and verify challenge matches - const clientDataJSON = atob(result.credential.response.clientDataJSON); + const clientDataJSON = fromBase64url(result.credential.response.clientDataJSON); const clientData = JSON.parse(clientDataJSON); // The challenge in clientDataJSON should match what we sent @@ -132,7 +145,7 @@ describe('AliasVaultPasskeyProvider', () => { /** * Convert base64url string to Uint8Array */ - const fromBase64url = (b64u: string): Uint8Array => { + const base64urlToBytes = (b64u: string): Uint8Array => { const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/'); const pad = b64.length % 4 === 2 ? '==' : b64.length % 4 === 3 ? '=' : ''; const binary = atob(b64 + pad); @@ -143,7 +156,7 @@ describe('AliasVaultPasskeyProvider', () => { return bytes; }; - const decodedChallenge = fromBase64url(clientData.challenge); + const decodedChallenge = base64urlToBytes(clientData.challenge); expect(Array.from(decodedChallenge)).toEqual(Array.from(challengeBytes)); }); @@ -161,7 +174,7 @@ describe('AliasVaultPasskeyProvider', () => { // Decode attestation object (base64) const attObjBytes = Uint8Array.from( - atob(result.credential.response.attestationObject), + fromBase64url(result.credential.response.attestationObject), c => c.charCodeAt(0) ); @@ -322,7 +335,7 @@ describe('AliasVaultPasskeyProvider', () => { const assertion = await AliasVaultPasskeyProvider.getAssertion(getRequest, storedRecord); // Decode and validate clientDataJSON - const clientDataJSON = atob(assertion.clientDataJSON); + const clientDataJSON = fromBase64url(assertion.clientDataJSON); const clientData = JSON.parse(clientDataJSON); expect(clientData.type).toBe('webauthn.get'); @@ -359,7 +372,7 @@ describe('AliasVaultPasskeyProvider', () => { const assertion = await AliasVaultPasskeyProvider.getAssertion(getRequest, storedRecord); // Decode authenticatorData - const authDataBytes = Uint8Array.from(atob(assertion.authenticatorData), c => c.charCodeAt(0)); + const authDataBytes = Uint8Array.from(fromBase64url(assertion.authenticatorData), c => c.charCodeAt(0)); // AuthenticatorData for assertion: rpIdHash (32) + flags (1) + signCount (4) = 37 bytes expect(authDataBytes.length).toBe(37); @@ -410,7 +423,7 @@ describe('AliasVaultPasskeyProvider', () => { const assertion = await AliasVaultPasskeyProvider.getAssertion(getRequest, storedRecord); // Decode signature - const sigBytes = Uint8Array.from(atob(assertion.signature), c => c.charCodeAt(0)); + const sigBytes = Uint8Array.from(fromBase64url(assertion.signature), c => c.charCodeAt(0)); // DER signature should start with SEQUENCE tag (0x30) expect(sigBytes[0]).toBe(0x30); @@ -477,7 +490,7 @@ describe('AliasVaultPasskeyProvider', () => { }); // Decode authenticatorData and check flags - const authDataBytes = Uint8Array.from(atob(assertion.authenticatorData), c => c.charCodeAt(0)); + const authDataBytes = Uint8Array.from(fromBase64url(assertion.authenticatorData), c => c.charCodeAt(0)); const flags = authDataBytes[32]; // Should have UV (0x04) set when required @@ -522,7 +535,7 @@ describe('AliasVaultPasskeyProvider', () => { // Decode and verify it matches the original user.id bytes if (assertion.userHandle) { - const decoded = atob(assertion.userHandle); + const decoded = fromBase64url(assertion.userHandle); const decodedBytes = new Uint8Array(decoded.length); for (let i = 0; i < decoded.length; i++) { decodedBytes[i] = decoded.charCodeAt(i); @@ -588,7 +601,7 @@ describe('AliasVaultPasskeyProvider', () => { }); // Decode authenticatorData and check flags - const authDataBytes = Uint8Array.from(atob(assertion.authenticatorData), c => c.charCodeAt(0)); + const authDataBytes = Uint8Array.from(fromBase64url(assertion.authenticatorData), c => c.charCodeAt(0)); const flags = authDataBytes[32]; // Should NOT have BE (0x08) or BS (0x10) set @@ -717,7 +730,7 @@ describe('AliasVaultPasskeyProvider', () => { // Decode and verify it matches original if (assertion.userHandle) { - const decoded = atob(assertion.userHandle); + const decoded = fromBase64url(assertion.userHandle); const decodedBytes = new Uint8Array(decoded.length); for (let i = 0; i < decoded.length; i++) { decodedBytes[i] = decoded.charCodeAt(i); @@ -765,13 +778,13 @@ describe('AliasVaultPasskeyProvider', () => { ); // Reconstruct the signed data - const authDataBytes = Uint8Array.from(atob(assertion.authenticatorData), c => c.charCodeAt(0)); - const clientDataBytes = new TextEncoder().encode(atob(assertion.clientDataJSON)); + const authDataBytes = Uint8Array.from(fromBase64url(assertion.authenticatorData), c => c.charCodeAt(0)); + const clientDataBytes = new TextEncoder().encode(fromBase64url(assertion.clientDataJSON)); const clientDataHash = new Uint8Array(await crypto.subtle.digest('SHA-256', clientDataBytes)); const signedData = new Uint8Array([...authDataBytes, ...clientDataHash]); // Decode DER signature to raw format for verification - const derSig = Uint8Array.from(atob(assertion.signature), c => c.charCodeAt(0)); + const derSig = Uint8Array.from(fromBase64url(assertion.signature), c => c.charCodeAt(0)); // Simple DER decoder for ECDSA signature (SEQUENCE of two INTEGERs) let offset = 2; // Skip SEQUENCE tag and length diff --git a/apps/browser-extension/src/utils/passkey/types.ts b/apps/browser-extension/src/utils/passkey/types.ts index 1aa249e48..28be003aa 100644 --- a/apps/browser-extension/src/utils/passkey/types.ts +++ b/apps/browser-extension/src/utils/passkey/types.ts @@ -16,21 +16,22 @@ export type { /** * WebAuthn credential response types (for injection script compatibility) + * All fields use base64url encoding per RFC 4648 §5 (URL-safe, no padding) */ export type PasskeyCreateCredentialResponse = { id: string; // base64url credential ID rawId: string; // base64url (same as id for compatibility) - clientDataJSON: string; // base64 encoded client data JSON - attestationObject: string; // base64 encoded attestation object (CBOR) + clientDataJSON: string; // base64url encoded client data JSON + attestationObject: string; // base64url encoded attestation object (CBOR) }; export type PasskeyGetCredentialResponse = { id: string; // base64url credential ID rawId: string; // base64url (same as id for compatibility) - clientDataJSON: string; // base64 encoded client data JSON - authenticatorData: string; // base64 encoded authenticator data - signature: string; // base64 encoded DER signature - userHandle: string | null; // base64 encoded user ID (null if not provided during creation) + clientDataJSON: string; // base64url encoded client data JSON + authenticatorData: string; // base64url encoded authenticator data + signature: string; // base64url encoded DER signature + userHandle: string | null; // base64url encoded user ID (null if not provided during creation) }; export type StoredPasskeyRecord = { @@ -38,7 +39,7 @@ export type StoredPasskeyRecord = { credentialId: string; // base64url identifier (string) publicKey: JsonWebKey; // JWK (P-256) privateKey: JsonWebKey; // JWK (P-256) - userId?: string | null; // base64url encoded user.id (stored as string for consistency) + userId?: string | null; // standard base64 encoded user.id (used for userHandle in authentication) userName?: string; userDisplayName?: string; };