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 <[email protected]>
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
//! Headless stand-in for the desktop app, for end-to-end tests of the browser extension and
|
//! 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
|
//! `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.
|
//! Never ship this: it auto-approves.
|
||||||
//!
|
//!
|
||||||
//! KESTROVE_IPC_DIR=/tmp/kv-e2e KESTROVE_TEST_ORIGIN=http://127.0.0.1:8765 \
|
//! KESTROVE_IPC_DIR=/tmp/kv-e2e KESTROVE_TEST_ORIGIN=http://127.0.0.1:8765 \
|
||||||
//! cargo run --example test-host
|
//! cargo run --example test-host
|
||||||
//!
|
//!
|
||||||
//! Prints `READY <endpoint>`, then `PAIRED <words>` and `SAVED <origin> <username>` as they happen.
|
//! Prints `READY <endpoint>`, then `PAIRED <words>`, `SAVED <origin> <username>` and
|
||||||
|
//! `PASSKEY <create|get> <rp_id> <user>` as they happen.
|
||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -14,6 +15,7 @@ use std::thread;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
||||||
|
use kestrove_desktop::passkey::{PasskeyKind, PasskeyView};
|
||||||
use kestrove_desktop::ipc;
|
use kestrove_desktop::ipc;
|
||||||
use kv_ffi::{ItemDto, ItemKindDto, VaultCore};
|
use kv_ffi::{ItemDto, ItemKindDto, VaultCore};
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ struct Quiet;
|
|||||||
impl Ui for Quiet {
|
impl Ui for Quiet {
|
||||||
fn pairing_requested(&self, _: &PairingView) {}
|
fn pairing_requested(&self, _: &PairingView) {}
|
||||||
fn save_requested(&self, _: &SaveView) {}
|
fn save_requested(&self, _: &SaveView) {}
|
||||||
|
fn passkey_requested(&self, _: &PasskeyView) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -53,6 +56,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!("PAIRED {}", p.words.join(" "));
|
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() {
|
for s in b.pending_saves() {
|
||||||
if b.resolve_save(&s.id, true).is_ok() {
|
if b.resolve_save(&s.id, true).is_ok() {
|
||||||
println!("SAVED {} {}", s.origin, s.username);
|
println!("SAVED {} {}", s.origin, s.username);
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ pub mod qr;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod title_bar;
|
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};
|
pub use kv_ipc::{framing, ipc, private_file};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::{io, thread};
|
|||||||
|
|
||||||
use kestrove_desktop::autolock::{self, Activity, Reason};
|
use kestrove_desktop::autolock::{self, Activity, Reason};
|
||||||
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
||||||
|
use kestrove_desktop::passkey::PasskeyView;
|
||||||
use kestrove_desktop::device_key::DeviceKey;
|
use kestrove_desktop::device_key::DeviceKey;
|
||||||
use kestrove_desktop::sync::{
|
use kestrove_desktop::sync::{
|
||||||
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
|
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
|
||||||
@@ -136,6 +137,13 @@ impl Ui for TauriUi {
|
|||||||
self.surface();
|
self.surface();
|
||||||
let _ = self.handle.emit("save-request", req);
|
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) {
|
fn activity(&self) {
|
||||||
self.activity.touch();
|
self.activity.touch();
|
||||||
}
|
}
|
||||||
@@ -355,6 +363,36 @@ fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn pending_passkeys(app: State<'_, App>) -> Vec<PasskeyView> {
|
||||||
|
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<String>,
|
||||||
|
master_password: Option<String>,
|
||||||
|
) -> 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)
|
// ---------------------------------------------------------------- sync server (docs/03)
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -645,6 +683,8 @@ fn main() {
|
|||||||
list_pairings,
|
list_pairings,
|
||||||
revoke_pairing,
|
revoke_pairing,
|
||||||
pending_saves,
|
pending_saves,
|
||||||
|
pending_passkeys,
|
||||||
|
resolve_passkey,
|
||||||
resolve_save,
|
resolve_save,
|
||||||
sync_status,
|
sync_status,
|
||||||
sync_now,
|
sync_now,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ struct NoUi;
|
|||||||
impl Ui for NoUi {
|
impl Ui for NoUi {
|
||||||
fn pairing_requested(&self, _: &PairingView) {}
|
fn pairing_requested(&self, _: &PairingView) {}
|
||||||
fn save_requested(&self, _: &SaveView) {}
|
fn save_requested(&self, _: &SaveView) {}
|
||||||
|
fn passkey_requested(&self, _: &kestrove_desktop::passkey::PasskeyView) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Host {
|
struct Host {
|
||||||
|
|||||||
+62
-1
@@ -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 PairingView { id: string; name: string; caller: string; words: string[] }
|
||||||
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
|
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
|
||||||
interface SaveView { id: string; origin: string; username: string; update: boolean }
|
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 {
|
interface SyncStatus {
|
||||||
server_url: string | null; pins: string[]; logged_in: boolean; syncing: boolean; last_sync_at: number | null; last_error: string | null;
|
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;
|
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");
|
else document.execCommand("copy");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ browser prompts (pairing, save)
|
// ------------------------------------------------------------------ browser prompts (pairing, save, passkeys)
|
||||||
|
|
||||||
async function renderPrompts(state: AppState) {
|
async function renderPrompts(state: AppState) {
|
||||||
overlay.replaceChildren();
|
overlay.replaceChildren();
|
||||||
const pairing = await invoke<PairingView | null>("pending_pairing");
|
const pairing = await invoke<PairingView | null>("pending_pairing");
|
||||||
if (pairing) overlay.append(pairingCard(pairing, state));
|
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<PasskeyView[]>("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) {
|
if (state.has_account && !state.locked) {
|
||||||
for (const s of await invoke<SaveView[]>("pending_saves")) overlay.append(saveCard(s));
|
for (const s of await invoke<SaveView[]>("pending_saves")) overlay.append(saveCard(s));
|
||||||
} else if (state.has_account && (await invoke<SaveView[]>("pending_saves")).length) {
|
} else if (state.has_account && (await invoke<SaveView[]>("pending_saves")).length) {
|
||||||
@@ -980,6 +988,57 @@ function pairingCard(pr: PairingView, state: AppState): HTMLElement {
|
|||||||
h("div", "actions end", reject, accept));
|
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 {
|
function saveCard(s: SaveView): HTMLElement {
|
||||||
const err = errBox();
|
const err = errBox();
|
||||||
const yes = button(s.update ? "Update password" : "Save", "primary");
|
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("pairing-request", () => render());
|
||||||
void listen("save-request", () => render());
|
void listen("save-request", () => render());
|
||||||
|
void listen("passkey-request", () => render());
|
||||||
|
void listen("passkey-settled", () => render());
|
||||||
void listen("locked", () => {
|
void listen("locked", () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
void render();
|
void render();
|
||||||
|
|||||||
Reference in New Issue
Block a user