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

309 lines · 11824 bytes

  1//! # The privacy invariant test (spec §1.5)
  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 (spec §1.5):
  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 CI gate).
 21//!
 22//! The runtime *differential secret-typing* test (drive the hooks with a
 23//! planted secret, assert it never reaches the socket or SQLite in any
 24//! encoding) is stubbed `#[ignore]` below and lands with the terminal
 25//! collector in Phase 2.
 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 CI gate (spec §1.5): 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 (spec §1.5, §5 Phase 2) —
138/// the ship gate for the terminal collector, now **active**.
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}