M3: working desktop app + browser pairing channel

- Builds and runs (tauri build --debug); placeholder icons; persistent VaultCore::open in the
  OS app-data dir; Secret Key remembered on the device; tray keeps the app serving the extension.
- Auto-lock: idle (system idle on Windows/macOS, in-app activity on Linux), screen lock, sleep.
- Local IPC for kestrove-nmh: 0700 dir + 0600 Unix socket with peer-uid checks; Windows named
  pipe (first instance, local only, user DACL, client SID check) behind cfg.
- Pairing channel: X25519 → HKDF → 6-word fingerprint confirmed in both UIs, per-connection
  session keys, AES-256-GCM with strict counters. Test vectors shared with kestrove-browser.
- Broker: query/fill/totp/save with origin re-checks; pairing and save prompts in the UI.
- kestrove-nmh relays ciphertext only; native-host installers for Linux/macOS/Windows.
- Tests: unit tests, tests/nmh_stdio.rs (real kestrove-nmh over stdio), examples/test-host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-16 20:13:54 +09:00
co-authored by Claude Opus 5.5
parent 30cdcee3d2
commit 923c466dea
44 changed files with 9289 additions and 270 deletions
+357 -21
View File
@@ -1,21 +1,59 @@
// Kestrove desktop (Windows/Linux; macOS uses kestrove-apple). Thin Tauri shell over kv-ffi.
// Kestrove desktop (Windows/Linux; macOS uses kestrove-apple, but this builds and runs there for
// development). Thin Tauri shell over kv-ffi: the web UI only renders and never sees keys.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{sync::Arc, thread, time::Duration};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::{io, thread};
use kv_ffi::{generate_password as core_generate_password, CoreError, ItemDto, ItemKindDto, VaultCore};
use kestrove_desktop::autolock::{self, Activity, Reason};
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
use kestrove_desktop::{ipc, private_file};
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
use serde::{Deserialize, Serialize};
use tauri::State;
use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, State, WindowEvent};
use zeroize::Zeroizing;
const CLIPBOARD_CLEAR: Duration = Duration::from_secs(90);
struct App {
// TODO(M3): persistent store + sync (kv-store/kv-sync) instead of in-memory.
core: Arc<VaultCore>,
broker: Arc<Broker>,
activity: Arc<Activity>,
data_dir: PathBuf,
ipc_error: Mutex<Option<String>>,
idle_minutes: Arc<AtomicU64>,
// Kept alive: on X11 the clipboard owner must outlive the copy.
clipboard: Mutex<Option<arboard::Clipboard>>,
}
impl App {
fn secret_key_path(&self) -> PathBuf {
self.data_dir.join("secret-key")
}
fn settings_path(&self) -> PathBuf {
self.data_dir.join("settings.json")
}
}
#[derive(Serialize, Deserialize)]
struct Settings {
idle_minutes: u64,
}
#[derive(Serialize)]
struct AppState {
has_account: bool,
locked: bool,
/// The Secret Key is kept on this device, so unlock only needs the master password.
remembers_secret_key: bool,
/// Browser integration could not start (e.g. another instance is running).
ipc_error: Option<String>,
idle_minutes: u64,
}
#[derive(Serialize)]
@@ -25,6 +63,7 @@ struct Summary {
username: Option<String>,
urls: Vec<String>,
favorite: bool,
has_totp: bool,
}
#[derive(Serialize)]
@@ -44,6 +83,22 @@ struct NewLogin {
username: Option<String>,
password: Option<String>,
urls: Vec<String>,
totp: Option<String>,
}
#[derive(Serialize)]
struct Totp {
code: String,
seconds_remaining: u64,
}
#[derive(Serialize)]
struct PairingSummary {
id: String,
name: String,
caller: String,
words: Vec<String>,
created_at: u64,
}
type CmdResult<T> = Result<T, String>;
@@ -52,20 +107,80 @@ fn err(e: CoreError) -> String {
e.to_string()
}
/// Tauri events to the UI; the window is brought forward so the user sees the prompt.
struct TauriUi {
handle: AppHandle,
activity: Arc<Activity>,
}
impl TauriUi {
fn surface(&self) {
if let Some(w) = self.handle.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
}
impl Ui for TauriUi {
fn pairing_requested(&self, req: &PairingView) {
self.surface();
let _ = self.handle.emit("pairing-request", req);
}
fn save_requested(&self, req: &SaveView) {
self.surface();
let _ = self.handle.emit("save-request", req);
}
fn activity(&self) {
self.activity.touch();
}
}
fn remember_secret_key(app: &App, secret_key: &str) {
if let Err(e) = private_file::write(&app.secret_key_path(), secret_key.trim().as_bytes()) {
eprintln!("kestrove: could not store the Secret Key: {e}");
}
}
fn stored_secret_key(app: &App) -> Option<Zeroizing<String>> {
let b = private_file::read(&app.secret_key_path()).ok()??;
String::from_utf8(b).ok().map(Zeroizing::new)
}
#[tauri::command]
fn state(app: State<'_, App>) -> AppState {
AppState { has_account: app.core.has_account(), locked: app.core.is_locked() }
AppState {
has_account: app.core.has_account(),
locked: app.core.is_locked(),
remembers_secret_key: stored_secret_key(&app).is_some(),
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
}
}
// Argon2id takes ~0.5 s, so these run as async commands off the main thread.
#[tauri::command]
async fn create_account(app: State<'_, App>, master_password: String) -> CmdResult<String> {
app.core.create_account(master_password).map_err(err)
let master_password = Zeroizing::new(master_password);
let sk = app.core.create_account(master_password.to_string()).map_err(err)?;
remember_secret_key(&app, &sk);
app.activity.touch();
Ok(sk)
}
/// `secret_key` may be omitted once this device remembers it.
#[tauri::command]
async fn unlock(app: State<'_, App>, master_password: String, secret_key: String) -> CmdResult<()> {
app.core.unlock(master_password, secret_key).map_err(err)
async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option<String>) -> CmdResult<()> {
let master_password = Zeroizing::new(master_password);
let typed = secret_key.map(Zeroizing::new).filter(|s| !s.trim().is_empty());
let sk = typed.clone().or_else(|| stored_secret_key(&app)).ok_or("Enter your Secret Key.")?;
app.core.unlock(master_password.to_string(), sk.trim().to_string()).map_err(err)?;
if let Some(sk) = typed {
remember_secret_key(&app, &sk);
}
app.activity.touch();
Ok(())
}
#[tauri::command]
@@ -73,6 +188,20 @@ fn lock(app: State<'_, App>) {
app.core.lock();
}
/// UI input (throttled in JS): feeds the idle timer where the OS has no system idle time.
#[tauri::command]
fn touch(app: State<'_, App>) {
app.activity.touch();
}
#[tauri::command]
fn set_idle_minutes(app: State<'_, App>, minutes: u64) -> CmdResult<()> {
let minutes = minutes.clamp(1, 240);
app.idle_minutes.store(minutes, Ordering::Relaxed);
let bytes = serde_json::to_vec(&Settings { idle_minutes: minutes }).map_err(|e| e.to_string())?;
private_file::write(&app.settings_path(), &bytes).map_err(|e| e.to_string())
}
#[tauri::command]
fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
Ok(app
@@ -80,7 +209,14 @@ fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
.list_items()
.map_err(err)?
.into_iter()
.map(|s| Summary { id: s.id, title: s.title, username: s.username, urls: s.urls, favorite: s.favorite })
.map(|s| Summary {
id: s.id,
title: s.title,
username: s.username,
urls: s.urls,
favorite: s.favorite,
has_totp: s.has_totp,
})
.collect())
}
@@ -100,7 +236,7 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
username: item.username,
password: item.password,
urls: item.urls,
totp: None,
totp: item.totp.filter(|t| !t.trim().is_empty()),
notes: None,
favorite: false,
tags: vec![],
@@ -109,15 +245,50 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
.map_err(err)
}
/// Copies the password from Rust so it never needs to round-trip through JS, then clears it after 90 s.
/// TODO(M3): on Windows also set ExcludeClipboardContentFromMonitorProcessing (docs/04).
#[tauri::command]
fn copy_secret(app: State<'_, App>, id: String) -> CmdResult<()> {
let secret = app.core.get_item(id).map_err(err)?.password.unwrap_or_default();
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
cb.set_text(secret.clone()).map_err(|e| e.to_string())?;
fn delete_item(app: State<'_, App>, id: String) -> CmdResult<()> {
app.core.delete_item(id).map_err(err)
}
#[tauri::command]
fn totp(app: State<'_, App>, id: String) -> CmdResult<Option<Totp>> {
let Some(secret) = app.core.get_item(id).map_err(err)?.totp else { return Ok(None) };
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let c = totp_code(secret, now).map_err(err)?;
Ok(Some(Totp { code: c.code, seconds_remaining: c.seconds_remaining }))
}
/// Copies from Rust so the secret never round-trips through JS, then clears it after 90 s.
#[tauri::command]
fn copy_secret(app: State<'_, App>, id: String, field: Option<String>) -> CmdResult<()> {
let item = app.core.get_item(id).map_err(err)?;
let secret = Zeroizing::new(match field.as_deref() {
Some("username") => item.username.unwrap_or_default(),
Some("totp") => {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
item.totp.map(|t| totp_code(t, now)).transpose().map_err(err)?.map(|c| c.code).unwrap_or_default()
}
_ => item.password.unwrap_or_default(),
});
let mut guard = app.clipboard.lock().unwrap_or_else(|e| e.into_inner());
if guard.is_none() {
*guard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
let cb = guard.as_mut().expect("clipboard initialized above");
#[cfg(windows)]
{
// docs/04: keep secrets out of clipboard history, cloud sync and monitors.
use arboard::SetExtWindows;
cb.set().exclude_from_monitoring().exclude_from_cloud().exclude_from_history().text(secret.to_string())
}
#[cfg(not(windows))]
{
cb.set_text(secret.to_string())
}
.map_err(|e| e.to_string())?;
drop(guard);
thread::spawn(move || {
thread::sleep(Duration::from_secs(90));
thread::sleep(CLIPBOARD_CLEAR);
if let Ok(mut cb) = arboard::Clipboard::new() {
if cb.get_text().ok().as_deref() == Some(secret.as_str()) {
let _ = cb.clear();
@@ -129,22 +300,187 @@ fn copy_secret(app: State<'_, App>, id: String) -> CmdResult<()> {
#[tauri::command]
fn generate_password(length: u32) -> String {
core_generate_password(length, true)
core_generate_password(length.clamp(8, 128), true)
}
// ---------------------------------------------------------------- browser pairing + saves
#[tauri::command]
fn pending_pairing(app: State<'_, App>) -> Option<PairingView> {
app.broker.pending_pairing()
}
#[tauri::command]
fn resolve_pairing(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
app.broker.resolve_pairing(&id, accept)
}
#[tauri::command]
fn list_pairings(app: State<'_, App>) -> Vec<PairingSummary> {
app.broker
.pairings()
.into_iter()
.map(|p| PairingSummary { id: p.id, name: p.name, caller: p.caller, words: p.words, created_at: p.created_at })
.collect()
}
#[tauri::command]
fn revoke_pairing(app: State<'_, App>, id: String) -> CmdResult<()> {
app.broker.revoke_pairing(&id)
}
#[tauri::command]
fn pending_saves(app: State<'_, App>) -> Vec<SaveView> {
app.broker.pending_saves()
}
#[tauri::command]
fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
app.broker.resolve_save(&id, accept).map(|_| ())
}
// ---------------------------------------------------------------- startup
fn start_ipc(broker: Arc<Broker>) -> io::Result<()> {
let listener = ipc::Listener::bind()?;
thread::Builder::new().name("ipc-accept".into()).spawn(move || loop {
match listener.accept() {
Ok(conn) => {
let b = broker.clone();
let _ = thread::Builder::new().name("ipc-conn".into()).spawn(move || {
let _ = b.serve(conn);
});
}
Err(e) => {
eprintln!("kestrove: IPC accept failed: {e}");
thread::sleep(Duration::from_secs(1));
}
}
})?;
Ok(())
}
fn lock_and_notify(handle: &AppHandle, core: &VaultCore, reason: &str) {
core.lock();
let _ = handle.emit("locked", reason);
}
fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&data_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?;
}
let core = VaultCore::open(data_dir.join("vault.sqlite").to_string_lossy().into_owned())?;
let activity = Activity::new();
let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() });
let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?);
let idle_minutes = private_file::read(&data_dir.join("settings.json"))
.ok()
.flatten()
.and_then(|b| serde_json::from_slice::<Settings>(&b).ok())
.map_or(autolock::DEFAULT_IDLE.as_secs() / 60, |s| s.idle_minutes);
let idle_minutes = Arc::new(AtomicU64::new(idle_minutes));
let ipc_error = start_ipc(broker.clone()).err().map(|e| {
eprintln!("kestrove: browser integration disabled: {e}");
if e.kind() == io::ErrorKind::AddrInUse {
"Another Kestrove instance is serving the browser extension.".to_string()
} else {
format!("Browser integration could not start: {e}")
}
});
{
let (handle, core, minutes) = (app.handle().clone(), core.clone(), idle_minutes.clone());
let unlocked = core.clone();
autolock::spawn(
activity.clone(),
move || Duration::from_secs(minutes.load(Ordering::Relaxed) * 60),
move || !unlocked.is_locked(),
move |reason| {
let why = match reason {
Reason::Idle => "idle",
Reason::ScreenLocked => "screen-locked",
Reason::Sleep => "sleep",
};
lock_and_notify(&handle, &core, why);
},
);
}
// Tray: the app keeps running (for the browser extension) when its window is closed.
let open = MenuItem::with_id(app, "open", "Open Kestrove", true, None::<&str>)?;
let lock_item = MenuItem::with_id(app, "lock", "Lock", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &lock_item, &quit])?;
let mut tray = TrayIconBuilder::with_id("main").menu(&menu).tooltip("Kestrove").on_menu_event({
let core = core.clone();
move |handle, event| match event.id().as_ref() {
"open" => {
if let Some(w) = handle.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
"lock" => lock_and_notify(handle, &core, "manual"),
"quit" => {
core.lock();
handle.exit(0);
}
_ => {}
}
});
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(app)?;
app.manage(App {
core,
broker,
activity,
data_dir,
ipc_error: Mutex::new(ipc_error),
idle_minutes,
clipboard: Mutex::new(None),
});
Ok(())
}
fn main() {
tauri::Builder::default()
.manage(App { core: VaultCore::new_in_memory() })
.setup(setup)
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
// Keep serving the extension from the tray; Quit is in the tray menu.
api.prevent_close();
let _ = window.hide();
}
})
.invoke_handler(tauri::generate_handler![
state,
create_account,
unlock,
lock,
touch,
set_idle_minutes,
list_items,
get_item,
save_item,
delete_item,
totp,
copy_secret,
generate_password
generate_password,
pending_pairing,
resolve_pairing,
list_pairings,
revoke_pairing,
pending_saves,
resolve_save
])
.run(tauri::generate_context!())
.expect("error while running Kestrove");