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/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}