jsecmon: port stealth::obfuscate as a Typed Jerboa kernel

Jaime Fournier

6247acc38fdf59f26a6f691a0771ef91a661289f

diff --git a/README.md b/README.md
index 4517056..e32546e 100644
--- 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`). |
diff --git a/tests/obfuscate_vectors.rs b/tests/obfuscate_vectors.rs
new file mode 100644
index 0000000..33c53dc
--- /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);
+}
diff --git a/typed/obfuscate.ss b/typed/obfuscate.ss
new file mode 100644
index 0000000..6f0ca41
--- /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)))))