crates/signal-schema/tests/privacy_invariant.rs
391 lines · 15546 bytes
1//! # The privacy invariant test
2//!
3//! This is the single most important test in the project. It exists from day
4//! one, before the sensitive terminal collector does, so the guardrail is in
5//! place before the code it guards.
6//!
7//! The invariant:
8//!
9//! > No process persists, transmits, or exposes any representation from which
10//! > the content or identity of an individual keystroke, command argument, or
11//! > typed character can be recovered. Only order-free aggregates leave the
12//! > terminal collector.
13//!
14//! At M0 we encode the *structural* half as real, passing tests:
15//! 1. `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider.
16//! 2. `wire_format_has_no_content_field` — the `Signal` type declares no
17//! content-carrying field (only `tag: Option<Tag>` is a string, and it is
18//! the audited identifier exception).
19//! 3. `forbidden_symbol_scan` — the tree contains none of the banned
20//! keylogger APIs / shell patterns (the static gate).
21//! 4. `differential_secret_typing` — the runtime gate: drive the real hooks
22//! with a planted secret and assert it never reaches the spool or the
23//! wire in any encoding. Active. The full-pipeline version, through the
24//! collector, the history store and the hub, is
25//! `signald/tests/differential_secret_typing.rs`.
26
27use std::mem::size_of;
28use std::path::PathBuf;
29
30use signal_schema::Value;
31
32/// The payload channel is exactly an `f64` — no room for a character, a string,
33/// or a byte buffer. This is the load-bearing structural guarantee.
34#[test]
35fn value_channel_is_exactly_f64() {
36 assert_eq!(
37 size_of::<Value>(),
38 size_of::<f64>(),
39 "Value must be a transparent f64 payload; anything wider is a content channel"
40 );
41 // Construction only accepts an f64. If someone adds a String-accepting
42 // constructor, this file is where the review happens.
43 let _v = Value(2.5_f64);
44}
45
46/// The `Signal` type must declare no content-carrying field. We assert this
47/// structurally by scanning the schema source: within the `struct Signal`
48/// declaration, the only `String`-typed payload permitted is the audited
49/// `tag`. Any `content` / `text` / `bytes` / `payload` field fails the build.
50#[test]
51fn wire_format_has_no_content_field() {
52 let lib = repo_root().join("crates/signal-schema/src/lib.rs");
53 let src = std::fs::read_to_string(&lib).expect("read signal-schema/src/lib.rs");
54
55 let start = src.find("pub struct Signal {").expect("Signal struct present");
56 let body = &src[start..];
57 let end = body.find('}').expect("Signal struct closes");
58 // Field declarations only — strip doc/comment lines so prose like
59 // "non-content identifier" doesn't false-positive.
60 let body: String = body[..end]
61 .lines()
62 .filter(|l| !l.trim_start().starts_with("//"))
63 .collect::<Vec<_>>()
64 .join("\n");
65
66 for banned in ["content", "text", "bytes", "payload", "keystroke", "command"] {
67 assert!(
68 !body.contains(banned),
69 "Signal declares a forbidden content field containing `{banned}`; \
70 the wire format must have nowhere to put typed content"
71 );
72 }
73 // The one audited string exception is the identifier tag.
74 assert!(
75 body.contains("tag: Option<Tag>"),
76 "the only string in Signal must be the audited `tag` identifier"
77 );
78}
79
80/// The static gate: the tree must contain none of the APIs a
81/// real keylogger would use, nor any shell hook that touches the line buffer.
82/// Any hit fails the build. The banned list itself lives here and is reviewed.
83// APIs for input taps / global key monitoring, and shell line-buffer refs.
84//
85// Prefixes where a prefix is safe: `CGEvent` covers `CGEventTap` and the
86// rest of the family, `IOHID` covers `IOHIDManager`, `IOHIDQueue`,
87// `IOHIDDevice` and `IOHIDElement`.
88//
89// Bare `NSEvent` is deliberately absent. The keylogger-shaped API is the
90// monitor, not the class, and a menu-bar face will need `NSEvent` to draw
91// a UI. Banning the class would cost future work and buy no safety.
92//
93// The zle parameters are listed in both `$X` and `${X}` form: `${BUFFER}`
94// is valid zsh and does not contain the substring `$BUFFER`, so the sigil
95// form alone was bypassable by two characters.
96const BANNED: &[&str] = &[
97 "CGEvent",
98 "IOHID",
99 "kAXTrusted",
100 "AXObserver",
101 "AXUIElement",
102 "addGlobalMonitorForEvents",
103 "addLocalMonitorForEvents",
104 "$BUFFER",
105 "${BUFFER}",
106 "$LBUFFER",
107 "${LBUFFER}",
108 "$RBUFFER",
109 "${RBUFFER}",
110];
111
112/// The banned tokens appearing in `text`.
113fn violations(text: &str) -> Vec<&'static str> {
114 BANNED.iter().copied().filter(|b| text.contains(b)).collect()
115}
116
117/// The scan passing proves the tree is clean; it proves nothing about whether
118/// the gate would catch a violation. These pin what it catches, and — just as
119/// importantly — what it deliberately does not.
120#[test]
121fn the_gate_catches_real_violations_and_leaves_legitimate_code_alone() {
122 // Each of these is how the corresponding API actually gets written.
123 for (sample, why) in [
124 ("local saved=${BUFFER}", "braced zle buffer — the form that used to slip through"),
125 ("print -r -- $RBUFFER", "the right half of the line buffer"),
126 ("local x=${LBUFFER}", "braced left buffer"),
127 ("let tap = CGEventTapCreate(.cgSessionEventTap, ...)", "event tap"),
128 ("let q = IOHIDQueueCreate(kCFAllocatorDefault, dev, 8, 0)", "HID queue"),
129 ("IOHIDManagerRegisterInputValueCallback(mgr, cb, nil)", "HID manager"),
130 ("NSEvent.addLocalMonitorForEvents(matching: .keyDown) { $0 }", "local key monitor"),
131 ("NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { _ in }", "global key monitor"),
132 ("AXObserverCreate(pid, callback, &observer)", "accessibility observation"),
133 ("AXUIElementCopyAttributeValue(el, kAXValueAttribute, &v)", "accessibility read"),
134 ] {
135 assert!(
136 !violations(sample).is_empty(),
137 "the gate would not catch {why}: {sample:?}"
138 );
139 }
140
141 // False positives cost future work. A menu-bar face needs NSEvent to draw
142 // a UI, our own hook rebinds self-insert, and prose naming the parameters
143 // is how the privacy contract is documented.
144 for (sample, why) in [
145 ("let p = NSEvent.mouseLocation", "NSEvent for UI, not monitoring"),
146 ("zle -N self-insert _signald_self_insert", "our own keystroke counter"),
147 ("# never reference the BUFFER/LBUFFER/RBUFFER zle parameters", "prose in the contract"),
148 ("ProcessInfo.processInfo.thermalState", "an ordinary aggregate read"),
149 ] {
150 assert!(
151 violations(sample).is_empty(),
152 "the gate false-positives on {why}: {sample:?} -> {:?}",
153 violations(sample)
154 );
155 }
156}
157
158#[test]
159fn forbidden_symbol_scan() {
160 const CODE_EXTS: &[&str] = &["rs", "zsh", "sh", "swift", "m", "c", "h"];
161
162 let this_file = PathBuf::from(file!());
163 let this_name = this_file.file_name().unwrap();
164
165 let mut offenders = Vec::new();
166 let mut scanned: Vec<String> = Vec::new();
167 for path in walk(&repo_root()) {
168 // Skip build artifacts and this test (which names the banned tokens).
169 // `target` is Rust's; `.build` is SwiftPM's (macos-collector/.build).
170 if path
171 .components()
172 .any(|c| matches!(c.as_os_str().to_str(), Some("target") | Some(".build")))
173 {
174 continue;
175 }
176 if path.file_name() == Some(this_name) {
177 continue;
178 }
179 let ext_ok = path
180 .extension()
181 .and_then(|e| e.to_str())
182 .map(|e| CODE_EXTS.contains(&e))
183 .unwrap_or(false);
184 if !ext_ok {
185 continue;
186 }
187 let Ok(text) = std::fs::read_to_string(&path) else {
188 continue;
189 };
190 scanned.push(path.display().to_string());
191 for banned in violations(&text) {
192 offenders.push(format!("{}: {banned}", path.display()));
193 }
194 }
195
196 assert!(
197 offenders.is_empty(),
198 "forbidden keylogger symbol(s) found:\n{}",
199 offenders.join("\n")
200 );
201
202 // A gate that scans nothing reports success while checking nothing, which
203 // is worse than having no gate. Assert it actually read the files that
204 // matter: the shell hook is where a line-buffer reference would appear,
205 // and the Swift collector is where an input-tap API would.
206 assert!(
207 scanned.len() >= 10,
208 "scan read only {} files; the walk is broken",
209 scanned.len()
210 );
211 for required in ["signald-hooks.zsh", "Hardware.swift"] {
212 assert!(
213 scanned.iter().any(|p| p.ends_with(required)),
214 "scan never reached {required}; it is not covering the tree.\nscanned: {scanned:#?}"
215 );
216 }
217}
218
219/// The differential secret-typing acceptance test — the ship gate for the
220/// terminal collector.
221///
222/// This drives the **real production hook** (`shell-hooks/signald-hooks.zsh`)
223/// through a **real interactive zsh under a real pseudo-terminal** (zsh's own
224/// `zsh/zpty` module — no external dependency), *typing a planted secret* the
225/// way a person would at a prompt. Real `zle` keystroke counting increments a
226/// number per key and discards the key, so the only thing the shell emits is a
227/// count. This test proves that empirically: the secret must never appear —
228/// plain, reversed, hex, or base64 — in the spool the shell writes, nor in the
229/// `f64`-only wire encoding of the signals derived from those counts.
230///
231/// This crate is the privacy boundary and stays dependency-free, so it exercises
232/// the schema-reachable half (spool + wire). The **full pipeline** gate — the
233/// same real typing driven through the terminal collector, the SQLite history
234/// store, and the hub — lives in `crates/signald/tests/differential_secret_typing.rs`
235/// (that crate owns the collector and the store).
236#[test]
237fn differential_secret_typing() {
238 use std::process::Command;
239
240 // A distinctive, non-dictionary secret so an incidental byte-collision is
241 // not credible.
242 const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET";
243
244 let root = repo_root();
245 let hooks = root.join("shell-hooks/signald-hooks.zsh");
246 assert!(hooks.exists(), "production hook missing at {}", hooks.display());
247
248 let nanos = std::time::SystemTime::now()
249 .duration_since(std::time::UNIX_EPOCH)
250 .unwrap()
251 .as_nanos();
252 let tmp = std::env::temp_dir().join(format!("ambient-diff-schema-{}-{nanos}", std::process::id()));
253 std::fs::create_dir_all(&tmp).unwrap();
254 let spool = tmp.join("terminal.spool");
255 let driver = tmp.join("driver.zsh");
256 std::fs::write(&spool, b"").unwrap();
257
258 // pty driver: spawn interactive zsh under zpty, source the real hook, and
259 // type the secret as ordinary `echo` arguments — real keystrokes, real zle.
260 const DRIVER: &str = r#"
261zmodload zsh/zpty || exit 3
262zpty SH zsh -f -i || exit 4
263drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done }
264sleep 0.4; drain
265zpty -w SH "source $HOOKS"
266sleep 0.3; drain
267zpty -w SH "echo $SECRET"
268sleep 0.5; drain
269zpty -w SH "echo typed $SECRET twice $SECRET"
270sleep 0.5; drain
271zpty -w SH "exit"
272sleep 0.3
273zpty -d SH 2>/dev/null
274"#;
275 std::fs::write(&driver, DRIVER).unwrap();
276
277 let status = Command::new("zsh")
278 .arg("-f")
279 .arg(&driver)
280 .env("SIGNALD_SPOOL", &spool)
281 .env("HOOKS", &hooks)
282 .env("SECRET", SECRET)
283 .status()
284 .expect("run zsh/zpty harness (the privacy ship-gate must run)");
285 assert!(status.success(), "zsh/zpty harness failed");
286
287 let spool_bytes = std::fs::read(&spool).unwrap();
288 let _ = std::fs::remove_dir_all(&tmp);
289
290 // Parse count records (four integers per line) and confirm the typing was
291 // genuinely counted — otherwise we'd be "proving" absence over an empty run.
292 let mut max_keys = 0u64;
293 let mut wire_bytes: Vec<u8> = Vec::new();
294 for line in String::from_utf8_lossy(&spool_bytes).lines() {
295 let nums: Vec<u64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect();
296 if nums.len() != 4 {
297 continue; // not a well-formed count record
298 }
299 max_keys = max_keys.max(nums[1]);
300 // Build a signal from the count alone — the only thing available — and
301 // encode it to the real wire format.
302 let sig = signal_schema::Signal {
303 schema_version: signal_schema::SCHEMA_VERSION,
304 ts: nums[0],
305 source: signal_schema::Source::Terminal,
306 name: signal_schema::SignalName::KeysPerMin,
307 value: Value(nums[1] as f64),
308 tag: None,
309 };
310 wire_bytes.extend_from_slice(&signal_schema::wire::encode(&sig));
311 }
312 assert!(
313 max_keys >= SECRET.chars().count() as u64,
314 "secret does not appear to have been typed through the hook \
315 (max keys counted = {max_keys}, secret len = {})",
316 SECRET.chars().count()
317 );
318
319 // The secret must be absent from the spool and the wire, in any encoding.
320 let b = SECRET.as_bytes();
321 let reversed: Vec<u8> = b.iter().rev().copied().collect();
322 let needles: [(&str, Vec<u8>); 4] = [
323 ("plain", b.to_vec()),
324 ("reversed", reversed),
325 ("hex", to_hex(b).into_bytes()),
326 ("base64", to_base64(b).into_bytes()),
327 ];
328 for (label, artifact) in [("spool", &spool_bytes), ("wire", &wire_bytes)] {
329 for (enc, needle) in &needles {
330 assert!(
331 !byte_contains(artifact, needle),
332 "SECRET LEAK: found the secret ({enc}) in the {label} — only \
333 aggregate counts may leave the terminal collector"
334 );
335 }
336 }
337}
338
339fn byte_contains(hay: &[u8], needle: &[u8]) -> bool {
340 !needle.is_empty() && hay.len() >= needle.len() && hay.windows(needle.len()).any(|w| w == needle)
341}
342
343fn to_hex(bytes: &[u8]) -> String {
344 let mut s = String::with_capacity(bytes.len() * 2);
345 for byte in bytes {
346 s.push_str(&format!("{byte:02x}"));
347 }
348 s
349}
350
351fn to_base64(bytes: &[u8]) -> String {
352 const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
353 let mut out = String::new();
354 for chunk in bytes.chunks(3) {
355 let b0 = chunk[0] as u32;
356 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
357 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
358 let n = (b0 << 16) | (b1 << 8) | b2;
359 out.push(T[(n >> 18 & 63) as usize] as char);
360 out.push(T[(n >> 12 & 63) as usize] as char);
361 out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
362 out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
363 }
364 out
365}
366
367// --- helpers ---
368
369fn repo_root() -> PathBuf {
370 // CARGO_MANIFEST_DIR = <root>/crates/signal-schema
371 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
372 .join("../..")
373 .canonicalize()
374 .expect("canonicalize repo root")
375}
376
377fn walk(dir: &PathBuf) -> Vec<PathBuf> {
378 let mut out = Vec::new();
379 let Ok(entries) = std::fs::read_dir(dir) else {
380 return out;
381 };
382 for entry in entries.flatten() {
383 let path = entry.path();
384 if path.is_dir() {
385 out.extend(walk(&path));
386 } else {
387 out.push(path);
388 }
389 }
390 out
391}