//! 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) {} fn passkey_requested(&self, _: &kestrove_desktop::passkey::PasskeyView) {} } struct Host { child: Child, stdin: ChildStdin, stdout: BufReader, } 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![], sign_in_with: None, 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"); }