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:
@@ -0,0 +1,148 @@
|
||||
//! Drives the real `kestrove-nmh` binary over stdio, the way a browser does, through the
|
||||
//! paired AEAD channel to a broker serving the app's IPC endpoint (isolated via KESTROVE_IPC_DIR).
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::{BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
||||
use kestrove_desktop::channel::{make_confirm, random, Ephemeral, Session};
|
||||
use kestrove_desktop::framing::{read_json, write_json};
|
||||
use kestrove_desktop::ipc;
|
||||
use kv_ffi::{ItemDto, ItemKindDto, VaultCore};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
struct NoUi;
|
||||
impl Ui for NoUi {
|
||||
fn pairing_requested(&self, _: &PairingView) {}
|
||||
fn save_requested(&self, _: &SaveView) {}
|
||||
}
|
||||
|
||||
struct Host {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn spawn(ipc_dir: &std::path::Path) -> Self {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_kestrove-nmh"))
|
||||
.arg("chrome-extension://abcdefghijklmnopabcdefghijklmnop/")
|
||||
.env(ipc::ENV_IPC_DIR, ipc_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
Self { child, stdin, stdout }
|
||||
}
|
||||
|
||||
fn rpc(&mut self, v: Value) -> Value {
|
||||
write_json(&mut self.stdin, &v).unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
read_json(&mut self.stdout).unwrap().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Host {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(h: &mut Host, s: &mut Session, rid: u64, body: Value) -> Value {
|
||||
let (n, c) = s.seal(&serde_json::to_vec(&json!({ "rid": rid, "body": body })).unwrap());
|
||||
let r = h.rpc(json!({ "t": "msg", "n": n, "c": B64.encode(c) }));
|
||||
assert_eq!(r["t"], "msg", "{r}");
|
||||
let pt = s.open(r["n"].as_u64().unwrap(), &B64.decode(r["c"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let v: Value = serde_json::from_slice(&pt).unwrap();
|
||||
assert_eq!(v["rid"], rid);
|
||||
v["body"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_and_fill_through_the_native_host() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ipc_dir = dir.path().join("ipc");
|
||||
|
||||
// No app yet: the host answers "unavailable" instead of hanging.
|
||||
let mut early = Host::spawn(&ipc_dir);
|
||||
assert_eq!(early.rpc(json!({ "t": "ping" })), json!({ "t": "error", "code": "unavailable" }));
|
||||
|
||||
let core = VaultCore::new_in_memory();
|
||||
core.create_account("correct horse battery".into()).unwrap();
|
||||
let id = core
|
||||
.save_item(ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: "Local".into(),
|
||||
username: Some("alice".into()),
|
||||
password: Some("pw-123".into()),
|
||||
urls: vec!["http://127.0.0.1:8765".into()],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let broker = Broker::new(core, Arc::new(NoUi), PairingStore::in_memory());
|
||||
// SAFETY: this test binary has a single test, so nothing else reads the environment concurrently.
|
||||
unsafe { std::env::set_var(ipc::ENV_IPC_DIR, &ipc_dir) };
|
||||
let listener = ipc::Listener::bind().unwrap();
|
||||
{
|
||||
let b = broker.clone();
|
||||
thread::spawn(move || loop {
|
||||
let conn = listener.accept().unwrap();
|
||||
let b = b.clone();
|
||||
thread::spawn(move || b.serve(conn));
|
||||
});
|
||||
}
|
||||
|
||||
// The same host process reconnects once the app is up.
|
||||
assert_eq!(early.rpc(json!({ "t": "ping" }))["t"], "pong");
|
||||
drop(early);
|
||||
|
||||
let mut h = Host::spawn(&ipc_dir);
|
||||
let ext = Ephemeral::generate();
|
||||
let r = h.rpc(json!({ "t": "pair_init", "pub": B64.encode(ext.public), "name": "Test browser" }));
|
||||
assert_eq!(r["t"], "pair_started");
|
||||
let pid = r["pairing_id"].as_str().unwrap().to_string();
|
||||
let p = ext.pair_as_ext(&B64.decode(r["pub"].as_str().unwrap()).unwrap().try_into().unwrap()).unwrap();
|
||||
let pending = broker.pending_pairing().unwrap();
|
||||
assert_eq!(pending.words, p.words.to_vec(), "both sides show the same words");
|
||||
assert_eq!(pending.caller, "chrome-extension://abcdefghijklmnopabcdefghijklmnop/");
|
||||
let proof = B64.encode(make_confirm(&p.key, &pid));
|
||||
assert_eq!(h.rpc(json!({ "t": "pair_confirm", "pairing_id": pid, "proof": proof }))["t"], "pair_waiting");
|
||||
broker.resolve_pairing(&pid, true).unwrap(); // the user clicks "pair" in the app
|
||||
assert_eq!(h.rpc(json!({ "t": "pair_confirm", "pairing_id": pid, "proof": proof }))["t"], "pair_done");
|
||||
|
||||
let ne = random::<16>();
|
||||
let r = h.rpc(json!({ "t": "open", "pairing_id": pid, "nonce": B64.encode(ne) }));
|
||||
let na: [u8; 16] = B64.decode(r["nonce"].as_str().unwrap()).unwrap().try_into().unwrap();
|
||||
let mut s = Session::ext(&p.key, &ne, &na);
|
||||
|
||||
let ids = call(&mut h, &mut s, 1, json!({ "type": "query", "origin": "http://127.0.0.1:8765" }));
|
||||
assert_eq!(ids["items"][0]["id"], id);
|
||||
let cred = call(&mut h, &mut s, 2, json!({ "type": "fill", "origin": "http://127.0.0.1:8765", "itemId": id }));
|
||||
assert_eq!(cred, json!({ "type": "credential", "username": "alice", "password": "pw-123" }));
|
||||
let other = call(&mut h, &mut s, 3, json!({ "type": "fill", "origin": "http://127.0.0.2:8765", "itemId": id }));
|
||||
assert_eq!(other["code"], "origin_mismatch");
|
||||
|
||||
// A replayed ciphertext is refused and the session is dropped.
|
||||
let (n, c) = s.seal(br#"{"rid":4,"body":{"type":"hello","version":"1"}}"#);
|
||||
let frame = json!({ "t": "msg", "n": n, "c": B64.encode(c) });
|
||||
assert_eq!(h.rpc(frame.clone())["t"], "msg");
|
||||
assert_eq!(h.rpc(frame)["code"], "bad_session");
|
||||
|
||||
// A second host process (new browser connection) needs a fresh session; the old one is useless there.
|
||||
let mut h2 = Host::spawn(&ipc_dir);
|
||||
let (n, c) = Session::ext(&p.key, &ne, &na).seal(b"{}");
|
||||
assert_eq!(h2.rpc(json!({ "t": "msg", "n": n, "c": B64.encode(c) }))["code"], "bad_session");
|
||||
}
|
||||
Reference in New Issue
Block a user