Refactor base64url usage (#520)

This commit is contained in:
Leendert de Borst
2025-10-03 11:21:12 +02:00
parent 1cf49eed7e
commit dad476548e
10 changed files with 315 additions and 46 deletions

View File

@@ -53,7 +53,7 @@ export async function handleWebAuthnCreate(data: any): Promise<any> {
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<any> {
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();

View File

@@ -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: <CredentialAddEdit />, showBackButton: true, title: t('credentials.addCredential') },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: t('credentials.credentialDetails') },
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.editCredential') },
{ path: '/credentials/passkeys', element: <PasskeysList />, showBackButton: false },
{ path: '/credentials/passkeys/create', element: <PasskeyCreate />, showBackButton: true, title: 'Create Passkey' },
{ path: '/credentials/passkeys/authenticate', element: <PasskeyAuthenticate />, showBackButton: true, title: 'Sign in with Passkey' },
{ path: '/emails', element: <EmailsList />, showBackButton: false },
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: t('emails.title') },
{ path: '/settings', element: <Settings />, showBackButton: false },
@@ -80,8 +84,6 @@ const App: React.FC = () => {
{ path: '/settings/language', element: <LanguageSettings />, showBackButton: true, title: t('settings.language') },
{ path: '/settings/auto-lock', element: <AutoLockSettings />, showBackButton: true, title: t('settings.autoLockTimeout') },
{ path: '/logout', element: <Logout />, showBackButton: false },
{ path: '/passkeys/create', element: <PasskeyCreate />, showBackButton: true, title: 'Create Passkey' },
{ path: '/passkeys/authenticate', element: <PasskeyAuthenticate />, showBackButton: true, title: 'Sign in with Passkey' },
], [t]);
useEffect(() => {

View File

@@ -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
});

View File

@@ -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;
}

View File

@@ -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<Credential[]>([]);
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<void> => {
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<void> => {
setIsLoading(true);
await onRefresh();
setIsLoading(false);
}, [onRefresh, setIsLoading]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
)}
</div>
);
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<void> => {
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 (
<div className="flex justify-center items-center p-8">
<LoadingSpinner />
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">{t('passkeys.title')}</h2>
<ReloadButton onClick={syncVaultAndRefresh} />
</div>
{credentials.length > 0 ? (
<div className="mb-4">
<input
type="text"
value={searchTerm}
onChange={(e) => 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"
/>
</div>
) : (
<></>
)}
{credentials.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p>
{t('passkeys.welcomeTitle')}
</p>
<p>
{t('passkeys.welcomeDescription')}
</p>
</div>
) : (
<ul className="space-y-2">
{filteredCredentials.map(cred => (
<CredentialCard key={cred.Id} credential={cred} />
))}
</ul>
)}
</div>
);
};
export default PasskeysList;

View File

@@ -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

View File

@@ -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

View File

@@ -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
};
}

View File

@@ -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<string, StoredPasskeyRecord>;
@@ -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

View File

@@ -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;
};