peach_passkeys_spec.md

26.1 KB · raw

rendered · source

1# Peach Passkeys — Comprehensive Spec2_Peach v1.5.0 target_34---56## What this is and isn't78This spec covers Peach acting as a **software passkey provider**: creating, storing, and fulfilling WebAuthn credentials on behalf of the user, entirely locally, with private keys encrypted in the Peach vault.910It does NOT cover:11- Platform authenticators (Touch ID, Windows Hello) — those are handled by the OS and work independently of Peach12- Hardware security keys (YubiKey etc.) — same13- Syncing passkeys via Google's hybrid cross-device (caBLE) flow — requires Google relay servers, not available in Ungoogled Chromium and intentionally out of scope for a local-first product1415The result is: every passkey Peach creates is stored in your vault, backed up in your CJK phrase, synced via Peach Sync to paired devices, and works on any Chromium 115+ or Firefox browser with the Peach extension installed.1617---1819## Why the legacy implementation broke2021The legacy Peach implementation almost certainly monkey-patched `navigator.credentials` in a content script:2223```js24// What legacy likely tried25const origCreate = navigator.credentials.create.bind(navigator.credentials);26navigator.credentials.create = async (options) => { ... };27```2829This doesn't work because Chrome's WebAuthn implementation lives in C++. The JavaScript `navigator.credentials` object is a thin proxy to it. Patching the JS property does nothing to the native implementation — the browser still shows its own native UI and handles the request internally. Some sites also detect and refuse monkey-patched credential APIs.3031The correct mechanism is `chrome.webAuthenticationProxy`, a Chrome extension API that registers the extension as the browser-level interceptor for all WebAuthn calls. There is no content-script equivalent.3233---3435## `chrome.webAuthenticationProxy` — how it works3637The API fires events in the background service worker:3839```40navigator.credentials.create({ publicKey: ... })  →  chrome.webAuthenticationProxy.onCreateRequest fires41navigator.credentials.get({ publicKey: ... })     →  chrome.webAuthenticationProxy.onGetRequest fires42```4344The extension MUST respond to every event it receives (unless the request is cancelled via `onRequestCanceled`). Responding with an error lets the browser fall back to its native handler — this is how Peach handles requests for credentials it doesn't hold.4546Three additional events:47- `onIsUvpaaRequest` — site asking "is user verification (biometrics) available?" — Peach responds `true` when unlocked, `false` when locked48- `onCreateRequest` — passkey registration49- `onGetRequest` — passkey authentication5051Setup requires calling `chrome.webAuthenticationProxy.attach()` once. Only one extension can be attached at a time — this is a real conflict point with Bitwarden/1Password if users have both installed.5253---5455## Manifest changes5657**`manifest.json` (Chrome):**58```json59{60  "permissions": [61    "storage", "activeTab", "scripting", "clipboardWrite",62    "alarms", "idle", "nativeMessaging",63    "webAuthenticationProxy"64  ]65}66```6768**`manifest.firefox.json`:** Firefox does not implement `webAuthenticationProxy` — passkey provider support is Firefox's own open specification gap. For Firefox, Peach falls back to passkey *storage and display* only (no interception). Note this in the UI.6970---7172## Data model7374Add a new `VaultEntry` type. The `type` discriminant expands from `"login" | "note"` to `"login" | "note" | "passkey"`.7576```typescript77// src/shared/types.ts additions7879export interface PasskeyEntry {80  id: string;                    // crypto.randomUUID()81  type: "passkey";82  title: string;                 // rpName — "GitHub", "Notion", etc.83  rpId: string;                  // "github.com" — used for credential lookup84  rpName: string;                // display name from registration85  credentialId: string;          // base64url encoded, random 32 bytes86  publicKeyCose: string;         // base64url COSE-encoded public key (for export/import interop)87  privateKeyJwk: JsonWebKey;     // stored encrypted in vault; never leaves vault unencrypted88  userHandle: string;            // base64url encoded user.id from site89  userName: string;              // user.name (email or username shown in selector)90  userDisplayName: string;       // user.displayName91  algorithm: -7 | -8;           // -7 = ECDSA P-256 (ES256), -8 = EdDSA (Ed25519)92  signCount: number;             // monotonically incremented on each authentication93  aaguid: string;                // 16 zero bytes, base64url — software authenticator94  backupEligible: boolean;       // BE authenticator data flag — true (CJK backup = eligible)95  backupState: boolean;          // BS flag — true when a CJK backup exists96  createdAt: number;97  modifiedAt: number;98  lastUsedAt?: number;99  deletedAt?: number;100}101102// Extend VaultEntry union (or make it a discriminated union properly)103export type AnyVaultEntry = VaultEntry | PasskeyEntry;104```105106`privateKeyJwk` is stored inside the encrypted vault blob just like any other field — AES-256-GCM at rest. It is never exposed in the UI, only used internally for signing.107108---109110## Crypto implementation111112### Key generation — registration113114Use `crypto.subtle` directly in the background service worker. Do not touch `navigator.credentials` (which would be circular).115116```typescript117// src/background/passkey-crypto.ts118119export async function generatePasskeyKeypair(120  algorithm: -7 | -8121): Promise<{ publicKeyCose: Uint8Array; privateKeyJwk: JsonWebKey }> {122  if (algorithm === -7) {123    // ECDSA P-256 (ES256) — widest site support124    const keyPair = await crypto.subtle.generateKey(125      { name: "ECDSA", namedCurve: "P-256" },126      true, // extractable — needed to store JWK127      ["sign", "verify"]128    );129    const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);130    const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);131    const publicKeyCose = p256RawToCose(new Uint8Array(publicKeyRaw));132    return { publicKeyCose, privateKeyJwk };133  } else {134    // EdDSA Ed25519 — algorithm -8135    const keyPair = await crypto.subtle.generateKey(136      { name: "Ed25519" },137      true,138      ["sign", "verify"]139    );140    const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);141    const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);142    const publicKeyCose = ed25519RawToCose(new Uint8Array(publicKeyRaw));143    return { publicKeyCose, privateKeyJwk };144  }145}146```147148**COSE encoding:** The public key must be returned to the site in COSE format (RFC 8152). For P-256 this is a CBOR map with keys `1` (kty=2 EC), `3` (alg=-7), `-1` (crv=1 P-256), `-2` (x), `-3` (y). Use `cbor-x` (small, fast, tree-shakeable) — already evaluate if it adds acceptable bundle weight (~15KB gzipped).149150```typescript151// P-256 COSE map (kty: EC2, alg: ES256, crv: P-256)152function p256RawToCose(raw: Uint8Array): Uint8Array {153  // raw is 65 bytes: 0x04 | x (32) | y (32)154  const x = raw.slice(1, 33);155  const y = raw.slice(33, 65);156  return encode(new Map([157    [1, 2],    // kty: EC2158    [3, -7],   // alg: ES256159    [-1, 1],   // crv: P-256160    [-2, x],   // x161    [-3, y],   // y162  ]));163}164```165166### Signing — authentication167168```typescript169export async function signPasskeyAssertion(170  privateKeyJwk: JsonWebKey,171  algorithm: -7 | -8,172  authenticatorData: Uint8Array,173  clientDataHash: Uint8Array174): Promise<Uint8Array> {175  const signInput = concat(authenticatorData, clientDataHash);176177  if (algorithm === -7) {178    const key = await crypto.subtle.importKey(179      "jwk", privateKeyJwk,180      { name: "ECDSA", namedCurve: "P-256" },181      false, ["sign"]182    );183    const sig = await crypto.subtle.sign(184      { name: "ECDSA", hash: "SHA-256" },185      key, signInput186    );187    return new Uint8Array(sig); // DER-encoded by WebCrypto188  } else {189    const key = await crypto.subtle.importKey(190      "jwk", privateKeyJwk,191      { name: "Ed25519" },192      false, ["sign"]193    );194    const sig = await crypto.subtle.sign("Ed25519", key, signInput);195    return new Uint8Array(sig);196  }197}198```199200### Authenticator data construction201202Every WebAuthn response includes an `authenticatorData` structure (binary, CBOR-free but specifically formatted):203204```205[rpIdHash: 32][flags: 1][signCount: 4][aaguid: 16][credIdLen: 2][credId: N][cosePublicKey: M]206```207208Flags byte for registration: `0b01000101` = UP (user presence) | UV (user verification) | AT (attested credential data) | BE (backup eligible) | BS (backup state if backed up).209210Flags byte for authentication: `0b00000101` = UP | UV (no AT for auth).211212```typescript213export function buildAuthenticatorData(opts: {214  rpId: string;215  flags: number;216  signCount: number;217  credentialId?: Uint8Array;   // only for registration218  publicKeyCose?: Uint8Array;  // only for registration219}): Uint8Array {220  const rpIdHash = await crypto.subtle.digest(221    "SHA-256", new TextEncoder().encode(opts.rpId)222  );223  // ... pack fields per spec224}225```226227---228229## Background service worker integration230231```typescript232// src/background/passkey.ts233234import { chrome } from "...";235import { buildAuthenticatorData, generatePasskeyKeypair, signPasskeyAssertion } from "./passkey-crypto";236import { getVaultEntries, addEntry, updateEntry } from "./vault";237import { isProUser } from "./license";238239export async function setupPasskeyProxy(): Promise<void> {240  try {241    await chrome.webAuthenticationProxy.attach();242  } catch (e) {243    // Another extension is already attached (likely 1Password/Bitwarden)244    // Log and surface in Settings245    console.warn("Peach: could not attach webAuthenticationProxy —", e);246    return;247  }248249  chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestId) => {250    const unlocked = isVaultUnlocked(); // from vault.ts251    chrome.webAuthenticationProxy.completeIsUvpaaRequest({ requestId, isUvpaa: unlocked });252  });253254  chrome.webAuthenticationProxy.onCreateRequest.addListener(handleCreate);255  chrome.webAuthenticationProxy.onGetRequest.addListener(handleGet);256  chrome.webAuthenticationProxy.onRequestCanceled.addListener(({ requestId }) => {257    pendingRequests.delete(requestId);258  });259}260261async function handleCreate(262  request: chrome.webAuthenticationProxy.CreateRequest263): Promise<void> {264  const { requestId, requestDetailsJson } = request;265  const options = JSON.parse(requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;266267  if (!isVaultUnlocked()) {268    // Vault locked — show popup, ask user to unlock first269    await chrome.action.openPopup();270    // Complete with error so browser can show fallback271    chrome.webAuthenticationProxy.completeCreateRequest({272      requestId,273      error: { name: "NotAllowedError" },274    });275    return;276  }277278  // Show passkey creation confirmation UI in popup279  const approved = await promptUserApproval("create", {280    rpId: options.rp.id,281    rpName: options.rp.name,282    userName: options.user.name,283    userDisplayName: options.user.displayName,284  });285286  if (!approved) {287    chrome.webAuthenticationProxy.completeCreateRequest({288      requestId,289      error: { name: "NotAllowedError" },290    });291    return;292  }293294  // Pick best algorithm from site's preference list295  const algorithm = pickAlgorithm(options.pubKeyCredParams);296  const credentialId = crypto.getRandomValues(new Uint8Array(32));297  const userHandle = base64urlDecode(options.user.id as string);298299  const { publicKeyCose, privateKeyJwk } = await generatePasskeyKeypair(algorithm);300301  const flags = 0b01001101; // UP | UV | AT | BE302  const authData = await buildAuthenticatorData({303    rpId: options.rp.id!,304    flags,305    signCount: 0,306    credentialId,307    publicKeyCose,308  });309310  // "none" attestation — no attestation certificate, just self-signed311  const attestationObject = encode(new Map([312    ["fmt", "none"],313    ["attStmt", new Map()],314    ["authData", authData],315  ]));316317  // Store in vault318  const entry: PasskeyEntry = {319    id: crypto.randomUUID(),320    type: "passkey",321    title: options.rp.name,322    rpId: options.rp.id!,323    rpName: options.rp.name,324    credentialId: base64urlEncode(credentialId),325    publicKeyCose: base64urlEncode(publicKeyCose),326    privateKeyJwk,327    userHandle: options.user.id as string,328    userName: options.user.name,329    userDisplayName: options.user.displayName,330    algorithm,331    signCount: 0,332    aaguid: base64urlEncode(new Uint8Array(16)), // 16 zero bytes333    backupEligible: true,334    backupState: false, // becomes true when CJK backup exists335    createdAt: Date.now(),336    modifiedAt: Date.now(),337  };338  await addEntry(entry);339340  chrome.webAuthenticationProxy.completeCreateRequest({341    requestId,342    responseJson: JSON.stringify({343      id: base64urlEncode(credentialId),344      rawId: base64urlEncode(credentialId),345      type: "public-key",346      response: {347        clientDataJSON: base64urlEncode(buildClientData("webauthn.create", options.challenge as string)),348        attestationObject: base64urlEncode(attestationObject),349      },350      authenticatorAttachment: "platform",351    }),352  });353}354355async function handleGet(356  request: chrome.webAuthenticationProxy.GetRequest357): Promise<void> {358  const { requestId, requestDetailsJson } = request;359  const options = JSON.parse(requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;360361  const rpId = options.rpId ?? extractRpIdFromOrigin(request.topLevelOrigin);362  const allowedIds = (options.allowCredentials ?? []).map(c => c.id);363364  // Find matching credential in vault365  const entries = await getVaultEntries();366  const passkeys = entries.filter((e): e is PasskeyEntry => e.type === "passkey" && e.rpId === rpId);367  const match = passkeys.find(p =>368    allowedIds.length === 0 || allowedIds.includes(p.credentialId)369  );370371  if (!match) {372    // No credential for this site — let browser handle natively373    chrome.webAuthenticationProxy.completeGetRequest({374      requestId,375      error: { name: "NotAllowedError" },376    });377    return;378  }379380  if (!isVaultUnlocked()) {381    await chrome.action.openPopup();382    chrome.webAuthenticationProxy.completeGetRequest({383      requestId,384      error: { name: "NotAllowedError" },385    });386    return;387  }388389  const approved = await promptUserApproval("get", {390    rpId, rpName: match.rpName,391    userName: match.userName,392  });393394  if (!approved) {395    chrome.webAuthenticationProxy.completeGetRequest({396      requestId,397      error: { name: "NotAllowedError" },398    });399    return;400  }401402  const newSignCount = match.signCount + 1;403  const flags = 0b00000101; // UP | UV404  const authData = await buildAuthenticatorData({405    rpId,406    flags,407    signCount: newSignCount,408  });409410  const clientDataHash = new Uint8Array(411    await crypto.subtle.digest("SHA-256",412      base64urlDecode(options.challenge as string)413    )414  );415416  const signature = await signPasskeyAssertion(417    match.privateKeyJwk,418    match.algorithm,419    authData,420    clientDataHash421  );422423  // Update sign count and lastUsedAt424  await updateEntry(match.id, {425    signCount: newSignCount,426    lastUsedAt: Date.now(),427    modifiedAt: Date.now(),428  });429430  chrome.webAuthenticationProxy.completeGetRequest({431    requestId,432    responseJson: JSON.stringify({433      id: match.credentialId,434      rawId: match.credentialId,435      type: "public-key",436      response: {437        clientDataJSON: base64urlEncode(buildClientData("webauthn.get", options.challenge as string)),438        authenticatorData: base64urlEncode(authData),439        signature: base64urlEncode(signature),440        userHandle: match.userHandle,441      },442      authenticatorAttachment: "platform",443    }),444  });445}446```447448---449450## User approval flow — the critical UX moment451452This is where the legacy implementation almost certainly fell apart. WebAuthn requests happen synchronously from the site's perspective and must resolve quickly. The extension needs to show approval UI and get a response back — all before the site times out.453454**The mechanism:** use `chrome.action.openPopup()` + a `chrome.storage.session` pending-request pattern.4554561. `handleCreate`/`handleGet` stores the pending request details in `chrome.storage.session` with `requestId` as key4572. Calls `chrome.action.openPopup()` to force the popup open4583. The popup detects a pending passkey request on mount and renders the approval screen4594. User approves or denies — popup sends `APPROVE_PASSKEY` or `DENY_PASSKEY` message to background4605. Background resolves the stored promise and calls `completeCreateRequest`/`completeGetRequest`461462```typescript463// Simplified promise bridge464const pendingRequests = new Map<string, {465  resolve: (approved: boolean) => void;466  timeout: ReturnType<typeof setTimeout>;467}>();468469async function promptUserApproval(type: "create" | "get", details: object): Promise<boolean> {470  return new Promise(async (resolve) => {471    const requestId = crypto.randomUUID();472    const timeout = setTimeout(() => {473      pendingRequests.delete(requestId);474      resolve(false); // timed out = deny475    }, 60_000); // 60s timeout476477    pendingRequests.set(requestId, { resolve, timeout });478479    await chrome.storage.session.set({480      [`passkey_pending_${requestId}`]: { type, details, requestId }481    });482483    chrome.action.openPopup().catch(() => {484      // openPopup() may fail if popup is already open or in some contexts485      // Fallback: show a notification badge486    });487  });488}489```490491**Popup PasskeyApproval view:**492493```tsx494// src/popup/views/PasskeyApproval.tsx495496export function PasskeyApprovalView(props: {497  type: "create" | "get";498  rpName: string;499  rpId: string;500  userName: string;501  onApprove: () => void;502  onDeny: () => void;503}) {504  return (505    <div class="peach-passkey-approval">506      <div class="peach-passkey-icon">🔑</div>507      <h2 class="peach-passkey-headline">508        {props.type === "create" ? "Save a passkey?" : "Sign in with passkey?"}509      </h2>510      <p class="peach-passkey-site">{props.rpName}</p>511      <p class="peach-passkey-domain">{props.rpId}</p>512      <p class="peach-passkey-user">{props.userName}</p>513      <div class="peach-passkey-actions">514        <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onDeny}>515          deny516        </button>517        <button class="peach-btn peach-btn--accent peach-btn--md" onClick={props.onApprove}>518          {props.type === "create" ? "save passkey" : "sign in"}519        </button>520      </div>521    </div>522  );523}524```525526---527528## Vault UI — passkey entries529530### In the vault list531532Passkey entries appear in the main vault list alongside login entries. Distinguishing marks:533534- Icon: a key glyph (distinct from the lock used for login entries)535- No username/password fields visible536- Shows `rpName` as title, `rpId` as subtitle537- The `🔑` type badge or a small "passkey" mono label538539### In the entry detail view540541A passkey entry's detail view shows:542- Site name and domain543- Username / user handle544- Algorithm (ES256 or EdDSA, for transparency)545- Created date, last used date546- Sign count (shown as "used N times")547- **No private key exposed** — not copyable, not viewable. Ever.548- A "Delete passkey" button with a warning: "Deleting this passkey will lock you out of this site unless you set up another sign-in method first."549550### In the health dashboard551552Passkeys get their own section: **Passkeys**. No "weak" or "reused" concepts apply. What matters:553- **Unused** — passkeys never authenticated (sign count = 0) after 30+ days554- **Old** — last used more than a year ago (may want to refresh)555556### In import/export557558**Export:** Passkey private keys are included in the encrypted Peach JSON backup (CJK phrase backup automatically includes them since it backs up the full vault). They are NEVER included in CSV or plaintext exports — the export UI for those formats shows a notice: "Passkeys cannot be exported in plaintext format."559560**Import:** A Peach JSON import that includes passkey entries works transparently — the private keys come back with the vault. For import from other password managers: 1Password and Bitwarden both support passkey export in a standardised JSON format (defined by the FIDO Alliance credential exchange format). Peach should handle this format.561562---563564## Algorithm selection565566When a site sends `pubKeyCredParams`, Peach prefers in this order:5671. `-8` (EdDSA/Ed25519) — Ed25519 is already in use in Peach for licensing; `crypto.subtle` supports it in all modern Chromium5682. `-7` (ECDSA P-256) — widest site compatibility, fallback5693. `-257` (RSA-PSS) — not implemented; decline570571Most sites accept P-256 (`-7`). Some modern sites prefer Ed25519. Sites that only offer RSA get a `NotSupportedError`.572573---574575## Tier: Free vs Pro576577**Free:** passkey storage (view, manage, delete stored passkeys) and passkey authentication (signing in to sites with stored credentials). Every user should be able to use passkeys they've already created.578579**Pro:** passkey creation (registering new passkeys on sites). This is the active value-add — creating a new passkey requires the extension to be attached as a WebAuthn proxy, which is the distinctive feature.580581Rationale: if a user created passkeys with another manager and wants to migrate them into Peach via import, that's free. Creating new ones from scratch via the Peach proxy is Pro.582583---584585## Settings — Passkey Provider586587Add a "Passkeys" section to Settings:588589```590──────────────────────────────────────591passkey provider592──────────────────────────────────────593[toggle] Intercept passkey requests594         Peach will handle passkey sign-in and595         creation on all sites.596597         ⚠ Only one passkey provider can be active598           at a time. Disable Bitwarden or 1Password599           passkey features to avoid conflicts.600601Status:  ● Active   (or "● Another provider is active")602603Browser: Chrome 120 ✓  /  Firefox (interception unavailable)604──────────────────────────────────────605```606607The toggle calls `chrome.webAuthenticationProxy.attach()` or `detach()`. If `attach()` throws (another extension has it), show the conflict message.608609---610611## Known limitations and open questions612613### Conflict with other passkey providers614Only one extension can hold `chrome.webAuthenticationProxy` at a time. If Bitwarden or 1Password is attached, Peach's `attach()` call throws. The Settings UI must detect and surface this clearly. There's no automatic arbitration — the user must manually disable the other extension's passkey provider.615616### `chrome.action.openPopup()` restrictions617`openPopup()` only works if the browser window is focused and active. In automated flows or when called from a background service worker during certain browser states, it silently fails. Mitigation: fall back to `chrome.notifications` to alert the user to open the popup.618619### Firefox620Firefox does not implement `chrome.webAuthenticationProxy` (or its equivalent `browser.webAuthenticationProxy`). The Firefox build of Peach can only offer passkey storage and display — it cannot intercept WebAuthn requests. The UI should clearly communicate this: "Passkey sign-in interception is not available in Firefox. Your saved passkeys are stored and can be used in Chrome."621622### Ungoogled Chromium623`chrome.webAuthenticationProxy` IS part of open-source Chromium (not Google-specific). It should be available in Ungoogled Chromium 115+. What does NOT work in Ungoogled Chromium:624- Cross-device hybrid authenticator (caBLE) — Google relay servers required625- iCloud Keychain integration on macOS — broken in Ungoogled Chromium (#2606 upstream)626- Google Password Manager passkeys — obviously627628Peach's software passkey provider has no dependency on any of the above. It should work correctly.629630### Sign count and sync631If the same passkey is used on two devices that haven't synced yet, the sign counts diverge. Sites that strictly enforce monotonically increasing sign counts may reject authentications from a device with a lower count. Peach Sync resolves this with last-write-wins on `modifiedAt`, which means the higher sign count wins after sync. This is correct behaviour — the lower count comes from a device that hasn't yet received the latest sync state.632633### Resident keys / discoverable credentials634Sites that use "conditional UI" (passkey autofill in the username field) rely on the browser knowing which discoverable credentials exist for the current origin. This is surfaced via `chrome.webAuthenticationProxy.onGetRequest` with an empty `allowCredentials` list. Peach handles this by filtering its passkey entries by `rpId` and returning any match. This should work correctly for conditional UI.635636### PRF extension637The WebAuthn PRF extension (used by Bitwarden to unlock the vault with a passkey) generates a deterministic secret from the passkey private key + a site-provided salt. Peach could use this to offer "unlock vault with a passkey" — a future feature, not in this spec.638639---640641## New files642643| File | Purpose |644|------|---------|645| `src/background/passkey.ts` | `setupPasskeyProxy()`, `handleCreate()`, `handleGet()`, approval bridge |646| `src/background/passkey-crypto.ts` | Key generation, COSE encoding, authenticator data, signing |647| `src/popup/views/PasskeyApproval.tsx` | Approval prompt UI |648| `src/popup/views/PasskeyEntry.tsx` | Passkey detail view |649650## Modified files651652| File | Change |653|------|--------|654| `src/shared/types.ts` | Add `PasskeyEntry` interface; extend `MessageType` with `APPROVE_PASSKEY`, `DENY_PASSKEY`, `GET_PENDING_PASSKEY`, `DELETE_PASSKEY`, `GET_PASSKEYS` |655| `src/background/entry.ts` | Wire `setupPasskeyProxy()` on service worker startup |656| `src/background/messages.ts` | Handle passkey message types |657| `src/popup/views/Vault.tsx` | Render passkey entries in the list |658| `src/popup/views/Settings.tsx` | Passkey provider section |659| `src/popup/App.tsx` | Check for pending passkey approval on mount; route to `PasskeyApprovalView` |660| `manifest.json` | Add `"webAuthenticationProxy"` permission |661| `package.json` | Add `cbor-x` dependency |662663---664665## Dependencies666667**`cbor-x`** — CBOR encode/decode, required for authenticator data and attestation objects. ~15KB gzipped. Tree-shakeable. Pure JS, no WASM. Evaluated alternatives: `cbor-web` (heavier), manual encoding (viable for the small subset we need but error-prone).668669Consider writing the CBOR encoding manually for the small fixed structures used here (registration COSE key, attestation object) to avoid adding a dependency. The encoding is ~50 lines of straightforward bit-packing.