Complete psk hex codec: decode + 32-byte precondition
Jaime Fournier
abbcb6bf2398f94ee53d6a2886aea43280b748aa
--- 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 | --- 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]))); +} --- 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)))))))))