llvmir: add the pure psk-hex kernels to the native LLVM build

ober

51f920cfe40f0ab5d7265bbb79dc71fd7358645e

diff --git a/Makefile b/Makefile
index ae07ae9..05b5f14 100644
--- a/Makefile
+++ b/Makefile
@@ -633,7 +633,8 @@ agent: rust native-runtime
 # language. See $(JERBOA)/docs/llvmir-backend.md.
 LLVMIR_DIR     ?= build/llvmir
 LLVMIR_PURE    := typed/analytics.ss typed/strbytes.ss typed/triage.ss \
-                  typed/lolbin.ss typed/obfuscate.ss typed/dga.ss
+                  typed/lolbin.ss typed/obfuscate.ss typed/dga.ss \
+                  typed/psk-hex.ss
 # LLVM tool discovery: PATH first, then the Homebrew llvm keg.
 LLVM_BIN ?= $(shell if command -v llvm-as >/dev/null 2>&1; then dirname "$$(command -v llvm-as)"; \
               elif [ -x /opt/homebrew/opt/llvm/bin/llvm-as ]; then echo /opt/homebrew/opt/llvm/bin; \
diff --git a/llvmir/harness.c b/llvmir/harness.c
index cc05b66..70981f8 100644
--- a/llvmir/harness.c
+++ b/llvmir/harness.c
@@ -32,6 +32,11 @@ extern uint64_t jt_llvm_jsecmon_dga__score_label(Buf);
 extern uint64_t jt_llvm_jsecmon_typed_obfuscate__obf_key_str(uint64_t);
 extern Buf      jt_llvm_jsecmon_typed_obfuscate__obfuscate_string(Buf);
 extern Buf      jt_llvm_jsecmon_typed_obfuscate__deobfuscate_string(Buf);
+extern _Bool    jt_llvm_jsecmon_typed_psk_hex__constant_time_eq_p(Buf, Buf);
+extern Buf      jt_llvm_jsecmon_typed_psk_hex__hex_encode(Buf);
+extern Buf      jt_llvm_jsecmon_typed_psk_hex__hex_decode(Buf);
+extern _Bool    jt_llvm_jsecmon_typed_psk_hex__hex_string_p(Buf);
+extern _Bool    jt_llvm_jsecmon_typed_psk_hex__psk_hex_32_p(Buf);
 
 static int failures = 0;
 
@@ -103,6 +108,34 @@ int main(void) {
         check_bool("obfuscate/deobfuscate round-trips \"/etc/shadow\"", roundtrip && changed, 1);
     }
 
+    /* psk-hex — pure constant-time compare + lowercase hex codec (no crypto) */
+    {
+        unsigned char raw[4] = { 0xde, 0xad, 0xbe, 0xef };
+        Buf rawb = { raw, 4 };
+        Buf enc = jt_llvm_jsecmon_typed_psk_hex__hex_encode(rawb);
+        int enc_ok = (enc.len == 8) && (memcmp(enc.ptr, "deadbeef", 8) == 0);
+        check_bool("hex_encode([de ad be ef]) == \"deadbeef\"", enc_ok, 1);
+
+        Buf dec = jt_llvm_jsecmon_typed_psk_hex__hex_decode(S("deadbeef"));
+        int dec_ok = (dec.len == 4) && (memcmp(dec.ptr, raw, 4) == 0);
+        check_bool("hex_decode(\"deadbeef\") == [de ad be ef]", dec_ok, 1);
+    }
+    {
+        unsigned char x[3] = {1,2,3}, y[3] = {1,2,3}, z[3] = {1,2,4}, w[2] = {1,2};
+        Buf bx = {x,3}, by = {y,3}, bz = {z,3}, bw = {w,2};
+        check_bool("constant_time_eq?(equal)", jt_llvm_jsecmon_typed_psk_hex__constant_time_eq_p(bx,by), 1);
+        check_bool("constant_time_eq?(differ)", jt_llvm_jsecmon_typed_psk_hex__constant_time_eq_p(bx,bz), 0);
+        check_bool("constant_time_eq?(len mismatch)", jt_llvm_jsecmon_typed_psk_hex__constant_time_eq_p(bx,bw), 0);
+    }
+    check_bool("hex_string?(\"deadbeef\")", jt_llvm_jsecmon_typed_psk_hex__hex_string_p(S("deadbeef")), 1);
+    check_bool("hex_string?(\"deadbee\") [odd]", jt_llvm_jsecmon_typed_psk_hex__hex_string_p(S("deadbee")), 0);
+    check_bool("hex_string?(\"zz\") [non-hex]", jt_llvm_jsecmon_typed_psk_hex__hex_string_p(S("zz")), 0);
+    check_bool("psk_hex_32?(64 hex digits)",
+               jt_llvm_jsecmon_typed_psk_hex__psk_hex_32_p(
+                   S("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")), 1);
+    check_bool("psk_hex_32?(\"dead\")",
+               jt_llvm_jsecmon_typed_psk_hex__psk_hex_32_p(S("dead")), 0);
+
     if (failures == 0) {
         printf("\njsecmon-llvmir: PASS — all kernels match the Rust backend\n");
         return 0;
diff --git a/typed/psk-hex.ss b/typed/psk-hex.ss
new file mode 100644
index 0000000..c83895d
--- /dev/null
+++ b/typed/psk-hex.ss
@@ -0,0 +1,93 @@
+;;; jsecmon — PSK pure kernels (constant-time compare + lowercase hex codec).
+;;;
+;;; This is the PURE, crypto-free half of (jsecmon typed psk), carved into its
+;;; own module so it compiles straight to native code through the Typed Jerboa
+;;; -> LLVM IR backend (no Rust crate, no RustCrypto). `(jsecmon typed psk)`
+;;; stays the source of truth for the Rust path and its callers; the bodies
+;;; here mirror that module's pure functions byte-for-byte (the Typed Jerboa
+;;; checker forbids re-exporting imported names, so the pure kernels are
+;;; defined here rather than imported). The crypto half (HKDF/SHA256/AES-GCM)
+;;; stays on the vetted RustCrypto path in psk.ss and is intentionally absent.
+;;;
+;;; Verified by jerboa-secmon's `make llvmir-bin`: every kernel below is
+;;; asserted against the Rust backend's output.
+
+(typed-library (jsecmon typed psk-hex)
+  (export constant-time-eq? hex-encode hex-decode hex-string? psk-hex-32?)
+
+  ;; --- 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)))))))
+
+  ;; --- hex decoding + length validation (from_hex's hex::decode + check) ---
+
+  ;; value of a hex-digit byte as 0..15; 16 (out of range) signals "not hex".
+  ;; Accepts 0-9 (48..57), a-f (97..102), A-F (65..70).
+  (def (hex-val (c : Nat)) : Nat
+    (if (and (>= c 48) (<= c 57)) (- c 48)
+        (if (and (>= c 97) (<= c 102)) (+ (- c 97) 10)
+            (if (and (>= c 65) (<= c 70)) (+ (- c 65) 10)
+                16))))
+
+  (def (hex-digit? (c : Nat)) : Bool
+    (< (hex-val c) 16))
+
+  ;; #t iff every byte in [i,n) is a hex digit.
+  (def (all-hex-from (bs : Bytes) (i : Nat) (n : Nat)) : Bool
+    (if (>= i n)
+        #t
+        (and (hex-digit? (bytevector-u8-ref bs i))
+             (all-hex-from bs (+ i 1) n))))
+
+  ;; str is decodable hex: an even number of digits, all hex. Empty string ok.
+  (def (hex-string? (s : String)) : Bool
+    (let ((bs (string->utf8 s)))
+      (let ((n (bytevector-length bs)))
+        (and (= n (* 2 (/ n 2)))
+             (all-hex-from bs 0 n)))))
+
+  ;; secmon from_hex's full precondition for a PSK: exactly 64 hex digits
+  ;; (32 bytes). Collapses its two error arms (invalid hex / wrong byte count)
+  ;; into the single accept/reject the caller acts on.
+  (def (psk-hex-32? (s : String)) : Bool
+    (and (= (string-length s) 64) (hex-string? s)))
+
+  ;; decode a hex string to bytes: output byte j is (digit 2j << 4) | digit
+  ;; 2j+1. Inverse of hex-encode. Assumes the caller checked hex-string?; a
+  ;; non-hex digit contributes its 16 sentinel, so only validated input
+  ;; round-trips faithfully.
+  (def (hex-decode (s : String)) : Bytes
+    (let ((bs (string->utf8 s)))
+      (bytes-build (/ (bytevector-length bs) 2)
+        (j (+ (* 16 (hex-val (bytevector-u8-ref bs (* 2 j))))
+              (hex-val (bytevector-u8-ref bs (+ (* 2 j) 1)))))))))