Skip to main content

kk_crypto/
eka.rs

1// Copyright (c) 2026 John A Keeney, Entrouter. All rights reserved.
2// Licensed under the Apache License, Version 2.0 with Additional Terms.
3// NO COMMERCIAL USE without prior written authorization from Entrouter.
4// Unauthorized commercial use will be prosecuted to the fullest extent of the law.
5// See the LICENSE file in the project root for full license information.
6// NOTICE: Removal of this header is a violation of the license.
7
8//! # KK Entropy Key Agreement (KK-EKA)
9//!
10//! A 3-message PSK-based key agreement protocol where both parties
11//! contribute fresh entropy. No public-key cryptography - authentication
12//! is via KK-MAC over a pre-shared key.
13//!
14//! ## Protocol Flow
15//!
16//! ```text
17//! Alice (Initiator)                           Bob (Responder)
18//! ─────────────────                           ───────────────
19//! entropy_a = gather()
20//! commit_a = kk_hash(serialize(entropy_a))
21//!
22//!     ──── msg1: commit_a (32B) ──────────────►
23//!                                              entropy_b = gather()
24//!                                              auth_b = kk_mac(psk, entropy_b || commit_a)
25//!     ◄──── msg2: entropy_b (48B) + auth_b (32B)
26//!
27//! verify auth_b
28//! auth_a = kk_mac(psk, entropy_a || entropy_b)
29//!
30//!     ──── msg3: entropy_a (48B) + auth_a (32B) ►
31//!                                              verify commit_a == kk_hash(entropy_a)
32//!                                              verify auth_a
33//!
34//! BOTH: session_key = kk_kdf(psk, entropy_a || entropy_b, "KK-EKA-session", 32)
35//! BOTH: zeroize ephemeral state
36//! ```
37//!
38//! ## Security Properties
39//!
40//! - **Forward secrecy** - session key depends on ephemeral entropy from BOTH parties
41//! - **Mutual authentication** - both prove PSK knowledge via kk_mac
42//! - **Contributory** - neither party alone controls the session key
43//! - **Commitment binding** - Alice commits to entropy BEFORE seeing Bob's
44//! - **Temporal freshness** - entropy snapshots carry nanosecond timestamps
45
46use zeroize::Zeroize;
47
48use crate::entropy::{self, EntropySnapshot};
49use crate::error::{KkError, Result};
50use crate::kk_mix;
51
52/// KK-EKA session label used for key derivation.
53const EKA_SESSION_INFO: &[u8] = b"KK-EKA-session";
54
55// ─── Wire types ──────────────────────────────────────────────────────────────
56
57/// Message 1: Alice's commitment to her entropy (32 bytes).
58#[derive(Clone)]
59pub struct EkaMsg1 {
60    pub commit: [u8; 32],
61}
62
63impl EkaMsg1 {
64    pub const BYTES: usize = 32;
65
66    pub fn to_bytes(&self) -> Vec<u8> {
67        self.commit.to_vec()
68    }
69
70    pub fn from_bytes(data: &[u8]) -> Result<Self> {
71        if data.len() < Self::BYTES {
72            return Err(KkError::InvalidPacket("EkaMsg1 too short".into()));
73        }
74        let mut commit = [0u8; 32];
75        commit.copy_from_slice(&data[..32]);
76        Ok(Self { commit })
77    }
78}
79
80/// Message 2: Bob's entropy (48B) + authentication tag (32B) = 80 bytes.
81#[derive(Clone)]
82pub struct EkaMsg2 {
83    pub entropy_b_bytes: [u8; 48],
84    pub auth_b: [u8; 32],
85}
86
87impl EkaMsg2 {
88    pub const BYTES: usize = 80;
89
90    pub fn to_bytes(&self) -> Vec<u8> {
91        let mut out = Vec::with_capacity(Self::BYTES);
92        out.extend_from_slice(&self.entropy_b_bytes);
93        out.extend_from_slice(&self.auth_b);
94        out
95    }
96
97    pub fn from_bytes(data: &[u8]) -> Result<Self> {
98        if data.len() < Self::BYTES {
99            return Err(KkError::InvalidPacket("EkaMsg2 too short".into()));
100        }
101        let mut entropy_b_bytes = [0u8; 48];
102        entropy_b_bytes.copy_from_slice(&data[..48]);
103        let mut auth_b = [0u8; 32];
104        auth_b.copy_from_slice(&data[48..80]);
105        Ok(Self {
106            entropy_b_bytes,
107            auth_b,
108        })
109    }
110}
111
112/// Message 3: Alice's entropy (48B) + authentication tag (32B) = 80 bytes.
113#[derive(Clone)]
114pub struct EkaMsg3 {
115    pub entropy_a_bytes: [u8; 48],
116    pub auth_a: [u8; 32],
117}
118
119impl EkaMsg3 {
120    pub const BYTES: usize = 80;
121
122    pub fn to_bytes(&self) -> Vec<u8> {
123        let mut out = Vec::with_capacity(Self::BYTES);
124        out.extend_from_slice(&self.entropy_a_bytes);
125        out.extend_from_slice(&self.auth_a);
126        out
127    }
128
129    pub fn from_bytes(data: &[u8]) -> Result<Self> {
130        if data.len() < Self::BYTES {
131            return Err(KkError::InvalidPacket("EkaMsg3 too short".into()));
132        }
133        let mut entropy_a_bytes = [0u8; 48];
134        entropy_a_bytes.copy_from_slice(&data[..48]);
135        let mut auth_a = [0u8; 32];
136        auth_a.copy_from_slice(&data[48..80]);
137        Ok(Self {
138            entropy_a_bytes,
139            auth_a,
140        })
141    }
142}
143
144// ─── Initiator (Alice) ──────────────────────────────────────────────────────
145
146/// Alice's side of the KK-EKA protocol.
147///
148/// Created via `new()`, which gathers entropy and produces `EkaMsg1`.
149/// After receiving Bob's `EkaMsg2`, call `process_msg2()` to complete
150/// the handshake and derive the session key.
151pub struct EkaInitiator {
152    psk: Vec<u8>,
153    #[allow(dead_code)] // kept for zeroization on Drop
154    entropy_a: EntropySnapshot,
155    entropy_a_serialized: Vec<u8>,
156    commit_a: [u8; 32],
157}
158
159impl Drop for EkaInitiator {
160    fn drop(&mut self) {
161        self.psk.zeroize();
162        self.entropy_a_serialized.zeroize();
163        self.commit_a.zeroize();
164        // EntropySnapshot has its own Drop
165    }
166}
167
168impl EkaInitiator {
169    /// Begin the KK-EKA handshake as initiator.
170    ///
171    /// Gathers fresh entropy and returns `(Self, EkaMsg1)` where `EkaMsg1`
172    /// contains the commitment hash to send to the responder.
173    pub fn new(psk: &[u8]) -> Result<(Self, EkaMsg1)> {
174        let entropy_a = entropy::gather()?;
175        Self::new_with_entropy(psk, entropy_a)
176    }
177
178    /// Begin the handshake with a caller-supplied entropy snapshot.
179    ///
180    /// This exists for deterministic testing. Production code should use `new()`.
181    #[doc(hidden)]
182    pub fn new_with_entropy(psk: &[u8], entropy_a: EntropySnapshot) -> Result<(Self, EkaMsg1)> {
183        let entropy_a_serialized = entropy_a.to_bytes();
184        let commit_a = kk_mix::kk_hash(&entropy_a_serialized);
185
186        let msg1 = EkaMsg1 { commit: commit_a };
187        let state = Self {
188            psk: psk.to_vec(),
189            entropy_a,
190            entropy_a_serialized,
191            commit_a,
192        };
193        Ok((state, msg1))
194    }
195
196    /// Process Bob's response and complete the handshake.
197    ///
198    /// Verifies Bob's authentication tag, produces `EkaMsg3` for Bob,
199    /// and derives the shared session key. Consumes `self` - the
200    /// initiator state is zeroized on drop.
201    pub fn process_msg2(self, msg2: &EkaMsg2) -> Result<(EkaMsg3, [u8; 32])> {
202        // Verify auth_b = kk_mac(psk, entropy_b || commit_a)
203        let mut auth_b_message = Vec::with_capacity(48 + 32);
204        auth_b_message.extend_from_slice(&msg2.entropy_b_bytes);
205        auth_b_message.extend_from_slice(&self.commit_a);
206
207        if !kk_mix::kk_mac_verify(&self.psk, &auth_b_message, &msg2.auth_b) {
208            return Err(KkError::CommitmentMismatch);
209        }
210
211        // auth_a = kk_mac(psk, entropy_a_serialized || entropy_b)
212        let mut auth_a_message = Vec::with_capacity(48 + 48);
213        auth_a_message.extend_from_slice(&self.entropy_a_serialized);
214        auth_a_message.extend_from_slice(&msg2.entropy_b_bytes);
215        let auth_a = kk_mix::kk_mac(&self.psk, &auth_a_message);
216
217        let mut entropy_a_bytes = [0u8; 48];
218        entropy_a_bytes.copy_from_slice(&self.entropy_a_serialized);
219
220        let msg3 = EkaMsg3 {
221            entropy_a_bytes,
222            auth_a,
223        };
224
225        // Derive session key = kk_kdf(psk, entropy_a || entropy_b, "KK-EKA-session", 32)
226        let mut salt = Vec::with_capacity(48 + 48);
227        salt.extend_from_slice(&self.entropy_a_serialized);
228        salt.extend_from_slice(&msg2.entropy_b_bytes);
229        let session_key_vec = kk_mix::kk_kdf(&self.psk, &salt, EKA_SESSION_INFO, 32);
230        let mut session_key = [0u8; 32];
231        session_key.copy_from_slice(&session_key_vec);
232
233        Ok((msg3, session_key))
234    }
235}
236
237// ─── Responder (Bob) ────────────────────────────────────────────────────────
238
239/// Bob's side of the KK-EKA protocol.
240///
241/// Created via `new()`, which gathers entropy and produces `EkaMsg2`.
242/// After receiving Alice's `EkaMsg3`, call `process_msg3()` to verify
243/// and derive the session key.
244pub struct EkaResponder {
245    psk: Vec<u8>,
246    entropy_b_serialized: Vec<u8>,
247    commit_a: [u8; 32],
248}
249
250impl Drop for EkaResponder {
251    fn drop(&mut self) {
252        self.psk.zeroize();
253        self.entropy_b_serialized.zeroize();
254        self.commit_a.zeroize();
255    }
256}
257
258impl EkaResponder {
259    /// Begin the KK-EKA handshake as responder.
260    ///
261    /// Gathers fresh entropy, authenticates with a MAC, and returns
262    /// `(Self, EkaMsg2)` to send back to the initiator.
263    pub fn new(psk: &[u8], msg1: &EkaMsg1) -> Result<(Self, EkaMsg2)> {
264        let entropy_b = entropy::gather()?;
265        Self::new_with_entropy(psk, msg1, entropy_b)
266    }
267
268    /// Begin the handshake with a caller-supplied entropy snapshot.
269    ///
270    /// This exists for deterministic testing. Production code should use `new()`.
271    #[doc(hidden)]
272    pub fn new_with_entropy(
273        psk: &[u8],
274        msg1: &EkaMsg1,
275        entropy_b: EntropySnapshot,
276    ) -> Result<(Self, EkaMsg2)> {
277        let entropy_b_serialized = entropy_b.to_bytes();
278
279        // auth_b = kk_mac(psk, entropy_b_serialized || commit_a)
280        let mut auth_b_message = Vec::with_capacity(48 + 32);
281        auth_b_message.extend_from_slice(&entropy_b_serialized);
282        auth_b_message.extend_from_slice(&msg1.commit);
283
284        let auth_b = kk_mix::kk_mac(psk, &auth_b_message);
285
286        let mut entropy_b_bytes = [0u8; 48];
287        entropy_b_bytes.copy_from_slice(&entropy_b_serialized);
288
289        let msg2 = EkaMsg2 {
290            entropy_b_bytes,
291            auth_b,
292        };
293
294        let state = Self {
295            psk: psk.to_vec(),
296            entropy_b_serialized,
297            commit_a: msg1.commit,
298        };
299        Ok((state, msg2))
300    }
301
302    /// Process Alice's final message and derive the session key.
303    ///
304    /// Verifies Alice's commitment (hash matches revealed entropy)
305    /// and her authentication tag, then derives the shared session key.
306    /// Consumes `self` - state is zeroized on drop.
307    pub fn process_msg3(self, msg3: &EkaMsg3) -> Result<[u8; 32]> {
308        // Verify commitment: kk_hash(entropy_a) must match commit_a from msg1
309        let recomputed_commit = kk_mix::kk_hash(&msg3.entropy_a_bytes);
310        if recomputed_commit != self.commit_a {
311            return Err(KkError::CommitmentMismatch);
312        }
313
314        // Verify auth_a = kk_mac(psk, entropy_a || entropy_b)
315        let mut auth_a_message = Vec::with_capacity(48 + 48);
316        auth_a_message.extend_from_slice(&msg3.entropy_a_bytes);
317        auth_a_message.extend_from_slice(&self.entropy_b_serialized);
318        if !kk_mix::kk_mac_verify(&self.psk, &auth_a_message, &msg3.auth_a) {
319            return Err(KkError::CommitmentMismatch);
320        }
321
322        // Derive session key = kk_kdf(psk, entropy_a || entropy_b, "KK-EKA-session", 32)
323        let mut salt = Vec::with_capacity(48 + 48);
324        salt.extend_from_slice(&msg3.entropy_a_bytes);
325        salt.extend_from_slice(&self.entropy_b_serialized);
326        let session_key_vec = kk_mix::kk_kdf(&self.psk, &salt, EKA_SESSION_INFO, 32);
327        let mut session_key = [0u8; 32];
328        session_key.copy_from_slice(&session_key_vec);
329
330        Ok(session_key)
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn eka_happy_path_live_entropy() {
340        let psk = b"test-psk-for-eka";
341
342        // Alice starts
343        let (alice, msg1) = EkaInitiator::new(psk).unwrap();
344
345        // Bob responds
346        let (bob, msg2) = EkaResponder::new(psk, &msg1).unwrap();
347
348        // Alice completes
349        let (msg3, alice_key) = alice.process_msg2(&msg2).unwrap();
350
351        // Bob completes
352        let bob_key = bob.process_msg3(&msg3).unwrap();
353
354        // Both must derive the same session key
355        assert_eq!(alice_key, bob_key);
356        // Key must not be all zeros
357        assert_ne!(alice_key, [0u8; 32]);
358    }
359
360    #[test]
361    fn eka_wire_format_sizes() {
362        assert_eq!(EkaMsg1::BYTES, 32);
363        assert_eq!(EkaMsg2::BYTES, 80);
364        assert_eq!(EkaMsg3::BYTES, 80);
365
366        let psk = b"size-test";
367        let (_, msg1) = EkaInitiator::new(psk).unwrap();
368        assert_eq!(msg1.to_bytes().len(), 32);
369
370        let (_, msg2) = EkaResponder::new(psk, &msg1).unwrap();
371        assert_eq!(msg2.to_bytes().len(), 80);
372    }
373
374    #[test]
375    fn eka_msg_roundtrip() {
376        let psk = b"roundtrip-test";
377        let (alice, msg1) = EkaInitiator::new(psk).unwrap();
378
379        // msg1 roundtrip
380        let msg1_bytes = msg1.to_bytes();
381        let msg1_restored = EkaMsg1::from_bytes(&msg1_bytes).unwrap();
382        assert_eq!(msg1.commit, msg1_restored.commit);
383
384        // msg2 roundtrip
385        let (bob, msg2) = EkaResponder::new(psk, &msg1).unwrap();
386        let msg2_bytes = msg2.to_bytes();
387        let msg2_restored = EkaMsg2::from_bytes(&msg2_bytes).unwrap();
388        assert_eq!(msg2.entropy_b_bytes, msg2_restored.entropy_b_bytes);
389        assert_eq!(msg2.auth_b, msg2_restored.auth_b);
390
391        // msg3 roundtrip
392        let (msg3, _) = alice.process_msg2(&msg2).unwrap();
393        let msg3_bytes = msg3.to_bytes();
394        let msg3_restored = EkaMsg3::from_bytes(&msg3_bytes).unwrap();
395        assert_eq!(msg3.entropy_a_bytes, msg3_restored.entropy_a_bytes);
396        assert_eq!(msg3.auth_a, msg3_restored.auth_a);
397
398        // Bob accepts restored msg3
399        let key = bob.process_msg3(&msg3_restored).unwrap();
400        assert_ne!(key, [0u8; 32]);
401    }
402
403    #[test]
404    fn eka_different_sessions_different_keys() {
405        let psk = b"same-psk";
406
407        let (alice1, msg1_a) = EkaInitiator::new(psk).unwrap();
408        let (bob1, msg2_a) = EkaResponder::new(psk, &msg1_a).unwrap();
409        let (msg3_a, key_a) = alice1.process_msg2(&msg2_a).unwrap();
410        let _ = bob1.process_msg3(&msg3_a).unwrap();
411
412        let (alice2, msg1_b) = EkaInitiator::new(psk).unwrap();
413        let (bob2, msg2_b) = EkaResponder::new(psk, &msg1_b).unwrap();
414        let (msg3_b, key_b) = alice2.process_msg2(&msg2_b).unwrap();
415        let _ = bob2.process_msg3(&msg3_b).unwrap();
416
417        assert_ne!(
418            key_a, key_b,
419            "different sessions must derive different keys"
420        );
421    }
422}