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
3 files src/background/messages.ts+6 −6src/background/passkey.ts+85 −42src/background/vault.ts+6 −55
src/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       }

view file

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   }

view file

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) {

view file