df243b15db9d
passkey.ts: attach passkey to existing login entry at same domain (or create new login entry); read/write via entry.passkey sub-object; flip GET_PASSKEYS / GET_PASSKEY_FOR_DOMAIN to new shape
- time
- 2026-04-28 22:08 UTC · 6 months ago
- machine
- koh2
- parent
- cad47b22243c
↓ peach-browser-df243b15.tar.gzatlas at this save
files changed3 files
src/background/messages.ts+6 −6src/background/passkey.ts+85 −42src/background/vault.ts+6 −55src/background/messages.ts+6 −6
@@ -362,7 +362,7 @@362362 case "GET_PASSKEYS":363363 return {364364 ok: true,365- data: vault.getEntries().filter((entry) => entry.type === "passkey"),365+ data: vault.getEntries().filter((entry) => entry.passkey !== undefined),366366 };367367 case "DELETE_PASSKEY": {368368 const { id } = msg.payload as { id: string };@@ -665,16 +665,16 @@665665 if (!vault.isUnlocked()) return { ok: true, data: null };666666 const match = vault667667 .getEntries()668- .find((entry) => entry.type === "passkey" && entry.rpId === domain);669- if (!match) return { ok: true, data: null };668+ .find((entry) => entry.passkey?.rpId === domain);669+ if (!match || !match.passkey) return { ok: true, data: null };670670 // Slim shape — only the fields State 11 needs. Never expose private671671 // key material, even though this is locked behind isUnlocked.672672 return {673673 ok: true,674674 data: {675- rpId: match.rpId,676- userName: match.userName,677- browser: match.browser,675+ rpId: match.passkey.rpId,676+ userName: match.passkey.userName,677+ browser: match.passkey.browser,678678 },679679 };680680 }src/background/passkey.ts+85 −42
@@ -12,7 +12,7 @@1212 } from "./passkey-crypto";1313 import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";1414 import type {15- PasskeyEntry,15+ PasskeyData,1616 PasskeyProviderState,1717 PendingPasskeyRequest,1818 VaultEntry,@@ -255,8 +255,25 @@255255 await detachPasskeyProxy();256256 }257257 258-function passkeyEntries(entries: VaultEntry[]): PasskeyEntry[] {259- return entries.filter((entry): entry is PasskeyEntry => entry.type === "passkey");258+/**259+ * Find the entry that already holds a passkey for the given RP, or the most260+ * recently-modified login entry whose URL/additionalUrls match the RP — that261+ * entry is the natural attach target when the user adds a passkey to a site262+ * they already have a password for. Returns null when there's no candidate;263+ * the caller creates a new entry in that case.264+ */265+function findAttachTarget(rpId: string): VaultEntry | null {266+ const all = vault.getEntries();267+ const passkeyMatch = all.find(268+ (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt269+ );270+ if (passkeyMatch) return passkeyMatch;271+ const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);272+ if (loginMatches.length === 0) return null;273+ // Pick the most recently modified candidate to be deterministic.274+ return loginMatches.reduce((best, e) =>275+ (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best276+ );260277 }261278 262279 async function handleCreateRequest(@@ -323,10 +340,7 @@323340 const attestationObject = createAttestationObject(authData);324341 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));325342 326- const entry: PasskeyEntry = {327- id: crypto.randomUUID(),328- type: "passkey",329- title: rpName,343+ const passkey: PasskeyData = {330344 rpId,331345 rpName,332346 credentialId: base64urlEncode(credentialIdBytes),@@ -338,21 +352,33 @@338352 algorithm,339353 signCount: 0,340354 aaguid: base64urlEncode(new Uint8Array(16)),341- backupEligible: true,355+ backupEligible: false,342356 backupState: false,343357 browser: "chrome",344358 transports: ["internal"],345359 createdAt: Date.now(),346- modifiedAt: Date.now(),347360 };348361 349- await vault.addEntry(entry);362+ // Attach to an existing login (same domain) when one exists; otherwise363+ // create a fresh login entry that exists solely to hold the passkey.364+ const target = findAttachTarget(rpId);365+ if (target) {366+ await vault.updateEntry(target.id, { passkey });367+ } else {368+ await vault.addEntry({369+ type: "login",370+ title: rpName,371+ url: `https://${rpId}`,372+ username: userName,373+ passkey,374+ });375+ }350376 351377 await chrome.webAuthenticationProxy.completeCreateRequest({352378 requestId: request.requestId,353379 responseJson: JSON.stringify({354- id: entry.credentialId,355- rawId: entry.credentialId,380+ id: passkey.credentialId,381+ rawId: passkey.credentialId,356382 type: "public-key",357383 response: {358384 clientDataJSON: base64urlEncode(clientDataJSON),@@ -394,15 +420,17 @@394420 return;395421 }396422 397- const entries = passkeyEntries(vault.getEntries());423+ const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>424+ entry.passkey !== undefined && !entry.deletedAt425+ );398426 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);399427 400- const match = entries.find((entry) => {428+ const match = passkeyBearing.find((entry) => {401429 if (allowedCredentialIds.length > 0) {402- return allowedCredentialIds.includes(entry.credentialId);430+ return allowedCredentialIds.includes(entry.passkey.credentialId);403431 }404432 if (!options.rpId) return false;405- return entry.rpId === options.rpId;433+ return entry.passkey.rpId === options.rpId;406434 });407435 408436 if (!match) {@@ -410,11 +438,12 @@410438 return;411439 }412440 441+ const credential = match.passkey;413442 // Chrome's webAuthenticationProxy path requires Peach-held private key414443 // material to sign the assertion. Firefox-created passkeys delegate to the415444 // platform authenticator — the browser will route to the platform UI416445 // automatically when we reject, which is the right outcome.417- if (!match.privateKeyJwk) {446+ if (!credential.privateKeyJwk) {418447 await rejectGet(419448 request.requestId,420449 "NotAllowedError",@@ -426,61 +455,64 @@426455 const approved = await promptUserApproval({427456 requestId: request.requestId,428457 type: "get",429- rpId: match.rpId,430- rpName: match.rpName,431- userName: match.userName,432- userDisplayName: match.userDisplayName,458+ rpId: credential.rpId,459+ rpName: credential.rpName,460+ userName: credential.userName,461+ userDisplayName: credential.userDisplayName,433462 });434463 if (!approved) {435464 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");436465 return;437466 }438467 439- const nextSignCount = (match.signCount ?? 0) + 1;440- const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);468+ const nextSignCount = credential.signCount + 1;469+ const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);441470 const authData = await buildAuthenticatorData({442- rpId: match.rpId,471+ rpId: credential.rpId,443472 flags,444473 signCount: nextSignCount,445474 });446- const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));475+ const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));447476 const clientDataHash = new Uint8Array(448477 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))449478 );450479 const signature = await signPasskeyAssertion(451- match.privateKeyJwk,452- match.algorithm,480+ credential.privateKeyJwk,481+ credential.algorithm,453482 authData,454483 clientDataHash455484 );456485 457486 await vault.updateEntry(match.id, {458- signCount: nextSignCount,459- lastUsedAt: Date.now(),487+ passkey: {488+ ...credential,489+ signCount: nextSignCount,490+ lastUsedAt: Date.now(),491+ },460492 });461493 462494 const responseJson = JSON.stringify({463- id: match.credentialId,464- rawId: match.credentialId,495+ id: credential.credentialId,496+ rawId: credential.credentialId,465497 type: "public-key",466498 response: {467499 clientDataJSON: base64urlEncode(clientDataJSON),468500 authenticatorData: base64urlEncode(authData),469501 signature: base64urlEncode(signature),470- userHandle: match.userHandle,502+ userHandle: credential.userHandle,471503 },472504 authenticatorAttachment: "platform",473505 clientExtensionResults: {},474506 });475507 476508 console.log("[Peach passkey] completing get assertion", {477- rpId: match.rpId,478- credentialId: match.credentialId,479- algorithm: match.algorithm,509+ rpId: credential.rpId,510+ credentialId: credential.credentialId,511+ algorithm: credential.algorithm,480512 signCount: nextSignCount,481513 flags: flags.toString(16),482514 signatureBytes: signature.length,483- userHandle: match.userHandle,515+ userHandle: credential.userHandle,484516 });485517 486518 await chrome.webAuthenticationProxy.completeGetRequest({@@ -558,10 +590,7 @@558590 }): Promise<{ ok: boolean; error?: string }> {559591 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };560592 561- const entry: PasskeyEntry = {562- id: crypto.randomUUID(),563- type: "passkey",564- title: payload.rpName || payload.rpId,593+ const passkey: PasskeyData = {565594 rpId: payload.rpId,566595 rpName: payload.rpName,567596 credentialId: payload.credentialId,@@ -577,10 +606,24 @@577606 browser: "firefox",578607 transports: payload.transports ?? ["internal"],579608 createdAt: Date.now(),580- modifiedAt: Date.now(),581609 };582610 583- await vault.addEntry(entry);611+ // Same attach-or-create flow as the Chrome path: prefer attaching to an612+ // existing login entry for the same domain so the user ends up with one613+ // entry per site, not two (a login + a passkey-only).614+ const target = findAttachTarget(payload.rpId);615+ if (target) {616+ await vault.updateEntry(target.id, { passkey });617+ } else {618+ await vault.addEntry({619+ type: "login",620+ title: payload.rpName || payload.rpId,621+ url: `https://${payload.rpId}`,622+ username: payload.userName,623+ passkey,624+ });625+ }626+584627 if (chrome.storage.session) {585628 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);586629 }src/background/vault.ts+6 −55
@@ -395,7 +395,7 @@395395 396396 for (const entry of all) {397397 if (entry.deletedAt) continue;398- if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;398+ if (entry.type === "note" || entry.type === "card") continue;399399 400400 // An entry can match multiple sites via `additionalUrls`. Index each401401 // hostname so `queryDomain` returns the entry regardless of which alias@@ -1141,60 +1141,11 @@11411141 nextPatch.canary = patch.canary;11421142 }11431143 1144- if ("rpId" in patch) {1145- nextPatch.rpId = patch.rpId?.trim();1146- }1147-1148- if ("rpName" in patch) {1149- nextPatch.rpName = patch.rpName?.trim();1150- }1151-1152- if ("credentialId" in patch) {1153- nextPatch.credentialId = patch.credentialId?.trim();1154- }1155-1156- if ("publicKeyCose" in patch) {1157- nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1158- }1159-1160- if ("privateKeyJwk" in patch) {1161- nextPatch.privateKeyJwk = patch.privateKeyJwk;1162- }1163-1164- if ("userHandle" in patch) {1165- nextPatch.userHandle = patch.userHandle?.trim();1166- }1167-1168- if ("userName" in patch) {1169- nextPatch.userName = patch.userName?.trim();1170- }1171-1172- if ("userDisplayName" in patch) {1173- nextPatch.userDisplayName = patch.userDisplayName?.trim();1174- }1175-1176- if ("algorithm" in patch) {1177- nextPatch.algorithm = patch.algorithm;1178- }1179-1180- if ("signCount" in patch) {1181- nextPatch.signCount = patch.signCount;1182- }1183-1184- if ("aaguid" in patch) {1185- nextPatch.aaguid = patch.aaguid?.trim();1186- }1187-1188- if ("backupEligible" in patch) {1189- nextPatch.backupEligible = patch.backupEligible;1190- }1191-1192- if ("backupState" in patch) {1193- nextPatch.backupState = patch.backupState;1194- }1195-1196- if ("lastUsedAt" in patch) {1197- nextPatch.lastUsedAt = patch.lastUsedAt;1144+ if ("passkey" in patch) {1145+ // Passkey sub-object replaces all the legacy flat passkey fields. A1146+ // patch with `passkey: undefined` removes the credential from the entry1147+ // (used by "remove passkey" without deleting the whole entry).1148+ nextPatch.passkey = patch.passkey;11981149 }11991150 12001151 if ("tags" in patch) {