Files
samiandClaude Opus 5.5 a8f56ee00f
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Sign in with Google, Apple…: remember prompt, item row, new-login picker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:29:50 +07:00

151 lines
6.2 KiB
Rust

//! 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<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![],
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");
}