d8f1032bb523

Entry.tsx adds passkey section + gates login fields for passkey-only, App.tsx routes all entries through EntryView, popup Vault/Health/Export/Import use entry.passkey, delete unused PasskeyEntryView + PasskeyEntry interface

time
2026-04-28 22:12 UTC · 6 months ago
machine
koh2
parent
df243b15db9d
9 files src/background/passkey-crypto.ts+0 −5src/popup/App.tsx+15 −21src/popup/views/Entry.tsx+87 −3src/popup/views/Export.tsx+16 −5src/popup/views/Health.tsx+4 −3src/popup/views/Import.tsx+2 −2src/popup/views/PasskeyEntry.tsx+0 −171src/popup/views/Vault.tsx+15 −23src/shared/types.ts+0 −26
src/background/passkey-crypto.ts+0 −5
@@ -1,5 +1,3 @@1-import type { PasskeyEntry } from "../shared/types";2-31 const textEncoder = new TextEncoder();42 53 function toArrayBuffer(data: Uint8Array): ArrayBuffer {@@ -346,6 +344,3 @@346344   ]);347345 }348346 349-export function isPasskeyEntry(entry: PasskeyEntry | { type?: string }): entry is PasskeyEntry {350-  return entry.type === "passkey";351-}

view file

src/popup/App.tsx+15 −21
@@ -1,6 +1,7 @@11 import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";22 import { ACCENT_COLORS } from "../shared/types";3-import type { AccentColorName, PasskeyEntry, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";3+import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";4+import { isPasskeyOnly } from "../shared/types";45 import { LockView } from "./views/Lock";56 import { Onboarding } from "./views/Onboarding";67 import { ProCelebration } from "./views/ProCelebration";@@ -9,7 +10,6 @@910 import { AddView } from "./views/Add";1011 import { PeachWordmark } from "./components/PeachMark";1112 import { PasskeyApprovalView } from "./views/PasskeyApproval";12-import { PasskeyEntryView } from "./views/PasskeyEntry";1313 import { extractDomain } from "../shared/domain";1414 import { popupCopy } from "./copy";1515 import {@@ -469,7 +469,9 @@469469       new_entry: () => handleAdd(),470470       edit_entry: () => {471471         const entry = selectedEntry();472-        if (!entry || entry.type === "passkey") return;472+        // Passkey-only entries have nothing user-editable in the Add view; the473+        // passkey credential itself is opaque. Skip the edit action for them.474+        if (!entry || isPasskeyOnly(entry)) return;473475         setEditEntry(entry);474476         setView("add");475477       },@@ -690,7 +692,7 @@690692             }691693             return false;692694           }693-          if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;695+          if (e.passkey?.rpId) return e.passkey.rpId === tabDomain;694696           return false;695697         }) ?? null;696698       }@@ -806,7 +808,9 @@806808   const isShellOverlayOpen = () => view() !== "entry" || restoreView() !== null;807809 808810   function handleFixEntry(entry: VaultEntry) {809-    if (entry.type === "passkey") {811+    // Passkey-only entries have no editable surface — drop into view mode812+    // instead of opening the Add editor.813+    if (isPasskeyOnly(entry)) {810814       setSelectedEntry(entry);811815       setView("entry");812816       return;@@ -816,9 +820,6 @@816820   }817821 818822   const isLockedView = () => view() === "lock";819-  const selectedPasskeyEntry = createMemo(() =>820-    selectedEntry()?.type === "passkey" ? (selectedEntry() as PasskeyEntry) : null821-  );822823 823824   async function handleApprovePendingPasskey() {824825     const pending = pendingPasskey();@@ -969,19 +970,12 @@969970             <div class="peach-shell-body">970971               <div class="peach-shell-veil"></div>971972               <div class="peach-content-frame" data-content-phase={contentPhase()} classList={{ navigating: contentTransitioning() }}>972-                <Show973-                  when={selectedPasskeyEntry()}974-                  fallback={975-                    <EntryView976-                      entry={selectedEntry()}977-                      onDelete={handleDelete}978-                      onFieldSaved={() => void loadEntries()}979-                      onEscape={() => { setView("list"); }}980-                    />981-                  }982-                >983-                  <PasskeyEntryView entry={selectedPasskeyEntry()} onDelete={handleDelete} />984-                </Show>973+                <EntryView974+                  entry={selectedEntry()}975+                  onDelete={handleDelete}976+                  onFieldSaved={() => void loadEntries()}977+                  onEscape={() => { setView("list"); }}978+                />985979               </div>986980 987981               <section class="peach-search-wrap" classList={{ "is-hidden-for-overlay": isShellOverlayOpen() }}>

view file

src/popup/views/Entry.tsx+87 −3
@@ -1,5 +1,7 @@11 import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";22 import type { VaultEntry } from "../../shared/types";3+import { isPasskeyOnly } from "../../shared/types";4+import { detectBrowser } from "../../shared/browser";35 import { getDisplayDomain } from "../../shared/domain";46 import { faviconUrl } from "../../shared/favicon";57 import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";@@ -723,7 +725,7 @@723725               </Show>724726 725727               {/* ── Login fields ─────────────────────────────────── */}726-              <Show when={entry().type !== "note" && entry().type !== "card"}>728+              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>727729                 <section class="peach-fields" style="position:relative;z-index:1;">728730 729731                   {/* Username */}@@ -917,7 +919,7 @@917919               </Show>918920 919921               {/* ── TOTP ─────────────────────────────────────────── */}920-              <Show when={entry().type === "login"}>922+              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>921923                 <Show922924                   when={totpState() || editingField() === "totp"}923925                   fallback={@@ -989,8 +991,90 @@989991                 </Show>990992               </Show>991993 994+              {/* ── Passkey ──────────────────────────────────────── */}995+              <Show when={entry().passkey}>996+                {(passkey) => {997+                  const currentBrowser = detectBrowser();998+                  const crossBrowser =999+                    passkey().browser &&1000+                    currentBrowser !== "unknown" &&1001+                    passkey().browser !== currentBrowser;1002+                  const algorithmLabel =1003+                    passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1004+                  const formatDate = (ts?: number) =>1005+                    ts ? new Date(ts).toLocaleDateString() : "Never";1006+                  return (1007+                    <section1008+                      class="peach-fields peach-passkey-section"1009+                      style="position:relative;z-index:1;margin-top:6px;"1010+                    >1011+                      <div class="peach-field-card">1012+                        <span class="peach-label">passkey · {passkey().rpId}</span>1013+                        <div class="peach-field-value-row">1014+                          <span class="peach-field-value">{passkey().userName}</span>1015+                          <span class="peach-passkey-badge">passkey</span>1016+                        </div>1017+                      </div>1018+1019+                      <div class="peach-field-card">1020+                        <span class="peach-label">algorithm</span>1021+                        <div class="peach-field-value-row">1022+                          <span class="peach-field-value">{algorithmLabel}</span>1023+                        </div>1024+                      </div>1025+1026+                      <div class="peach-field-card">1027+                        <span class="peach-label">activity</span>1028+                        <div class="peach-field-value-row peach-passkey-activity">1029+                          <span class="peach-field-value">1030+                            used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1031+                          </span>1032+                          <span class="peach-passkey-meta">1033+                            created {formatDate(passkey().createdAt)}1034+                          </span>1035+                          <span class="peach-passkey-meta">1036+                            last used {formatDate(passkey().lastUsedAt)}1037+                          </span>1038+                        </div>1039+                      </div>1040+1041+                      <Show when={crossBrowser}>1042+                        <div class="peach-info-notice peach-info-notice--warn">1043+                          {`This passkey was created on ${1044+                            passkey().browser === "chrome" ? "Chrome" : "Firefox"1045+                          }. To use it on ${1046+                            currentBrowser === "chrome" ? "Chrome" : "Firefox"1047+                          }, re-register it on this device.`}1048+                        </div>1049+                      </Show>1050+1051+                      <div class="peach-info-notice">1052+                        {passkey().browser === "firefox"1053+                          ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1054+                          : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1055+                      </div>1056+1057+                      <button1058+                        class="peach-add-field-link peach-entry-add-link"1059+                        style="color:rgba(220,38,38,0.7);"1060+                        onClick={async () => {1061+                          const id = entry().id;1062+                          await sendMessage({1063+                            type: "UPDATE_ENTRY",1064+                            payload: { id, patch: { passkey: undefined } },1065+                          });1066+                          await props.onFieldSaved();1067+                        }}1068+                      >1069+                        − remove passkey1070+                      </button>1071+                    </section>1072+                  );1073+                }}1074+              </Show>1075+9921076               {/* ── Tags editor / add-tags link ──────────────────── */}993-              <Show when={entry().type !== "passkey"}>1077+              <Show when={true}>9941078                 <Show9951079                   when={editingTags()}9961080                   fallback={

view file

src/popup/views/Export.tsx+16 −5
@@ -402,11 +402,16 @@402402     try {403403       const res = await sendMessage({ type: "GET_ENTRIES" });404404       const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];405-      const active = entries.filter(406-        (e) => e.deletedAt === undefined && e.type !== "passkey"407-      );405+      // Passkeys are device-bound credentials and cannot be moved between406+      // devices via export. Strip the passkey sub-object out of any login407+      // entry that carries one; the entry itself still exports if it has a408+      // password too. Passkey-only entries (no password) are skipped entirely.409+      const active = entries410+        .filter((e) => e.deletedAt === undefined)411+        .filter((e) => !(e.passkey && !e.password))412+        .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));408413       const excludedPasskeys = entries.filter(409-        (e) => e.deletedAt === undefined && e.type === "passkey"414+        (e) => e.deletedAt === undefined && e.passkey410415       ).length;411416 412417       if (active.length === 0) {@@ -551,8 +556,14 @@551556   let logins = 0;552557   for (const e of entries) {553558     if (e.deletedAt) continue;559+    if (e.passkey) {560+      passkeys++;561+      // A login that ALSO has a password counts toward the login total too —562+      // exporting it preserves the password (the passkey gets stripped).563+      if (e.type === "login" && e.password) logins++;564+      continue;565+    }554566     if (e.type === "card") cards++;555-    else if (e.type === "passkey") passkeys++;556567     else if (e.type === "note") notes++;557568     else logins++;558569   }

view file

src/popup/views/Health.tsx+4 −3
@@ -117,12 +117,13 @@117117 118118   const stalePasskeys = createMemo(() =>119119     props.entries120-      .filter((e) => e.type === "passkey")120+      .filter((e) => e.passkey !== undefined)121121       .map((entry) => {122-        const lastActivity = entry.lastUsedAt ?? entry.createdAt;122+        const passkey = entry.passkey!;123+        const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;123124         const ageMs = Date.now() - lastActivity;124125         const status =125-          (entry.signCount ?? 0) === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000126+          passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000126127             ? "Unused"127128             : ageMs >= ONE_YEAR_MS128129               ? "Old"

view file

src/popup/views/Import.tsx+2 −2
@@ -587,8 +587,8 @@587587                 <div class="peach-import-preview-row">588588                   <span class="peach-import-preview-title">{entry.title}</span>589589                   <span class="peach-import-preview-meta">590-                    {entry.type === "passkey"591-                      ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`590+                    {entry.passkey591+                      ? `${entry.passkey.userName} • ${entry.passkey.rpId} (passkey)`592592                       : entry.type === "note"593593                         ? "secure note"594594                         : entry.username || entry.url || "password item"}

view file

src/popup/views/PasskeyEntry.tsx+0 −171
@@ -1,171 +0,0 @@1-import { createSignal, For, Show } from "solid-js";2-3-import type { PasskeyEntry } from "../../shared/types";4-import { detectBrowser } from "../../shared/browser";5-6-interface PasskeyEntryProps {7-  entry: PasskeyEntry | null;8-  onDelete: () => void | Promise<void>;9-}10-11-function formatDate(timestamp?: number): string {12-  if (!timestamp) return "Never";13-  return new Date(timestamp).toLocaleDateString();14-}15-16-function formatUsedCount(count?: number): string {17-  const used = count ?? 0;18-  return `used ${used} time${used === 1 ? "" : "s"}`;19-}20-21-function algorithmLabel(algorithm: -7 | -8): string {22-  return algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";23-}24-25-function titleSizeClass(title: string): string {26-  if (title.length >= 28) return "peach-entry-name--xxl";27-  if (title.length >= 20) return "peach-entry-name--xl";28-  if (title.length >= 14) return "peach-entry-name--lg";29-  return "";30-}31-32-export function PasskeyEntryView(props: PasskeyEntryProps) {33-  const [deleteConfirmPending, setDeleteConfirmPending] = createSignal(false);34-  const currentBrowser = detectBrowser();35-36-  function crossBrowserMessage(entryBrowser: "chrome" | "firefox" | undefined): string | null {37-    if (!entryBrowser) return null;38-    if (currentBrowser === "unknown") return null;39-    if (entryBrowser === currentBrowser) return null;40-    const from = entryBrowser === "chrome" ? "Chrome" : "Firefox";41-    const to = currentBrowser === "chrome" ? "Chrome" : "Firefox";42-    return `This passkey was created on ${from}. To use it on ${to}, re-register it on this device.`;43-  }44-45-  return (46-    <div class="peach-stage" style="position:relative;">47-      <Show when={props.entry} fallback={<div class="peach-list-empty"><span>no passkey selected</span></div>}>48-        {(entry) => (49-          <>50-            <div51-              class="peach-ghost"52-              aria-hidden="true"53-              style="position:absolute;top:30px;left:12px;right:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:150px;font-style:italic;font-weight:400;color:var(--t0);opacity:0.03;pointer-events:none;line-height:1;overflow:hidden;white-space:nowrap;z-index:0;"54-            >55-              {entry().title}56-            </div>57-58-            <div class="peach-entry-header" style="position:relative;z-index:1;">59-              <div class="peach-entry-header-name">60-                <h1 class={`peach-entry-name ${titleSizeClass(entry().title)}`} aria-label={entry().title}>61-                  <span class="peach-entry-name-text">{entry().title}</span>62-                </h1>63-              </div>64-              <Show65-                when={deleteConfirmPending()}66-                fallback={67-                  <button68-                    class="peach-entry-trash-btn"69-                    onClick={() => setDeleteConfirmPending(true)}70-                    aria-label="Remove passkey"71-                    title="Remove passkey"72-                  >73-                    <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">74-                      <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />75-                      <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />76-                    </svg>77-                  </button>78-                }79-              >80-                <div class="peach-entry-trash-confirm">81-                  <span class="peach-entry-trash-confirm-label">remove?</span>82-                  <button83-                    class="peach-btn peach-btn--sm peach-btn--danger"84-                    style="font-size:10px;padding:2px 8px;min-height:22px;"85-                    onClick={() => {86-                      setDeleteConfirmPending(false);87-                      void props.onDelete();88-                    }}89-                  >90-                    yes91-                  </button>92-                  <button93-                    class="peach-btn peach-btn--ghost peach-btn--sm"94-                    style="font-size:10px;padding:2px 8px;min-height:22px;"95-                    onClick={() => setDeleteConfirmPending(false)}96-                  >97-                    no98-                  </button>99-                </div>100-              </Show>101-            </div>102-103-            <div class="peach-domain" style="position:relative;z-index:1;">104-              <div class="peach-domain-row">105-                <span class="peach-domain-value">{entry().rpId}</span>106-                <span class="peach-passkey-badge">passkey</span>107-              </div>108-            </div>109-110-            <section class="peach-fields" style="position:relative;z-index:1;">111-              <div class="peach-field-card">112-                <span class="peach-label">username</span>113-                <div class="peach-field-value-row">114-                  <span class="peach-field-value">{entry().userName}</span>115-                </div>116-              </div>117-118-              <div class="peach-field-card">119-                <span class="peach-label">display name</span>120-                <div class="peach-field-value-row">121-                  <span class="peach-field-value">{entry().userDisplayName}</span>122-                </div>123-              </div>124-125-              <div class="peach-field-card">126-                <span class="peach-label">algorithm</span>127-                <div class="peach-field-value-row">128-                  <span class="peach-field-value">{algorithmLabel(entry().algorithm)}</span>129-                </div>130-              </div>131-132-              <div class="peach-field-card">133-                <span class="peach-label">activity</span>134-                <div class="peach-field-value-row peach-passkey-activity">135-                  <span class="peach-field-value">{formatUsedCount(entry().signCount)}</span>136-                  <span class="peach-passkey-meta">created {formatDate(entry().createdAt)}</span>137-                  <span class="peach-passkey-meta">last used {formatDate(entry().lastUsedAt)}</span>138-                </div>139-              </div>140-            </section>141-142-            <Show when={entry().tags && entry().tags!.length > 0}>143-              <div144-                style="display:flex;flex-wrap:wrap;gap:5px;margin:6px 0 2px;position:relative;z-index:1;"145-              >146-                <For each={entry().tags!}>147-                  {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}148-                </For>149-              </div>150-            </Show>151-152-            <div class="peach-passkey-actions">153-              <Show when={crossBrowserMessage(entry().browser)}>154-                {(msg) => (155-                  <div class="peach-info-notice peach-info-notice--warn">156-                    {msg()}157-                  </div>158-                )}159-              </Show>160-              <div class="peach-info-notice">161-                {entry().browser === "firefox"162-                  ? "This passkey is held by your platform authenticator. Peach stores only the metadata."163-                  : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}164-              </div>165-            </div>166-          </>167-        )}168-      </Show>169-    </div>170-  );171-}

view file at the parent

src/popup/views/Vault.tsx+15 −23
@@ -142,13 +142,13 @@142142 143143   function entryDomain(entry: VaultEntry): string {144144     if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);145-    if (entry.type === "passkey" && entry.rpId) return entry.rpId;145+    if (entry.passkey?.rpId) return entry.passkey.rpId;146146     return "";147147   }148148 149149   function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {150150     if (!domain) return false;151-    if (entry.type === "passkey") return entry.rpId === domain;151+    if (entry.passkey?.rpId === domain) return true;152152     if (entry.type !== "login") return false;153153     // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of154154     // each entry URL too so subdomain entries also match.@@ -174,26 +174,18 @@174174 175175     const q = search().trim();176176     if (q) {177+      const searchableFor = (e: VaultEntry): string => {178+        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;179+        const passkeyText = e.passkey180+          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`181+          : "";182+        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;183+      };177184       result = result178-        .filter((entry) => {179-          const searchable = entry.type === "note"180-            ? `${entry.title} ${entry.notes ?? ""}`181-            : entry.type === "passkey"182-              ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`183-            : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;184-          return fuzzyMatch(q, searchable);185-        })185+        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))186186         .sort((a, b) => {187-          const aSearchable = a.type === "note"188-            ? `${a.title} ${a.notes ?? ""}`.toLowerCase()189-            : a.type === "passkey"190-              ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()191-            : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();192-          const bSearchable = b.type === "note"193-            ? `${b.title} ${b.notes ?? ""}`.toLowerCase()194-            : b.type === "passkey"195-              ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()196-            : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();187+          const aSearchable = searchableFor(a).toLowerCase();188+          const bSearchable = searchableFor(b).toLowerCase();197189           const qLower = q.toLowerCase();198190           const aExact = isExactPrefix(qLower, aSearchable);199191           const bExact = isExactPrefix(qLower, bSearchable);@@ -428,13 +420,13 @@428420           <For each={filteredEntries()}>429421             {(entry, index) => {430422               const isNote = entry.type === "note";431-              const isPasskey = entry.type === "passkey";423+              const isPasskey = entry.passkey !== undefined;432424               const domain = entry.type === "note"433425                 ? (entry.notes?.slice(0, 40) ?? "note")434-                : entry.type === "passkey"435-                  ? entry.rpId ?? "passkey"436426                 : entry.url437427                   ? getDisplayDomain(entry.url)428+                  : entry.passkey?.rpId429+                    ? entry.passkey.rpId438430                   : "saved entry";439431               const isTabMatch = () =>440432                 props.currentTabDomain !== undefined &&

view file

src/shared/types.ts+0 −26
@@ -163,32 +163,6 @@163163   cvc: string;164164 }165165 166-export interface PasskeyEntry extends VaultEntry {167-  type: "passkey";168-  rpId: string;169-  rpName: string;170-  credentialId: string;171-  publicKeyCose: string;172-  /**173-   * Encrypted vault holds the private key only for Chrome-created passkeys,174-   * where Peach is the authenticator. Firefox-created passkeys delegate to175-   * the platform authenticator, so this field is absent — `browser`176-   * distinguishes the two paths.177-   */178-  privateKeyJwk?: JsonWebKey;179-  userHandle: string;180-  userName: string;181-  userDisplayName: string;182-  algorithm: -7 | -8;183-  signCount: number;184-  aaguid: string;185-  backupEligible: boolean;186-  backupState: boolean;187-  lastUsedAt?: number;188-  // Optional on read for pre-v2.10.0 entries; new entries always set it.189-  browser?: "chrome" | "firefox";190-  transports?: string[];191-}192166 193167 export interface PendingPasskeyRequest {194168   requestId: number;

view file