Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

crates/signald/tests/differential_secret_typing.rs

v1.2.0
ambient-companions/crates/signald/tests/differential_secret_typing.rs history · blame · raw

240 lines · 9036 bytes

  1//! # The differential secret-typing test — the privacy ship-gate
  2//!
  3//! This is the acceptance test for the whole terminal collector, and the single
  4//! most important test in the project. It drives the **real production hook**
  5//! (`shell-hooks/signald-hooks.zsh`) through a **real interactive zsh under a
  6//! real pseudo-terminal** (using zsh's own `zsh/zpty` module, so there is no
  7//! Python/libc dependency), *typing a known secret string* the way a person
  8//! would type it at a prompt.
  9//!
 10//! It then runs the exact daemon write path — the terminal [`collect`]or, the
 11//! [`Hub`] (last-value cache + fan-out), and the SQLite [`History`] store — and
 12//! asserts the secret **never appears**, in any of {plain, reversed, hex,
 13//! base64}, in:
 14//!   * the shell spool the hook writes,
 15//!   * the wire encoding of every emitted signal,
 16//!   * the SQLite history files and the `recent()` query result,
 17//!   * the in-memory hub snapshot.
 18//!
 19//! Why this proves no content leaks: real `zle` keystroke counting increments a
 20//! *number* per key and discards the key; the hook flushes only that number.
 21//! So the only thing the whole pipeline ever receives about the typing is a
 22//! count — there is no channel through which the characters could travel. The
 23//! test types the secret for real and checks every downstream artifact to prove
 24//! that empirically, not just by inspection.
 25
 26use std::path::{Path, PathBuf};
 27use std::process::Command;
 28
 29use signal_schema::{wire, SignalName};
 30use signald::collectors::terminal;
 31use signald::history::History;
 32use signald::hub::Hub;
 33
 34/// A distinctive planted secret — long and non-dictionary so an incidental
 35/// byte-collision in a binary artifact is not credible.
 36const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET";
 37
 38#[test]
 39fn secret_typed_at_prompt_never_reaches_any_output() {
 40    let harness = TempDir::new("ambient-differential");
 41    let spool = harness.path().join("terminal.spool");
 42    let db = harness.path().join("signald.sqlite");
 43
 44    // 1. Type the secret through the real hook + real zle under a real pty.
 45    let spool_bytes = drive_real_hook(&harness, &spool)
 46        .expect("zsh/zpty harness must run (this is the privacy ship-gate)");
 47
 48    // The path must genuinely have processed the typing: at least one flush
 49    // must have counted as many keys as the secret is long (it was typed after
 50    // `echo `). Otherwise we'd be "proving" absence over an empty run.
 51    let flushes: Vec<terminal::Flush> = String::from_utf8_lossy(&spool_bytes)
 52        .lines()
 53        .filter_map(terminal::parse_flush)
 54        .collect();
 55    let max_keys = flushes.iter().map(|f| f.keys).max().unwrap_or(0);
 56    assert!(
 57        max_keys >= SECRET.chars().count() as u64,
 58        "the secret does not appear to have been typed through the collector \
 59         (max keys counted = {max_keys}, secret length = {})",
 60        SECRET.chars().count()
 61    );
 62
 63    // 2. Run the exact daemon write path: collector -> hub(+history).
 64    let signals = terminal::Collector::new().collect(&spool);
 65    assert!(!signals.is_empty(), "terminal collector produced no signals");
 66    assert!(signals.iter().any(|s| s.name == SignalName::KeysPerMin));
 67    assert!(signals.iter().any(|s| s.name == SignalName::SessionSeconds));
 68
 69    let hub = Hub::with_history(History::open(&db).unwrap());
 70    let mut wire_bytes = Vec::new();
 71    for s in &signals {
 72        wire_bytes.extend_from_slice(&wire::encode(s));
 73        hub.publish(s.clone());
 74    }
 75
 76    // 3. Read history back through the real query a renderer would use.
 77    let read_back = History::open(&db).unwrap().recent(1000).unwrap();
 78    assert!(!read_back.is_empty(), "history query returned nothing");
 79
 80    // 4. Gather every artifact and scan them all for the secret, in any encoding.
 81    let mut artifacts: Vec<(&str, Vec<u8>)> = Vec::new();
 82    artifacts.push(("shell spool", spool_bytes.clone()));
 83    artifacts.push(("wire encoding", wire_bytes));
 84    artifacts.push(("collected signals (debug)", format!("{signals:?}").into_bytes()));
 85    artifacts.push(("hub snapshot (debug)", format!("{:?}", hub.snapshot()).into_bytes()));
 86    artifacts.push(("history recent() (debug)", format!("{read_back:?}").into_bytes()));
 87    // Every on-disk SQLite file (main db + -wal + -shm).
 88    for f in sqlite_files(&db) {
 89        let bytes = std::fs::read(&f).unwrap_or_default();
 90        artifacts.push(("sqlite file", bytes));
 91    }
 92
 93    let needles = secret_encodings(SECRET);
 94    for (label, bytes) in &artifacts {
 95        for (enc, needle) in &needles {
 96            assert!(
 97                !contains(bytes, needle),
 98                "SECRET LEAK: found the secret ({enc}) in `{label}` — the terminal \
 99                 collector must emit aggregate counts only"
100            );
101        }
102    }
103}
104
105// --- the zsh/zpty typing harness ---
106
107/// Write a zpty driver, run it under `zsh`, and return the spool it produced.
108/// The driver spawns an interactive zsh under a pty, sources the real hook, and
109/// types the secret as `echo` arguments — real keystrokes through real `zle`.
110fn drive_real_hook(harness: &TempDir, spool: &Path) -> Option<Vec<u8>> {
111    let hooks = repo_root().join("shell-hooks/signald-hooks.zsh");
112    assert!(hooks.exists(), "production hook missing at {}", hooks.display());
113    let driver = harness.path().join("driver.zsh");
114    std::fs::write(&driver, DRIVER).ok()?;
115    // Ensure a clean spool.
116    let _ = std::fs::write(spool, b"");
117
118    let status = Command::new("zsh")
119        .arg("-f")
120        .arg(&driver)
121        .env("SIGNALD_SPOOL", spool)
122        .env("HOOKS", &hooks)
123        .env("SECRET", SECRET)
124        .status()
125        .ok()?;
126    if !status.success() {
127        return None;
128    }
129    std::fs::read(spool).ok().filter(|b| !b.is_empty())
130}
131
132/// The pty driver. `zsh/zpty` gives us a real pseudo-terminal from within zsh —
133/// no external dependency. We drain output between writes so the inner shell
134/// makes progress, and type the secret as ordinary command-line input.
135const DRIVER: &str = r#"
136zmodload zsh/zpty || exit 3
137zpty SH zsh -f -i || exit 4
138drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done }
139sleep 0.4; drain
140zpty -w SH "source $HOOKS"
141sleep 0.3; drain
142zpty -w SH "echo $SECRET"
143sleep 0.5; drain
144zpty -w SH "echo typed $SECRET twice $SECRET"
145sleep 0.5; drain
146zpty -w SH "exit"
147sleep 0.3
148zpty -d SH 2>/dev/null
149"#;
150
151// --- encodings + scanning ---
152
153/// The secret in every representation worth checking (plain, reversed, hex,
154/// base64), as raw byte needles to search for.
155fn secret_encodings(secret: &str) -> Vec<(&'static str, Vec<u8>)> {
156    let b = secret.as_bytes();
157    let reversed: Vec<u8> = b.iter().rev().copied().collect();
158    vec![
159        ("plain", b.to_vec()),
160        ("reversed", reversed),
161        ("hex", hex(b).into_bytes()),
162        ("base64", base64(b).into_bytes()),
163    ]
164}
165
166fn contains(hay: &[u8], needle: &[u8]) -> bool {
167    if needle.is_empty() || hay.len() < needle.len() {
168        return false;
169    }
170    hay.windows(needle.len()).any(|w| w == needle)
171}
172
173fn hex(bytes: &[u8]) -> String {
174    let mut s = String::with_capacity(bytes.len() * 2);
175    for b in bytes {
176        s.push_str(&format!("{b:02x}"));
177    }
178    s
179}
180
181fn base64(bytes: &[u8]) -> String {
182    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
183    let mut out = String::new();
184    for chunk in bytes.chunks(3) {
185        let b0 = chunk[0] as u32;
186        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
187        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
188        let n = (b0 << 16) | (b1 << 8) | b2;
189        out.push(T[(n >> 18 & 63) as usize] as char);
190        out.push(T[(n >> 12 & 63) as usize] as char);
191        out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
192        out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
193    }
194    out
195}
196
197// --- helpers ---
198
199fn sqlite_files(db: &Path) -> Vec<PathBuf> {
200    let mut v = vec![db.to_path_buf()];
201    for suffix in ["-wal", "-shm"] {
202        let mut p = db.as_os_str().to_os_string();
203        p.push(suffix);
204        v.push(PathBuf::from(p));
205    }
206    v.into_iter().filter(|p| p.exists()).collect()
207}
208
209fn repo_root() -> PathBuf {
210    // CARGO_MANIFEST_DIR = <root>/crates/signald
211    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
212        .join("../..")
213        .canonicalize()
214        .expect("canonicalize repo root")
215}
216
217struct TempDir {
218    dir: PathBuf,
219}
220
221impl TempDir {
222    fn new(prefix: &str) -> TempDir {
223        let nanos = std::time::SystemTime::now()
224            .duration_since(std::time::UNIX_EPOCH)
225            .unwrap()
226            .as_nanos();
227        let dir = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()));
228        std::fs::create_dir_all(&dir).unwrap();
229        TempDir { dir }
230    }
231    fn path(&self) -> &Path {
232        &self.dir
233    }
234}
235
236impl Drop for TempDir {
237    fn drop(&mut self) {
238        let _ = std::fs::remove_dir_all(&self.dir);
239    }
240}