jsecmon: port psk constant-time-eq + hex-encode crypto prims

Jaime Fournier

e45de92a6eefb09614a2549c681c785373672842

diff --git a/README.md b/README.md
index 291e3eb..423a3c1 100644
--- a/README.md
+++ b/README.md
@@ -37,5 +37,8 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `dga::shannon_entropy`   | `typed/dga.ss`     | ✅ ported, vectors pass         |
 | `dga::score_domain`      | `typed/dga.ss`     | ⏳ needs string ops (lowercase, split, ends_with, char classes) |
 | `lolbin`, `sigma`, `triage` | —               | ⏳ pure logic, queued           |
-| `crypto::{psk,ecies}`    | —                  | ⏳ FFI-delegated; orchestration only |
+| `psk::constant_time_eq`  | `typed/psk.ss`     | ✅ ported, vectors pass         |
+| `psk::from_hex` (hex codec) | `typed/psk.ss`  | ✅ hex-encode ported; decode + length check queued |
+| `psk` HKDF/SHA256/AES-GCM | —                 | ⏳ FFI-delegated to vetted crates (not reimplemented) |
+| `crypto::ecies`          | —                  | ⏳ FFI-delegated; orchestration only |
 | monitors / server / storage / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/tests/psk_vectors.rs b/tests/psk_vectors.rs
new file mode 100644
index 0000000..5748ac3
--- /dev/null
+++ b/tests/psk_vectors.rs
@@ -0,0 +1,48 @@
+//! Parity vectors for the PSK crypto primitives ported from
+//! secmon/src/crypto/psk.rs to Typed Jerboa.
+//!
+//! `constant_time_eq_p` is the Jerboa lowering of `psk.rs::constant_time_eq`
+//! (the `_p` suffix is the mangling of the trailing `?`). `hex_encode` is the
+//! lowercase codec used to round-trip a PSK; its byte-for-byte parity with the
+//! `hex` crate is proven in jerboa/tests/fixtures/typed/rust-bytes-build.ss, so
+//! here we pin the literal expected encodings.
+
+use jerboa_typed_generated::jsecmon_typed_psk::{constant_time_eq_p, hex_encode};
+
+fn hex(data: &[u8]) -> String {
+    String::from_utf8(hex_encode(data.to_vec())).unwrap()
+}
+
+#[test]
+fn constant_time_eq_matches_secmon() {
+    // equal buffers compare equal
+    assert!(constant_time_eq_p(vec![1, 2, 3], vec![1, 2, 3]));
+    assert!(constant_time_eq_p(vec![], vec![]));
+
+    // any differing byte is unequal (first, middle, last)
+    assert!(!constant_time_eq_p(vec![9, 2, 3], vec![1, 2, 3]));
+    assert!(!constant_time_eq_p(vec![1, 9, 3], vec![1, 2, 3]));
+    assert!(!constant_time_eq_p(vec![1, 2, 9], vec![1, 2, 3]));
+
+    // mismatched lengths are unequal (secmon's early `return false`)
+    assert!(!constant_time_eq_p(vec![1, 2, 3], vec![1, 2]));
+    assert!(!constant_time_eq_p(vec![], vec![0]));
+
+    // psk.rs proof scenario: a correct 32-byte proof verifies, a proof that
+    // differs in a single byte (wrong PSK) does not.
+    let proof = vec![0x42u8; 32];
+    let mut wrong = proof.clone();
+    wrong[17] ^= 0x01;
+    assert!(constant_time_eq_p(proof.clone(), proof.clone()));
+    assert!(!constant_time_eq_p(proof, wrong));
+}
+
+#[test]
+fn hex_encode_matches_hex_crate() {
+    assert_eq!(hex(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
+    assert_eq!(hex(&[0x42; 4]), "42424242"); // the psk.rs test PSK byte
+    assert_eq!(hex(&[0x00, 0x0f, 0xa0, 0xff]), "000fa0ff");
+    assert_eq!(hex(&[]), "");
+    // a full 32-byte PSK round-trips to 64 lowercase hex chars
+    assert_eq!(hex(&[0x42u8; 32]).len(), 64);
+}
diff --git a/typed/psk.ss b/typed/psk.ss
new file mode 100644
index 0000000..2f9a4d6
--- /dev/null
+++ b/typed/psk.ss
@@ -0,0 +1,48 @@
+;;; jsecmon — PSK auth crypto primitives.
+;;;
+;;; Portable kernels ported from secmon/src/crypto/psk.rs to Typed Jerboa,
+;;; compiled to Rust by the Jerboa typed backend.
+;;;
+;;; Scope: the pure, timing-sensitive logic that benefits from being written
+;;; once in a checked language — the constant-time comparison used by
+;;; `verify_response`, and the lowercase hex codec used by `from_hex`. The
+;;; actual primitive crypto (HKDF-SHA256 key derivation, the SHA256 proof
+;;; HMAC, AES-256-GCM transport) stays as calls into vetted Rust crates; those
+;;; are not things to reimplement in any language.
+
+(typed-library (jsecmon typed psk)
+  (export constant-time-eq? hex-encode)
+
+  ;; --- constant-time comparison (psk.rs::constant_time_eq) ---
+
+  ;; OR every byte-xor together; the running time depends only on the length
+  ;; n, never on where the first mismatch occurs.
+  (def (ct-fold (a : Bytes) (b : Bytes) (i : Nat) (n : Nat) (acc : Nat)) : Nat
+    (if (>= i n)
+        acc
+        (ct-fold a b (+ i 1) n
+                 (bitwise-ior acc (bitwise-xor (bytevector-u8-ref a i)
+                                               (bytevector-u8-ref b i))))))
+
+  ;; Timing-safe equality. Mismatched lengths are unequal without folding,
+  ;; matching secmon's early `return false` (the length is not itself secret).
+  (def (constant-time-eq? (a : Bytes) (b : Bytes)) : Bool
+    (if (= (bytevector-length a) (bytevector-length b))
+        (= (ct-fold a b 0 (bytevector-length a) 0) 0)
+        #f))
+
+  ;; --- lowercase hex encoding (inverse of from_hex's hex::decode) ---
+
+  ;; map a 0..15 nibble to its lowercase-hex ASCII byte: 0-9 -> '0'..'9' (48),
+  ;; 10-15 -> 'a'..'f' (87 + n).
+  (def (nibble-hex (x : Nat)) : Nat
+    (if (< x 10) (+ 48 x) (+ 87 x)))
+
+  ;; two output bytes per input byte; output index j maps to input byte j/2,
+  ;; even j the high nibble, odd j the low nibble.
+  (def (hex-encode (data : Bytes)) : Bytes
+    (bytes-build (* 2 (bytevector-length data))
+      (j (let ((b (bytevector-u8-ref data (bitwise-arithmetic-shift-right j 1))))
+           (if (= (bitwise-and j 1) 0)
+               (nibble-hex (bitwise-and (bitwise-arithmetic-shift-right b 4) 15))
+               (nibble-hex (bitwise-and b 15))))))))