jsecmon: port stealth::obfuscate as a Typed Jerboa kernel
Jaime Fournier
6247acc38fdf59f26a6f691a0771ef91a661289f
--- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `sigma` (Sigma rule importer) | `jsecmon/sigma.ss` | ✅ **untyped layer** — port of secmon's `src/sigma.rs`: parse a Sigma YAML rule (via `(std text yaml)`), map `logsource.category` → event_type, translate the BTreeMap-first selection's fields (`Field` → json_eq, `Field\|contains/startswith/endswith/re` → data_contains, Windows field names aliased to Linux JSON paths), `level` → severity, `attack.tNNNN` tags → ATT&CK IDs, and render secmon's `YamlRule` YAML back out. `make sigma-check` reproduces secmon's four conversion vectors (process_creation, network_connection, unsupported-category skip, safe-name). Pure YAML+strings, untyped. | | `psk::constant_time_eq` | `typed/psk.ss` | ✅ ported, vectors pass | | `psk::from_hex` (hex codec) | `typed/psk.ss` | ✅ hex encode + decode + 32-byte precondition; vectors pass (decode∘encode identity over all 256 byte values) | +| `stealth::obfuscate` (compile-time string XOR) | `typed/obfuscate.ss` | ✅ **typed kernel** — keeps sensitive strings out of the binary in plaintext. Lowers secmon's `obfuscate!`/`obfuscate_bytes!` scheme: length-derived wrapping-u8 key (`len*31+42` / `len*37+13`) + XOR. Since XOR preserves length, decode recomputes the key from the buffer — a clean involutive pair, no stored key. `make test` reproduces secmon's `test_obfuscate_roundtrip` + `test_obfuscated_not_plaintext` plus key-derivation and all-256-byte round-trip vectors. | | `psk` HKDF/SHA256/AES-GCM | — | ⏳ FFI-delegated to vetted crates (not reimplemented) | | `crypto::ecies` | — | ⏳ FFI-delegated; orchestration only | | `storage` (events table, store/query/filters) | `jsecmon/storage.ss` | ✅ **untyped layer** — SQLite event store on `(std db sqlite-native)` (rusqlite): secmon's schema (events + indexes + collector_state), `store-event` INSERT-OR-IGNORE dedup, and the full EventFilter WHERE builder (host/type/severity/since/until/pid/process_name LIKE/search/exclude_event_ids). `query-events` returns row hashes with `data` parsed from JSON, so detect/triage/analytics consume them directly. `make storage-check` round-trips store→query→detect→analytics (host risk 30, same as `detect-check`). | new file mode 100644 --- /dev/null +++ b/tests/obfuscate_vectors.rs @@ -0,0 +1,80 @@ +//! Parity vectors for the string-obfuscation kernels ported from +//! secmon/src/stealth/obfuscate.rs to Typed Jerboa. +//! +//! secmon obfuscates at compile time via the `obfuscate!`/`obfuscate_bytes!` +//! macros (XOR each byte with `(len*31+42)` / `(len*37+13)` as a wrapping u8) +//! and decodes at runtime. The kernels lower the portable part — key +//! derivation + XOR — and exploit that XOR preserves length so the decoder +//! recomputes the key from the buffer. We reproduce secmon's two unit tests +//! (`test_obfuscate_roundtrip`, `test_obfuscated_not_plaintext`) and pin the +//! exact keys the wrapping formula yields. + +use jerboa_typed_generated::jsecmon_typed_obfuscate::{ + deobfuscate_bytes, deobfuscate_string, obf_key_bytes, obf_key_str, obfuscate_bytes, + obfuscate_string, obfuscate_xor, +}; + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + hay.windows(needle.len()).any(|w| w == needle) +} + +#[test] +fn key_derivation_matches_wrapping_formula() { + // (len*31+42) mod 256, with the macro's `as u8` reduction folded in. + assert_eq!(obf_key_str(0), 42); + assert_eq!(obf_key_str(11), 127); // "hello world" + assert_eq!(obf_key_str(15), 251); // "secret password" + // (len*37+13) mod 256 for the byte-slice macro. + assert_eq!(obf_key_bytes(0), 13); + assert_eq!(obf_key_bytes(4), 161); + // wraps past 256 for longer inputs, exactly like wrapping u8 arithmetic. + assert_eq!(obf_key_str(100), (100 * 31 + 42) % 256); + assert_eq!(obf_key_bytes(200), (200 * 37 + 13) % 256); +} + +#[test] +fn obfuscate_roundtrip() { + // secmon test_obfuscate_roundtrip: decode∘obfuscate is the identity. + let s = "hello world"; + let obf = obfuscate_string(s.to_string()); + assert_eq!(deobfuscate_string(obf), s.as_bytes().to_vec()); + // the empty string and non-ASCII (multi-byte UTF-8) also round-trip. + assert_eq!(deobfuscate_string(obfuscate_string(String::new())), Vec::<u8>::new()); + let u = "café—naïve"; + assert_eq!( + deobfuscate_string(obfuscate_string(u.to_string())), + u.as_bytes().to_vec() + ); +} + +#[test] +fn obfuscated_not_plaintext() { + // secmon test_obfuscated_not_plaintext: the stored bytes leak nothing. + let obf = obfuscate_string("secret password".to_string()); + assert!(!contains(&obf, b"secret")); + assert!(!contains(&obf, b"password")); + // every byte is altered (key is non-zero), so no plaintext byte survives. + assert_eq!(obf.len(), "secret password".len()); + assert!(obf.iter().zip(b"secret password").all(|(o, p)| o != p)); +} + +#[test] +fn bytes_variant_roundtrips_with_its_own_key() { + let data: Vec<u8> = (0u16..=255).map(|b| b as u8).collect(); + let obf = obfuscate_bytes(data.clone()); + assert_eq!(deobfuscate_bytes(obf.clone()), data); + // the byte macro uses a different key schedule than the string macro. + let s_obf = obfuscate_xor(data.clone(), obf_key_str(data.len() as u64)); + assert_ne!(obf, s_obf); +} + +#[test] +fn xor_is_involutive() { + let data = vec![0x00u8, 0xff, 0x42, 0x13, 0x80]; + for key in [0u64, 1, 42, 127, 255] { + let once = obfuscate_xor(data.clone(), key); + assert_eq!(obfuscate_xor(once, key), data); + } + // key 0 is the identity. + assert_eq!(obfuscate_xor(data.clone(), 0), data); +} new file mode 100644 --- /dev/null +++ b/typed/obfuscate.ss @@ -0,0 +1,56 @@ +;;; jsecmon — compile-time string obfuscation (secmon src/stealth/obfuscate.rs). +;;; +;;; Keeps sensitive strings (paths, signatures, command names the agent hunts +;;; for) out of the binary in plaintext: each is XOR'd with a key derived from +;;; its own length, so a `strings(1)` sweep of the image finds nothing useful. +;;; This is exactly the kind of secret-handling logic the user wants written +;;; once in a checked language and compiled to Rust, so it lives in Typed +;;; Jerboa. +;;; +;;; secmon does the XOR at compile time inside the `obfuscate!`/`obfuscate_bytes!` +;;; macros; the portable, security-relevant content is the *scheme* — the key +;;; derivation and the XOR — which is what we lower here. The key is a function +;;; of the length alone, and XOR preserves length, so decoding needs no stored +;;; key: it recomputes the same key from the obfuscated buffer's length. That +;;; makes obfuscate/deobfuscate a clean involutive pair. +;;; +;;; Faithfulness note: secmon derives the key from `$s.len() as u8` — the UTF-8 +;;; *byte* length, reduced mod 256 — with wrapping u8 multiply/add. Masking the +;;; final result with 255 reproduces that exactly (modular arithmetic commutes +;;; with the intermediate `as u8`), for strings of any length. + +(typed-library (jsecmon typed obfuscate) + (export obf-key-str obf-key-bytes + obfuscate-string deobfuscate-string + obfuscate-bytes deobfuscate-bytes + obfuscate-xor) + + ;; --- key derivation (the `const KEY` in each macro) --- + ;; string key: (len * 31 + 42) wrapping, as u8 → (len*31+42) mod 256 + (def (obf-key-str (n : Nat)) : Nat + (bitwise-and (+ (* n 31) 42) 255)) + ;; bytes key: (len * 37 + 13) wrapping, as u8 + (def (obf-key-bytes (n : Nat)) : Nat + (bitwise-and (+ (* n 37) 13) 255)) + + ;; --- the XOR primitive (ObfuscatedString::decode_bytes, and the macro body) --- + ;; XOR every byte with the single-byte key; involutive, so this is both the + ;; obfuscation and the de-obfuscation step once the key is known. + (def (obfuscate-xor (data : Bytes) (key : Nat)) : Bytes + (bytes-build (bytevector-length data) + (i (bitwise-xor (bytevector-u8-ref data i) key)))) + + ;; --- string obfuscation (obfuscate! / obfstr!) --- + ;; encode: utf-8 the string, key from its byte length, XOR. + (def (obfuscate-string (s : String)) : Bytes + (let ((bs (string->utf8 s))) + (obfuscate-xor bs (obf-key-str (bytevector-length bs))))) + ;; decode: XOR preserves length, so the key is recoverable from the buffer. + (def (deobfuscate-string (data : Bytes)) : Bytes + (obfuscate-xor data (obf-key-str (bytevector-length data)))) + + ;; --- byte-slice obfuscation (obfuscate_bytes!) — same scheme, other key --- + (def (obfuscate-bytes (data : Bytes)) : Bytes + (obfuscate-xor data (obf-key-bytes (bytevector-length data)))) + (def (deobfuscate-bytes (data : Bytes)) : Bytes + (obfuscate-xor data (obf-key-bytes (bytevector-length data)))))