peach_passkeys_spec.md
Peach Passkeys — Comprehensive Spec
Peach v1.5.0 target
What this is and isn't
This 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.
It does NOT cover:
- Platform authenticators (Touch ID, Windows Hello) — those are handled by the OS and work independently of Peach
- Hardware security keys (YubiKey etc.) — same
- 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 product
The 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.
Why the legacy implementation broke
The legacy Peach implementation almost certainly monkey-patched navigator.credentials in a content script:
// What legacy likely tried
const origCreate = navigator.credentials.create.bind(navigator.credentials);
navigator.credentials.create = async (options) => { ... };
This 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.
The 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.
chrome.webAuthenticationProxy — how it works
The API fires events in the background service worker:
navigator.credentials.create({ publicKey: ... }) → chrome.webAuthenticationProxy.onCreateRequest fires
navigator.credentials.get({ publicKey: ... }) → chrome.webAuthenticationProxy.onGetRequest fires
The 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.
Three additional events:
onIsUvpaaRequest— site asking "is user verification (biometrics) available?" — Peach respondstruewhen unlocked,falsewhen lockedonCreateRequest— passkey registrationonGetRequest— passkey authentication
Setup 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.
Manifest changes
manifest.json (Chrome):
{
"permissions": [
"storage", "activeTab", "scripting", "clipboardWrite",
"alarms", "idle", "nativeMessaging",
"webAuthenticationProxy"
]
}
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.
Data model
Add a new VaultEntry type. The type discriminant expands from "login" | "note" to "login" | "note" | "passkey".
// src/shared/types.ts additions
export interface PasskeyEntry {
id: string; // crypto.randomUUID()
type: "passkey";
title: string; // rpName — "GitHub", "Notion", etc.
rpId: string; // "github.com" — used for credential lookup
rpName: string; // display name from registration
credentialId: string; // base64url encoded, random 32 bytes
publicKeyCose: string; // base64url COSE-encoded public key (for export/import interop)
privateKeyJwk: JsonWebKey; // stored encrypted in vault; never leaves vault unencrypted
userHandle: string; // base64url encoded user.id from site
userName: string; // user.name (email or username shown in selector)
userDisplayName: string; // user.displayName
algorithm: -7 | -8; // -7 = ECDSA P-256 (ES256), -8 = EdDSA (Ed25519)
signCount: number; // monotonically incremented on each authentication
aaguid: string; // 16 zero bytes, base64url — software authenticator
backupEligible: boolean; // BE authenticator data flag — true (CJK backup = eligible)
backupState: boolean; // BS flag — true when a CJK backup exists
createdAt: number;
modifiedAt: number;
lastUsedAt?: number;
deletedAt?: number;
}
// Extend VaultEntry union (or make it a discriminated union properly)
export type AnyVaultEntry = VaultEntry | PasskeyEntry;
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.
Crypto implementation
Key generation — registration
Use crypto.subtle directly in the background service worker. Do not touch navigator.credentials (which would be circular).
// src/background/passkey-crypto.ts
export async function generatePasskeyKeypair(
algorithm: -7 | -8
): Promise<{ publicKeyCose: Uint8Array; privateKeyJwk: JsonWebKey }> {
if (algorithm === -7) {
// ECDSA P-256 (ES256) — widest site support
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true, // extractable — needed to store JWK
["sign", "verify"]
);
const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
const publicKeyCose = p256RawToCose(new Uint8Array(publicKeyRaw));
return { publicKeyCose, privateKeyJwk };
} else {
// EdDSA Ed25519 — algorithm -8
const keyPair = await crypto.subtle.generateKey(
{ name: "Ed25519" },
true,
["sign", "verify"]
);
const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
const publicKeyCose = ed25519RawToCose(new Uint8Array(publicKeyRaw));
return { publicKeyCose, privateKeyJwk };
}
}
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).
// P-256 COSE map (kty: EC2, alg: ES256, crv: P-256)
function p256RawToCose(raw: Uint8Array): Uint8Array {
// raw is 65 bytes: 0x04 | x (32) | y (32)
const x = raw.slice(1, 33);
const y = raw.slice(33, 65);
return encode(new Map([
[1, 2], // kty: EC2
[3, -7], // alg: ES256
[-1, 1], // crv: P-256
[-2, x], // x
[-3, y], // y
]));
}
Signing — authentication
export async function signPasskeyAssertion(
privateKeyJwk: JsonWebKey,
algorithm: -7 | -8,
authenticatorData: Uint8Array,
clientDataHash: Uint8Array
): Promise<Uint8Array> {
const signInput = concat(authenticatorData, clientDataHash);
if (algorithm === -7) {
const key = await crypto.subtle.importKey(
"jwk", privateKeyJwk,
{ name: "ECDSA", namedCurve: "P-256" },
false, ["sign"]
);
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
key, signInput
);
return new Uint8Array(sig); // DER-encoded by WebCrypto
} else {
const key = await crypto.subtle.importKey(
"jwk", privateKeyJwk,
{ name: "Ed25519" },
false, ["sign"]
);
const sig = await crypto.subtle.sign("Ed25519", key, signInput);
return new Uint8Array(sig);
}
}
Authenticator data construction
Every WebAuthn response includes an authenticatorData structure (binary, CBOR-free but specifically formatted):
[rpIdHash: 32][flags: 1][signCount: 4][aaguid: 16][credIdLen: 2][credId: N][cosePublicKey: M]
Flags byte for registration: 0b01000101 = UP (user presence) | UV (user verification) | AT (attested credential data) | BE (backup eligible) | BS (backup state if backed up).
Flags byte for authentication: 0b00000101 = UP | UV (no AT for auth).
export function buildAuthenticatorData(opts: {
rpId: string;
flags: number;
signCount: number;
credentialId?: Uint8Array; // only for registration
publicKeyCose?: Uint8Array; // only for registration
}): Uint8Array {
const rpIdHash = await crypto.subtle.digest(
"SHA-256", new TextEncoder().encode(opts.rpId)
);
// ... pack fields per spec
}
Background service worker integration
// src/background/passkey.ts
import { chrome } from "...";
import { buildAuthenticatorData, generatePasskeyKeypair, signPasskeyAssertion } from "./passkey-crypto";
import { getVaultEntries, addEntry, updateEntry } from "./vault";
import { isProUser } from "./license";
export async function setupPasskeyProxy(): Promise<void> {
try {
await chrome.webAuthenticationProxy.attach();
} catch (e) {
// Another extension is already attached (likely 1Password/Bitwarden)
// Log and surface in Settings
console.warn("Peach: could not attach webAuthenticationProxy —", e);
return;
}
chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestId) => {
const unlocked = isVaultUnlocked(); // from vault.ts
chrome.webAuthenticationProxy.completeIsUvpaaRequest({ requestId, isUvpaa: unlocked });
});
chrome.webAuthenticationProxy.onCreateRequest.addListener(handleCreate);
chrome.webAuthenticationProxy.onGetRequest.addListener(handleGet);
chrome.webAuthenticationProxy.onRequestCanceled.addListener(({ requestId }) => {
pendingRequests.delete(requestId);
});
}
async function handleCreate(
request: chrome.webAuthenticationProxy.CreateRequest
): Promise<void> {
const { requestId, requestDetailsJson } = request;
const options = JSON.parse(requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;
if (!isVaultUnlocked()) {
// Vault locked — show popup, ask user to unlock first
await chrome.action.openPopup();
// Complete with error so browser can show fallback
chrome.webAuthenticationProxy.completeCreateRequest({
requestId,
error: { name: "NotAllowedError" },
});
return;
}
// Show passkey creation confirmation UI in popup
const approved = await promptUserApproval("create", {
rpId: options.rp.id,
rpName: options.rp.name,
userName: options.user.name,
userDisplayName: options.user.displayName,
});
if (!approved) {
chrome.webAuthenticationProxy.completeCreateRequest({
requestId,
error: { name: "NotAllowedError" },
});
return;
}
// Pick best algorithm from site's preference list
const algorithm = pickAlgorithm(options.pubKeyCredParams);
const credentialId = crypto.getRandomValues(new Uint8Array(32));
const userHandle = base64urlDecode(options.user.id as string);
const { publicKeyCose, privateKeyJwk } = await generatePasskeyKeypair(algorithm);
const flags = 0b01001101; // UP | UV | AT | BE
const authData = await buildAuthenticatorData({
rpId: options.rp.id!,
flags,
signCount: 0,
credentialId,
publicKeyCose,
});
// "none" attestation — no attestation certificate, just self-signed
const attestationObject = encode(new Map([
["fmt", "none"],
["attStmt", new Map()],
["authData", authData],
]));
// Store in vault
const entry: PasskeyEntry = {
id: crypto.randomUUID(),
type: "passkey",
title: options.rp.name,
rpId: options.rp.id!,
rpName: options.rp.name,
credentialId: base64urlEncode(credentialId),
publicKeyCose: base64urlEncode(publicKeyCose),
privateKeyJwk,
userHandle: options.user.id as string,
userName: options.user.name,
userDisplayName: options.user.displayName,
algorithm,
signCount: 0,
aaguid: base64urlEncode(new Uint8Array(16)), // 16 zero bytes
backupEligible: true,
backupState: false, // becomes true when CJK backup exists
createdAt: Date.now(),
modifiedAt: Date.now(),
};
await addEntry(entry);
chrome.webAuthenticationProxy.completeCreateRequest({
requestId,
responseJson: JSON.stringify({
id: base64urlEncode(credentialId),
rawId: base64urlEncode(credentialId),
type: "public-key",
response: {
clientDataJSON: base64urlEncode(buildClientData("webauthn.create", options.challenge as string)),
attestationObject: base64urlEncode(attestationObject),
},
authenticatorAttachment: "platform",
}),
});
}
async function handleGet(
request: chrome.webAuthenticationProxy.GetRequest
): Promise<void> {
const { requestId, requestDetailsJson } = request;
const options = JSON.parse(requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;
const rpId = options.rpId ?? extractRpIdFromOrigin(request.topLevelOrigin);
const allowedIds = (options.allowCredentials ?? []).map(c => c.id);
// Find matching credential in vault
const entries = await getVaultEntries();
const passkeys = entries.filter((e): e is PasskeyEntry => e.type === "passkey" && e.rpId === rpId);
const match = passkeys.find(p =>
allowedIds.length === 0 || allowedIds.includes(p.credentialId)
);
if (!match) {
// No credential for this site — let browser handle natively
chrome.webAuthenticationProxy.completeGetRequest({
requestId,
error: { name: "NotAllowedError" },
});
return;
}
if (!isVaultUnlocked()) {
await chrome.action.openPopup();
chrome.webAuthenticationProxy.completeGetRequest({
requestId,
error: { name: "NotAllowedError" },
});
return;
}
const approved = await promptUserApproval("get", {
rpId, rpName: match.rpName,
userName: match.userName,
});
if (!approved) {
chrome.webAuthenticationProxy.completeGetRequest({
requestId,
error: { name: "NotAllowedError" },
});
return;
}
const newSignCount = match.signCount + 1;
const flags = 0b00000101; // UP | UV
const authData = await buildAuthenticatorData({
rpId,
flags,
signCount: newSignCount,
});
const clientDataHash = new Uint8Array(
await crypto.subtle.digest("SHA-256",
base64urlDecode(options.challenge as string)
)
);
const signature = await signPasskeyAssertion(
match.privateKeyJwk,
match.algorithm,
authData,
clientDataHash
);
// Update sign count and lastUsedAt
await updateEntry(match.id, {
signCount: newSignCount,
lastUsedAt: Date.now(),
modifiedAt: Date.now(),
});
chrome.webAuthenticationProxy.completeGetRequest({
requestId,
responseJson: JSON.stringify({
id: match.credentialId,
rawId: match.credentialId,
type: "public-key",
response: {
clientDataJSON: base64urlEncode(buildClientData("webauthn.get", options.challenge as string)),
authenticatorData: base64urlEncode(authData),
signature: base64urlEncode(signature),
userHandle: match.userHandle,
},
authenticatorAttachment: "platform",
}),
});
}
User approval flow — the critical UX moment
This 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.
The mechanism: use chrome.action.openPopup() + a chrome.storage.session pending-request pattern.
handleCreate/handleGetstores the pending request details inchrome.storage.sessionwithrequestIdas key- Calls
chrome.action.openPopup()to force the popup open - The popup detects a pending passkey request on mount and renders the approval screen
- User approves or denies — popup sends
APPROVE_PASSKEYorDENY_PASSKEYmessage to background - Background resolves the stored promise and calls
completeCreateRequest/completeGetRequest
// Simplified promise bridge
const pendingRequests = new Map<string, {
resolve: (approved: boolean) => void;
timeout: ReturnType<typeof setTimeout>;
}>();
async function promptUserApproval(type: "create" | "get", details: object): Promise<boolean> {
return new Promise(async (resolve) => {
const requestId = crypto.randomUUID();
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
resolve(false); // timed out = deny
}, 60_000); // 60s timeout
pendingRequests.set(requestId, { resolve, timeout });
await chrome.storage.session.set({
[`passkey_pending_${requestId}`]: { type, details, requestId }
});
chrome.action.openPopup().catch(() => {
// openPopup() may fail if popup is already open or in some contexts
// Fallback: show a notification badge
});
});
}
Popup PasskeyApproval view:
// src/popup/views/PasskeyApproval.tsx
export function PasskeyApprovalView(props: {
type: "create" | "get";
rpName: string;
rpId: string;
userName: string;
onApprove: () => void;
onDeny: () => void;
}) {
return (
<div class="peach-passkey-approval">
<div class="peach-passkey-icon">🔑</div>
<h2 class="peach-passkey-headline">
{props.type === "create" ? "Save a passkey?" : "Sign in with passkey?"}
</h2>
<p class="peach-passkey-site">{props.rpName}</p>
<p class="peach-passkey-domain">{props.rpId}</p>
<p class="peach-passkey-user">{props.userName}</p>
<div class="peach-passkey-actions">
<button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onDeny}>
deny
</button>
<button class="peach-btn peach-btn--accent peach-btn--md" onClick={props.onApprove}>
{props.type === "create" ? "save passkey" : "sign in"}
</button>
</div>
</div>
);
}
Vault UI — passkey entries
In the vault list
Passkey entries appear in the main vault list alongside login entries. Distinguishing marks:
- Icon: a key glyph (distinct from the lock used for login entries)
- No username/password fields visible
- Shows
rpNameas title,rpIdas subtitle - The
🔑type badge or a small "passkey" mono label
In the entry detail view
A passkey entry's detail view shows:
- Site name and domain
- Username / user handle
- Algorithm (ES256 or EdDSA, for transparency)
- Created date, last used date
- Sign count (shown as "used N times")
- No private key exposed — not copyable, not viewable. Ever.
- 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."
In the health dashboard
Passkeys get their own section: Passkeys. No "weak" or "reused" concepts apply. What matters:
- Unused — passkeys never authenticated (sign count = 0) after 30+ days
- Old — last used more than a year ago (may want to refresh)
In import/export
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."
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.
Algorithm selection
When a site sends pubKeyCredParams, Peach prefers in this order:
-8(EdDSA/Ed25519) — Ed25519 is already in use in Peach for licensing;crypto.subtlesupports it in all modern Chromium-7(ECDSA P-256) — widest site compatibility, fallback-257(RSA-PSS) — not implemented; decline
Most sites accept P-256 (-7). Some modern sites prefer Ed25519. Sites that only offer RSA get a NotSupportedError.
Tier: Free vs Pro
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.
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.
Rationale: 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.
Settings — Passkey Provider
Add a "Passkeys" section to Settings:
──────────────────────────────────────
passkey provider
──────────────────────────────────────
[toggle] Intercept passkey requests
Peach will handle passkey sign-in and
creation on all sites.
⚠ Only one passkey provider can be active
at a time. Disable Bitwarden or 1Password
passkey features to avoid conflicts.
Status: ● Active (or "● Another provider is active")
Browser: Chrome 120 ✓ / Firefox (interception unavailable)
──────────────────────────────────────
The toggle calls chrome.webAuthenticationProxy.attach() or detach(). If attach() throws (another extension has it), show the conflict message.
Known limitations and open questions
Conflict with other passkey providers
Only 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.
chrome.action.openPopup() restrictions
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.
Firefox
Firefox 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."
Ungoogled Chromium
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:
- Cross-device hybrid authenticator (caBLE) — Google relay servers required
- iCloud Keychain integration on macOS — broken in Ungoogled Chromium (#2606 upstream)
- Google Password Manager passkeys — obviously
Peach's software passkey provider has no dependency on any of the above. It should work correctly.
Sign count and sync
If 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.
Resident keys / discoverable credentials
Sites 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.
PRF extension
The 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.
New files
| File | Purpose |
|---|---|
src/background/passkey.ts |
setupPasskeyProxy(), handleCreate(), handleGet(), approval bridge |
src/background/passkey-crypto.ts |
Key generation, COSE encoding, authenticator data, signing |
src/popup/views/PasskeyApproval.tsx |
Approval prompt UI |
src/popup/views/PasskeyEntry.tsx |
Passkey detail view |
Modified files
| File | Change |
|---|---|
src/shared/types.ts |
Add PasskeyEntry interface; extend MessageType with APPROVE_PASSKEY, DENY_PASSKEY, GET_PENDING_PASSKEY, DELETE_PASSKEY, GET_PASSKEYS |
src/background/entry.ts |
Wire setupPasskeyProxy() on service worker startup |
src/background/messages.ts |
Handle passkey message types |
src/popup/views/Vault.tsx |
Render passkey entries in the list |
src/popup/views/Settings.tsx |
Passkey provider section |
src/popup/App.tsx |
Check for pending passkey approval on mount; route to PasskeyApprovalView |
manifest.json |
Add "webAuthenticationProxy" permission |
package.json |
Add cbor-x dependency |
Dependencies
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).
Consider 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.