Initial scaffold for kestrove-desktop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
//! Native messaging host for Chrome/Edge/Firefox (kestrove-spec/docs/05).
|
||||
//!
|
||||
//! The browser starts this process and talks over stdio: each message is a u32 length
|
||||
//! (native byte order) followed by UTF-8 JSON, at most 1 MiB to the browser.
|
||||
//! This process holds **no** secrets. It relays each request to the running desktop app over
|
||||
//! a local IPC channel (Windows named pipe `\\.\pipe\kestrove-nmh-<user>`, Unix socket
|
||||
//! `$XDG_RUNTIME_DIR/kestrove/nmh.sock` with 0600 perms). The app re-checks origins and asks for
|
||||
//! approval or unlock as needed.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const MAX_IN: usize = 4 * 1024 * 1024;
|
||||
|
||||
fn read_message(stdin: &mut impl Read) -> io::Result<Option<Value>> {
|
||||
let mut len = [0u8; 4];
|
||||
match stdin.read_exact(&mut len) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), // browser closed the port
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let len = u32::from_ne_bytes(len) as usize;
|
||||
if len > MAX_IN {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large"));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
stdin.read_exact(&mut buf)?;
|
||||
serde_json::from_slice(&buf).map(Some).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
fn write_message(stdout: &mut impl Write, v: &Value) -> io::Result<()> {
|
||||
let bytes = serde_json::to_vec(v)?;
|
||||
stdout.write_all(&(bytes.len() as u32).to_ne_bytes())?;
|
||||
stdout.write_all(&bytes)?;
|
||||
stdout.flush()
|
||||
}
|
||||
|
||||
/// TODO(M3): forward to the desktop app over local IPC, inside the paired AEAD channel.
|
||||
fn relay(req: &Value) -> Value {
|
||||
match req.get("type").and_then(Value::as_str) {
|
||||
Some("hello") => json!({ "type": "hello", "version": env!("CARGO_PKG_VERSION"), "paired": false }),
|
||||
Some(_) => json!({ "type": "error", "code": "unavailable" }),
|
||||
None => json!({ "type": "error", "code": "bad_request" }),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let (mut stdin, mut stdout) = (io::stdin().lock(), io::stdout().lock());
|
||||
while let Some(req) = read_message(&mut stdin)? {
|
||||
write_message(&mut stdout, &relay(&req))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Kestrove desktop (Windows/Linux; macOS uses kestrove-apple). Thin Tauri shell over kv-ffi.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::{sync::Arc, thread, time::Duration};
|
||||
|
||||
use kv_ffi::{generate_password as core_generate_password, CoreError, ItemDto, ItemKindDto, VaultCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
struct App {
|
||||
// TODO(M3): persistent store + sync (kv-store/kv-sync) instead of in-memory.
|
||||
core: Arc<VaultCore>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppState {
|
||||
has_account: bool,
|
||||
locked: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Summary {
|
||||
id: String,
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
urls: Vec<String>,
|
||||
favorite: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Detail {
|
||||
id: String,
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
urls: Vec<String>,
|
||||
totp: Option<String>,
|
||||
notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NewLogin {
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
urls: Vec<String>,
|
||||
}
|
||||
|
||||
type CmdResult<T> = Result<T, String>;
|
||||
|
||||
fn err(e: CoreError) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn state(app: State<'_, App>) -> AppState {
|
||||
AppState { has_account: app.core.has_account(), locked: app.core.is_locked() }
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lock(app: State<'_, App>) {
|
||||
app.core.lock();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
|
||||
Ok(app
|
||||
.core
|
||||
.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 })
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_item(app: State<'_, App>, id: String) -> CmdResult<Detail> {
|
||||
let d = app.core.get_item(id).map_err(err)?;
|
||||
Ok(Detail { id: d.id, title: d.title, username: d.username, password: d.password, urls: d.urls, totp: d.totp, notes: d.notes })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
|
||||
app.core
|
||||
.save_item(ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: item.title,
|
||||
username: item.username,
|
||||
password: item.password,
|
||||
urls: item.urls,
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
})
|
||||
.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())?;
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_secs(90));
|
||||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
||||
if cb.get_text().ok().as_deref() == Some(secret.as_str()) {
|
||||
let _ = cb.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn generate_password(length: u32) -> String {
|
||||
core_generate_password(length, true)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.manage(App { core: VaultCore::new_in_memory() })
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
state,
|
||||
create_account,
|
||||
unlock,
|
||||
lock,
|
||||
list_items,
|
||||
get_item,
|
||||
save_item,
|
||||
copy_secret,
|
||||
generate_password
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Kestrove");
|
||||
}
|
||||
Reference in New Issue
Block a user