kk_crypto/session.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 Rope Ratchet: Forward Secrecy
9//!
10//! A 4-strand ratchet that provides ~192-bit forward secrecy using
11//! only KK primitives. Once a message key is derived and the ratchet
12//! advances, the old state is zeroized and irrecoverable.
13//!
14//! ## Strands
15//!
16//! | Strand | Source | Purpose |
17//! |-----------|-------------------|--------------------------------------|
18//! | Entropy | `EntropySnapshot` | Environmental randomness per message |
19//! | Temporal | `ε.timestamp` | Binds ratchet to real-world time |
20//! | Chain | Previous chain | One-way forward secrecy |
21//! | Counter | Monotonic `u64` | Deterministic ordering |
22//!
23//! ## The KK Innovation
24//!
25//! All 4 strand outputs are fed into a single KK sponge absorb phase
26//! with entropy-derived rotations. The 32-round permutation (960 MFR +
27//! 480 DDR operations) mixes all strands simultaneously, and the
28//! cipher's mathematical structure changes per message because the
29//! rotation schedule is derived from the entropy snapshot.
30//!
31//! This is fundamentally different from existing ratchet designs
32//! (Signal Double Ratchet, etc.) where the cipher is fixed and only
33//! the keys change. In KK, both the key AND the algebraic structure
34//! of the permutation change with every message.
35//!
36//! ## Security
37//!
38//! - ~192-bit forward secrecy (384-bit sponge capacity)
39//! - Per-message cipher structure rotation via entropy-derived schedules
40//! - 4 independent entropy sources mixed through 32-round permutation
41//! - Stronger than Signal's Double Ratchet (~128-bit DH security)
42//!
43//! ## Protocol
44//!
45//! ```text
46//! Sender: packet = encode_session(&mut send_ratchet, plaintext)
47//! transmit(packet.to_bytes())
48//!
49//! Receiver: packet = RopePacket::from_bytes(&wire_data)
50//! plaintext = decode_session(&mut recv_ratchet, &packet)
51//! ```
52//!
53//! For bidirectional communication, each party creates two ratchets
54//! with different contexts (one for sending, one for receiving).
55//!
56//! Messages must be processed in order (strict counter synchronization).
57
58use crate::codec::{self, KkAeadPacket, KkPacket};
59use crate::entropy::{self, EntropySnapshot};
60use crate::error::{KkError, Result};
61use crate::kk_mix;
62use zeroize::Zeroize;
63
64/// Domain separation byte for the Rope Ratchet sponge (0x04).
65///
66/// Distinct from DOMAIN_HASH (0x01), DOMAIN_KDF (0x02), DOMAIN_MAC (0x03).
67const DOMAIN_SESSION: &[u8] = b"KK-rope-mix-v1";
68
69// KDF info labels for strand evolution
70const STRAND_ENT_INFO: &[u8] = b"KK-rope-ent-v1";
71const STRAND_TMP_INFO: &[u8] = b"KK-rope-tmp-v1";
72const STRAND_CHN_INFO: &[u8] = b"KK-rope-chn-v1";
73
74// KDF info labels for initial strand derivation from shared secret
75const INIT_ENT_INFO: &[u8] = b"KK-rope-init-ent";
76const INIT_TMP_INFO: &[u8] = b"KK-rope-init-tmp";
77const INIT_CHN_INFO: &[u8] = b"KK-rope-init-chn";
78
79// ─────────────────────────────────────────────────────────────────
80// RopeStep: metadata for one ratchet advance
81// ─────────────────────────────────────────────────────────────────
82
83/// Metadata from a single ratchet step, embedded in messages so the
84/// receiver can perform the same derivation.
85///
86/// Contains the entropy snapshot (the unrepeatable moment) and the
87/// message counter (for ordering). Both are needed to reproduce
88/// the exact strand evolution on the receiving side.
89#[derive(Clone)]
90pub struct RopeStep {
91 /// The entropy snapshot captured during this step.
92 pub snapshot: EntropySnapshot,
93 /// The message counter at this step.
94 pub counter: u64,
95}
96
97impl RopeStep {
98 /// Serialized size: 8 (counter) + 48 (snapshot) = 56 bytes.
99 pub const BYTES: usize = 8 + 48;
100
101 /// Serialize the step metadata for transmission.
102 pub fn to_bytes(&self) -> Vec<u8> {
103 let mut out = Vec::with_capacity(Self::BYTES);
104 out.extend_from_slice(&self.counter.to_le_bytes());
105 out.extend_from_slice(&self.snapshot.to_bytes());
106 out
107 }
108
109 /// Deserialize step metadata from received bytes.
110 pub fn from_bytes(data: &[u8]) -> Result<Self> {
111 if data.len() < Self::BYTES {
112 return Err(KkError::InvalidPacket(format!(
113 "rope step too short: need {}, got {}",
114 Self::BYTES,
115 data.len()
116 )));
117 }
118 let counter = u64::from_le_bytes(
119 data[..8]
120 .try_into()
121 .map_err(|_| KkError::InvalidPacket("bad counter bytes".into()))?,
122 );
123 let snapshot = EntropySnapshot::from_bytes(&data[8..56])?;
124 Ok(Self { snapshot, counter })
125 }
126}
127
128// ─────────────────────────────────────────────────────────────────
129// RopeRatchet: the 4-strand forward-secret ratchet
130// ─────────────────────────────────────────────────────────────────
131
132/// A forward-secret session ratchet built on 4 strands mixed through
133/// the KK sponge.
134///
135/// Each call to [`advance`](Self::advance) or [`receive`](Self::receive)
136/// irreversibly evolves the internal state. Old message keys become
137/// unrecoverable, providing forward secrecy at ~192-bit security
138/// (inherited from the KK sponge's 384-bit capacity).
139///
140/// # Two-Way Communication
141///
142/// For bidirectional messaging, create two ratchets with different
143/// contexts:
144///
145/// ```rust
146/// use kk_crypto::session::RopeRatchet;
147///
148/// let secret = b"shared-secret";
149/// let mut send = RopeRatchet::new(secret, b"alice-to-bob").unwrap();
150/// let mut recv = RopeRatchet::new(secret, b"bob-to-alice").unwrap();
151/// ```
152///
153/// Alice uses `send` for outgoing and a second ratchet initialized
154/// with `b"bob-to-alice"` for incoming. Bob mirrors this.
155pub struct RopeRatchet {
156 /// Entropy strand: evolved from environmental randomness.
157 entropy_strand: [u8; 32],
158 /// Temporal strand: evolved from timestamps.
159 temporal_strand: [u8; 32],
160 /// Chain strand: one-way ratchet for forward secrecy.
161 chain_strand: [u8; 32],
162 /// Monotonically increasing message counter.
163 counter: u64,
164}
165
166impl Drop for RopeRatchet {
167 fn drop(&mut self) {
168 self.entropy_strand.zeroize();
169 self.temporal_strand.zeroize();
170 self.chain_strand.zeroize();
171 self.counter = 0;
172 }
173}
174
175impl RopeRatchet {
176 /// Create a new ratchet from a shared secret and direction context.
177 ///
178 /// The `context` parameter provides domain separation between
179 /// directions (e.g., `b"alice-to-bob"` vs `b"bob-to-alice"`).
180 /// Two ratchets with the same `shared_secret` but different
181 /// `context` values produce completely independent key streams.
182 ///
183 /// Both parties must use identical `(shared_secret, context)` pairs
184 /// for the same communication direction.
185 pub fn new(shared_secret: &[u8], context: &[u8]) -> Result<Self> {
186 // Hash the context to produce a fixed-size salt for KDF
187 let salt = kk_mix::kk_hash(context);
188
189 // Derive independent initial seeds for each strand
190 let mut e = kk_mix::kk_kdf(shared_secret, &salt, INIT_ENT_INFO, 32);
191 let mut t = kk_mix::kk_kdf(shared_secret, &salt, INIT_TMP_INFO, 32);
192 let mut c = kk_mix::kk_kdf(shared_secret, &salt, INIT_CHN_INFO, 32);
193
194 let mut entropy_strand = [0u8; 32];
195 let mut temporal_strand = [0u8; 32];
196 let mut chain_strand = [0u8; 32];
197
198 entropy_strand.copy_from_slice(&e);
199 temporal_strand.copy_from_slice(&t);
200 chain_strand.copy_from_slice(&c);
201
202 e.zeroize();
203 t.zeroize();
204 c.zeroize();
205
206 Ok(Self {
207 entropy_strand,
208 temporal_strand,
209 chain_strand,
210 counter: 0,
211 })
212 }
213
214 /// Advance the ratchet and derive a message key (sender side).
215 ///
216 /// Gathers fresh entropy, evolves all 4 strands, and mixes them
217 /// through the KK sponge with entropy-derived rotations.
218 ///
219 /// Returns the 32-byte message key and a [`RopeStep`] that must
220 /// be included in the transmitted message so the receiver can
221 /// derive the same key.
222 ///
223 /// # Security
224 ///
225 /// The returned message key is sensitive material. Zeroize it
226 /// after use. The old chain state is destroyed during this call,
227 /// providing forward secrecy.
228 pub fn advance(&mut self) -> Result<([u8; 32], RopeStep)> {
229 let snapshot = entropy::gather()?;
230 let key = self.step(&snapshot)?;
231 let step = RopeStep {
232 snapshot,
233 counter: self.counter,
234 };
235 Ok((key, step))
236 }
237
238 /// Advance the ratchet using received step metadata (receiver side).
239 ///
240 /// Uses the sender's entropy snapshot and counter from the
241 /// [`RopeStep`] to reproduce the exact same derivation, yielding
242 /// the identical message key.
243 ///
244 /// # Errors
245 ///
246 /// Returns `KkError::InvalidPacket` if the counter is not exactly
247 /// one more than the current counter (strict ordering).
248 pub fn receive(&mut self, step: &RopeStep) -> Result<[u8; 32]> {
249 let expected = self.counter + 1;
250 if step.counter != expected {
251 return Err(KkError::InvalidPacket(format!(
252 "counter mismatch: expected {expected}, got {} (strict ordering)",
253 step.counter
254 )));
255 }
256 self.step(&step.snapshot)
257 }
258
259 /// The current message counter (0 = no messages yet).
260 pub fn counter(&self) -> u64 {
261 self.counter
262 }
263
264 /// Advance with a caller-supplied snapshot (deterministic, for test vectors).
265 #[doc(hidden)]
266 pub fn advance_with_snapshot(
267 &mut self,
268 snapshot: EntropySnapshot,
269 ) -> Result<([u8; 32], RopeStep)> {
270 let key = self.step(&snapshot)?;
271 let step = RopeStep {
272 snapshot,
273 counter: self.counter,
274 };
275 Ok((key, step))
276 }
277
278 /// Core ratchet step: evolve all 4 strands and mix through sponge.
279 ///
280 /// This is where the KK innovation lives. All 4 strand outputs
281 /// are concatenated and fed into `kk_kdf` as the key, with the
282 /// entropy snapshot bytes as salt. Internally, `kk_kdf` creates
283 /// a sponge with entropy-derived rotations and runs the full
284 /// 32-round permutation (960 MFR + 480 DDR), mixing all strands
285 /// simultaneously. The cipher's mathematical structure changes
286 /// per message because the rotation schedule is derived from the
287 /// entropy snapshot.
288 fn step(&mut self, snapshot: &EntropySnapshot) -> Result<[u8; 32]> {
289 // ── Evolve entropy strand ──
290 // New entropy strand = KK-KDF(old_entropy, snapshot.bytes, domain)
291 let mut e_new = kk_mix::kk_kdf(&self.entropy_strand, &snapshot.bytes, STRAND_ENT_INFO, 32);
292 self.entropy_strand.copy_from_slice(&e_new);
293 e_new.zeroize();
294
295 // ── Evolve temporal strand ──
296 // New temporal strand = KK-KDF(old_temporal, timestamp_bytes, domain)
297 let ts_bytes = snapshot.timestamp_nanos.to_le_bytes();
298 let mut t_new = kk_mix::kk_kdf(&self.temporal_strand, &ts_bytes, STRAND_TMP_INFO, 32);
299 self.temporal_strand.copy_from_slice(&t_new);
300 t_new.zeroize();
301
302 // ── Evolve chain strand ──
303 // Increment counter first (counter 0 = no messages, first message = 1)
304 self.counter += 1;
305 let ctr_bytes = self.counter.to_le_bytes();
306 let mut c_new = kk_mix::kk_kdf(&self.chain_strand, &ctr_bytes, STRAND_CHN_INFO, 32);
307 self.chain_strand.copy_from_slice(&c_new);
308 c_new.zeroize();
309
310 // ── THE KK INNOVATION ──
311 // Concatenate all 4 strand outputs into a single block.
312 // This is fed as the key into kk_kdf, with the entropy snapshot
313 // as salt. Internally, kk_kdf creates a sponge with entropy-
314 // derived rotations (with_entropy_rotations(salt)), so the
315 // permutation structure itself, the algebra, changes per message.
316 // The 32-round permutation (960 MFR + 480 DDR) mixes all 4 strands
317 // simultaneously. No separate combine step needed.
318 let mut combined = Vec::with_capacity(104);
319 combined.extend_from_slice(&self.entropy_strand); // 32 bytes
320 combined.extend_from_slice(&self.temporal_strand); // 32 bytes
321 combined.extend_from_slice(&self.chain_strand); // 32 bytes
322 combined.extend_from_slice(&ctr_bytes); // 8 bytes
323
324 // kk_kdf(key=all_strands, salt=entropy, info=domain, len=64)
325 // → sponge with entropy-derived rotations absorbs everything
326 // → 32-round permutation mixes all strand material
327 // → squeeze 64 bytes: 32 for new chain + 32 for message key
328 let mut output = kk_mix::kk_kdf(&combined, &snapshot.bytes, DOMAIN_SESSION, 64);
329 combined.zeroize();
330
331 // First 32 bytes: new chain key (replaces current, forward secrecy).
332 // The old chain_strand value is gone, you cannot go backwards.
333 self.chain_strand.copy_from_slice(&output[..32]);
334
335 // Second 32 bytes: message key (returned to caller).
336 let mut message_key = [0u8; 32];
337 message_key.copy_from_slice(&output[32..64]);
338
339 output.zeroize();
340
341 Ok(message_key)
342 }
343}
344
345// ─────────────────────────────────────────────────────────────────
346// RopePacket: encrypted message with forward secrecy
347// ─────────────────────────────────────────────────────────────────
348
349/// An encrypted message with forward secrecy.
350///
351/// Contains the ratchet step metadata (so the receiver can derive the
352/// same message key) and the inner [`KkPacket`] (the actual encrypted
353/// payload, which carries its own entropy snapshot and integrity
354/// commitment).
355///
356/// Double entropy: the ratchet step uses one `EntropySnapshot` for
357/// key derivation, and the inner `KkPacket` captures its own independent
358/// snapshot for per-symbol encryption. Two unrepeatable moments per message.
359///
360/// ## Wire Format
361///
362/// ```text
363/// [8-byte counter][48-byte ratchet snapshot][inner KkPacket bytes...]
364/// ```
365#[derive(Clone)]
366pub struct RopePacket {
367 /// Ratchet step metadata (counter + entropy snapshot).
368 pub step: RopeStep,
369 /// The inner encrypted packet, keyed by the ratchet's message key.
370 pub inner: KkPacket,
371}
372
373impl RopePacket {
374 /// Serialize the full forward-secret packet for transmission.
375 pub fn to_bytes(&self) -> Vec<u8> {
376 let step_bytes = self.step.to_bytes();
377 let inner_bytes = self.inner.to_bytes();
378 let mut out = Vec::with_capacity(step_bytes.len() + inner_bytes.len());
379 out.extend_from_slice(&step_bytes);
380 out.extend_from_slice(&inner_bytes);
381 out
382 }
383
384 /// Deserialize a forward-secret packet from received bytes.
385 pub fn from_bytes(data: &[u8]) -> Result<Self> {
386 if data.len() < RopeStep::BYTES {
387 return Err(KkError::InvalidPacket(
388 "rope packet too short for step metadata".into(),
389 ));
390 }
391 let step = RopeStep::from_bytes(&data[..RopeStep::BYTES])?;
392 let inner = KkPacket::from_bytes(&data[RopeStep::BYTES..])?;
393 Ok(Self { step, inner })
394 }
395}
396
397// ─────────────────────────────────────────────────────────────────
398// High-level API: encode/decode with forward secrecy
399// ─────────────────────────────────────────────────────────────────
400
401/// Encode plaintext with forward secrecy.
402///
403/// Advances the ratchet, derives a per-message key, and encrypts
404/// the plaintext using the standard KK codec. The ratchet step
405/// metadata is embedded in the returned [`RopePacket`].
406///
407/// After this call, the old ratchet state is irreversibly destroyed.
408/// Even if the ratchet is later compromised, past message keys
409/// cannot be recovered.
410///
411/// # Example
412///
413/// ```rust
414/// use kk_crypto::session::{RopeRatchet, encode_session, decode_session};
415///
416/// let secret = b"shared-secret";
417/// let mut sender = RopeRatchet::new(secret, b"a-to-b").unwrap();
418/// let mut receiver = RopeRatchet::new(secret, b"a-to-b").unwrap();
419///
420/// let packet = encode_session(&mut sender, b"hello forward secrecy").unwrap();
421/// let plaintext = decode_session(&mut receiver, &packet).unwrap();
422/// assert_eq!(plaintext, b"hello forward secrecy");
423/// ```
424pub fn encode_session(ratchet: &mut RopeRatchet, plaintext: &[u8]) -> Result<RopePacket> {
425 let (mut message_key, step) = ratchet.advance()?;
426
427 // Use the ratchet's message key as the shared secret for the
428 // standard KK codec. The codec gathers its own independent entropy,
429 // adds its own temporal commitment, and produces a full KkPacket.
430 let inner = codec::encode(&message_key, plaintext)?;
431 message_key.zeroize();
432
433 Ok(RopePacket { step, inner })
434}
435
436/// Decode a forward-secret packet.
437///
438/// Advances the receiver's ratchet using the packet's step metadata,
439/// derives the same per-message key, and decrypts the inner
440/// [`KkPacket`].
441///
442/// # Errors
443///
444/// - `KkError::InvalidPacket` if the counter is out of sequence
445/// - `KkError::CommitmentMismatch` if the inner packet fails integrity
446pub fn decode_session(ratchet: &mut RopeRatchet, packet: &RopePacket) -> Result<Vec<u8>> {
447 let mut message_key = ratchet.receive(&packet.step)?;
448
449 let plaintext = codec::decode(&message_key, &packet.inner)?;
450 message_key.zeroize();
451
452 Ok(plaintext)
453}
454
455// ─────────────────────────────────────────────────────────────────
456// Session AEAD (forward secrecy + authenticated associated data)
457// ─────────────────────────────────────────────────────────────────
458
459/// A forward-secret AEAD packet: ratchet step + inner AEAD packet.
460///
461/// Combines the Rope Ratchet (forward secrecy, key evolution) with
462/// AEAD (authenticated associated data). The AAD is authenticated
463/// but not encrypted.
464#[derive(Clone)]
465pub struct RopeAeadPacket {
466 /// The ratchet step metadata (strand, counter, direction)
467 pub step: RopeStep,
468 /// The inner KK-AEAD packet
469 pub inner: KkAeadPacket,
470}
471
472impl RopeAeadPacket {
473 /// Serialize for transmission.
474 pub fn to_bytes(&self) -> Vec<u8> {
475 let step_bytes = self.step.to_bytes();
476 let inner_bytes = self.inner.to_bytes();
477 let mut out = Vec::with_capacity(step_bytes.len() + inner_bytes.len());
478 out.extend_from_slice(&step_bytes);
479 out.extend_from_slice(&inner_bytes);
480 out
481 }
482
483 /// Deserialize from received bytes.
484 pub fn from_bytes(data: &[u8]) -> Result<Self> {
485 let step = RopeStep::from_bytes(data)?;
486 let step_len = step.to_bytes().len();
487 let inner = KkAeadPacket::from_bytes(&data[step_len..])?;
488 Ok(Self { step, inner })
489 }
490}
491
492/// Encode a forward-secret AEAD packet.
493///
494/// Advances the ratchet, derives a per-message key, then encrypts
495/// the plaintext with AEAD (AAD is authenticated but not encrypted).
496pub fn encode_session_aead(
497 ratchet: &mut RopeRatchet,
498 plaintext: &[u8],
499 aad: &[u8],
500) -> Result<RopeAeadPacket> {
501 let (mut message_key, step) = ratchet.advance()?;
502 let inner = codec::encode_aead(&message_key, plaintext, aad)?;
503 message_key.zeroize();
504 Ok(RopeAeadPacket { step, inner })
505}
506
507/// Decode a forward-secret AEAD packet.
508///
509/// Advances the receiver's ratchet, derives the same per-message key,
510/// and decrypts the inner AEAD packet (verifying both ciphertext and AAD).
511pub fn decode_session_aead(ratchet: &mut RopeRatchet, packet: &RopeAeadPacket) -> Result<Vec<u8>> {
512 let mut message_key = ratchet.receive(&packet.step)?;
513 let plaintext = codec::decode_aead(&message_key, &packet.inner)?;
514 message_key.zeroize();
515 Ok(plaintext)
516}