crates/signald/tests/differential_secret_typing.rs
240 lines · 9041 bytes
1//! # The differential secret-typing test — the privacy ship-gate (spec §1.5, §5)
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::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 the spec calls out (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}