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```js2425const 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```typescript777879export interface PasskeyEntry {80 id: string; 81 type: "passkey";82 title: string; 83 rpId: string; 84 rpName: string; 85 credentialId: string; 86 publicKeyCose: string; 87 privateKeyJwk: JsonWebKey; 88 userHandle: string; 89 userName: string; 90 userDisplayName: string; 91 algorithm: -7 | -8; 92 signCount: number; 93 aaguid: string; 94 backupEligible: boolean; 95 backupState: boolean; 96 createdAt: number;97 modifiedAt: number;98 lastUsedAt?: number;99 deletedAt?: number;100}101102103export 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```typescript117118119export async function generatePasskeyKeypair(120 algorithm: -7 | -8121): Promise<{ publicKeyCose: Uint8Array; privateKeyJwk: JsonWebKey }> {122 if (algorithm === -7) {123 124 const keyPair = await crypto.subtle.generateKey(125 { name: "ECDSA", namedCurve: "P-256" },126 true, 127 ["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 135 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```typescript151152function p256RawToCose(raw: Uint8Array): Uint8Array {153 154 const x = raw.slice(1, 33);155 const y = raw.slice(33, 65);156 return encode(new Map([157 [1, 2], 158 [3, -7], 159 [-1, 1], 160 [-2, x], 161 [-3, y], 162 ]));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); 188 } 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; 218 publicKeyCose?: Uint8Array; 219}): Uint8Array {220 const rpIdHash = await crypto.subtle.digest(221 "SHA-256", new TextEncoder().encode(opts.rpId)222 );223 224}225```226227---228229## Background service worker integration230231```typescript232233234import { 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 244 245 console.warn("Peach: could not attach webAuthenticationProxy —", e);246 return;247 }248249 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestId) => {250 const unlocked = isVaultUnlocked(); 251 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 269 await chrome.action.openPopup();270 271 chrome.webAuthenticationProxy.completeCreateRequest({272 requestId,273 error: { name: "NotAllowedError" },274 });275 return;276 }277278 279 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 295 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; 302 const authData = await buildAuthenticatorData({303 rpId: options.rp.id!,304 flags,305 signCount: 0,306 credentialId,307 publicKeyCose,308 });309310 311 const attestationObject = encode(new Map([312 ["fmt", "none"],313 ["attStmt", new Map()],314 ["authData", authData],315 ]));316317 318 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)), 333 backupEligible: true,334 backupState: false, 335 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 365 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 373 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; 404 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 424 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```typescript463464const 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); 475 }, 60_000); 476477 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 485 486 });487 });488}489```490491**Popup PasskeyApproval view:**492493```tsx494495496export 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.