//! # The privacy invariant test (spec §1.5) //! //! 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 (spec §1.5): //! //! > 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 CI gate). //! //! The runtime *differential secret-typing* test (drive the hooks with a //! planted secret, assert it never reaches the socket or SQLite in any //! encoding) is stubbed `#[ignore]` below and lands with the terminal //! collector in Phase 2. 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(3.14_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 CI gate (spec §1.5): 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. #[test] fn forbidden_symbol_scan() { // APIs for input taps / global key monitoring, and shell line-buffer refs. const BANNED: &[&str] = &[ "CGEventTap", "IOHIDManager", "kAXTrusted", "addGlobalMonitorForEvents", "$BUFFER", "$LBUFFER", ]; 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(); 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; }; for banned in BANNED { if text.contains(banned) { offenders.push(format!("{}: {banned}", path.display())); } } } assert!( offenders.is_empty(), "forbidden keylogger symbol(s) found:\n{}", offenders.join("\n") ); } /// The differential secret-typing acceptance test (spec §1.5, §5 Phase 2) — /// the ship gate for the terminal collector, now **active**. /// /// 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 (three 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() != 3 { 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 }