//! # The privacy invariant test //! //! This is the single most important test in the project. It exists from day //! one, before the sensitive terminal collector does, so the guardrail is in //! place before the code it guards. //! //! The invariant: //! //! > No process persists, transmits, or exposes any representation from which //! > the content or identity of an individual keystroke, command argument, or //! > typed character can be recovered. Only order-free aggregates leave the //! > terminal collector. //! //! At M0 we encode the *structural* half as real, passing tests: //! 1. `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider. //! 2. `wire_format_has_no_content_field` — the `Signal` type declares no //! content-carrying field (only `tag: Option` is a string, and it is //! the audited identifier exception). //! 3. `forbidden_symbol_scan` — the tree contains none of the banned //! keylogger APIs / shell patterns (the static gate). //! 4. `differential_secret_typing` — the runtime gate: drive the real hooks //! with a planted secret and assert it never reaches the spool or the //! wire in any encoding. Active. The full-pipeline version, through the //! collector, the history store and the hub, is //! `signald/tests/differential_secret_typing.rs`. use std::mem::size_of; use std::path::PathBuf; use signal_schema::Value; /// The payload channel is exactly an `f64` — no room for a character, a string, /// or a byte buffer. This is the load-bearing structural guarantee. #[test] fn value_channel_is_exactly_f64() { assert_eq!( size_of::(), size_of::(), "Value must be a transparent f64 payload; anything wider is a content channel" ); // Construction only accepts an f64. If someone adds a String-accepting // constructor, this file is where the review happens. let _v = Value(2.5_f64); } /// The `Signal` type must declare no content-carrying field. We assert this /// structurally by scanning the schema source: within the `struct Signal` /// declaration, the only `String`-typed payload permitted is the audited /// `tag`. Any `content` / `text` / `bytes` / `payload` field fails the build. #[test] fn wire_format_has_no_content_field() { let lib = repo_root().join("crates/signal-schema/src/lib.rs"); let src = std::fs::read_to_string(&lib).expect("read signal-schema/src/lib.rs"); let start = src.find("pub struct Signal {").expect("Signal struct present"); let body = &src[start..]; let end = body.find('}').expect("Signal struct closes"); // Field declarations only — strip doc/comment lines so prose like // "non-content identifier" doesn't false-positive. let body: String = body[..end] .lines() .filter(|l| !l.trim_start().starts_with("//")) .collect::>() .join("\n"); for banned in ["content", "text", "bytes", "payload", "keystroke", "command"] { assert!( !body.contains(banned), "Signal declares a forbidden content field containing `{banned}`; \ the wire format must have nowhere to put typed content" ); } // The one audited string exception is the identifier tag. assert!( body.contains("tag: Option"), "the only string in Signal must be the audited `tag` identifier" ); } /// The static gate: the tree must contain none of the APIs a /// real keylogger would use, nor any shell hook that touches the line buffer. /// Any hit fails the build. The banned list itself lives here and is reviewed. // APIs for input taps / global key monitoring, and shell line-buffer refs. // // Prefixes where a prefix is safe: `CGEvent` covers `CGEventTap` and the // rest of the family, `IOHID` covers `IOHIDManager`, `IOHIDQueue`, // `IOHIDDevice` and `IOHIDElement`. // // Bare `NSEvent` is deliberately absent. The keylogger-shaped API is the // monitor, not the class, and a menu-bar face will need `NSEvent` to draw // a UI. Banning the class would cost future work and buy no safety. // // The zle parameters are listed in both `$X` and `${X}` form: `${BUFFER}` // is valid zsh and does not contain the substring `$BUFFER`, so the sigil // form alone was bypassable by two characters. const BANNED: &[&str] = &[ "CGEvent", "IOHID", "kAXTrusted", "AXObserver", "AXUIElement", "addGlobalMonitorForEvents", "addLocalMonitorForEvents", "$BUFFER", "${BUFFER}", "$LBUFFER", "${LBUFFER}", "$RBUFFER", "${RBUFFER}", ]; /// The banned tokens appearing in `text`. fn violations(text: &str) -> Vec<&'static str> { BANNED.iter().copied().filter(|b| text.contains(b)).collect() } /// The scan passing proves the tree is clean; it proves nothing about whether /// the gate would catch a violation. These pin what it catches, and — just as /// importantly — what it deliberately does not. #[test] fn the_gate_catches_real_violations_and_leaves_legitimate_code_alone() { // Each of these is how the corresponding API actually gets written. for (sample, why) in [ ("local saved=${BUFFER}", "braced zle buffer — the form that used to slip through"), ("print -r -- $RBUFFER", "the right half of the line buffer"), ("local x=${LBUFFER}", "braced left buffer"), ("let tap = CGEventTapCreate(.cgSessionEventTap, ...)", "event tap"), ("let q = IOHIDQueueCreate(kCFAllocatorDefault, dev, 8, 0)", "HID queue"), ("IOHIDManagerRegisterInputValueCallback(mgr, cb, nil)", "HID manager"), ("NSEvent.addLocalMonitorForEvents(matching: .keyDown) { $0 }", "local key monitor"), ("NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { _ in }", "global key monitor"), ("AXObserverCreate(pid, callback, &observer)", "accessibility observation"), ("AXUIElementCopyAttributeValue(el, kAXValueAttribute, &v)", "accessibility read"), ] { assert!( !violations(sample).is_empty(), "the gate would not catch {why}: {sample:?}" ); } // False positives cost future work. A menu-bar face needs NSEvent to draw // a UI, our own hook rebinds self-insert, and prose naming the parameters // is how the privacy contract is documented. for (sample, why) in [ ("let p = NSEvent.mouseLocation", "NSEvent for UI, not monitoring"), ("zle -N self-insert _signald_self_insert", "our own keystroke counter"), ("# never reference the BUFFER/LBUFFER/RBUFFER zle parameters", "prose in the contract"), ("ProcessInfo.processInfo.thermalState", "an ordinary aggregate read"), ] { assert!( violations(sample).is_empty(), "the gate false-positives on {why}: {sample:?} -> {:?}", violations(sample) ); } } #[test] fn forbidden_symbol_scan() { const CODE_EXTS: &[&str] = &["rs", "zsh", "sh", "swift", "m", "c", "h"]; let this_file = PathBuf::from(file!()); let this_name = this_file.file_name().unwrap(); let mut offenders = Vec::new(); let mut scanned: Vec = Vec::new(); for path in walk(&repo_root()) { // Skip build artifacts and this test (which names the banned tokens). // `target` is Rust's; `.build` is SwiftPM's (macos-collector/.build). if path .components() .any(|c| matches!(c.as_os_str().to_str(), Some("target") | Some(".build"))) { continue; } if path.file_name() == Some(this_name) { continue; } let ext_ok = path .extension() .and_then(|e| e.to_str()) .map(|e| CODE_EXTS.contains(&e)) .unwrap_or(false); if !ext_ok { continue; } let Ok(text) = std::fs::read_to_string(&path) else { continue; }; scanned.push(path.display().to_string()); for banned in violations(&text) { offenders.push(format!("{}: {banned}", path.display())); } } assert!( offenders.is_empty(), "forbidden keylogger symbol(s) found:\n{}", offenders.join("\n") ); // A gate that scans nothing reports success while checking nothing, which // is worse than having no gate. Assert it actually read the files that // matter: the shell hook is where a line-buffer reference would appear, // and the Swift collector is where an input-tap API would. assert!( scanned.len() >= 10, "scan read only {} files; the walk is broken", scanned.len() ); for required in ["signald-hooks.zsh", "Hardware.swift"] { assert!( scanned.iter().any(|p| p.ends_with(required)), "scan never reached {required}; it is not covering the tree.\nscanned: {scanned:#?}" ); } } /// The differential secret-typing acceptance test — the ship gate for the /// terminal collector. /// /// This drives the **real production hook** (`shell-hooks/signald-hooks.zsh`) /// through a **real interactive zsh under a real pseudo-terminal** (zsh's own /// `zsh/zpty` module — no external dependency), *typing a planted secret* the /// way a person would at a prompt. Real `zle` keystroke counting increments a /// number per key and discards the key, so the only thing the shell emits is a /// count. This test proves that empirically: the secret must never appear — /// plain, reversed, hex, or base64 — in the spool the shell writes, nor in the /// `f64`-only wire encoding of the signals derived from those counts. /// /// This crate is the privacy boundary and stays dependency-free, so it exercises /// the schema-reachable half (spool + wire). The **full pipeline** gate — the /// same real typing driven through the terminal collector, the SQLite history /// store, and the hub — lives in `crates/signald/tests/differential_secret_typing.rs` /// (that crate owns the collector and the store). #[test] fn differential_secret_typing() { use std::process::Command; // A distinctive, non-dictionary secret so an incidental byte-collision is // not credible. const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET"; let root = repo_root(); let hooks = root.join("shell-hooks/signald-hooks.zsh"); assert!(hooks.exists(), "production hook missing at {}", hooks.display()); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let tmp = std::env::temp_dir().join(format!("ambient-diff-schema-{}-{nanos}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); let spool = tmp.join("terminal.spool"); let driver = tmp.join("driver.zsh"); std::fs::write(&spool, b"").unwrap(); // pty driver: spawn interactive zsh under zpty, source the real hook, and // type the secret as ordinary `echo` arguments — real keystrokes, real zle. const DRIVER: &str = r#" zmodload zsh/zpty || exit 3 zpty SH zsh -f -i || exit 4 drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done } sleep 0.4; drain zpty -w SH "source $HOOKS" sleep 0.3; drain zpty -w SH "echo $SECRET" sleep 0.5; drain zpty -w SH "echo typed $SECRET twice $SECRET" sleep 0.5; drain zpty -w SH "exit" sleep 0.3 zpty -d SH 2>/dev/null "#; std::fs::write(&driver, DRIVER).unwrap(); let status = Command::new("zsh") .arg("-f") .arg(&driver) .env("SIGNALD_SPOOL", &spool) .env("HOOKS", &hooks) .env("SECRET", SECRET) .status() .expect("run zsh/zpty harness (the privacy ship-gate must run)"); assert!(status.success(), "zsh/zpty harness failed"); let spool_bytes = std::fs::read(&spool).unwrap(); let _ = std::fs::remove_dir_all(&tmp); // Parse count records (four integers per line) and confirm the typing was // genuinely counted — otherwise we'd be "proving" absence over an empty run. let mut max_keys = 0u64; let mut wire_bytes: Vec = Vec::new(); for line in String::from_utf8_lossy(&spool_bytes).lines() { let nums: Vec = line.split_whitespace().filter_map(|t| t.parse().ok()).collect(); if nums.len() != 4 { continue; // not a well-formed count record } max_keys = max_keys.max(nums[1]); // Build a signal from the count alone — the only thing available — and // encode it to the real wire format. let sig = signal_schema::Signal { schema_version: signal_schema::SCHEMA_VERSION, ts: nums[0], source: signal_schema::Source::Terminal, name: signal_schema::SignalName::KeysPerMin, value: Value(nums[1] as f64), tag: None, }; wire_bytes.extend_from_slice(&signal_schema::wire::encode(&sig)); } assert!( max_keys >= SECRET.chars().count() as u64, "secret does not appear to have been typed through the hook \ (max keys counted = {max_keys}, secret len = {})", SECRET.chars().count() ); // The secret must be absent from the spool and the wire, in any encoding. let b = SECRET.as_bytes(); let reversed: Vec = b.iter().rev().copied().collect(); let needles: [(&str, Vec); 4] = [ ("plain", b.to_vec()), ("reversed", reversed), ("hex", to_hex(b).into_bytes()), ("base64", to_base64(b).into_bytes()), ]; for (label, artifact) in [("spool", &spool_bytes), ("wire", &wire_bytes)] { for (enc, needle) in &needles { assert!( !byte_contains(artifact, needle), "SECRET LEAK: found the secret ({enc}) in the {label} — only \ aggregate counts may leave the terminal collector" ); } } } fn byte_contains(hay: &[u8], needle: &[u8]) -> bool { !needle.is_empty() && hay.len() >= needle.len() && hay.windows(needle.len()).any(|w| w == needle) } fn to_hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for byte in bytes { s.push_str(&format!("{byte:02x}")); } s } fn to_base64(bytes: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::new(); for chunk in bytes.chunks(3) { let b0 = chunk[0] as u32; let b1 = *chunk.get(1).unwrap_or(&0) as u32; let b2 = *chunk.get(2).unwrap_or(&0) as u32; let n = (b0 << 16) | (b1 << 8) | b2; out.push(T[(n >> 18 & 63) as usize] as char); out.push(T[(n >> 12 & 63) as usize] as char); out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' }); out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' }); } out } // --- helpers --- fn repo_root() -> PathBuf { // CARGO_MANIFEST_DIR = /crates/signal-schema PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .expect("canonicalize repo root") } fn walk(dir: &PathBuf) -> Vec { let mut out = Vec::new(); let Ok(entries) = std::fs::read_dir(dir) else { return out; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { out.extend(walk(&path)); } else { out.push(path); } } out }