//! # The differential secret-typing test — the privacy ship-gate (spec §1.5, §5) //! //! This is the acceptance test for the whole terminal collector, and the single //! most important test in the project. It drives the **real production hook** //! (`shell-hooks/signald-hooks.zsh`) through a **real interactive zsh under a //! real pseudo-terminal** (using zsh's own `zsh/zpty` module, so there is no //! Python/libc dependency), *typing a known secret string* the way a person //! would type it at a prompt. //! //! It then runs the exact daemon write path — the terminal [`collect`]or, the //! [`Hub`] (last-value cache + fan-out), and the SQLite [`History`] store — and //! asserts the secret **never appears**, in any of {plain, reversed, hex, //! base64}, in: //! * the shell spool the hook writes, //! * the wire encoding of every emitted signal, //! * the SQLite history files and the `recent()` query result, //! * the in-memory hub snapshot. //! //! Why this proves no content leaks: real `zle` keystroke counting increments a //! *number* per key and discards the key; the hook flushes only that number. //! So the only thing the whole pipeline ever receives about the typing is a //! count — there is no channel through which the characters could travel. The //! test types the secret for real and checks every downstream artifact to prove //! that empirically, not just by inspection. use std::path::{Path, PathBuf}; use std::process::Command; use signal_schema::{wire, SignalName}; use signald::collectors::terminal; use signald::history::History; use signald::hub::Hub; /// A distinctive planted secret — long and non-dictionary so an incidental /// byte-collision in a binary artifact is not credible. const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET"; #[test] fn secret_typed_at_prompt_never_reaches_any_output() { let harness = TempDir::new("ambient-differential"); let spool = harness.path().join("terminal.spool"); let db = harness.path().join("signald.sqlite"); // 1. Type the secret through the real hook + real zle under a real pty. let spool_bytes = drive_real_hook(&harness, &spool) .expect("zsh/zpty harness must run (this is the privacy ship-gate)"); // The path must genuinely have processed the typing: at least one flush // must have counted as many keys as the secret is long (it was typed after // `echo `). Otherwise we'd be "proving" absence over an empty run. let flushes: Vec = String::from_utf8_lossy(&spool_bytes) .lines() .filter_map(terminal::parse_flush) .collect(); let max_keys = flushes.iter().map(|f| f.keys).max().unwrap_or(0); assert!( max_keys >= SECRET.chars().count() as u64, "the secret does not appear to have been typed through the collector \ (max keys counted = {max_keys}, secret length = {})", SECRET.chars().count() ); // 2. Run the exact daemon write path: collector -> hub(+history). let signals = terminal::Collector::new().collect(&spool); assert!(!signals.is_empty(), "terminal collector produced no signals"); assert!(signals.iter().any(|s| s.name == SignalName::KeysPerMin)); assert!(signals.iter().any(|s| s.name == SignalName::SessionSeconds)); let hub = Hub::with_history(History::open(&db).unwrap()); let mut wire_bytes = Vec::new(); for s in &signals { wire_bytes.extend_from_slice(&wire::encode(s)); hub.publish(s.clone()); } // 3. Read history back through the real query a renderer would use. let read_back = History::open(&db).unwrap().recent(1000).unwrap(); assert!(!read_back.is_empty(), "history query returned nothing"); // 4. Gather every artifact and scan them all for the secret, in any encoding. let mut artifacts: Vec<(&str, Vec)> = Vec::new(); artifacts.push(("shell spool", spool_bytes.clone())); artifacts.push(("wire encoding", wire_bytes)); artifacts.push(("collected signals (debug)", format!("{signals:?}").into_bytes())); artifacts.push(("hub snapshot (debug)", format!("{:?}", hub.snapshot()).into_bytes())); artifacts.push(("history recent() (debug)", format!("{read_back:?}").into_bytes())); // Every on-disk SQLite file (main db + -wal + -shm). for f in sqlite_files(&db) { let bytes = std::fs::read(&f).unwrap_or_default(); artifacts.push(("sqlite file", bytes)); } let needles = secret_encodings(SECRET); for (label, bytes) in &artifacts { for (enc, needle) in &needles { assert!( !contains(bytes, needle), "SECRET LEAK: found the secret ({enc}) in `{label}` — the terminal \ collector must emit aggregate counts only" ); } } } // --- the zsh/zpty typing harness --- /// Write a zpty driver, run it under `zsh`, and return the spool it produced. /// The driver spawns an interactive zsh under a pty, sources the real hook, and /// types the secret as `echo` arguments — real keystrokes through real `zle`. fn drive_real_hook(harness: &TempDir, spool: &Path) -> Option> { let hooks = repo_root().join("shell-hooks/signald-hooks.zsh"); assert!(hooks.exists(), "production hook missing at {}", hooks.display()); let driver = harness.path().join("driver.zsh"); std::fs::write(&driver, DRIVER).ok()?; // Ensure a clean spool. let _ = std::fs::write(spool, b""); let status = Command::new("zsh") .arg("-f") .arg(&driver) .env("SIGNALD_SPOOL", spool) .env("HOOKS", &hooks) .env("SECRET", SECRET) .status() .ok()?; if !status.success() { return None; } std::fs::read(spool).ok().filter(|b| !b.is_empty()) } /// The pty driver. `zsh/zpty` gives us a real pseudo-terminal from within zsh — /// no external dependency. We drain output between writes so the inner shell /// makes progress, and type the secret as ordinary command-line input. const DRIVER: &str = r#" zmodload zsh/zpty || exit 3 zpty SH zsh -f -i || exit 4 drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done } sleep 0.4; drain zpty -w SH "source $HOOKS" sleep 0.3; drain zpty -w SH "echo $SECRET" sleep 0.5; drain zpty -w SH "echo typed $SECRET twice $SECRET" sleep 0.5; drain zpty -w SH "exit" sleep 0.3 zpty -d SH 2>/dev/null "#; // --- encodings + scanning --- /// The secret in every representation the spec calls out (plain, reversed, hex, /// base64), as raw byte needles to search for. fn secret_encodings(secret: &str) -> Vec<(&'static str, Vec)> { let b = secret.as_bytes(); let reversed: Vec = b.iter().rev().copied().collect(); vec![ ("plain", b.to_vec()), ("reversed", reversed), ("hex", hex(b).into_bytes()), ("base64", base64(b).into_bytes()), ] } fn contains(hay: &[u8], needle: &[u8]) -> bool { if needle.is_empty() || hay.len() < needle.len() { return false; } hay.windows(needle.len()).any(|w| w == needle) } fn hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { s.push_str(&format!("{b:02x}")); } s } fn base64(bytes: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::new(); for chunk in bytes.chunks(3) { let b0 = chunk[0] as u32; let b1 = *chunk.get(1).unwrap_or(&0) as u32; let b2 = *chunk.get(2).unwrap_or(&0) as u32; let n = (b0 << 16) | (b1 << 8) | b2; out.push(T[(n >> 18 & 63) as usize] as char); out.push(T[(n >> 12 & 63) as usize] as char); out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' }); out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' }); } out } // --- helpers --- fn sqlite_files(db: &Path) -> Vec { let mut v = vec![db.to_path_buf()]; for suffix in ["-wal", "-shm"] { let mut p = db.as_os_str().to_os_string(); p.push(suffix); v.push(PathBuf::from(p)); } v.into_iter().filter(|p| p.exists()).collect() } fn repo_root() -> PathBuf { // CARGO_MANIFEST_DIR = /crates/signald PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .expect("canonicalize repo root") } struct TempDir { dir: PathBuf, } impl TempDir { fn new(prefix: &str) -> TempDir { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let dir = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); TempDir { dir } } fn path(&self) -> &Path { &self.dir } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.dir); } }