a1d75b982320

add biometric unlock path to LockView: hasMasterKey + isAvailable check on mount, auto-prompt 400ms after, BiometricPlugin.authenticate -> KeystorePlugin.retrieveMasterKey -> UNLOCK_WITH_KEY; biometric button rendered when available; biometric_key_invalidated falls back to password and clears biometricsEnabled

time
2026-05-07 02:21 UTC · 5 months ago
machine
koh2
parent
3872f604f17d
1 file src/popup/views/Lock.tsx+93 −0
src/popup/views/Lock.tsx+93 −0
@@ -10,6 +10,7 @@1010 import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";1111 import { parsePayload } from "../../crypto/peachPayload";1212 import { parseImportSource } from "../../shared/import";13+import { isNativePlatform, nativePlugin } from "../../shared/native";1314 1415 const peachMarkImg = new Image();1516 peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(@@ -64,6 +65,8 @@6465   const MIN_ZXCVBN_SCORE = 2;6566 6667   const [strengthScore, setStrengthScore] = createSignal<number | null>(null);68+  const [biometricAvailable, setBiometricAvailable] = createSignal(false);69+  const [biometricBusy, setBiometricBusy] = createSignal(false);6770 6871   const canCreate = createMemo(6972     () =>@@ -127,6 +130,75 @@127130     setLoading(false);128131   }129132 133+  async function checkBiometricAvailable(): Promise<void> {134+    if (!props.hasVault || !isNativePlatform()) return;135+    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");136+    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");137+    if (!keystore || !biometric) return;138+    try {139+      const stored = await keystore.hasMasterKey();140+      if (!stored.exists) return;141+      const avail = await biometric.isAvailable();142+      if (!avail.available) return;143+      setBiometricAvailable(true);144+      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);145+    } catch { /* missing plugins or runtime error — silently skip */ }146+  }147+148+  async function handleBiometricUnlock(): Promise<void> {149+    if (biometricBusy()) return;150+    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");151+    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");152+    if (!biometric || !keystore) return;153+    setBiometricBusy(true);154+    setError("");155+    try {156+      await biometric.authenticate({157+        title: "Unlock Peach",158+        subtitle: "Use biometrics to access your vault",159+        negativeButtonText: "Use Password",160+      });161+      const { masterKeyHex } = await keystore.retrieveMasterKey();162+      const res = (await sendMessage({163+        type: "UNLOCK_WITH_KEY",164+        payload: { masterKeyHex },165+      })) as { ok: boolean; error?: string };166+      if (res.ok) {167+        await props.onUnlock();168+        return;169+      }170+      if (res.error === "biometric_key_invalidated") {171+        await sendMessage({172+          type: "UPDATE_SETTINGS",173+          payload: { biometricsEnabled: false },174+        });175+        setBiometricAvailable(false);176+        setError("biometric key expired — re-enable in settings");177+      } else {178+        setError("biometric unlock failed");179+      }180+    } catch (e) {181+      const reason = e instanceof Error ? e.message : String(e);182+      if (reason.includes("user_cancelled")) return;183+      if (reason.includes("biometric_key_invalidated")) {184+        await sendMessage({185+          type: "UPDATE_SETTINGS",186+          payload: { biometricsEnabled: false },187+        });188+        setBiometricAvailable(false);189+        setError("biometric key expired — re-enable in settings");190+        return;191+      }192+      if (reason.includes("biometric_lockout")) {193+        setError("biometric locked out — use your password");194+      }195+    } finally {196+      setBiometricBusy(false);197+    }198+  }199+200+  onMount(() => { void checkBiometricAvailable(); });201+130202   async function handleCreate() {131203     if (!password().trim()) return;132204     if (password().length < MIN_LENGTH) {@@ -261,6 +333,27 @@261333             >262334               {loading() ? "unlocking..." : "unlock"}263335             </button>336+337+            <Show when={biometricAvailable()}>338+              <button339+                class="peach-biometric-btn"340+                type="button"341+                onClick={() => void handleBiometricUnlock()}342+                disabled={biometricBusy() || loading()}343+                aria-label="Unlock with biometrics"344+              >345+                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">346+                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>347+                  <path d="M15 17v-3"/>348+                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>349+                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>350+                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>351+                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>352+                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>353+                </svg>354+                {biometricBusy() ? "verifying…" : "use biometrics"}355+              </button>356+            </Show>264357 265358             <button266359               class="peach-lock-recover-link"

view file