From 6f06b1e6314c719bdc07aed12188919d2617284f Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 24 Oct 2026 15:02:10 +0700 Subject: [PATCH] Approve sites' passkey requests from the browser extension The app comes forward with a prompt to sign in with or save a passkey, confirmed with the master password (the user verification each use needs). Asked while locked: unlock, then it continues. Co-Authored-By: Claude Opus 5.5 --- src-tauri/examples/test-host.rs | 16 +++++++-- src-tauri/src/lib.rs | 2 +- src-tauri/src/main.rs | 40 +++++++++++++++++++++ src-tauri/tests/nmh_stdio.rs | 1 + src/main.ts | 63 ++++++++++++++++++++++++++++++++- 5 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src-tauri/examples/test-host.rs b/src-tauri/examples/test-host.rs index a0576d3..4dd0cc8 100644 --- a/src-tauri/examples/test-host.rs +++ b/src-tauri/examples/test-host.rs @@ -1,12 +1,13 @@ //! Headless stand-in for the desktop app, for end-to-end tests of the browser extension and //! `kestrove-nmh`. It runs the same `Broker` as the app over the same IPC endpoint, with an -//! in-memory vault holding one test login, and plays the user: it approves pairings and saves. +//! in-memory vault holding one test login, and plays the user: it approves pairings, saves and passkeys. //! Never ship this: it auto-approves. //! //! KESTROVE_IPC_DIR=/tmp/kv-e2e KESTROVE_TEST_ORIGIN=http://127.0.0.1:8765 \ //! cargo run --example test-host //! -//! Prints `READY `, then `PAIRED ` and `SAVED ` as they happen. +//! Prints `READY `, then `PAIRED `, `SAVED ` and +//! `PASSKEY ` as they happen. use std::io::Write; use std::sync::Arc; @@ -14,6 +15,7 @@ use std::thread; use std::time::Duration; use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui}; +use kestrove_desktop::passkey::{PasskeyKind, PasskeyView}; use kestrove_desktop::ipc; use kv_ffi::{ItemDto, ItemKindDto, VaultCore}; @@ -21,6 +23,7 @@ struct Quiet; impl Ui for Quiet { fn pairing_requested(&self, _: &PairingView) {} fn save_requested(&self, _: &SaveView) {} + fn passkey_requested(&self, _: &PasskeyView) {} } fn main() -> Result<(), Box> { @@ -53,6 +56,15 @@ fn main() -> Result<(), Box> { println!("PAIRED {}", p.words.join(" ")); } } + for p in b.pending_passkeys() { + if b.resolve_passkey(&p.id, true, None).is_ok() { + let (kind, user) = match p.kind { + PasskeyKind::Create => ("create", p.user_name.clone()), + PasskeyKind::Get => ("get", p.choices.first().map(|c| c.user_name.clone()).unwrap_or_default()), + }; + println!("PASSKEY {kind} {} {user}", p.rp_id); + } + } for s in b.pending_saves() { if b.resolve_save(&s.id, true).is_ok() { println!("SAVED {} {}", s.origin, s.username); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 47cfcbf..73ae455 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,5 +9,5 @@ pub mod qr; pub mod sync; pub mod title_bar; -pub use kv_ffi::bridge::{broker, channel, words}; +pub use kv_ffi::bridge::{broker, channel, passkey, words}; pub use kv_ipc::{framing, ipc, private_file}; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5ba6d9e..8413a18 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,7 @@ use std::{io, thread}; use kestrove_desktop::autolock::{self, Activity, Reason}; use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui}; +use kestrove_desktop::passkey::PasskeyView; use kestrove_desktop::device_key::DeviceKey; use kestrove_desktop::sync::{ self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService, @@ -136,6 +137,13 @@ impl Ui for TauriUi { self.surface(); let _ = self.handle.emit("save-request", req); } + fn passkey_requested(&self, req: &PasskeyView) { + self.surface(); + let _ = self.handle.emit("passkey-request", req); + } + fn passkey_settled(&self, id: &str) { + let _ = self.handle.emit("passkey-settled", id); + } fn activity(&self) { self.activity.touch(); } @@ -355,6 +363,36 @@ fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> Ok(()) } +#[tauri::command] +fn pending_passkeys(app: State<'_, App>) -> Vec { + app.broker.pending_passkeys() +} + +/// A page's passkey request (docs/05 "Passkeys in the browser"). Accepting needs the master password: the user +/// verification every passkey use requires, where the app has no biometrics to ask. +#[tauri::command] +fn resolve_passkey( + app: State<'_, App>, + id: String, + accept: bool, + credential_id: Option, + master_password: Option, +) -> CmdResult<()> { + if accept { + let pw = Zeroizing::new(master_password.unwrap_or_default()); + app.core.confirm_master_password(pw.to_string()).map_err(|e| match e { + CoreError::BadCredentials => "Wrong master password.".to_string(), + e => err(e), + })?; + } + app.broker.resolve_passkey(&id, accept, credential_id.as_deref())?; + app.activity.touch(); + if accept { + app.sync.request_sync(); + } + Ok(()) +} + // ---------------------------------------------------------------- sync server (docs/03) #[tauri::command] @@ -645,6 +683,8 @@ fn main() { list_pairings, revoke_pairing, pending_saves, + pending_passkeys, + resolve_passkey, resolve_save, sync_status, sync_now, diff --git a/src-tauri/tests/nmh_stdio.rs b/src-tauri/tests/nmh_stdio.rs index 86e0966..3e360e8 100644 --- a/src-tauri/tests/nmh_stdio.rs +++ b/src-tauri/tests/nmh_stdio.rs @@ -20,6 +20,7 @@ struct NoUi; impl Ui for NoUi { fn pairing_requested(&self, _: &PairingView) {} fn save_requested(&self, _: &SaveView) {} + fn passkey_requested(&self, _: &kestrove_desktop::passkey::PasskeyView) {} } struct Host { diff --git a/src/main.ts b/src/main.ts index 6eac892..bf54e23 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,8 @@ interface ItemDto { id: string; title: string; username: string | null; password interface PairingView { id: string; name: string; caller: string; words: string[] } interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number } interface SaveView { id: string; origin: string; username: string; update: boolean } +interface PasskeyChoice { credential_id: string; user_name: string; title: string } +interface PasskeyView { id: string; kind: "get" | "create"; origin: string; rp_id: string; rp_name: string; user_name: string; choices: PasskeyChoice[]; locked: boolean } interface SyncStatus { server_url: string | null; pins: string[]; logged_in: boolean; syncing: boolean; last_sync_at: number | null; last_error: string | null; pending_approval: boolean; pending_devices: number; integrity_alarm: string | null; device_fingerprint: string | null; @@ -938,12 +940,18 @@ function copyText(area: HTMLTextAreaElement) { else document.execCommand("copy"); } -// ------------------------------------------------------------------ browser prompts (pairing, save) +// ------------------------------------------------------------------ browser prompts (pairing, save, passkeys) async function renderPrompts(state: AppState) { overlay.replaceChildren(); const pairing = await invoke("pending_pairing"); if (pairing) overlay.append(pairingCard(pairing, state)); + // Asked while locked: the unlock screen is behind; once unlocked the request shows its passkeys. + for (const pk of await invoke("pending_passkeys")) { + overlay.append(state.locked || pk.locked + ? h("div", "kv-sheet prompt", banner("info", `${pk.rp_id} asks for a passkey`, "Unlock Kestrove to continue.")) + : passkeyCard(pk)); + } if (state.has_account && !state.locked) { for (const s of await invoke("pending_saves")) overlay.append(saveCard(s)); } else if (state.has_account && (await invoke("pending_saves")).length) { @@ -980,6 +988,57 @@ function pairingCard(pr: PairingView, state: AppState): HTMLElement { h("div", "actions end", reject, accept)); } +/** A site's passkey request. Approving needs the master password: the user verification each use requires. */ +function passkeyCard(pk: PasskeyView): HTMLElement { + const err = errBox(); + const create = pk.kind === "create"; + const pw = input({ type: "password", autocomplete: "current-password", placeholder: "Master password" }); + const yes = button(create ? "Save passkey" : "Sign in", "primary"); + const no = button(create ? "Use another device" : "Not now", "secondary"); + let chosen = pk.choices[0]?.credential_id ?? null; + const list = create + ? h("div", "kv-card rows", h("div", "kv-frow", tile(pk.rp_id), + h("div", "val", h("strong", "", pk.user_name || "This account"), h("span", "kv-small kv-muted", pk.rp_name ? `${pk.rp_name} ยท ${pk.rp_id}` : pk.rp_id)))) + : h("div", "kv-card rows", ...pk.choices.map((c, i) => { + const radio = input({ type: "radio", name: `pk-${pk.id}`, checked: i === 0 }); + radio.onchange = () => { chosen = c.credential_id; }; + return h("label", "kv-frow", radio, tile(c.title), + h("div", "val", h("strong", "", c.user_name || c.title), h("span", "kv-small kv-muted", c.title))); + })); + const resolve = async (accept: boolean) => { + if (accept && !pw.value) { + setErr(err, "Enter your master password to confirm it's you."); + pw.focus(); + return; + } + yes.disabled = no.disabled = true; + try { + await invoke("resolve_passkey", { id: pk.id, accept, credentialId: accept ? chosen : null, masterPassword: accept ? pw.value : null }); + pw.value = ""; + await render(); + } catch (e) { + setErr(err, e); + yes.disabled = no.disabled = false; + pw.select(); + } + }; + yes.onclick = () => resolve(true); + no.onclick = () => resolve(false); + pw.onkeydown = (e) => { + if (e.key === "Enter") void resolve(true); + }; + queueMicrotask(() => pw.focus()); + return h("div", "kv-sheet prompt", + h("h2", "kv-title-2", create ? "Save a passkey?" : "Sign in with a passkey?"), + p("", create + ? `${pk.origin.replace(/^https?:\/\//, "")} wants to create a passkey. Kestrove keeps it with your logins and syncs it to your devices.` + : `${pk.origin.replace(/^https?:\/\//, "")} asks you to sign in with a passkey.`), + list, + field("Confirm with your master password", pw), + err, + h("div", "actions end", no, yes)); +} + function saveCard(s: SaveView): HTMLElement { const err = errBox(); const yes = button(s.update ? "Update password" : "Save", "primary"); @@ -1006,6 +1065,8 @@ function saveCard(s: SaveView): HTMLElement { void listen("pairing-request", () => render()); void listen("save-request", () => render()); +void listen("passkey-request", () => render()); +void listen("passkey-settled", () => render()); void listen("locked", () => { closeModal(); void render();