Update passkey add and retrieve data flow (#520)

This commit is contained in:
Leendert de Borst
2025-09-30 20:51:45 +02:00
parent 0f62d15d74
commit 61c124364a
5 changed files with 386 additions and 78 deletions

View File

@@ -3,7 +3,7 @@ import { onMessage, sendMessage } from "webext-bridge/background";
import { handleResetAutoLockTimer, handlePopupHeartbeat, handleSetAutoLockTimeout } from '@/entrypoints/background/AutolockTimeoutHandler';
import { handleClipboardCopied, handleCancelClipboardClear, handleGetClipboardClearTimeout, handleSetClipboardClearTimeout, handleGetClipboardCountdownState } from '@/entrypoints/background/ClipboardClearHandler';
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, handleStorePasskey, handleUpdatePasskeyLastUsed, handleClearAllPasskeys, handlePasskeyPopupResponse, initializePasskeys } from '@/entrypoints/background/PasskeyHandler';
import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, handleStorePasskey, handleUpdatePasskeyLastUsed, handleClearAllPasskeys, handlePasskeyPopupResponse, initializePasskeys, handleGetRequestData, handleGetPasskeyById } from '@/entrypoints/background/PasskeyHandler';
import { handleOpenPopup, handlePopupWithCredential, handleOpenPopupCreateCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
@@ -71,6 +71,8 @@ export default defineBackground({
onMessage('UPDATE_PASSKEY_LAST_USED', ({ data }) => handleUpdatePasskeyLastUsed(data as { credentialId: string }));
onMessage('CLEAR_ALL_PASSKEYS', () => handleClearAllPasskeys());
onMessage('PASSKEY_POPUP_RESPONSE', ({ data }) => handlePasskeyPopupResponse(data as { requestId: string; credential?: any; fallback?: boolean; cancelled?: boolean }));
onMessage('GET_REQUEST_DATA', ({ data }) => handleGetRequestData(data as { requestId: string }));
onMessage('GET_PASSKEY_BY_ID', ({ data }) => handleGetPasskeyById(data as { credentialId: string }));
// Initialize passkeys from storage TODO: remove this once proper vault integration is added
await initializePasskeys();

View File

@@ -10,7 +10,8 @@ interface IPasskeyData {
rpId: string;
credentialId: string;
displayName: string;
publicKey: unknown;
publicKey: JsonWebKey;
privateKey: JsonWebKey;
createdAt: number;
updatedAt: number;
lastUsedAt: number | null;
@@ -26,6 +27,9 @@ const pendingRequests = new Map<string, {
reject: (error: any) => void;
}>();
// Store request data temporarily (to avoid URL length limits)
const pendingRequestData = new Map<string, any>();
/**
* Handle WebAuthn settings request
*/
@@ -52,14 +56,18 @@ export async function handleWebAuthnCreate(data: {
console.log('handleWebAuthnCreate: origin', origin);
console.log('handleWebAuthnCreate: publicKey', publicKey);
// Create popup using main popup with hash navigation
// Store request data temporarily (to avoid URL length limits)
const requestData = {
type: 'create',
requestId,
origin,
publicKey
};
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({
request: encodeURIComponent(JSON.stringify({
type: 'create',
requestId,
origin,
publicKey
}))
requestId
}).toString();
try {
@@ -114,30 +122,62 @@ export async function handleWebAuthnGet(data: {
const requestId = Math.random().toString(36).substr(2, 9);
// Get passkeys for this origin
console.log('handleWebAuthnGet: origin', origin);
console.log('handleWebAuthnGet: all passkeys', Array.from(sessionPasskeys.entries()));
const passkeys = getPasskeysForOrigin(origin);
console.log('handleWebAuthnGet: found passkeys', passkeys);
// Filter by allowCredentials if specified
let filteredPasskeys = passkeys;
console.log('handleWebAuthnGet: before filter, passkeys count', passkeys.length);
console.log('handleWebAuthnGet: allowCredentials', publicKey.allowCredentials);
if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) {
const allowedIds = new Set(publicKey.allowCredentials.map(c => c.id));
filteredPasskeys = passkeys.filter(pk => allowedIds.has(pk.credentialId));
console.log('handleWebAuthnGet: allowedIds', Array.from(allowedIds));
filteredPasskeys = passkeys.filter(pk => {
const matches = allowedIds.has(pk.credentialId);
console.log('handleWebAuthnGet: checking', pk.credentialId, 'matches?', matches);
return matches;
});
console.log('handleWebAuthnGet: after filter, filteredPasskeys count', filteredPasskeys.length);
}
const passkeyList = filteredPasskeys.map(pk => ({
let passkeyList = filteredPasskeys.map(pk => ({
id: pk.credentialId,
displayName: pk.displayName,
lastUsed: pk.lastUsedAt ? new Date(pk.lastUsedAt).toLocaleDateString() : null
}));
console.log('handleWebAuthnGet: final passkeyList', passkeyList);
// Create popup using main popup with hash navigation
// If allowCredentials was specified but we have no matches, show all passkeys anyway
// (This is what password managers do - they show their own passkeys even if the site
// doesn't explicitly request them, allowing users to use extension passkeys)
if (passkeyList.length === 0 && passkeys.length > 0) {
console.log('handleWebAuthnGet: no matching allowCredentials, showing all passkeys instead');
passkeyList = passkeys.map(pk => ({
id: pk.credentialId,
displayName: pk.displayName,
lastUsed: pk.lastUsedAt ? new Date(pk.lastUsedAt).toLocaleDateString() : null
}));
}
// Store request data temporarily (to avoid URL length limits)
const requestData = {
type: 'get',
requestId,
origin,
publicKey,
passkeys: passkeyList
};
console.log('handleWebAuthnGet: storing request data', requestData);
console.log('handleWebAuthnGet: passkeyList length', passkeyList.length);
pendingRequestData.set(requestId, requestData);
console.log('handleWebAuthnGet: stored in map, map size', pendingRequestData.size);
// Create popup using main popup with hash navigation - only pass requestId
const popupUrl = browser.runtime.getURL('/popup.html') + '#/passkeys/authenticate?' + new URLSearchParams({
request: encodeURIComponent(JSON.stringify({
type: 'get',
requestId,
origin,
publicKey,
passkeys: passkeyList
}))
requestId
}).toString();
try {
@@ -182,16 +222,18 @@ export async function handleStorePasskey(data: {
rpId: string;
credentialId: string;
displayName: string;
publicKey: unknown;
publicKey: JsonWebKey;
privateKey: JsonWebKey;
}): Promise<{ success: boolean }> {
const { rpId, credentialId, displayName, publicKey } = data;
const { rpId, credentialId, displayName, publicKey, privateKey } = data;
const passkey: IPasskeyData = {
id: Date.now().toString(),
rpId: rpId.replace(/^https?:\/\//, '').split('/')[0], // Extract domain
rpId, // Already processed by the popup, no need to extract domain again
credentialId,
displayName,
publicKey,
privateKey,
createdAt: Date.now(),
updatedAt: Date.now(),
lastUsedAt: null,
@@ -246,14 +288,17 @@ export async function handleUpdatePasskeyLastUsed(data: {
*/
function getPasskeysForOrigin(origin: string): IPasskeyData[] {
const rpId = origin.replace(/^https?:\/\//, '').split('/')[0];
console.log('getPasskeysForOrigin: searching for rpId', rpId);
const passkeys: IPasskeyData[] = [];
for (const [_key, passkey] of sessionPasskeys.entries()) {
for (const [key, passkey] of sessionPasskeys.entries()) {
console.log('getPasskeysForOrigin: checking passkey', key, passkey.rpId);
if (passkey.rpId === rpId || passkey.rpId === `.${rpId}`) {
passkeys.push(passkey);
}
}
console.log('getPasskeysForOrigin: found', passkeys.length, 'passkeys');
return passkeys;
}
@@ -299,6 +344,35 @@ export async function handlePasskeyPopupResponse(data: {
return { success: true };
}
/**
* Get passkey by credential ID
*/
export async function handleGetPasskeyById(data: { credentialId: string }): Promise<IPasskeyData | null> {
const { credentialId } = data;
for (const [_key, passkey] of sessionPasskeys.entries()) {
if (passkey.credentialId === credentialId) {
return passkey;
}
}
return null;
}
/**
* Get request data by request ID
*/
export async function handleGetRequestData(data: { requestId: string }): Promise<any> {
const { requestId } = data;
console.log('handleGetRequestData: requestId', requestId);
console.log('handleGetRequestData: map size', pendingRequestData.size);
console.log('handleGetRequestData: map keys', Array.from(pendingRequestData.keys()));
const requestData = pendingRequestData.get(requestId);
console.log('handleGetRequestData: found data', requestData);
console.log('handleGetRequestData: passkeys in data', requestData?.passkeys);
return requestData || null;
}
/**
* Clear all passkeys (for development)
*/

View File

@@ -29,21 +29,43 @@ const PasskeyAuthenticate: React.FC = () => {
const [loading, setLoading] = useState(false);
useEffect(() => {
// Get the request data from hash (format: #/passkeys/authenticate?request=...)
const params = new URLSearchParams(location.search);
const requestData = params.get('request');
const fetchRequestData = async () => {
// Get the requestId from URL
const params = new URLSearchParams(location.search);
const requestId = params.get('requestId');
if (requestData) {
try {
const parsed = JSON.parse(decodeURIComponent(requestData));
setRequest(parsed);
} catch (error) {
console.error('Failed to parse request data:', error);
if (requestId) {
try {
// Fetch the full request data from background
const response = await sendMessage('GET_REQUEST_DATA', { requestId }, 'background');
console.log('PasskeyAuthenticate: full response', response);
console.log('PasskeyAuthenticate: response type', typeof response);
const keys = response ? Object.keys(response) : [];
console.log('PasskeyAuthenticate: response keys', keys);
keys.forEach(key => {
console.log(`PasskeyAuthenticate: ${key} =`, (response as any)[key]);
});
// The response might be wrapped in a data property
const data = response;
console.log('PasskeyAuthenticate: request data', data);
console.log('PasskeyAuthenticate: passkeys', data?.passkeys);
console.log('PasskeyAuthenticate: passkeys is array?', Array.isArray(data?.passkeys));
console.log('PasskeyAuthenticate: passkeys length', data?.passkeys?.length);
if (data) {
setRequest(data);
}
} catch (error) {
console.error('Failed to fetch request data:', error);
}
}
}
// Mark initial loading as complete
setIsInitialLoading(false);
// Mark initial loading as complete
setIsInitialLoading(false);
};
fetchRequestData();
}, [location, setIsInitialLoading]);
/**
@@ -56,18 +78,103 @@ const PasskeyAuthenticate: React.FC = () => {
setLoading(true);
// Generate mock assertion for POC
// Get the stored passkey to access the private key
const passkeyData = await sendMessage('GET_PASSKEY_BY_ID', { credentialId: selectedPasskey }, 'background');
if (!passkeyData) {
console.error('Passkey not found');
setLoading(false);
return;
}
// Calculate rpId hash
const rpId = request.publicKey.rpId || new URL(request.origin).hostname;
const rpIdBuffer = new TextEncoder().encode(rpId);
const rpIdHashBuffer = await crypto.subtle.digest('SHA-256', rpIdBuffer);
const rpIdHash = new Uint8Array(rpIdHashBuffer);
// Flags: UP (User Present) = 1, UV (User Verified) = 1
const flags = new Uint8Array([0x05]); // Binary: 00000101
// Sign count (increment on each use)
const signCount = new Uint8Array([0, 0, 0, 1]);
// Construct authenticatorData (37 bytes minimum)
const authenticatorData = new Uint8Array([
...rpIdHash, // 32 bytes
...flags, // 1 byte
...signCount // 4 bytes
]);
// Create clientDataJSON
const clientDataJSON = JSON.stringify({
type: 'webauthn.get',
challenge: request.publicKey.challenge,
origin: request.origin
});
// Create signature over authenticatorData + hash(clientDataJSON)
const clientDataHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(clientDataJSON));
const dataToSign = new Uint8Array([...authenticatorData, ...new Uint8Array(clientDataHash)]);
// Import the private key and sign
const privateKey = await crypto.subtle.importKey(
'jwk',
passkeyData.privateKey,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey,
dataToSign
);
// Convert raw signature (r || s) to DER format for WebAuthn
const rawSignature = new Uint8Array(signatureBuffer);
const r = rawSignature.slice(0, 32);
const s = rawSignature.slice(32, 64);
// Helper to encode integer in DER format
const encodeInteger = (int: Uint8Array): Uint8Array => {
// Remove leading zeros
let i = 0;
while (i < int.length && int[i] === 0) i++;
let trimmed = int.slice(i);
// Add leading zero if high bit is set (to keep it positive)
if (trimmed.length === 0) trimmed = new Uint8Array([0]);
if (trimmed[0] & 0x80) {
const padded = new Uint8Array(trimmed.length + 1);
padded[0] = 0;
padded.set(trimmed, 1);
trimmed = padded;
}
// DER encoding: 0x02 (INTEGER tag) + length + value
return new Uint8Array([0x02, trimmed.length, ...trimmed]);
};
const rDer = encodeInteger(r);
const sDer = encodeInteger(s);
// DER SEQUENCE: 0x30 (SEQUENCE tag) + length + r + s
const derSignature = new Uint8Array([
0x30,
rDer.length + sDer.length,
...rDer,
...sDer
]);
const credential = {
id: selectedPasskey,
rawId: selectedPasskey,
clientDataJSON: btoa(JSON.stringify({
type: 'webauthn.get',
challenge: request.publicKey.challenge,
origin: request.origin
})),
authenticatorData: btoa('mock_authenticator_data'),
signature: btoa('mock_signature'),
userHandle: btoa('user_handle')
clientDataJSON: btoa(clientDataJSON),
authenticatorData: btoa(String.fromCharCode(...authenticatorData)),
signature: btoa(String.fromCharCode(...derSignature)),
userHandle: null
};
// Update last used

View File

@@ -25,30 +25,37 @@ const PasskeyCreate: React.FC = () => {
const [loading, setLoading] = useState(false);
useEffect(() => {
console.log(location);
// Get the request data from hash (format: #/passkeys/create?request=...)
const params = new URLSearchParams(location.search);
const requestData = params.get('request');
const fetchRequestData = async () => {
console.log(location);
// Get the requestId from URL
const params = new URLSearchParams(location.search);
const requestId = params.get('requestId');
console.log('PasskeyCreate: useEffect: requestData', requestData);
console.log('PasskeyCreate: requestId', requestId);
if (requestData) {
try {
const parsed = JSON.parse(decodeURIComponent(requestData));
setRequest(parsed);
console.log('Parsed request data:', parsed);
if (requestId) {
try {
// Fetch the full request data from background
const data = await sendMessage('GET_REQUEST_DATA', { requestId }, 'background');
console.log('PasskeyCreate: fetched request data', data);
if (data) {
setRequest(data);
if (parsed.publicKey?.user?.displayName) {
setDisplayName(parsed.publicKey.user.displayName);
if (data.publicKey?.user?.displayName) {
setDisplayName(data.publicKey.user.displayName);
}
}
} catch (error) {
console.error('Failed to fetch request data:', error);
}
} catch (error) {
console.error('Failed to parse request data:', error);
}
}
// Mark initial loading as complete
console.log('PasskeyCreate: useEffect: setIsInitialLoading(false)');
setIsInitialLoading(false);
// Mark initial loading as complete
console.log('PasskeyCreate: useEffect: setIsInitialLoading(false)');
setIsInitialLoading(false);
};
fetchRequestData();
}, [location, setIsInitialLoading]);
/**
@@ -61,8 +68,100 @@ const PasskeyCreate: React.FC = () => {
setLoading(true);
// Generate mock credential for POC
const credentialId = btoa(Math.random().toString());
// Generate a real cryptographic key pair
const keyPair = await crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256'
},
true, // extractable
['sign', 'verify']
);
// Export the public key
const publicKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
const privateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
// Generate credential ID
const credIdBytes = crypto.getRandomValues(new Uint8Array(16));
// Convert to base64url (WebAuthn uses base64url, not standard base64)
const base64 = btoa(String.fromCharCode(...credIdBytes));
const credentialId = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
// Calculate rpId hash (SHA-256 of rpId)
const rpId = request.publicKey.rp?.id || new URL(request.origin).hostname;
const rpIdBuffer = new TextEncoder().encode(rpId);
const rpIdHashBuffer = await crypto.subtle.digest('SHA-256', rpIdBuffer);
const rpIdHash = new Uint8Array(rpIdHashBuffer);
// Flags: UP (User Present) = 1, UV (User Verified) = 1, AT (Attested Credential Data) = 1
const flags = new Uint8Array([0x45]); // Binary: 01000101
const signCount = new Uint8Array([0, 0, 0, 0]);
const aaguid = new Uint8Array(16); // All zeros for this implementation
// Convert JWK coordinates from base64url to bytes for COSE format
const base64UrlToBytes = (base64url: string): Uint8Array => {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64);
return new Uint8Array(binary.split('').map(c => c.charCodeAt(0)));
};
const xCoord = base64UrlToBytes(publicKeyJwk.x!);
const yCoord = base64UrlToBytes(publicKeyJwk.y!);
// COSE public key (ES256 format) - proper CBOR encoding
// Map with 5 entries: kty, alg, crv, x, y
const coseKey = new Uint8Array([
0xa5, // map(5)
0x01, // key 1 (kty)
0x02, // value: 2 (EC2)
0x03, // key 3 (alg)
0x26, // value: -7 (ES256) encoded as negative int
0x20, // key -1 (crv) encoded as negative int
0x01, // value: 1 (P-256)
0x21, // key -2 (x) encoded as negative int
0x58, 0x20, // byte string of length 32
...xCoord, // x coordinate (32 bytes)
0x22, // key -3 (y) encoded as negative int
0x58, 0x20, // byte string of length 32
...yCoord // y coordinate (32 bytes)
]);
// Construct authData
const credIdLength = new Uint8Array([0, credIdBytes.length]);
const authData = new Uint8Array([
...rpIdHash,
...flags,
...signCount,
...aaguid,
...credIdLength,
...credIdBytes,
...coseKey
]);
// CBOR encode attestation object: {fmt: "none", authData: bytes, attStmt: {}}
// Need to properly encode the authData length as a CBOR byte string
let authDataLengthBytes: number[];
if (authData.length <= 23) {
authDataLengthBytes = [0x40 | authData.length];
} else if (authData.length <= 255) {
authDataLengthBytes = [0x58, authData.length];
} else {
authDataLengthBytes = [0x59, authData.length >> 8, authData.length & 0xff];
}
// CBOR map keys must be in canonical order (sorted by byte length, then lexicographically)
// Order: "fmt" (3 chars), "attStmt" (7 chars), "authData" (8 chars)
const attestationObject = new Uint8Array([
0xa3, // map(3)
0x63, 0x66, 0x6d, 0x74, // "fmt" (text(3))
0x64, 0x6e, 0x6f, 0x6e, 0x65, // "none" (text(4))
0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // "attStmt" (text(7))
0xa0, // map(0) - empty map
0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, // "authData" (text(8))
...authDataLengthBytes, ...authData // byte string with proper length encoding
]);
const credential = {
id: credentialId,
rawId: credentialId,
@@ -71,16 +170,17 @@ const PasskeyCreate: React.FC = () => {
challenge: request.publicKey.challenge,
origin: request.origin
})),
attestationObject: btoa('mock_attestation_object')
attestationObject: btoa(String.fromCharCode(...attestationObject))
};
// Store passkey
// Store passkey with the private key for future authentication
await sendMessage('STORE_PASSKEY', {
rpId: request.origin,
rpId,
credentialId,
displayName,
publicKey: request.publicKey
}, 'background');
publicKey: publicKeyJwk as JsonWebKey,
privateKey: privateKeyJwk as JsonWebKey
} as any, 'background');
// Send response back
await sendMessage('PASSKEY_POPUP_RESPONSE', {

View File

@@ -30,12 +30,16 @@ export default defineUnlistedScript(() => {
return btoa(binary);
}
// Helper to convert base64 to ArrayBuffer
// Helper to convert base64/base64url to ArrayBuffer
/**
*
*/
function base64ToBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
// Handle both base64 and base64url formats
const base64Standard = base64.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const padded = base64Standard + '==='.slice((base64Standard.length + 3) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
@@ -110,17 +114,34 @@ export default defineUnlistedScript(() => {
} else if (e.detail.error) {
reject(new Error(e.detail.error));
} else if (e.detail.credential) {
// Mock credential for POC
// Create a proper credential object with required methods
const cred = e.detail.credential;
resolve({
const credential = {
id: cred.id,
type: 'public-key',
rawId: base64ToBuffer(cred.rawId),
response: {
clientDataJSON: base64ToBuffer(cred.clientDataJSON),
attestationObject: base64ToBuffer(cred.attestationObject)
attestationObject: base64ToBuffer(cred.attestationObject),
getTransports() {
return ['internal'];
},
getAuthenticatorData() {
// Parse authData from attestation object if needed
return new ArrayBuffer(0);
},
getPublicKey() {
return null;
},
getPublicKeyAlgorithm() {
return -7; // ES256
}
},
getClientExtensionResults() {
return {};
}
} as any);
};
resolve(credential as any);
} else {
// Cancelled
resolve(null);
@@ -195,9 +216,9 @@ export default defineUnlistedScript(() => {
} else if (e.detail.error) {
reject(new Error(e.detail.error));
} else if (e.detail.credential) {
// Mock credential for POC
// Create a proper credential object with required methods
const cred = e.detail.credential;
resolve({
const credential = {
id: cred.id,
type: 'public-key',
rawId: base64ToBuffer(cred.rawId),
@@ -206,8 +227,12 @@ export default defineUnlistedScript(() => {
authenticatorData: base64ToBuffer(cred.authenticatorData),
signature: base64ToBuffer(cred.signature),
userHandle: cred.userHandle ? base64ToBuffer(cred.userHandle) : null
},
getClientExtensionResults() {
return {};
}
} as any);
};
resolve(credential as any);
} else {
// Cancelled
resolve(null);