typed/psk: HKDF key-split + compute_proof challenge core
Jaime Fournier <jaimef@linbsd.org>
ddc41b8d2e35c82991b3815bc5bc84b90bb5a36f
diff --git a/tests/psk_vectors.rs b/tests/psk_vectors.rs
index bd941c7..c6c31f0 100644
--- a/tests/psk_vectors.rs
+++ b/tests/psk_vectors.rs
@@ -8,13 +8,21 @@
//! here we pin the literal expected encodings.
use jerboa_typed_generated::jsecmon_typed_psk::{
- constant_time_eq_p, hex_decode, hex_encode, hex_string_p, psk_hex_32_p,
+ compute_proof, constant_time_eq_p, derive_auth_key, derive_transport_key, hex_decode,
+ hex_encode, hex_string_p, psk_hex_32_p, verify_proof,
};
fn hex(data: &[u8]) -> String {
String::from_utf8(hex_encode(data.to_vec())).unwrap()
}
+fn unhex(s: &str) -> Vec<u8> {
+ (0..s.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
+ .collect()
+}
+
#[test]
fn constant_time_eq_matches_secmon() {
// equal buffers compare equal
@@ -94,3 +102,56 @@ fn psk_hex_32_matches_from_hex_precondition() {
// the encoded form of a real 32-byte PSK passes its own precondition
assert!(psk_hex_32_p(hex(&[0x42u8; 32])));
}
+
+// Reference values for PSK=0x42*32, nonce=0..31, ts=1_700_000_000, computed
+// independently with Python's hashlib/hmac (RFC 5869 HKDF + SHA-256), so these
+// cross-check the sha2/hkdf kernels against a separate implementation.
+const PSK_42: [u8; 32] = [0x42u8; 32];
+
+#[test]
+fn from_bytes_derives_auth_and_transport_keys() {
+ // secmon from_bytes: HKDF-SHA256(no salt) under two info tags -> two keys.
+ assert_eq!(
+ derive_auth_key(PSK_42.to_vec()),
+ unhex("892b7692f6f5283f9eccc4cca42c66fc8839ae1386e163f05cdb0d258cebc93e")
+ );
+ assert_eq!(
+ derive_transport_key(PSK_42.to_vec()),
+ unhex("ba1cc1ebdc48f9f07fa555807dae410a13093ed5d6375919ae3766cf7d748091")
+ );
+ // distinct info tags must give distinct keys (domain separation).
+ assert_ne!(derive_auth_key(PSK_42.to_vec()), derive_transport_key(PSK_42.to_vec()));
+}
+
+#[test]
+fn compute_proof_matches_reference() {
+ // SHA256(auth_key ‖ nonce ‖ ts.to_le_bytes() ‖ b"secmon-challenge-proof").
+ let auth = derive_auth_key(PSK_42.to_vec());
+ let nonce: Vec<u8> = (0u8..32).collect();
+ let proof = compute_proof(auth, nonce, 1_700_000_000);
+ assert_eq!(
+ proof,
+ unhex("8f356b4ac6d808b5734493865260fba1d525aac1a8234b9198437e5fe7165108")
+ );
+}
+
+#[test]
+fn verify_proof_round_trips_and_rejects_tampering() {
+ // verify_response's security core: a proof from the same PSK/nonce/ts
+ // verifies; a wrong PSK, wrong nonce, or wrong timestamp does not.
+ let auth = derive_auth_key(PSK_42.to_vec());
+ let nonce: Vec<u8> = (0u8..32).collect();
+ let ts = 1_700_000_000i64;
+ let good = compute_proof(auth.clone(), nonce.clone(), ts);
+
+ assert!(verify_proof(auth.clone(), nonce.clone(), ts, good.clone()));
+ // a one-byte-flipped proof is rejected (constant-time compare)
+ let mut bad = good.clone();
+ bad[0] ^= 0x01;
+ assert!(!verify_proof(auth.clone(), nonce.clone(), ts, bad));
+ // wrong timestamp -> different proof -> rejected
+ assert!(!verify_proof(auth.clone(), nonce.clone(), ts + 1, good.clone()));
+ // wrong key (different PSK) -> rejected
+ let other = derive_auth_key([0x43u8; 32].to_vec());
+ assert!(!verify_proof(other, nonce, ts, good));
+}
diff --git a/typed/psk.ss b/typed/psk.ss
index da02708..65329d3 100644
--- a/typed/psk.ss
+++ b/typed/psk.ss
@@ -11,7 +11,8 @@
;;; are not things to reimplement in any language.
(typed-library (jsecmon typed psk)
- (export constant-time-eq? hex-encode hex-decode hex-string? psk-hex-32?)
+ (export constant-time-eq? hex-encode hex-decode hex-string? psk-hex-32?
+ derive-auth-key derive-transport-key compute-proof verify-proof)
;; --- constant-time comparison (psk.rs::constant_time_eq) ---
@@ -88,4 +89,30 @@
(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)))))))))
+ (hex-val (bytevector-u8-ref bs (+ (* 2 j) 1))))))))
+
+ ;; --- key derivation + challenge proof (psk.rs from_bytes / compute_proof) ---
+
+ ;; secmon from_bytes: HKDF-SHA256 with no salt, expanding the PSK under a
+ ;; per-purpose info tag into a 32-byte key. Empty salt == HKDF None (verified
+ ;; against RFC 5869 TC3 in the crypto kernel), so passing "" reproduces it.
+ (def (derive-auth-key (psk : Bytes)) : Bytes
+ (hkdf-sha256 (string->utf8 "") psk (string->utf8 "secmon-psk-auth-v1") 32))
+ (def (derive-transport-key (psk : Bytes)) : Bytes
+ (hkdf-sha256 (string->utf8 "") psk (string->utf8 "secmon-psk-transport-v1") 32))
+
+ ;; secmon compute_proof: SHA256(auth_key ‖ nonce ‖ timestamp.to_le_bytes() ‖
+ ;; b"secmon-challenge-proof"). The i64 timestamp is encoded little-endian.
+ (def (compute-proof (auth-key : Bytes) (nonce : Bytes) (timestamp : Int)) : Bytes
+ (sha256
+ (bytevector-append
+ (bytevector-append
+ (bytevector-append auth-key nonce)
+ (integer->le-bytes timestamp))
+ (string->utf8 "secmon-challenge-proof"))))
+
+ ;; the security core of verify_response: recompute the expected proof and
+ ;; compare it to the candidate in constant time. Timestamp-freshness (a clock
+ ;; read + magnitude check, no secret) stays with the untyped caller.
+ (def (verify-proof (auth-key : Bytes) (nonce : Bytes) (timestamp : Int) (candidate : Bytes)) : Bool
+ (constant-time-eq? (compute-proof auth-key nonce timestamp) candidate)))