peach_passkeys_spec.md

26.1 KB · raw

rendered · source

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:

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:

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.

  1. handleCreate/handleGet stores the pending request details in chrome.storage.session with requestId as key
  2. Calls chrome.action.openPopup() to force the popup open
  3. The popup detects a pending passkey request on mount and renders the approval screen
  4. User approves or denies — popup sends APPROVE_PASSKEY or DENY_PASSKEY message to background
  5. 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:

In the entry detail view

A passkey entry's detail view shows:

In the health dashboard

Passkeys get their own section: Passkeys. No "weak" or "reused" concepts apply. What matters:

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:

  1. -8 (EdDSA/Ed25519) — Ed25519 is already in use in Peach for licensing; crypto.subtle supports it in all modern Chromium
  2. -7 (ECDSA P-256) — widest site compatibility, fallback
  3. -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:

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.