Complete psk hex codec: decode + 32-byte precondition

Jaime Fournier

abbcb6bf2398f94ee53d6a2886aea43280b748aa

diff --git a/README.md b/README.md
index 8007dbf..286e3ec 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `analytics::compute_host_risks` | `typed/analytics.ss` | ✅ risk-score kernel (clamped weighted sum); vectors pass (host grouping/sort/top-N stays untyped) |
 | `sigma`, `triage`         | —                  | ⏳ sigma=YAML import (I/O), triage=storage-coupled rules — untyped layer |
 | `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::from_hex` (hex codec) | `typed/psk.ss`  | ✅ hex encode + decode + 32-byte precondition; vectors pass (decode∘encode identity over all 256 byte values) |
 | `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
index 5748ac3..bd941c7 100644
--- a/tests/psk_vectors.rs
+++ b/tests/psk_vectors.rs
@@ -7,7 +7,9 @@
 //! `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};
+use jerboa_typed_generated::jsecmon_typed_psk::{
+    constant_time_eq_p, hex_decode, hex_encode, hex_string_p, psk_hex_32_p,
+};
 
 fn hex(data: &[u8]) -> String {
     String::from_utf8(hex_encode(data.to_vec())).unwrap()
@@ -46,3 +48,49 @@ fn hex_encode_matches_hex_crate() {
     // a full 32-byte PSK round-trips to 64 lowercase hex chars
     assert_eq!(hex(&[0x42u8; 32]).len(), 64);
 }
+
+#[test]
+fn hex_decode_matches_hex_crate() {
+    // matches hex::decode for lowercase, uppercase, and mixed input
+    assert_eq!(hex_decode("deadbeef".to_string()), vec![0xde, 0xad, 0xbe, 0xef]);
+    assert_eq!(hex_decode("DEADBEEF".to_string()), vec![0xde, 0xad, 0xbe, 0xef]);
+    assert_eq!(hex_decode("000fa0ff".to_string()), vec![0x00, 0x0f, 0xa0, 0xff]);
+    assert_eq!(hex_decode("".to_string()), Vec::<u8>::new());
+    // decode ∘ encode is the identity on arbitrary bytes
+    let data: Vec<u8> = (0u16..=255).map(|b| b as u8).collect();
+    assert_eq!(hex_decode(hex(&data)), data);
+}
+
+#[test]
+fn hex_string_p_validates_like_hex_decode() {
+    // even count of hex digits decodes; the empty string is valid hex ([])
+    assert!(hex_string_p("deadbeef".to_string()));
+    assert!(hex_string_p("".to_string()));
+    assert!(hex_string_p("00".to_string()));
+    // odd digit count is not decodable
+    assert!(!hex_string_p("deadbee".to_string()));
+    assert!(!hex_string_p("f".to_string()));
+    // a non-hex character anywhere fails
+    assert!(!hex_string_p("dead beef".to_string())); // space
+    assert!(!hex_string_p("zz".to_string()));
+    assert!(!hex_string_p("00gg".to_string()));
+}
+
+#[test]
+fn psk_hex_32_matches_from_hex_precondition() {
+    // secmon's from_hex accepts exactly 32 bytes = 64 hex digits.
+    let ok = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
+    assert_eq!(ok.len(), 64);
+    assert!(psk_hex_32_p(ok.to_string()));
+    // uppercase is still accepted (hex::decode is case-insensitive)
+    assert!(psk_hex_32_p(ok.to_ascii_uppercase()));
+    // 31 bytes (62 digits) and 33 bytes (66 digits) are rejected — "wrong length"
+    assert!(!psk_hex_32_p("00".repeat(31)));
+    assert!(!psk_hex_32_p("00".repeat(33)));
+    // right length but a non-hex digit is rejected — "invalid hex"
+    let mut bad = ok.to_string();
+    bad.replace_range(0..1, "x");
+    assert!(!psk_hex_32_p(bad));
+    // the encoded form of a real 32-byte PSK passes its own precondition
+    assert!(psk_hex_32_p(hex(&[0x42u8; 32])));
+}
diff --git a/typed/psk.ss b/typed/psk.ss
index 2f9a4d6..da02708 100644
--- a/typed/psk.ss
+++ b/typed/psk.ss
@@ -11,7 +11,7 @@
 ;;; are not things to reimplement in any language.
 
 (typed-library (jsecmon typed psk)
-  (export constant-time-eq? hex-encode)
+  (export constant-time-eq? hex-encode hex-decode hex-string? psk-hex-32?)
 
   ;; --- constant-time comparison (psk.rs::constant_time_eq) ---
 
@@ -45,4 +45,47 @@
       (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))))))))
+               (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)))))))))