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:
+357
-21
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user