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
↓ peach-browser-d8f1032b.tar.gzatlas at this save
files changed9 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 −26src/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-}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() }}>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={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 }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"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"}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-}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 &&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;