phantom_protocol/crypto/kdf.rs
1//! Shared key-derivation helpers (Phase 4.1).
2//!
3//! Functions here are deterministic and side-agnostic: the client and the
4//! server feed identical inputs and obtain identical outputs. They live in
5//! `crypto/` rather than `transport/` so both the server handshake path
6//! (`transport::handshake`) and the client API path (`api::session`) can
7//! call them without a circular module dependency.
8
9use hkdf::Hkdf;
10use sha2::Sha256;
11
12/// 32-byte key derivation that matches `blake3::derive_key`'s API
13/// shape (label string + IKM bytes → `[u8; 32]`) and dispatches per
14/// the active build:
15///
16/// - Default: `blake3::derive_key(label, ikm)` — pure-Rust, infallible.
17/// - `--features fips`: `HKDF-SHA256` with empty salt, `info = label
18/// bytes`. FIPS 140-3 §C-approved (SP 800-108) so the same
19/// call-sites stay valid under the fips swap.
20///
21/// PANIC-SAFETY: HKDF-SHA256 `expand` only errors when the requested
22/// output length exceeds 255 * HashLen (255 * 32 = 8160 bytes for
23/// SHA-256). 32 bytes is well within that ceiling, so the
24/// `expect_used` is statically safe.
25#[inline]
26pub fn derive_key_32(label: &str, ikm: &[u8]) -> [u8; 32] {
27 #[cfg(not(feature = "fips"))]
28 {
29 blake3::derive_key(label, ikm)
30 }
31 #[cfg(feature = "fips")]
32 {
33 let hk = Hkdf::<Sha256>::new(None, ikm);
34 let mut out = [0u8; 32];
35 #[allow(clippy::expect_used)]
36 // PANIC-SAFETY: 32 bytes is far below HKDF-SHA256's 255 *
37 // HashLen output ceiling.
38 hk.expand(label.as_bytes(), &mut out)
39 .expect("HKDF-SHA256 expand: 32 bytes is within the SHA-256 output bound");
40 out
41 }
42}
43
44/// HKDF `info` label for the 0-RTT early-data AEAD key.
45const EARLY_DATA_KEY_INFO: &[u8] = b"phantom-early-data-key-v3";
46/// HKDF `info` label for the 0-RTT early-data AEAD nonce.
47const EARLY_DATA_NONCE_INFO: &[u8] = b"phantom-early-data-nonce-v3";
48
49/// Derive the AEAD `(key, nonce)` pair that protects 0-RTT early-data
50/// carried inside a V3 `ClientHello`.
51///
52/// Both peers hold the two inputs:
53/// - `resumption_secret` — the 32-byte secret a prior handshake produced;
54/// the server keeps it in its `SessionCache`, the client gets it from
55/// `Session::resumption_hint()`.
56/// - `client_nonce` — the fresh 32-byte nonce in *this* connect's
57/// `ClientHello`, visible to both sides.
58///
59/// Construction: HKDF-SHA256 with `client_nonce` as the salt and
60/// `resumption_secret` as the IKM, then two `expand` calls with
61/// distinct `info` labels — one for the 32-byte key, one for the
62/// 12-byte AEAD nonce. HKDF-SHA256 (not BLAKE3) keeps this path
63/// FIPS-eligible.
64///
65/// The output is single-use: the key is bound to one `client_nonce`,
66/// which is itself one-shot (the server consumes the resumption ticket
67/// on first sight — see `SessionCache::try_resume`). So a fixed,
68/// deterministically-derived nonce is safe — the `(key, nonce)` pair is
69/// never reused for a second encryption.
70pub fn derive_early_data_keying(
71 resumption_secret: &[u8; 32],
72 client_nonce: &[u8; 32],
73) -> ([u8; 32], [u8; 12]) {
74 let hk = Hkdf::<Sha256>::new(Some(client_nonce), resumption_secret);
75 let mut key = [0u8; 32];
76 let mut nonce = [0u8; 12];
77 // HKDF-Expand only fails when the requested length exceeds
78 // 255 * HashLen (255 * 32 = 8160 bytes for SHA-256). 32 and 12 are
79 // both far below that ceiling, so these expansions are infallible.
80 // PANIC-SAFETY: see above — the length precondition is a compile-time
81 // constant well within the HKDF bound.
82 #[allow(clippy::expect_used)]
83 hk.expand(EARLY_DATA_KEY_INFO, &mut key)
84 .expect("HKDF expand: 32 bytes is within the SHA-256 output bound");
85 #[allow(clippy::expect_used)]
86 hk.expand(EARLY_DATA_NONCE_INFO, &mut nonce)
87 .expect("HKDF expand: 12 bytes is within the SHA-256 output bound");
88 (key, nonce)
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn deterministic_same_inputs_same_outputs() {
97 // The whole point: client and server, given identical inputs,
98 // derive byte-identical keying material.
99 let secret = [0x11u8; 32];
100 let nonce = [0x22u8; 32];
101 let a = derive_early_data_keying(&secret, &nonce);
102 let b = derive_early_data_keying(&secret, &nonce);
103 assert_eq!(a.0, b.0, "key must be deterministic");
104 assert_eq!(a.1, b.1, "nonce must be deterministic");
105 }
106
107 #[test]
108 fn distinct_client_nonce_yields_distinct_keying() {
109 let secret = [0x11u8; 32];
110 let (k1, n1) = derive_early_data_keying(&secret, &[0x01u8; 32]);
111 let (k2, n2) = derive_early_data_keying(&secret, &[0x02u8; 32]);
112 assert_ne!(k1, k2, "different client_nonce must change the key");
113 assert_ne!(n1, n2, "different client_nonce must change the nonce");
114 }
115
116 #[test]
117 fn distinct_resumption_secret_yields_distinct_keying() {
118 let nonce = [0x22u8; 32];
119 let (k1, _) = derive_early_data_keying(&[0xAAu8; 32], &nonce);
120 let (k2, _) = derive_early_data_keying(&[0xBBu8; 32], &nonce);
121 assert_ne!(k1, k2, "different resumption_secret must change the key");
122 }
123
124 #[test]
125 fn key_and_nonce_are_independent() {
126 // The two HKDF expansions use distinct info labels, so the key
127 // bytes and nonce bytes are not a prefix/suffix of one another.
128 let (key, nonce) = derive_early_data_keying(&[0x33u8; 32], &[0x44u8; 32]);
129 assert_ne!(
130 &key[..12],
131 &nonce[..],
132 "key prefix must not equal the nonce"
133 );
134 }
135
136 #[test]
137 fn derive_key_32_is_deterministic() {
138 let a = derive_key_32("phantom-self-test", b"some-ikm-bytes");
139 let b = derive_key_32("phantom-self-test", b"some-ikm-bytes");
140 assert_eq!(a, b, "derive_key_32 must be deterministic across calls");
141 }
142
143 #[test]
144 fn derive_key_32_label_changes_output() {
145 let a = derive_key_32("phantom-label-a", b"ikm");
146 let b = derive_key_32("phantom-label-b", b"ikm");
147 assert_ne!(a, b, "different labels must produce different keys");
148 }
149
150 /// fips-only KAT: locks the HKDF-SHA256 construction used by
151 /// `derive_key_32`. A mismatch on a clean build means the
152 /// underlying `hkdf` / `sha2` crates changed behavior or that
153 /// the cfg dispatch in `derive_key_32` is broken.
154 #[cfg(feature = "fips")]
155 #[test]
156 fn derive_key_32_fips_kat() {
157 let out = derive_key_32("phantom-rekey-v1", &[0x11u8; 32]);
158 // KAT computed from `Hkdf::<Sha256>::new(None, &[0x11; 32])`
159 // then `expand(b"phantom-rekey-v1", &mut [0u8; 32])`. Matches
160 // the bytes baked into `crypto::self_tests::test_hkdf_sha256`.
161 const KAT: [u8; 32] = [
162 0x41, 0x90, 0x72, 0xe4, 0xca, 0x1b, 0xa9, 0xca, 0xdc, 0x1b, 0x02, 0xd3, 0x75, 0xb0,
163 0xf8, 0x84, 0x70, 0xa7, 0x0f, 0xe9, 0x57, 0x13, 0x1d, 0x7b, 0x5b, 0x35, 0xe5, 0x74,
164 0x14, 0x34, 0xe4, 0x10,
165 ];
166 assert_eq!(
167 out, KAT,
168 "derive_key_32 fips path must match HKDF-SHA256 KAT"
169 );
170 }
171}