mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-15 10:55:31 -04:00
Refactor base64url usage (#520)
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user