crates/signal-schema/tests/privacy_invariant.rs
309 lines · 11835 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#[test]
84fn forbidden_symbol_scan() {
85 // APIs for input taps / global key monitoring, and shell line-buffer refs.
86 const BANNED: &[&str] = &[
87 "CGEventTap",
88 "IOHIDManager",
89 "kAXTrusted",
90 "addGlobalMonitorForEvents",
91 "$BUFFER",
92 "$LBUFFER",
93 ];
94 const CODE_EXTS: &[&str] = &["rs", "zsh", "sh", "swift", "m", "c", "h"];
95
96 let this_file = PathBuf::from(file!());
97 let this_name = this_file.file_name().unwrap();
98
99 let mut offenders = Vec::new();
100 for path in walk(&repo_root()) {
101 // Skip build artifacts and this test (which names the banned tokens).
102 // `target` is Rust's; `.build` is SwiftPM's (macos-collector/.build).
103 if path
104 .components()
105 .any(|c| matches!(c.as_os_str().to_str(), Some("target") | Some(".build")))
106 {
107 continue;
108 }
109 if path.file_name() == Some(this_name) {
110 continue;
111 }
112 let ext_ok = path
113 .extension()
114 .and_then(|e| e.to_str())
115 .map(|e| CODE_EXTS.contains(&e))
116 .unwrap_or(false);
117 if !ext_ok {
118 continue;
119 }
120 let Ok(text) = std::fs::read_to_string(&path) else {
121 continue;
122 };
123 for banned in BANNED {
124 if text.contains(banned) {
125 offenders.push(format!("{}: {banned}", path.display()));
126 }
127 }
128 }
129
130 assert!(
131 offenders.is_empty(),
132 "forbidden keylogger symbol(s) found:\n{}",
133 offenders.join("\n")
134 );
135}
136
137/// The differential secret-typing acceptance test — the ship gate for the
138/// terminal collector.
139///
140/// This drives the **real production hook** (`shell-hooks/signald-hooks.zsh`)
141/// through a **real interactive zsh under a real pseudo-terminal** (zsh's own
142/// `zsh/zpty` module — no external dependency), *typing a planted secret* the
143/// way a person would at a prompt. Real `zle` keystroke counting increments a
144/// number per key and discards the key, so the only thing the shell emits is a
145/// count. This test proves that empirically: the secret must never appear —
146/// plain, reversed, hex, or base64 — in the spool the shell writes, nor in the
147/// `f64`-only wire encoding of the signals derived from those counts.
148///
149/// This crate is the privacy boundary and stays dependency-free, so it exercises
150/// the schema-reachable half (spool + wire). The **full pipeline** gate — the
151/// same real typing driven through the terminal collector, the SQLite history
152/// store, and the hub — lives in `crates/signald/tests/differential_secret_typing.rs`
153/// (that crate owns the collector and the store).
154#[test]
155fn differential_secret_typing() {
156 use std::process::Command;
157
158 // A distinctive, non-dictionary secret so an incidental byte-collision is
159 // not credible.
160 const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET";
161
162 let root = repo_root();
163 let hooks = root.join("shell-hooks/signald-hooks.zsh");
164 assert!(hooks.exists(), "production hook missing at {}", hooks.display());
165
166 let nanos = std::time::SystemTime::now()
167 .duration_since(std::time::UNIX_EPOCH)
168 .unwrap()
169 .as_nanos();
170 let tmp = std::env::temp_dir().join(format!("ambient-diff-schema-{}-{nanos}", std::process::id()));
171 std::fs::create_dir_all(&tmp).unwrap();
172 let spool = tmp.join("terminal.spool");
173 let driver = tmp.join("driver.zsh");
174 std::fs::write(&spool, b"").unwrap();
175
176 // pty driver: spawn interactive zsh under zpty, source the real hook, and
177 // type the secret as ordinary `echo` arguments — real keystrokes, real zle.
178 const DRIVER: &str = r#"
179zmodload zsh/zpty || exit 3
180zpty SH zsh -f -i || exit 4
181drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done }
182sleep 0.4; drain
183zpty -w SH "source $HOOKS"
184sleep 0.3; drain
185zpty -w SH "echo $SECRET"
186sleep 0.5; drain
187zpty -w SH "echo typed $SECRET twice $SECRET"
188sleep 0.5; drain
189zpty -w SH "exit"
190sleep 0.3
191zpty -d SH 2>/dev/null
192"#;
193 std::fs::write(&driver, DRIVER).unwrap();
194
195 let status = Command::new("zsh")
196 .arg("-f")
197 .arg(&driver)
198 .env("SIGNALD_SPOOL", &spool)
199 .env("HOOKS", &hooks)
200 .env("SECRET", SECRET)
201 .status()
202 .expect("run zsh/zpty harness (the privacy ship-gate must run)");
203 assert!(status.success(), "zsh/zpty harness failed");
204
205 let spool_bytes = std::fs::read(&spool).unwrap();
206 let _ = std::fs::remove_dir_all(&tmp);
207
208 // Parse count records (four integers per line) and confirm the typing was
209 // genuinely counted — otherwise we'd be "proving" absence over an empty run.
210 let mut max_keys = 0u64;
211 let mut wire_bytes: Vec<u8> = Vec::new();
212 for line in String::from_utf8_lossy(&spool_bytes).lines() {
213 let nums: Vec<u64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect();
214 if nums.len() != 4 {
215 continue; // not a well-formed count record
216 }
217 max_keys = max_keys.max(nums[1]);
218 // Build a signal from the count alone — the only thing available — and
219 // encode it to the real wire format.
220 let sig = signal_schema::Signal {
221 schema_version: signal_schema::SCHEMA_VERSION,
222 ts: nums[0],
223 source: signal_schema::Source::Terminal,
224 name: signal_schema::SignalName::KeysPerMin,
225 value: Value(nums[1] as f64),
226 tag: None,
227 };
228 wire_bytes.extend_from_slice(&signal_schema::wire::encode(&sig));
229 }
230 assert!(
231 max_keys >= SECRET.chars().count() as u64,
232 "secret does not appear to have been typed through the hook \
233 (max keys counted = {max_keys}, secret len = {})",
234 SECRET.chars().count()
235 );
236
237 // The secret must be absent from the spool and the wire, in any encoding.
238 let b = SECRET.as_bytes();
239 let reversed: Vec<u8> = b.iter().rev().copied().collect();
240 let needles: [(&str, Vec<u8>); 4] = [
241 ("plain", b.to_vec()),
242 ("reversed", reversed),
243 ("hex", to_hex(b).into_bytes()),
244 ("base64", to_base64(b).into_bytes()),
245 ];
246 for (label, artifact) in [("spool", &spool_bytes), ("wire", &wire_bytes)] {
247 for (enc, needle) in &needles {
248 assert!(
249 !byte_contains(artifact, needle),
250 "SECRET LEAK: found the secret ({enc}) in the {label} — only \
251 aggregate counts may leave the terminal collector"
252 );
253 }
254 }
255}
256
257fn byte_contains(hay: &[u8], needle: &[u8]) -> bool {
258 !needle.is_empty() && hay.len() >= needle.len() && hay.windows(needle.len()).any(|w| w == needle)
259}
260
261fn to_hex(bytes: &[u8]) -> String {
262 let mut s = String::with_capacity(bytes.len() * 2);
263 for byte in bytes {
264 s.push_str(&format!("{byte:02x}"));
265 }
266 s
267}
268
269fn to_base64(bytes: &[u8]) -> String {
270 const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
271 let mut out = String::new();
272 for chunk in bytes.chunks(3) {
273 let b0 = chunk[0] as u32;
274 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
275 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
276 let n = (b0 << 16) | (b1 << 8) | b2;
277 out.push(T[(n >> 18 & 63) as usize] as char);
278 out.push(T[(n >> 12 & 63) as usize] as char);
279 out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
280 out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
281 }
282 out
283}
284
285// --- helpers ---
286
287fn repo_root() -> PathBuf {
288 // CARGO_MANIFEST_DIR = <root>/crates/signal-schema
289 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
290 .join("../..")
291 .canonicalize()
292 .expect("canonicalize repo root")
293}
294
295fn walk(dir: &PathBuf) -> Vec<PathBuf> {
296 let mut out = Vec::new();
297 let Ok(entries) = std::fs::read_dir(dir) else {
298 return out;
299 };
300 for entry in entries.flatten() {
301 let path = entry.path();
302 if path.is_dir() {
303 out.extend(walk(&path));
304 } else {
305 out.push(path);
306 }
307 }
308 out
309}