nomad-protocol 0.2.0

NOMAD Protocol - Network-Optimized Mobile Application Datagram. A secure UDP-based state synchronization protocol.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
//! Crypto session management with anti-replay protection
//!
//! This module combines all cryptographic primitives into a high-level
//! CryptoSession that handles:
//! - Sending and receiving encrypted frames
//! - Nonce management
//! - Anti-replay protection via sliding window
//! - Epoch/counter tracking

use crate::core::{CryptoError, HASH_SIZE, REPLAY_WINDOW_SIZE};

use super::{
    aead::{construct_aad, decrypt, encrypt, SessionKey},
    nonce::{construct_nonce, Direction},
    rekey::{OldKeyRetention, RekeyState},
    Role, SessionId,
};

/// Anti-replay sliding window.
///
/// Per 1-SECURITY.md:
/// - Window size: 2048 bits minimum
/// - Below window: MUST reject
/// - Seen nonce: MUST reject
/// - Above highest: Update window
pub struct ReplayWindow {
    /// Bitmap for tracking seen nonces
    bitmap: [u64; REPLAY_WINDOW_SIZE / 64],
    /// Highest nonce seen so far
    highest: u64,
    /// Whether we've seen any packets yet
    initialized: bool,
}

impl ReplayWindow {
    /// Create a new replay window.
    pub fn new() -> Self {
        Self {
            bitmap: [0; REPLAY_WINDOW_SIZE / 64],
            highest: 0,
            initialized: false,
        }
    }

    /// Check if a nonce is a replay (without updating).
    pub fn is_replay(&self, nonce: u64) -> bool {
        if !self.initialized {
            return false;
        }

        if nonce > self.highest {
            return false;
        }

        let diff = self.highest - nonce;
        if diff >= REPLAY_WINDOW_SIZE as u64 {
            return true; // Below window
        }

        let bit_index = diff as usize;
        let word_index = bit_index / 64;
        let bit_offset = bit_index % 64;
        (self.bitmap[word_index] & (1 << bit_offset)) != 0
    }

    /// Check if a nonce is a replay and update the window.
    ///
    /// Returns Ok(()) if the nonce is valid (not seen before).
    /// Returns Err(ReplayDetected) if the nonce is a replay.
    ///
    /// Per 1-SECURITY.md, replay check MUST occur BEFORE AEAD verification.
    pub fn check_and_update(&mut self, nonce: u64) -> Result<(), CryptoError> {
        if !self.initialized {
            // First packet - initialize
            self.highest = nonce;
            self.mark_seen(nonce);
            self.initialized = true;
            return Ok(());
        }

        if nonce > self.highest {
            // Advance the window
            let shift = nonce - self.highest;
            self.shift_window(shift);
            self.highest = nonce;
            self.mark_seen(nonce);
            Ok(())
        } else {
            let diff = self.highest - nonce;
            if diff >= REPLAY_WINDOW_SIZE as u64 {
                // Too old - below window
                return Err(CryptoError::ReplayDetected);
            }

            // Check if already seen
            if self.is_seen(nonce) {
                return Err(CryptoError::ReplayDetected);
            }

            // Mark as seen
            self.mark_seen(nonce);
            Ok(())
        }
    }

    /// Check if a nonce has been seen (internal helper).
    fn is_seen(&self, nonce: u64) -> bool {
        if nonce > self.highest {
            return false;
        }
        let diff = self.highest - nonce;
        if diff >= REPLAY_WINDOW_SIZE as u64 {
            return true; // Treat below-window as "seen" (rejected)
        }
        let bit_index = diff as usize;
        let word_index = bit_index / 64;
        let bit_offset = bit_index % 64;
        (self.bitmap[word_index] & (1 << bit_offset)) != 0
    }

    /// Mark a nonce as seen.
    fn mark_seen(&mut self, nonce: u64) {
        if nonce > self.highest {
            return; // Will be marked after shift
        }
        let diff = self.highest - nonce;
        if diff >= REPLAY_WINDOW_SIZE as u64 {
            return; // Too old
        }
        let bit_index = diff as usize;
        let word_index = bit_index / 64;
        let bit_offset = bit_index % 64;
        self.bitmap[word_index] |= 1 << bit_offset;
    }

    /// Shift the window forward.
    ///
    /// When we receive a new highest nonce, we need to shift all existing bits
    /// to make room for the new highest at position 0.
    /// Bit position represents (highest - nonce), so older nonces have higher bit positions.
    fn shift_window(&mut self, shift: u64) {
        if shift >= REPLAY_WINDOW_SIZE as u64 {
            // Complete reset - all previous nonces fall outside the window
            self.bitmap = [0; REPLAY_WINDOW_SIZE / 64];
            return;
        }

        let shift_words = (shift / 64) as usize;
        let shift_bits = (shift % 64) as u32;

        // Shift whole words (towards higher indices = older nonces)
        if shift_words > 0 {
            // Shift from high to low to avoid overwriting
            for i in (shift_words..self.bitmap.len()).rev() {
                self.bitmap[i] = self.bitmap[i - shift_words];
            }
            // Clear the newly freed low words
            for word in self.bitmap.iter_mut().take(shift_words) {
                *word = 0;
            }
        }

        // Shift remaining bits within words (towards higher bit positions)
        if shift_bits > 0 {
            let mut carry = 0u64;
            // Process from highest index to lowest (shift bits up within each word)
            for i in (0..self.bitmap.len()).rev() {
                let new_carry = self.bitmap[i] >> (64 - shift_bits);
                self.bitmap[i] = (self.bitmap[i] << shift_bits) | carry;
                carry = new_carry;
            }
        }
    }

    /// Reset the window (e.g., after rekey).
    pub fn reset(&mut self) {
        self.bitmap = [0; REPLAY_WINDOW_SIZE / 64];
        self.highest = 0;
        self.initialized = false;
    }
}

impl Default for ReplayWindow {
    fn default() -> Self {
        Self::new()
    }
}

/// A complete crypto session for secure communication.
///
/// Combines key management, nonce construction, AEAD, and anti-replay
/// into a single interface.
pub struct CryptoSession {
    /// Session ID
    session_id: SessionId,
    /// Our role (initiator or responder)
    role: Role,
    /// Current send key
    send_key: SessionKey,
    /// Current receive key
    recv_key: SessionKey,
    /// Rekey state (epoch, counters)
    rekey_state: RekeyState,
    /// Replay window for incoming packets
    replay_window: ReplayWindow,
    /// Old key retention for late packets during rekey
    old_keys: OldKeyRetention,
    /// Handshake hash for key derivation (kept for session resumption)
    #[allow(dead_code)]
    handshake_hash: [u8; HASH_SIZE],
    /// Rekey authentication key for PCS (derived from static DH).
    /// This key is mixed into rekey KDF to ensure post-compromise security.
    /// Without this key, an attacker who compromises session keys cannot
    /// derive future rekey keys.
    rekey_auth_key: [u8; HASH_SIZE],
}

impl CryptoSession {
    /// Create a new crypto session after handshake completion.
    ///
    /// # Arguments
    /// * `session_id` - Unique session identifier
    /// * `role` - Our role (initiator or responder)
    /// * `send_key` - Initial send key
    /// * `recv_key` - Initial receive key
    /// * `handshake_hash` - Hash of the handshake transcript
    /// * `rekey_auth_key` - Key derived from static DH for PCS during rekey
    pub fn new(
        session_id: SessionId,
        role: Role,
        send_key: SessionKey,
        recv_key: SessionKey,
        handshake_hash: [u8; HASH_SIZE],
        rekey_auth_key: [u8; HASH_SIZE],
    ) -> Self {
        Self {
            session_id,
            role,
            send_key,
            recv_key,
            rekey_state: RekeyState::new(),
            replay_window: ReplayWindow::new(),
            old_keys: OldKeyRetention::new(),
            handshake_hash,
            rekey_auth_key,
        }
    }

    /// Get the session ID.
    pub fn session_id(&self) -> &SessionId {
        &self.session_id
    }

    /// Get the current role.
    pub fn role(&self) -> Role {
        self.role
    }

    /// Get the current epoch.
    pub fn epoch(&self) -> u32 {
        self.rekey_state.epoch()
    }

    /// Check if we should initiate a rekey.
    pub fn should_rekey(&self) -> bool {
        self.rekey_state.should_rekey()
    }

    /// Check if keys are expired (session must terminate).
    pub fn keys_expired(&self) -> bool {
        self.rekey_state.keys_expired()
    }

    /// Get the direction for sending based on our role.
    fn send_direction(&self) -> Direction {
        match self.role {
            Role::Initiator => Direction::InitiatorToResponder,
            Role::Responder => Direction::ResponderToInitiator,
        }
    }

    /// Get the direction for receiving based on our role.
    fn recv_direction(&self) -> Direction {
        self.send_direction().opposite()
    }

    /// Encrypt a frame for sending.
    ///
    /// Returns (nonce_counter, ciphertext).
    pub fn encrypt_frame(
        &mut self,
        frame_type: u8,
        flags: u8,
        plaintext: &[u8],
    ) -> Result<(u64, Vec<u8>), CryptoError> {
        // Get counter and construct nonce
        let counter = self.rekey_state.increment_send()?;
        let nonce = construct_nonce(self.rekey_state.epoch(), self.send_direction(), counter);

        // Construct AAD
        let aad = construct_aad(frame_type, flags, self.session_id.as_bytes(), counter);

        // Encrypt
        let ciphertext = encrypt(&self.send_key, &nonce, &aad, plaintext)?;

        Ok((counter, ciphertext))
    }

    /// Decrypt a received frame.
    ///
    /// Performs replay check BEFORE decryption per spec.
    pub fn decrypt_frame(
        &mut self,
        frame_type: u8,
        flags: u8,
        nonce_counter: u64,
        ciphertext: &[u8],
    ) -> Result<Vec<u8>, CryptoError> {
        // 1. Replay check FIRST (cheap, prevents DoS)
        if self.replay_window.is_replay(nonce_counter) {
            return Err(CryptoError::ReplayDetected);
        }

        // Construct nonce and AAD
        let nonce = construct_nonce(self.rekey_state.epoch(), self.recv_direction(), nonce_counter);
        let aad = construct_aad(frame_type, flags, self.session_id.as_bytes(), nonce_counter);

        // 2. Try current keys first
        if let Ok(plaintext) = decrypt(&self.recv_key, &nonce, &aad, ciphertext) {
            // 3. Update replay window only after successful verification
            let _ = self.replay_window.check_and_update(nonce_counter);
            self.rekey_state.record_recv(nonce_counter);
            return Ok(plaintext);
        }

        // 4. Try old keys if within retention window
        self.old_keys.clear_if_expired();
        if let Some(old_recv_key) = self.get_old_recv_key() {
            // Try with previous epoch's nonce
            let old_epoch = self.rekey_state.epoch().saturating_sub(1);
            let old_nonce = construct_nonce(old_epoch, self.recv_direction(), nonce_counter);

            if let Ok(plaintext) = decrypt(old_recv_key, &old_nonce, &aad, ciphertext) {
                // Note: Don't update replay window for old epoch packets
                // (they have their own counter space)
                return Ok(plaintext);
            }
        }

        Err(CryptoError::DecryptionFailed)
    }

    /// Get the old receive key based on role.
    fn get_old_recv_key(&self) -> Option<&SessionKey> {
        match self.role {
            Role::Initiator => self.old_keys.old_responder_key(),
            Role::Responder => self.old_keys.old_initiator_key(),
        }
    }

    /// Perform a rekey operation with the given ephemeral DH result.
    ///
    /// Advances the epoch and derives new keys using PCS-secure derivation.
    /// The caller is responsible for performing the ephemeral key exchange
    /// and computing the DH shared secret.
    ///
    /// # Arguments
    /// * `ephemeral_dh` - The result of DH(my_ephemeral, their_ephemeral_public)
    ///
    /// # Security
    /// The rekey_auth_key (derived from static DH during handshake) is mixed
    /// into the KDF along with ephemeral_dh. This ensures:
    /// - Forward secrecy from the fresh ephemeral exchange
    /// - Post-compromise security from the static DH-derived auth key
    pub fn rekey(&mut self, ephemeral_dh: &[u8; 32]) -> Result<(), CryptoError> {
        use super::rekey::derive_rekey_keys;

        // Retain current keys
        self.old_keys
            .retain(self.send_key.clone(), self.recv_key.clone());

        // Advance epoch
        self.rekey_state.advance_epoch()?;

        // Derive new keys with PCS protection
        // IKM = ephemeral_dh || rekey_auth_key
        // This ensures that an attacker needs BOTH:
        // 1. To intercept the ephemeral exchange (forward secrecy)
        // 2. To know the static DH secret (post-compromise security)
        let (new_initiator_key, new_responder_key) =
            derive_rekey_keys(ephemeral_dh, &self.rekey_auth_key, self.rekey_state.epoch())?;

        // Update keys based on role
        match self.role {
            Role::Initiator => {
                self.send_key = new_initiator_key;
                self.recv_key = new_responder_key;
            }
            Role::Responder => {
                self.send_key = new_responder_key;
                self.recv_key = new_initiator_key;
            }
        }

        // Reset replay window for new epoch
        self.replay_window.reset();

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_replay_window_basic() {
        let mut window = ReplayWindow::new();

        // First packet should succeed
        assert!(window.check_and_update(0).is_ok());

        // Same packet should fail (replay)
        assert!(window.check_and_update(0).is_err());

        // New packet should succeed
        assert!(window.check_and_update(1).is_ok());

        // Out of order but in window should succeed
        assert!(window.check_and_update(5).is_ok());
        assert!(window.check_and_update(3).is_ok());
        assert!(window.check_and_update(4).is_ok());
        assert!(window.check_and_update(2).is_ok());

        // All replays
        assert!(window.check_and_update(0).is_err());
        assert!(window.check_and_update(3).is_err());
        assert!(window.check_and_update(5).is_err());
    }

    #[test]
    fn test_replay_window_large_gap() {
        let mut window = ReplayWindow::new();

        assert!(window.check_and_update(0).is_ok());
        assert!(window.check_and_update(1).is_ok());

        // Large jump
        assert!(window.check_and_update(1000).is_ok());

        // Old packets now below window
        assert!(window.check_and_update(0).is_err());
        assert!(window.check_and_update(1).is_err());

        // But recent packets in window should still work
        assert!(window.check_and_update(999).is_ok());
        assert!(window.check_and_update(998).is_ok());
    }

    #[test]
    fn test_replay_window_full_reset() {
        let mut window = ReplayWindow::new();

        for i in 0..100 {
            assert!(window.check_and_update(i).is_ok());
        }

        // Jump beyond window size
        assert!(window.check_and_update(100 + REPLAY_WINDOW_SIZE as u64).is_ok());

        // All previous should be below window
        for i in 0..100 {
            assert!(window.check_and_update(i).is_err());
        }
    }

    #[test]
    fn test_crypto_session_roundtrip() {
        let session_id = SessionId::generate();
        let send_key = SessionKey::from_bytes([0x01; 32]);
        let recv_key = SessionKey::from_bytes([0x02; 32]);
        let handshake_hash = [0x42; 32];
        let rekey_auth_key = [0x33; 32]; // PCS rekey authentication key

        let mut initiator = CryptoSession::new(
            session_id,
            Role::Initiator,
            send_key.clone(),
            recv_key.clone(),
            handshake_hash,
            rekey_auth_key,
        );

        let mut responder = CryptoSession::new(
            session_id,
            Role::Responder,
            recv_key.clone(),
            send_key.clone(),
            handshake_hash,
            rekey_auth_key,
        );

        // Initiator sends
        let plaintext = b"Hello, NOMAD!";
        let (counter, ciphertext) = initiator.encrypt_frame(0x03, 0x00, plaintext).unwrap();

        // Responder receives
        let decrypted = responder
            .decrypt_frame(0x03, 0x00, counter, &ciphertext)
            .unwrap();
        assert_eq!(decrypted, plaintext);

        // Responder sends back
        let reply = b"Hello back!";
        let (reply_counter, reply_ciphertext) =
            responder.encrypt_frame(0x03, 0x00, reply).unwrap();

        // Initiator receives
        let decrypted_reply = initiator
            .decrypt_frame(0x03, 0x00, reply_counter, &reply_ciphertext)
            .unwrap();
        assert_eq!(decrypted_reply, reply);
    }

    #[test]
    fn test_crypto_session_replay_detection() {
        let session_id = SessionId::generate();
        let send_key = SessionKey::from_bytes([0x01; 32]);
        let recv_key = SessionKey::from_bytes([0x02; 32]);
        let handshake_hash = [0x42; 32];
        let rekey_auth_key = [0x33; 32];

        let mut initiator = CryptoSession::new(
            session_id,
            Role::Initiator,
            send_key.clone(),
            recv_key.clone(),
            handshake_hash,
            rekey_auth_key,
        );

        let mut responder = CryptoSession::new(
            session_id,
            Role::Responder,
            recv_key.clone(),
            send_key.clone(),
            handshake_hash,
            rekey_auth_key,
        );

        let plaintext = b"test";
        let (counter, ciphertext) = initiator.encrypt_frame(0x03, 0x00, plaintext).unwrap();

        // First receive succeeds
        assert!(responder
            .decrypt_frame(0x03, 0x00, counter, &ciphertext)
            .is_ok());

        // Replay should fail
        assert!(responder
            .decrypt_frame(0x03, 0x00, counter, &ciphertext)
            .is_err());
    }

    #[test]
    fn test_crypto_session_wrong_aad() {
        let session_id = SessionId::generate();
        let send_key = SessionKey::from_bytes([0x01; 32]);
        let recv_key = SessionKey::from_bytes([0x02; 32]);
        let handshake_hash = [0x42; 32];
        let rekey_auth_key = [0x33; 32];

        let mut initiator = CryptoSession::new(
            session_id,
            Role::Initiator,
            send_key.clone(),
            recv_key.clone(),
            handshake_hash,
            rekey_auth_key,
        );

        let mut responder = CryptoSession::new(
            session_id,
            Role::Responder,
            recv_key.clone(),
            send_key.clone(),
            handshake_hash,
            rekey_auth_key,
        );

        let plaintext = b"test";
        let (counter, ciphertext) = initiator.encrypt_frame(0x03, 0x00, plaintext).unwrap();

        // Wrong frame type should fail
        assert!(responder
            .decrypt_frame(0x04, 0x00, counter, &ciphertext)
            .is_err());
    }
}