//! LO-Ratchet and message encryption (§6-7). //! //! KEM ratchet (X-Wing) + counter-mode message key derivation for ongoing message //! encryption. The KEM ratchet provides post-compromise security (recovering from //! secret key exposure). Forward secrecy is per-epoch (per KEM ratchet step), not //! per-message — see §6.13 of Specification.md for the design rationale. use std::collections::HashSet; use crate::constants; use crate::error::{Error, Result}; use crate::primitives::{aead, hkdf, hmac, random, xwing}; use subtle::ConstantTimeEq; use zeroize::{Zeroize, Zeroizing}; /// Ratchet header sent with each message. pub struct RatchetHeader { /// Sender's current ratchet public key (1216 bytes, always present). pub ratchet_pk: xwing::PublicKey, /// KEM ciphertext (present only on ratchet step). pub kem_ct: Option, /// Message number within the current send chain. pub n: u32, /// Length of the previous send chain. pub pn: u32, } /// LO-Ratchet state (§6.2). /// /// # Thread Safety /// /// `RatchetState` auto-derives `Send + Sync` but is not designed for /// concurrent access. All mutating operations (`encrypt`, `decrypt`, /// `to_bytes`, `reset`) require `&mut self`. Safe Rust's borrow checker /// prevents data races at compile time. The CAPI layer adds a runtime /// reentrancy guard (`AtomicBool`) for FFI callers that lack borrow checking. pub struct RatchetState { /// Current root key — advanced on each KEM ratchet step. root_key: [u8; 32], /// Current send epoch key — static within an epoch, replaced on each /// KEM ratchet step via KDF_Root. Message keys are derived as /// `KDF_MsgKey(send_epoch_key, counter)`. send_epoch_key: [u8; 32], /// Current receive epoch key — static within an epoch, replaced on each /// KEM ratchet step. Used to derive message keys for incoming messages. recv_epoch_key: [u8; 32], /// Local identity fingerprint (SHA3-256 of local identity public key). /// Bound into AAD for every ratchet message, preventing cross-session /// replay and fingerprint-swap confusion. local_fp: [u8; 32], /// Remote identity fingerprint (SHA3-256 of remote identity public key). remote_fp: [u8; 32], /// Local X-Wing secret key for the current send ratchet epoch. send_ratchet_sk: Option, /// Local X-Wing public key for the current send ratchet epoch. send_ratchet_pk: Option, /// Peer's X-Wing public key for the current receive ratchet epoch. recv_ratchet_pk: Option, /// Previous receive epoch key — retained for one epoch to handle /// late-arriving messages from the prior epoch. Zeroized when a second /// KEM ratchet step occurs (the old-old epoch is gone). prev_recv_epoch_key: Option>, /// Previous receive ratchet public key — identifies which epoch a /// late-arriving message belongs to. prev_recv_ratchet_pk: Option, /// Messages sent in the current send epoch. Used as AEAD counter nonce. /// Must never reach `u32::MAX` (enforced by `ChainExhausted` guard). send_count: u32, /// High-water mark for the current receive epoch. recv_count: u32, /// Length of the previous send epoch (sent in header as `pn`). prev_send_count: u32, /// Set on receiving a KEM ratchet from the peer, cleared on send ratchet. ratchet_pending: bool, /// Counters of successfully decrypted messages in the current receive epoch. /// Used for duplicate detection. Bounded at MAX_RECV_SEEN entries. /// Resets on KEM ratchet step. recv_seen: HashSet, /// Counters of successfully decrypted messages from the previous receive /// epoch. Resets when prev_recv_epoch_key is zeroized (second KEM ratchet). prev_recv_seen: HashSet, /// Monotonic epoch counter for anti-rollback protection. Incremented on /// every `to_bytes()` call. The caller **must** persist the epoch alongside /// the encrypted blob and reject `from_bytes` results whose epoch is <= /// their last-seen epoch. Without this check, an attacker with storage /// write access can replay an older blob, rolling back counters and causing /// catastrophic AEAD nonce reuse. epoch: u64, } impl Drop for RatchetState { fn drop(&mut self) { // Delegates to reset() to ensure all key material is zeroized on scope exit. self.reset(); } } /// Encrypted message output. pub struct EncryptedMessage { /// The ratchet header (sent in cleartext). pub header: RatchetHeader, /// The encrypted payload: ciphertext bytes followed by a 16-byte Poly1305 /// authentication tag (appended by `aead_encrypt`). pub ciphertext: Vec, } impl RatchetState { /// Initialize ratchet state for Alice (initiator) after LO-KEX (§6.2). /// /// Alice's EK becomes the initial send ratchet key. /// send_count starts at 1 so that counter 0 is never reused with the /// session-init epoch key. encrypt_first_message uses a random nonce /// (not counter-based); the first ratchet-encrypted message uses /// nonce_from_counter(1). /// /// # Security /// /// `root_key` and `epoch_key` are `[u8; 32]` (`Copy`) — the caller's copies /// remain on the stack and must be explicitly zeroized after this call. /// /// # Errors /// /// Returns `InvalidData` if `root_key` or `epoch_key` is all-zero. /// All-zero epoch key produces deterministic HMAC outputs (completely /// predictable message keys). Values come from KEX HKDF output; an /// all-zero value indicates a programming error. pub fn init_alice( mut root_key: [u8; 32], mut epoch_key: [u8; 32], local_fp: [u8; 32], remote_fp: [u8; 32], ek_pk: xwing::PublicKey, ek_sk: xwing::SecretKey, ) -> Result { // Constant-time: root_key and epoch_key are secret material. if bool::from(root_key.ct_eq(&[0u8; 32])) || bool::from(epoch_key.ct_eq(&[0u8; 32])) { // [u8; 32] is Copy — the parameter slots hold secret material that // would otherwise persist on the stack until overwritten. Zeroize // before returning to minimize the exposure window. root_key.zeroize(); epoch_key.zeroize(); return Err(Error::InvalidData); } // Reject self-pair and all-zero fingerprints. All-zero rejection matches // the from_bytes guard (#20) — without it, a state could encrypt/decrypt // but fail to roundtrip through serialization. if local_fp == remote_fp || local_fp == [0u8; 32] || remote_fp == [0u8; 32] { root_key.zeroize(); epoch_key.zeroize(); return Err(Error::InvalidData); } let state = Self { root_key, send_epoch_key: epoch_key, recv_epoch_key: [0u8; 32], local_fp, remote_fp, send_ratchet_sk: Some(ek_sk), send_ratchet_pk: Some(ek_pk), recv_ratchet_pk: None, prev_recv_epoch_key: None, prev_recv_ratchet_pk: None, send_count: 1, recv_count: 0, prev_send_count: 0, ratchet_pending: false, recv_seen: HashSet::new(), prev_recv_seen: HashSet::new(), epoch: 0, }; // [u8; 32] is Copy — field-init shorthand copied the bytes into the // struct; the callee's parameter slots still hold secret material. // Zeroize them so they don't linger on the stack. root_key.zeroize(); epoch_key.zeroize(); Ok(state) } /// Initialize ratchet state for Bob (responder) after LO-KEX (§6.2). /// /// Bob knows Alice's EK as the recv_ratchet_pk. /// recv_count starts at 1 so that counter 0 is never reused with the /// session-init epoch key. decrypt_first_message uses a random nonce /// (not counter-based); the first ratchet-received message uses /// nonce_from_counter(1). /// /// # Security /// /// `root_key` and `epoch_key` are `[u8; 32]` (`Copy`) — the caller's copies /// remain on the stack and must be explicitly zeroized after this call. /// /// # Errors /// /// Returns `InvalidData` if `root_key` or `epoch_key` is all-zero. pub fn init_bob( mut root_key: [u8; 32], mut epoch_key: [u8; 32], local_fp: [u8; 32], remote_fp: [u8; 32], peer_ek: xwing::PublicKey, ) -> Result { // Constant-time: root_key and epoch_key are secret material. if bool::from(root_key.ct_eq(&[0u8; 32])) || bool::from(epoch_key.ct_eq(&[0u8; 32])) { // [u8; 32] is Copy — zeroize the parameter stack slots before // returning to prevent secret material from lingering. root_key.zeroize(); epoch_key.zeroize(); return Err(Error::InvalidData); } // Reject self-pair and all-zero fingerprints. All-zero rejection matches // the from_bytes guard (#20) — without it, a state could encrypt/decrypt // but fail to roundtrip through serialization. if local_fp == remote_fp || local_fp == [0u8; 32] || remote_fp == [0u8; 32] { root_key.zeroize(); epoch_key.zeroize(); return Err(Error::InvalidData); } let state = Self { root_key, // Placeholder — never used directly. Bob's first encrypt() triggers // perform_kem_ratchet_send() (ratchet_pending=true), which overwrites // send_epoch_key via kdf_root before any message key is derived. send_epoch_key: [0u8; 32], recv_epoch_key: epoch_key, local_fp, remote_fp, send_ratchet_sk: None, send_ratchet_pk: None, recv_ratchet_pk: Some(peer_ek), prev_recv_epoch_key: None, prev_recv_ratchet_pk: None, send_count: 0, recv_count: 1, prev_send_count: 0, ratchet_pending: true, // Bob needs to ratchet before first send recv_seen: HashSet::new(), prev_recv_seen: HashSet::new(), epoch: 0, }; // [u8; 32] is Copy — field-init shorthand copied the bytes into the // struct; the callee's parameter slots still hold secret material. // Zeroize them so they don't linger on the stack. root_key.zeroize(); epoch_key.zeroize(); Ok(state) } /// Encrypt a message (§6.5). /// /// Returns an `EncryptedMessage` containing the ratchet header (cleartext /// metadata including the sender's ratchet public key and counters) and the /// AEAD ciphertext (encrypted payload with 16-byte authentication tag). /// /// **Length leakage:** XChaCha20-Poly1305 is a stream cipher — ciphertext length equals /// plaintext length plus the 16-byte tag. A passive observer can determine /// exact message length. Application-layer padding is required if message /// length must be hidden. /// /// AEAD encryption failure is treated as session-fatal: the ratchet state /// is zeroized and all subsequent operations will fail. This eliminates /// the risk of callers accidentally reusing a (key, nonce) pair with /// different plaintext after a failed encrypt. In practice, XChaCha20-Poly1305 /// encrypt only fails on integer overflow (plaintext.len() ≈ usize::MAX), /// which does not occur with well-formed input. /// /// # Timing /// /// When `ratchet_pending` is true, encrypt performs an X-Wing KEM keygen + /// encapsulate (~1 ms) before encrypting. This timing difference is inherent /// to the protocol design — KEM operations cannot be made constant-time with /// respect to a no-op. The ratchet direction is already visible in cleartext /// headers, so the timing leak reveals no additional information. /// /// # Errors /// /// - [`Error::ChainExhausted`] — `send_count` reached `u32::MAX` (nonce reuse prevention). /// This is permanent even if `ratchet_pending` is set: the guard fires before the /// ratchet step because `prev_send_count` would capture `u32::MAX`, making the /// state unserializable (`can_serialize` and `to_bytes` both reject /// `prev_send_count == u32::MAX`). The only recovery is a full session reset via new KEX. /// - [`Error::AeadFailed`] — AEAD encryption failed (session-fatal; state is zeroized). #[must_use = "dropping the encrypted message desynchronizes the ratchet (send_count was already advanced)"] pub fn encrypt(&mut self, plaintext: &[u8]) -> Result { // After reset() or AEAD error, root_key is zeroized. The session is // permanently unusable — a new KEX is required. InvalidData (not // Internal) because this is a caller error, not a soliton bug. // Constant-time: root_key is secret material. if bool::from(self.root_key.ct_eq(&[0u8; 32])) { return Err(Error::InvalidData); } // Guard against nonce reuse: send_count is used directly as the AEAD // counter nonce (nonce_from_counter). If send_count reached u32::MAX on // a live epoch key, the next increment would wrap and reuse a (key, nonce) // pair, breaking AEAD confidentiality and integrity. // Post-ratchet, perform_kem_ratchet_send always resets send_count to 0, // so the post-ratchet nonce is unconditionally safe from overflow. if self.send_count == u32::MAX { return Err(Error::ChainExhausted); } // Perform ratchet if needed (no send epoch yet, or direction changed). // IMPLICIT ATOMICITY: perform_kem_ratchet_send completes all fallible // operations (recv_ratchet_pk presence check, KEM keygen, KEM encapsulate, // kdf_root) before mutating // self. If any fail, self is unchanged. Unlike decrypt() which uses // explicit snapshot/rollback, encrypt() relies on this ordering for // atomicity. Maintainers must preserve this: no self mutation before // the last fallible operation in perform_kem_ratchet_send. let kem_ct = if self.send_ratchet_pk.is_none() || self.ratchet_pending { Some(self.perform_kem_ratchet_send()?) } else { None }; // Extract send_ratchet_pk. Structurally unreachable as None: // perform_kem_ratchet_send always sets send_ratchet_pk = Some(...), // and the else branch only runs when it's already Some. let send_pk = self.send_ratchet_pk.clone().ok_or(Error::Internal)?; // Derive message key from epoch key and counter (O(1), no chain advancement). let msg_key = kdf_msg_key(&self.send_epoch_key, self.send_count); // Each send_count value is unique per epoch key, making (key, nonce) // pairs non-repeating — critical for AEAD security. let nonce = nonce_from_counter(self.send_count); // §6.5: the header carries the sender's current ratchet public key and // KEM ciphertext so the receiver can advance its receive epoch. let header = RatchetHeader { ratchet_pk: send_pk, kem_ct, n: self.send_count, pn: self.prev_send_count, }; // AAD binds sender/recipient fingerprints + header to the ciphertext, // preventing cross-session and message-reorder attacks. let aad = build_ratchet_aad(&self.local_fp, &self.remote_fp, &header)?; // Encrypt (msg_key is zeroized when dropped at end of scope). match aead::aead_encrypt(&msg_key, &nonce, plaintext, &aad) { Ok(ciphertext) => { self.send_count += 1; Ok(EncryptedMessage { header, ciphertext }) } Err(e) => { // AEAD encrypt failure is catastrophic. Defense-in-depth: // the counter is only incremented on success (line above), // but a transient failure followed by retry could still // produce a valid encryption with compromised internal state. // Zeroize all session state to make it permanently unusable. // The caller must discard this session. // reset() zeroizes every field (keys, fingerprints, counters, // seen sets), keeping the fatal path resilient to future // additions to RatchetState. self.reset(); Err(e) } } } /// Decrypt a message (§6.6). /// /// Returns self-zeroizing plaintext on success, or an error on failure. /// On error after state mutation (AEAD failure, decapsulation failure), /// the ratchet state is fully rolled back to prevent desynchronization. /// `DuplicateMessage` is detected before any mutation in `decrypt_inner` /// (the duplicate check precedes `recv_seen.insert` and all KEM operations), /// so the rollback mechanism runs but restores identical state — a no-op. /// /// Snapshot scope: `root_key`, `recv_epoch_key`, `recv_count`, /// `recv_ratchet_pk`, `ratchet_pending`, `prev_recv_epoch_key`, /// `prev_recv_ratchet_pk`, `recv_seen`, and `prev_recv_seen`. /// Send-side state is never mutated by `decrypt()`. /// /// # Timing /// /// Three execution paths with different timing profiles: /// 1. **Previous epoch** — fast (O(1) key derivation + AEAD decrypt). /// 2. **Current epoch** — fast (O(1) key derivation + AEAD decrypt). /// 3. **New epoch (KEM ratchet step)** — slow (~1 ms X-Wing KEM decapsulate). /// /// The header's `ratchet_pk` and counters are cleartext, so which path is /// taken is already observable. Constant-time padding across all paths would /// require dummy KEM operations on every decrypt, which is prohibitively /// expensive and provides no security benefit given the cleartext header. /// /// # Errors /// /// - [`Error::DuplicateMessage`] — message already decrypted; detected before any /// mutation (rollback runs but is a no-op). /// - [`Error::ChainExhausted`] — counter would exceed `u32::MAX`. /// - [`Error::InvalidData`] — ratchet step required but header has no KEM ciphertext, /// or ratchet state has no send secret key (malformed message or corrupted state), /// or no previous epoch key for a previous-epoch message. /// - [`Error::DecapsulationFailed`] — X-Wing KEM decapsulation failed during ratchet step. /// - [`Error::AeadFailed`] — AEAD authentication failed (state rolled back). #[must_use = "decrypted plaintext contains sensitive data that must be consumed or zeroized"] pub fn decrypt( &mut self, header: &RatchetHeader, ciphertext: &[u8], ) -> Result>> { // After reset() or AEAD error, root_key is zeroized. The session is // permanently unusable — a new KEX is required. // Constant-time: root_key is secret material. if bool::from(self.root_key.ct_eq(&[0u8; 32])) { return Err(Error::InvalidData); } // Identify which epoch this message belongs to. let epoch_type = self.identify_epoch(header); // Structural check: PreviousEpoch requires a retained epoch key. if matches!(epoch_type, EpochType::PreviousEpoch) && self.prev_recv_epoch_key.is_none() { return Err(Error::InvalidData); } // Duplicate detection is deferred to after AEAD (post-decrypt). // Checking recv_seen before AEAD would create a timing oracle: // duplicates return in microseconds, non-duplicates take AEAD time. // By always running key derivation + AEAD, both paths have identical // timing. For NewEpoch, duplicate check is not applicable (new epoch // means new recv_seen). The header content already reveals epoch type, // so the NewEpoch KEM timing difference leaks nothing additional. // Save state snapshot for rollback on AEAD failure. // Send-side state (send_ratchet_sk/pk, send_count, send_epoch_key, // prev_send_count) is NOT snapshotted — decrypt() never mutates it. // Wrapped in Zeroizing so old key material is zeroized on drop (success path). let saved_root_key = Zeroizing::new(self.root_key); let saved_recv_epoch_key = Zeroizing::new(self.recv_epoch_key); let saved_recv_count = self.recv_count; let saved_recv_ratchet_pk = self.recv_ratchet_pk.clone(); let saved_ratchet_pending = self.ratchet_pending; let saved_prev_recv_epoch_key = self.prev_recv_epoch_key.clone(); let saved_prev_recv_ratchet_pk = self.prev_recv_ratchet_pk.clone(); let saved_recv_seen = self.recv_seen.clone(); let saved_prev_recv_seen = self.prev_recv_seen.clone(); // All mutations are in decrypt_inner; rollback on failure below. let result = self.decrypt_inner(header, ciphertext, epoch_type); match result { Ok(plaintext) => Ok(plaintext), Err(e) => { // Rollback all state on any failure. self.root_key = *saved_root_key; self.recv_epoch_key = *saved_recv_epoch_key; self.recv_count = saved_recv_count; self.recv_ratchet_pk = saved_recv_ratchet_pk; self.ratchet_pending = saved_ratchet_pending; self.prev_recv_epoch_key = saved_prev_recv_epoch_key; self.prev_recv_ratchet_pk = saved_prev_recv_ratchet_pk; self.recv_seen = saved_recv_seen; self.prev_recv_seen = saved_prev_recv_seen; Err(e) } } } /// Identify which epoch a message belongs to based on its ratchet_pk. fn identify_epoch(&self, header: &RatchetHeader) -> EpochType { // Check previous epoch first (grace period for late-arriving messages). if let Some(ref prev_pk) = self.prev_recv_ratchet_pk { if header .ratchet_pk .as_bytes() .ct_eq(prev_pk.as_bytes()) .into() { return EpochType::PreviousEpoch; } } // Check current epoch. if let Some(ref current_pk) = self.recv_ratchet_pk { if header .ratchet_pk .as_bytes() .ct_eq(current_pk.as_bytes()) .into() { return EpochType::CurrentEpoch; } } // Unknown ratchet_pk — new epoch (KEM ratchet step needed). EpochType::NewEpoch } /// Inner body of [`Self::decrypt`]: performs all state mutations and the /// actual decryption. Called after the snapshot is taken; the caller rolls /// back on any `Err` return. fn decrypt_inner( &mut self, header: &RatchetHeader, ciphertext: &[u8], epoch_type: EpochType, ) -> Result>> { // Counter overflow guard — applies to all paths. if header.n == u32::MAX { return Err(Error::ChainExhausted); } let msg_key = match epoch_type { EpochType::PreviousEpoch => { // Derive key from previous epoch key. let prev_key = self .prev_recv_epoch_key .as_ref() .ok_or(Error::InvalidData)?; kdf_msg_key(prev_key, header.n) } EpochType::CurrentEpoch => { // Derive key from current epoch key. kdf_msg_key(&self.recv_epoch_key, header.n) } EpochType::NewEpoch => { // Perform KEM ratchet: decapsulate using the local send key. // A ratchet step (new ratchet_pk) must include a KEM ciphertext; // its absence means the message is malformed or tampered. let kem_ct = header.kem_ct.as_ref().ok_or(Error::InvalidData)?; let send_sk = self.send_ratchet_sk.as_ref().ok_or(Error::InvalidData)?; let ss = xwing::decapsulate(send_sk, kem_ct)?; let (new_root, new_recv_epoch) = kdf_root(&self.root_key, ss.as_bytes())?; // Rotate previous epoch: current → previous, new → current. // Only save the previous epoch if there was a meaningful one // (recv_ratchet_pk was set). On the first KEM ratchet (init state), // recv_ratchet_pk is None, so there's no previous epoch to save. if self.recv_ratchet_pk.is_some() { self.prev_recv_epoch_key = Some(Zeroizing::new(self.recv_epoch_key)); self.prev_recv_ratchet_pk = self.recv_ratchet_pk.take(); self.prev_recv_seen = std::mem::take(&mut self.recv_seen); } else { // No previous epoch to save — clear any stale state. self.prev_recv_epoch_key = None; self.prev_recv_ratchet_pk = None; self.prev_recv_seen.clear(); self.recv_seen.clear(); } self.root_key = *new_root; self.recv_epoch_key = *new_recv_epoch; self.recv_ratchet_pk = Some(header.ratchet_pk.clone()); self.recv_count = 0; self.recv_seen = HashSet::new(); // Mark that a send-side ratchet step is needed before the next send. self.ratchet_pending = true; // Derive key from the new epoch key. kdf_msg_key(&self.recv_epoch_key, header.n) } }; // Attempt decryption with the derived message key. let plaintext = self.decrypt_with_key(&msg_key, header, ciphertext)?; // AEAD succeeded — check for duplicates and update recv_seen/recv_count. // Duplicate detection is post-AEAD to avoid timing oracle (RT-458): // both duplicate and non-duplicate messages take identical time // through key derivation + AEAD. Duplicates still succeed AEAD // (same key, nonce, ciphertext) but the plaintext is discarded. // // Enforce the serialization invariant (|recv_seen| < MAX_RECV_SEEN, // §6.8) at runtime. The check is on the post-insert size so a successful // decrypt never produces a live state that to_bytes would reject. match epoch_type { EpochType::PreviousEpoch => { if self.prev_recv_seen.contains(&header.n) { return Err(Error::DuplicateMessage); } if self.prev_recv_seen.len() + 1 >= constants::MAX_RECV_SEEN as usize { return Err(Error::ChainExhausted); } self.prev_recv_seen.insert(header.n); } EpochType::CurrentEpoch | EpochType::NewEpoch => { if self.recv_seen.contains(&header.n) { return Err(Error::DuplicateMessage); } if self.recv_seen.len() + 1 >= constants::MAX_RECV_SEEN as usize { return Err(Error::ChainExhausted); } self.recv_seen.insert(header.n); // High-water mark update. if header.n >= self.recv_count { // Safe: header.n < u32::MAX (checked above), so n+1 won't overflow. self.recv_count = header.n + 1; } } } Ok(plaintext) } /// Encrypt the first message of a session (session init payload, §5.4 Step 7). /// /// Uses random nonce (not counter-based) and the session init AAD. /// The epoch_key is passed through unchanged — with counter-mode derivation, /// the epoch key is not advanced by the first-message encryption. /// Returns `(payload, epoch_key)` where payload is: /// `nonce (24 bytes) || ciphertext || tag (16 bytes)`. #[must_use = "result contains the epoch key needed for ratchet initialization"] pub fn encrypt_first_message( epoch_key: Zeroizing<[u8; 32]>, plaintext: &[u8], aad: &[u8], ) -> Result<(Vec, Zeroizing<[u8; 32]>)> { // epoch_key is consumed by value — the caller cannot reuse it, // preventing accidental nonce reuse from repeated calls. // msg_key auto-zeroizes on drop. let msg_key = kdf_msg_key(&epoch_key, 0); let mut nonce = [0u8; 24]; random::random_bytes(&mut nonce); let ciphertext = aead::aead_encrypt(&msg_key, &nonce, plaintext, aad)?; // The random nonce must be transmitted alongside ciphertext so the // receiver can reconstruct the AEAD inputs for decryption. let mut payload = Vec::with_capacity(24 + ciphertext.len()); payload.extend_from_slice(&nonce); payload.extend_from_slice(&ciphertext); // Pass through the epoch key unchanged — counter-mode does not advance it. Ok((payload, epoch_key)) } /// Decrypt the first message of a session (§5.5 Step 5). /// /// Returns `(plaintext, epoch_key)` where plaintext is self-zeroizing /// and the epoch key should be passed to `init_bob`. #[must_use = "result contains the epoch key needed for ratchet initialization"] #[allow(clippy::type_complexity)] // (Zeroizing>, Zeroizing<[u8; 32]>) is clear as-is; a type alias adds no value pub fn decrypt_first_message( epoch_key: Zeroizing<[u8; 32]>, encrypted_payload: &[u8], aad: &[u8], ) -> Result<(Zeroizing>, Zeroizing<[u8; 32]>)> { // nonce (24) + Poly1305 tag (16) = 40 bytes minimum. // Return AeadFailed (not InvalidLength) to avoid distinguishing // "too short" from "bad tag" — both indicate a malformed ciphertext. const MIN_PAYLOAD_LEN: usize = 24 + 16; if encrypted_payload.len() < MIN_PAYLOAD_LEN { return Err(Error::AeadFailed); } // epoch_key is consumed by value — the caller cannot reuse it. // msg_key auto-zeroizes on drop. let msg_key = kdf_msg_key(&epoch_key, 0); // encrypted_payload.len() >= MIN_PAYLOAD_LEN (40) guarantees the 24-byte // prefix; try_into() is structurally infallible. let nonce: &[u8; 24] = encrypted_payload[..24] .try_into() .map_err(|_| Error::Internal)?; let ciphertext = &encrypted_payload[24..]; let plaintext = aead::aead_decrypt(&msg_key, nonce, ciphertext, aad)?; // Pass through the epoch key unchanged — counter-mode does not advance it. Ok((plaintext, epoch_key)) } /// Perform a KEM ratchet step when sending (§6.4). /// /// Generates a fresh X-Wing keypair, encapsulates to the peer's ratchet /// public key, and derives new root/send-chain keys via HKDF. This provides /// post-compromise security: even if the current key state is compromised, /// the next ratchet step re-establishes confidentiality using fresh KEM /// randomness. fn perform_kem_ratchet_send(&mut self) -> Result { let peer_pk = self.recv_ratchet_pk.as_ref().ok_or(Error::Internal)?; // Fresh randomness for post-compromise recovery: a new keypair ensures // that compromise of prior ratchet keys cannot decrypt future messages. let (new_pk, new_sk) = xwing::keygen()?; // KEM encapsulation produces a shared secret for new chain keys and // a ciphertext that the receiver needs to advance its receive chain. let (ct, ss) = xwing::encapsulate(peer_pk)?; // Rederive root and send epoch keys from the KEM shared secret, // injecting fresh entropy for forward secrecy. let (new_root, new_send_epoch) = kdf_root(&self.root_key, ss.as_bytes())?; self.root_key = *new_root; self.send_epoch_key = *new_send_epoch; // Spec §6.4: old send_ratchet_sk is zeroized via ZeroizeOnDrop when // the prior Option is dropped by this assignment. self.send_ratchet_sk = Some(new_sk); self.send_ratchet_pk = Some(new_pk); self.prev_send_count = self.send_count; self.send_count = 0; // KEM ratchet step complete — the new keypair is installed and ready // for the next send, so the pending flag is no longer needed. self.ratchet_pending = false; Ok(ct) } // skip_messages removed — counter-mode derivation eliminates the skip cache. // Any message within an epoch is derivable in O(1) from the epoch key and counter. /// Decrypt with a known message key. /// /// AAD uses the sender's fingerprint first — for decrypt, the sender is /// the remote party (remote_fp), the recipient is local (local_fp). fn decrypt_with_key( &self, msg_key: &[u8; 32], header: &RatchetHeader, ciphertext: &[u8], ) -> Result>> { let nonce = nonce_from_counter(header.n); // Decrypt AAD: sender=remote, recipient=local (mirror of encrypt). let aad = build_ratchet_aad(&self.remote_fp, &self.local_fp, header)?; aead::aead_decrypt(msg_key, &nonce, ciphertext, &aad) } /// Return the anti-rollback epoch counter. /// /// The epoch is incremented on every [`to_bytes`](Self::to_bytes) call. /// After deserializing with [`from_bytes`](Self::from_bytes), the caller /// **must** compare this value against their persisted last-seen epoch and /// reject the state if `epoch <= last_seen`. This prevents storage-layer /// replay attacks that roll back ratchet counters and cause AEAD nonce reuse. pub fn epoch(&self) -> u64 { self.epoch } /// Reset the session (§6.9). Zeroizes all state. /// /// After reset, the handle is in an uninitialized state. Calling /// `encrypt()` or `decrypt()` without re-initializing the session /// will fail with `Error::Internal` or `Error::InvalidData`. pub fn reset(&mut self) { self.root_key.zeroize(); self.send_epoch_key.zeroize(); self.recv_epoch_key.zeroize(); self.local_fp.zeroize(); self.remote_fp.zeroize(); // `= None` drops the prior `Some(SecretKey)`, firing `ZeroizeOnDrop`. self.send_ratchet_sk = None; self.send_ratchet_pk = None; self.recv_ratchet_pk = None; // Zeroize previous epoch key via Zeroizing<[u8; 32]> drop. self.prev_recv_epoch_key = None; self.prev_recv_ratchet_pk = None; self.send_count = 0; self.recv_count = 0; self.prev_send_count = 0; self.ratchet_pending = false; self.recv_seen.clear(); self.prev_recv_seen.clear(); // Epoch is not secret but zeroing prevents a reset'd state from being // serialized with a stale epoch value. self.epoch = 0; } /// Derive call encryption keys for an encrypted voice call. /// /// Delegates to [`crate::call::derive_call_keys`] using the ratchet's /// current root key. See that function's documentation for the full /// protocol description and security properties. /// /// # Advisory /// /// The derived keys depend on the current `root_key`. Both parties /// **must** call this at the same ratchet epoch — if one side processes /// additional ratchet messages (triggering a KEM ratchet step) between /// sending the CallOffer and calling `derive_call_keys`, the root keys /// will differ and the derived call keys will not match, producing /// valid-looking but incompatible keys with no diagnostic error. /// /// Compromise of `root_key` (before the next KEM ratchet step) would /// threaten all calls derived from it — the ephemeral KEM is the sole /// barrier per call. Callers SHOULD send a ratchet message after call /// setup to trigger a KEM step and limit the exposure window. pub fn derive_call_keys( &self, kem_ss: &[u8; 32], call_id: &[u8; 16], ) -> crate::error::Result { // After encrypt error or reset(), root_key is all-zero. Using it as // HKDF salt would make call keys depend only on kem_ss and call_id, // eliminating root_key's defense-in-depth contribution. // Constant-time: root_key is secret material. if bool::from(self.root_key.ct_eq(&[0u8; 32])) { return Err(crate::error::Error::InvalidData); } crate::call::derive_call_keys( &self.root_key, kem_ss, call_id, &self.local_fp, &self.remote_fp, ) } /// Returns the raw root key. /// /// Available only under the `test-utils` feature AND debug builds (enabled /// automatically for integration tests via the crate's self dev-dependency). /// Not part of the public API — use `derive_call_keys` in production code. /// /// # Security /// /// **Never enable `test-utils` in production.** Cargo features are additive — /// any crate in the dependency tree enabling `test-utils` exposes this /// accessor globally, returning a direct reference to the root key. /// The `debug_assertions` gate provides a hard compile-time block in release /// builds — even if `test-utils` is accidentally enabled, this function /// is not compiled into the binary. #[cfg(all(feature = "test-utils", debug_assertions))] #[deprecated(note = "test-utils only — do not call in production code")] pub fn root_key_bytes(&self) -> &[u8; 32] { &self.root_key } /// Raw pointer to the internal root_key bytes (test-utils only). #[cfg(all(feature = "test-utils", debug_assertions))] #[deprecated(note = "test-utils only — do not call in production code")] pub fn root_key_ptr(&self) -> *const u8 { self.root_key.as_ptr() } /// Raw pointer to the internal send_epoch_key bytes (test-utils only). #[cfg(all(feature = "test-utils", debug_assertions))] #[deprecated(note = "test-utils only — do not call in production code")] pub fn send_epoch_key_ptr(&self) -> *const u8 { self.send_epoch_key.as_ptr() } /// Raw pointer to the internal recv_epoch_key bytes (test-utils only). #[cfg(all(feature = "test-utils", debug_assertions))] #[deprecated(note = "test-utils only — do not call in production code")] pub fn recv_epoch_key_ptr(&self) -> *const u8 { self.recv_epoch_key.as_ptr() } /// Check whether the ratchet can be serialized without error. /// /// Returns `false` if any counter has reached `u32::MAX` (ChainExhausted). /// Used by the CAPI to pre-check before consuming the handle, so that a /// serialization failure does not irrecoverably destroy the session. /// /// **Edge case:** After encrypting the final message (`send_count` was /// `u32::MAX - 1`, now `u32::MAX`), the session cannot be serialized. /// This is intentional — persisting a state with exhausted nonce space /// would allow a future restore to attempt encryption with a reused nonce. /// A KEM ratchet (triggered by receiving a reply) resets `send_count` /// and `recv_count` to 0 and restores serializability for those counters. /// `prev_send_count` requires the same KEM ratchet; `epoch` requires /// `reset()` + new LO-KEX. pub fn can_serialize(&self) -> bool { self.send_count != u32::MAX && self.recv_count != u32::MAX && self.prev_send_count != u32::MAX // to_bytes() stores epoch+1; from_bytes rejects stored u64::MAX. // Therefore to_bytes must not write u64::MAX, meaning the maximum // serializable internal epoch is u64::MAX - 2 (writes u64::MAX - 1). // u64::MAX - 1 would write u64::MAX, which from_bytes rejects. // u64::MAX is caught by to_bytes' checked_add (returns ChainExhausted). && self.epoch < u64::MAX - 1 // to_bytes() rejects recv_seen/prev_recv_seen at MAX_RECV_SEEN or // more. decrypt_inner refuses to grow the set past // MAX_RECV_SEEN - 1 entries, so these checks are defense-in-depth // against a refactor that removes the runtime cap. && self.recv_seen.len() < constants::MAX_RECV_SEEN as usize && self.prev_recv_seen.len() < constants::MAX_RECV_SEEN as usize } /// Serialize the ratchet state to bytes (§6.8). /// /// The output is a deterministic binary encoding suitable for encrypted /// storage. /// /// # Security /// /// The caller **must** encrypt the output with authenticated encryption /// before persisting it (e.g., via [`crate::storage::encrypt_blob`]) — the serialized form /// contains all secret key material. Unauthenticated encryption is /// insufficient: a tampered blob may deserialize into a corrupted but /// structurally valid ratchet state. /// /// v5 wire layout: /// ```text /// version (1 byte: RATCHET_BLOB_VERSION, currently 0x01) /// epoch (u64 BE) — anti-rollback monotonic counter /// root_key (32) || send_epoch_key (32) || recv_epoch_key (32) /// local_fp (32) || remote_fp (32) /// send_ratchet_sk: 0x00 | 0x01 + len(u16 BE) + bytes /// send_ratchet_pk: 0x00 | 0x01 + len(u16 BE) + bytes /// recv_ratchet_pk: 0x00 | 0x01 + len(u16 BE) + bytes /// prev_recv_epoch_key: 0x00 | 0x01 + 32 bytes /// prev_recv_ratchet_pk: 0x00 | 0x01 + len(u16 BE) + bytes /// send_count (u32 BE) || recv_count (u32 BE) || prev_send_count (u32 BE) /// ratchet_pending (1 byte: 0x00 or 0x01) /// num_recv_seen (u32 BE) /// [num_recv_seen × u32 BE] /// num_prev_recv_seen (u32 BE) /// [num_prev_recv_seen × u32 BE] /// ``` /// # Returns /// /// `(bytes, epoch)` — the serialized blob and its epoch counter (equal to /// the epoch stored inside the blob). The caller must persist both the blob /// and the epoch atomically. /// /// **Anti-rollback protocol:** The returned `epoch` is the *floor* for /// future loads — pass it as `min_epoch` to `from_bytes_with_min_epoch` /// when loading a *subsequently serialized* blob (which will have epoch+1). /// Do **not** pass it when loading the same blob (that would reject, since /// `from_bytes_with_min_epoch` requires `blob_epoch > min_epoch`, not `>=`). /// /// ```text /// // First save: /// (blob_1, epoch_1) = to_bytes(); // epoch_1 = N /// persist(blob_1, epoch_1); /// /// // Load blob_1: /// state = from_bytes_with_min_epoch(blob_1, epoch_1 - 1); // N > N-1 ✓ /// /// // ... use state ... /// /// // Second save: /// (blob_2, epoch_2) = to_bytes(); // epoch_2 = N+1 /// persist(blob_2, epoch_2); /// /// // Load blob_2 (using epoch_1 as floor): /// state = from_bytes_with_min_epoch(blob_2, epoch_1); // N+1 > N ✓ /// /// // Attacker replays blob_1: /// from_bytes_with_min_epoch(blob_1, epoch_1); // N > N? NO → rejected ✓ /// ``` /// /// # Ownership /// /// Consumes the ratchet state. After serialization, the original in-memory /// state is dropped (and zeroized via Drop). To continue using /// the ratchet, deserialize the returned bytes with `from_bytes`. /// /// This is intentional: a non-consuming `&self` signature would allow the /// caller to serialize, continue encrypting with the original state, then /// later restore from the serialized bytes — producing a forked ratchet /// where both copies share the same epoch keys and `send_count`, causing /// catastrophic AEAD nonce reuse. pub fn to_bytes(self) -> Result<(Zeroizing>, u64)> { // Reject serialization if any counter has reached u32::MAX. if self.send_count == u32::MAX || self.recv_count == u32::MAX || self.prev_send_count == u32::MAX { return Err(Error::ChainExhausted); } const VERSION_SIZE: usize = 1; const EPOCH_SIZE: usize = 8; const EPOCH_KEYS_SIZE: usize = 32 * 3; // root_key + send_epoch_key + recv_epoch_key const FINGERPRINTS_SIZE: usize = 32 * 2; const OPTIONAL_ABSENT: usize = 1; const OPTIONAL_OVERHEAD: usize = 3; // 0x01 marker + 2-byte BE length prefix const COUNTERS_SIZE: usize = 4 * 3; const FLAGS_SIZE: usize = 1; const PREV_EPOCH_KEY_SIZE: usize = 1 + 32; // flag + key const SEEN_COUNT_SIZE: usize = 4; let est = VERSION_SIZE + EPOCH_SIZE + EPOCH_KEYS_SIZE + FINGERPRINTS_SIZE + self .send_ratchet_sk .as_ref() .map_or(OPTIONAL_ABSENT, |k| OPTIONAL_OVERHEAD + k.as_bytes().len()) + self .send_ratchet_pk .as_ref() .map_or(OPTIONAL_ABSENT, |k| OPTIONAL_OVERHEAD + k.as_bytes().len()) + self .recv_ratchet_pk .as_ref() .map_or(OPTIONAL_ABSENT, |k| OPTIONAL_OVERHEAD + k.as_bytes().len()) + if self.prev_recv_epoch_key.is_some() { PREV_EPOCH_KEY_SIZE } else { OPTIONAL_ABSENT } + self .prev_recv_ratchet_pk .as_ref() .map_or(OPTIONAL_ABSENT, |k| OPTIONAL_OVERHEAD + k.as_bytes().len()) + COUNTERS_SIZE + FLAGS_SIZE + SEEN_COUNT_SIZE + self.recv_seen.len() * 4 + SEEN_COUNT_SIZE + self.prev_recv_seen.len() * 4; let mut buf = Zeroizing::new(Vec::with_capacity(est)); // v5: counter-mode ratchet, no skip cache. buf.push(crate::constants::RATCHET_BLOB_VERSION); // Anti-rollback epoch. Reject at u64::MAX to prevent silent wrap to 0, // which would make the blob fail anti-rollback checks on next load. let epoch = self.epoch.checked_add(1).ok_or(Error::ChainExhausted)?; buf.extend_from_slice(&epoch.to_be_bytes()); // Fixed fields: root_key + send_epoch_key + recv_epoch_key (96 bytes). buf.extend_from_slice(&self.root_key); buf.extend_from_slice(&self.send_epoch_key); buf.extend_from_slice(&self.recv_epoch_key); // Identity fingerprints (64 bytes). buf.extend_from_slice(&self.local_fp); buf.extend_from_slice(&self.remote_fp); // Optional send ratchet secret key. encode_optional_bytes( &mut buf, self.send_ratchet_sk.as_ref().map(|k| k.as_bytes()), )?; // Optional send ratchet public key. encode_optional_bytes( &mut buf, self.send_ratchet_pk.as_ref().map(|k| k.as_bytes()), )?; // Optional recv ratchet public key. encode_optional_bytes( &mut buf, self.recv_ratchet_pk.as_ref().map(|k| k.as_bytes()), )?; // Previous receive epoch key (32 bytes, optional). match &self.prev_recv_epoch_key { Some(key) => { buf.push(0x01); buf.extend_from_slice(key.as_ref()); } None => buf.push(0x00), } // Previous receive ratchet public key (optional). encode_optional_bytes( &mut buf, self.prev_recv_ratchet_pk.as_ref().map(|k| k.as_bytes()), )?; // Counters (4 bytes each, big-endian). buf.extend_from_slice(&self.send_count.to_be_bytes()); buf.extend_from_slice(&self.recv_count.to_be_bytes()); buf.extend_from_slice(&self.prev_send_count.to_be_bytes()); // Flags. buf.push(if self.ratchet_pending { 1 } else { 0 }); // recv_seen — sorted for deterministic output. let mut sorted_seen: Vec = self.recv_seen.iter().copied().collect(); sorted_seen.sort_unstable(); // Mirrors decrypt_inner's runtime cap: a live set holds at most // MAX_RECV_SEEN - 1 entries (65535). if sorted_seen.len() >= constants::MAX_RECV_SEEN as usize { return Err(Error::InvalidData); } // len < MAX_RECV_SEEN (65536) verified above; fits in u32 on all platforms. let num_seen = u32::try_from(sorted_seen.len()).expect("len < MAX_RECV_SEEN fits in u32"); buf.extend_from_slice(&num_seen.to_be_bytes()); for n in &sorted_seen { buf.extend_from_slice(&n.to_be_bytes()); } // prev_recv_seen — sorted for deterministic output. let mut sorted_prev_seen: Vec = self.prev_recv_seen.iter().copied().collect(); sorted_prev_seen.sort_unstable(); // Mirrors decrypt_inner's runtime cap. if sorted_prev_seen.len() >= constants::MAX_RECV_SEEN as usize { return Err(Error::InvalidData); } // len < MAX_RECV_SEEN (65536) verified above; fits in u32 on all platforms. let num_prev_seen = u32::try_from(sorted_prev_seen.len()).expect("len < MAX_RECV_SEEN fits in u32"); buf.extend_from_slice(&num_prev_seen.to_be_bytes()); for n in &sorted_prev_seen { buf.extend_from_slice(&n.to_be_bytes()); } // Capacity must match exactly — if Vec reallocated during // serialization, the abandoned buffer (containing root keys, epoch // keys, fingerprints) was freed without zeroization. The Zeroizing // wrapper only covers the final allocation. debug_assert_eq!( buf.capacity(), est, "to_bytes capacity underestimated: reallocation leaked secret material to the heap" ); Ok((buf, epoch)) } /// Deserialize ratchet state from bytes (§6.8). /// /// Returns an error if the data is malformed or truncated. /// /// # Security /// /// The input must have been authenticated-decrypted before calling this /// function (e.g., via [`crate::storage::decrypt_blob`]). Feeding unauthenticated /// or tampered data may produce a structurally valid but corrupted state. /// /// **Double-deserialization hazard:** Calling `from_bytes` twice on the same /// bytes produces two independent ratchet states with identical epoch keys /// and counters. Encrypting with both causes catastrophic AEAD nonce reuse. /// Use [`from_bytes_with_min_epoch`](Self::from_bytes_with_min_epoch) to /// enforce anti-rollback, which also prevents double-deserialization when /// the caller correctly persists and checks the epoch. #[deprecated(note = "Use from_bytes_with_min_epoch for anti-rollback protection")] pub fn from_bytes(data: &[u8]) -> Result { let mut pos = 0; let read = |pos: &mut usize, n: usize| -> Result<&[u8]> { let end = pos .checked_add(n) .filter(|&e| e <= data.len()) .ok_or(Error::InvalidData)?; let slice = &data[*pos..end]; *pos = end; Ok(slice) }; // v5: counter-mode ratchet. Pre-1.0: no migration from older versions. let version = read(&mut pos, 1)?[0]; if version != crate::constants::RATCHET_BLOB_VERSION { return Err(Error::UnsupportedVersion); } // Anti-rollback epoch (u64 BE). to_bytes stores epoch+1, so the // Stored epoch u64::MAX means internal epoch u64::MAX, which cannot // be re-serialized (to_bytes' checked_add overflows). Reject to // prevent creating a permanently un-serializable state. // States with stored epoch u64::MAX - 1 (internal u64::MAX - 1) are // accepted but non-serializable: can_serialize() returns false, // to_bytes returns ChainExhausted. This is graceful — the session // is still usable for encrypt/decrypt, just not persistable. let epoch = u64::from_be_bytes(read(&mut pos, 8)?.try_into().map_err(|_| Error::Internal)?); if epoch == u64::MAX { return Err(Error::ChainExhausted); } let mut root_key = Zeroizing::new([0u8; 32]); root_key.copy_from_slice(read(&mut pos, 32)?); let mut send_epoch_key = Zeroizing::new([0u8; 32]); send_epoch_key.copy_from_slice(read(&mut pos, 32)?); let mut recv_epoch_key = Zeroizing::new([0u8; 32]); recv_epoch_key.copy_from_slice(read(&mut pos, 32)?); // All-zero root_key means the session was zeroized — not a valid state. if bool::from(root_key.ct_eq(&[0u8; 32])) { return Err(Error::InvalidData); } // Identity fingerprints. let mut local_fp = [0u8; 32]; local_fp.copy_from_slice(read(&mut pos, 32)?); let mut remote_fp = [0u8; 32]; remote_fp.copy_from_slice(read(&mut pos, 32)?); if local_fp == [0u8; 32] || remote_fp == [0u8; 32] { return Err(Error::InvalidData); } if local_fp == remote_fp { return Err(Error::InvalidData); } // Optional send ratchet secret key. let send_ratchet_sk = decode_optional_bytes(&mut pos, data)? .map(|b| xwing::SecretKey::from_bytes(b.to_vec()).map_err(|_| Error::InvalidData)) .transpose()?; // Optional send ratchet public key. let send_ratchet_pk = decode_optional_bytes(&mut pos, data)? .map(|b| xwing::PublicKey::from_bytes(b.to_vec()).map_err(|_| Error::InvalidData)) .transpose()?; // Co-presence: send_ratchet_sk and send_ratchet_pk must be both present or both absent. if send_ratchet_sk.is_some() != send_ratchet_pk.is_some() { return Err(Error::InvalidData); } // Defense-in-depth: all-zero X25519 secret key check. if let Some(ref sk) = send_ratchet_sk { let x25519_portion = &sk.as_bytes()[..32]; if bool::from(x25519_portion.ct_eq(&[0u8; 32])) { return Err(Error::InvalidData); } } // Optional recv ratchet public key. let recv_ratchet_pk = decode_optional_bytes(&mut pos, data)? .map(|b| xwing::PublicKey::from_bytes(b.to_vec()).map_err(|_| Error::InvalidData)) .transpose()?; // Previous receive epoch key (optional, fixed 32 bytes). let prev_recv_epoch_key = match read(&mut pos, 1)?[0] { 0x00 => None, 0x01 => { let mut key = Zeroizing::new([0u8; 32]); key.copy_from_slice(read(&mut pos, 32)?); Some(key) } _ => return Err(Error::InvalidData), }; // Previous receive ratchet public key. let prev_recv_ratchet_pk = decode_optional_bytes(&mut pos, data)? .map(|b| xwing::PublicKey::from_bytes(b.to_vec()).map_err(|_| Error::InvalidData)) .transpose()?; // Co-presence: prev_recv_epoch_key and prev_recv_ratchet_pk. if prev_recv_epoch_key.is_some() != prev_recv_ratchet_pk.is_some() { return Err(Error::InvalidData); } // Counters. let send_count = u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?); let recv_count = u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?); let prev_send_count = u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?); // u32::MAX is never a valid serialized counter value. if send_count == u32::MAX || recv_count == u32::MAX || prev_send_count == u32::MAX { return Err(Error::InvalidData); } if recv_count > 0 && recv_ratchet_pk.is_none() { return Err(Error::InvalidData); } // recv_count == 0 with recv_ratchet_pk present: now valid for the // transient state after a KEM ratchet step sets recv_count = 0 before // any messages are received on the new epoch. // (Removed the previous v4 guard that rejected this.) // Flags. let ratchet_pending = match read(&mut pos, 1)?[0] { 0x00 => false, 0x01 => true, _ => return Err(Error::InvalidData), }; if ratchet_pending && recv_ratchet_pk.is_none() { return Err(Error::InvalidData); } // Epoch key validity checks. // All-zero recv_epoch_key is only valid in Alice's initial state // (recv_count == 0, recv_ratchet_pk == None). Once the session has // progressed (recv_count > 0 OR recv_ratchet_pk present), the epoch // key must be non-zero — otherwise HMAC([0;32], 0x01||counter) // produces publicly computable message keys. if (recv_count > 0 || recv_ratchet_pk.is_some()) && bool::from(recv_epoch_key.ct_eq(&[0u8; 32])) { return Err(Error::InvalidData); } if send_count > 0 && !ratchet_pending && bool::from(send_epoch_key.ct_eq(&[0u8; 32])) { return Err(Error::InvalidData); } // Defense-in-depth: all-zero prev_recv_epoch_key would produce // deterministic message keys. AEAD would reject at decrypt time, // but rejecting early avoids wasted computation. if let Some(ref prek) = prev_recv_epoch_key { if bool::from(prek.ct_eq(&[0u8; 32])) { return Err(Error::InvalidData); } } if send_count > 0 && send_ratchet_sk.is_none() { return Err(Error::InvalidData); } if send_count == 0 && send_ratchet_sk.is_some() { return Err(Error::InvalidData); } if send_count == 0 && !ratchet_pending && recv_ratchet_pk.is_some() && send_ratchet_sk.is_none() { return Err(Error::InvalidData); } if send_count == 0 && recv_count == 0 && !ratchet_pending && send_ratchet_sk.is_none() && recv_ratchet_pk.is_none() { return Err(Error::InvalidData); } // recv_seen set. let num_seen = u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?); // Mirrors decrypt_inner's runtime cap: a live set holds at most // MAX_RECV_SEEN - 1 entries. if num_seen >= constants::MAX_RECV_SEEN { return Err(Error::InvalidData); } let mut recv_seen = HashSet::with_capacity(num_seen as usize); let mut prev_val: Option = None; for _ in 0..num_seen { let n = u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?); if n == u32::MAX { return Err(Error::InvalidData); } // Enforce strictly ascending order per Specification.md §6.8 wire format. // Strict ascending implicitly rejects duplicates (no separate check // needed). if prev_val.is_some_and(|p| n <= p) { return Err(Error::InvalidData); } prev_val = Some(n); recv_seen.insert(n); } // prev_recv_seen set. let num_prev_seen = u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?); // Mirrors decrypt_inner's runtime cap. if num_prev_seen >= constants::MAX_RECV_SEEN { return Err(Error::InvalidData); } let mut prev_recv_seen = HashSet::with_capacity(num_prev_seen as usize); let mut prev_val: Option = None; for _ in 0..num_prev_seen { let n = u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?); if n == u32::MAX { return Err(Error::InvalidData); } // Enforce strictly ascending order per Specification.md §6.8 wire format. if prev_val.is_some_and(|p| n <= p) { return Err(Error::InvalidData); } prev_val = Some(n); prev_recv_seen.insert(n); } // recv_seen entries must be consistent with the recv_count high-water // mark: every entry should be < recv_count during normal operation. // A crafted blob violating this would have semantically inconsistent state // (false-positive duplicate rejection for future counter values). if recv_seen.iter().any(|&n| n >= recv_count) { return Err(Error::InvalidData); } // No analogous high-water mark check for prev_recv_seen: when a receive // epoch rotates into previous, its recv_count is not persisted (there is // no prev_recv_count field). prev_recv_seen entries are bounded only by // the MAX_RECV_SEEN cap (guard 14) and u32::MAX exclusion (guard 15). // This asymmetry is intentional — prev_recv_seen is discarded on the // next KEM ratchet step and no computation depends on a high-water mark // relationship between its entries and any stored counter. Adding a // prev_recv_count field would add wire format complexity for no security // benefit. See Specification.md §6.8 guard 17. // prev_recv_seen without prev_recv_epoch_key is invalid. if !prev_recv_seen.is_empty() && prev_recv_epoch_key.is_none() { return Err(Error::InvalidData); } // Reject trailing data. if pos != data.len() { return Err(Error::InvalidData); } // [u8; 32] is Copy — the Zeroizing wrappers zeroize the originals on drop; // RatchetState::Drop handles the copies in the constructed struct. let state = Self { root_key: *root_key, send_epoch_key: *send_epoch_key, recv_epoch_key: *recv_epoch_key, local_fp, remote_fp, send_ratchet_sk, send_ratchet_pk, recv_ratchet_pk, prev_recv_epoch_key, prev_recv_ratchet_pk, send_count, recv_count, prev_send_count, ratchet_pending, recv_seen, prev_recv_seen, epoch, }; Ok(state) } /// Deserialize ratchet state with anti-rollback epoch validation. /// /// Equivalent to [`from_bytes`](Self::from_bytes) followed by a check that /// the deserialized epoch is strictly greater than `min_epoch`. Returns /// `InvalidData` if the epoch is stale (≤ `min_epoch`). /// /// The caller should persist the epoch from each successful deserialization /// and pass it as `min_epoch` on the next load. This prevents storage-level /// replay attacks where an attacker substitutes an older encrypted blob. /// /// # Anti-Rollback Protocol /// /// 1. Deserialize: `let state = RatchetState::from_bytes_with_min_epoch(blob, last_epoch)?;` /// 2. Persist: `last_epoch = state.epoch();` /// 3. On next load, pass the persisted `last_epoch` as `min_epoch`. /// /// **Session scoping:** `min_epoch` MUST be stored and compared per session /// (identified by the `local_fp, remote_fp` pair). Using a single global /// `min_epoch` across sessions allows cross-session replay: an attacker could /// substitute a blob from session A (higher epoch) into session B (lower epoch). pub fn from_bytes_with_min_epoch(data: &[u8], min_epoch: u64) -> Result { #[allow(deprecated)] let state = Self::from_bytes(data)?; if state.epoch <= min_epoch { return Err(Error::InvalidData); } Ok(state) } } /// Encode an optional byte slice: 0x00 if None, 0x01 + len(2 BE) + bytes if Some. /// /// Returns `Error::Internal` if a present value exceeds `u16::MAX` bytes — all /// callers pass fixed-size protocol keys well within that limit, so this is /// structurally unreachable in correct code. fn encode_optional_bytes(buf: &mut Vec, opt: Option<&[u8]>) -> Result<()> { match opt { Some(data) => { let len = u16::try_from(data.len()).map_err(|_| Error::Internal)?; buf.push(0x01); buf.extend_from_slice(&len.to_be_bytes()); buf.extend_from_slice(data); } None => buf.push(0x00), } Ok(()) } /// Decode an optional byte slice from serialized data. /// /// Truncated input returns `InvalidData` (not `InvalidLength`) to avoid leaking /// internal parser offsets. Structural violations (bad marker byte, zero-length /// present field) also return `InvalidData`. /// /// A present marker (`0x01`) with a zero-length body is rejected as /// `InvalidData` — all optional fields must be non-empty when present. fn decode_optional_bytes<'a>(pos: &mut usize, data: &'a [u8]) -> Result> { // Truncation returns InvalidData (not InvalidLength) to avoid leaking // internal parser offsets — same rationale as the read closure in from_bytes. if *pos >= data.len() { return Err(Error::InvalidData); } let flag = data[*pos]; *pos += 1; match flag { 0x00 => Ok(None), 0x01 => { let len_end = pos .checked_add(2) .filter(|&e| e <= data.len()) .ok_or(Error::InvalidData)?; // len_end = *pos + 2, guaranteed in bounds above; 2-byte slice // structurally converts, but map_err avoids a panic if refactored. let len = u16::from_be_bytes( data[*pos..len_end] .try_into() .map_err(|_| Error::Internal)?, ) as usize; // Zero-length present field is semantically invalid — all optional // cryptographic fields (keys, signatures) must be non-empty. if len == 0 { return Err(Error::InvalidData); } *pos = len_end; let data_end = pos .checked_add(len) .filter(|&e| e <= data.len()) .ok_or(Error::InvalidData)?; let slice = &data[*pos..data_end]; *pos = data_end; Ok(Some(slice)) } _ => Err(Error::InvalidData), } } /// Identifies which epoch a received message belongs to. #[derive(Clone, Copy, PartialEq, Eq)] #[allow(clippy::enum_variant_names)] enum EpochType { /// Message from the immediately preceding epoch (prev_recv_epoch_key). PreviousEpoch, /// Message from the current epoch (recv_epoch_key). CurrentEpoch, /// Message from a new epoch — requires a KEM ratchet step. NewEpoch, } /// Counter-mode message key derivation (§6.3). /// /// `msg_key = HMAC-SHA3-256(key=epoch_key, data=0x01 || big_endian_32(counter))` /// /// O(1) — any message position within an epoch is directly derivable without /// sequential chain advancement. The 0x01 prefix (MSG_KEY_DOMAIN_BYTE) provides /// domain separation from any other potential HMAC use of the epoch key. fn kdf_msg_key(epoch_key: &[u8; 32], counter: u32) -> Zeroizing<[u8; 32]> { let mut data = [0u8; 5]; data[0] = constants::MSG_KEY_DOMAIN_BYTE; data[1..5].copy_from_slice(&counter.to_be_bytes()); Zeroizing::new(hmac::hmac_sha3_256(epoch_key, &data)) } /// Root KDF (§6.4): derive new root key and epoch key from KEM shared secret. /// /// HKDF-SHA3-256(salt=root_key, ikm=kem_ss, info=RATCHET_HKDF_INFO) → 64 bytes. /// The info string ("lo-ratchet-v1") provides domain separation from other HKDF /// uses in the protocol. // New root key and epoch key pair produced by kdf_root. type RootKdfOutput = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>); fn kdf_root(root_key: &[u8; 32], kem_ss: &[u8]) -> Result { let mut output = Zeroizing::new([0u8; 64]); // Output size (64) is compile-time constant — InvalidLength structurally unreachable. hkdf::hkdf_sha3_256(root_key, kem_ss, constants::RATCHET_HKDF_INFO, &mut *output)?; let mut new_root = Zeroizing::new([0u8; 32]); let mut new_epoch = Zeroizing::new([0u8; 32]); new_root.copy_from_slice(&output[..32]); new_epoch.copy_from_slice(&output[32..64]); Ok((new_root, new_epoch)) } /// Encode a nonce from a message counter (big-endian 192-bit). /// /// Bytes 0-19 are zero. Each nonce is paired with a unique per-message /// key derived via `kdf_msg_key`, so the nonce need only be distinct within /// a single key's use. A u32 counter in bytes 20-23 provides sufficient /// uniqueness for up to 2^32 messages per epoch. Placing the counter in /// the last 4 bytes of the 24-byte XChaCha20 nonce leaves room for future /// counter width expansion without a wire format break. fn nonce_from_counter(n: u32) -> [u8; 24] { let mut nonce = [0u8; 24]; nonce[20..24].copy_from_slice(&n.to_be_bytes()); nonce } /// Encode a RatchetHeader into deterministic binary for AAD (§7.4). /// /// Wire layout: /// ```text /// ratchet_pk (1216 bytes, fixed) /// has_kem_ct (1 byte: 0x00 or 0x01) /// [if has_kem_ct] len(kem_ct) (2 bytes, big-endian u16) || kem_ct (1120 bytes) /// n (4 bytes, big-endian u32) /// pn (4 bytes, big-endian u32) /// ``` fn encode_ratchet_header(header: &RatchetHeader) -> Result> { // 1-byte has_kem_ct flag + (2-byte u16 length prefix + ciphertext if present) let kem_size = if header.kem_ct.is_some() { 1 + 2 + constants::XWING_CIPHERTEXT_SIZE } else { 1 }; // ratchet_pk (1216) + kem_size + n (4 bytes BE) + pn (4 bytes BE) let mut buf = Vec::with_capacity(constants::XWING_PUBLIC_KEY_SIZE + kem_size + 8); // ratchet_pk (1216 bytes, fixed — no length prefix) buf.extend_from_slice(header.ratchet_pk.as_bytes()); // has_kem_ct flag if let Some(ref kem_ct) = header.kem_ct { buf.push(0x01); // len(kem_ct) || kem_ct let ct = kem_ct.as_bytes(); // X-Wing ciphertext is always XWING_CIPHERTEXT_SIZE (1120) bytes — // structurally unreachable, but checked at runtime for consistency // with encode_optional_bytes (which uses the same u16 length prefix). let len = u16::try_from(ct.len()).map_err(|_| Error::Internal)?; buf.extend_from_slice(&len.to_be_bytes()); buf.extend_from_slice(ct); } else { buf.push(0x00); } // big_endian_32bit(n) buf.extend_from_slice(&header.n.to_be_bytes()); // big_endian_32bit(pn) buf.extend_from_slice(&header.pn.to_be_bytes()); Ok(buf) } /// Build AAD for ratchet messages (§7.3). /// /// # Security /// /// The AAD binds sender/recipient identity fingerprints and the ratchet header /// to the AEAD encryption, preventing cross-session replay and cross-party /// confusion. fn build_ratchet_aad( sender_fp: &[u8; 32], recipient_fp: &[u8; 32], header: &RatchetHeader, ) -> Result> { let header_bytes = encode_ratchet_header(header)?; let mut aad = Vec::with_capacity(constants::DM_AAD.len() + 32 + 32 + header_bytes.len()); aad.extend_from_slice(constants::DM_AAD); aad.extend_from_slice(sender_fp); aad.extend_from_slice(recipient_fp); aad.extend_from_slice(&header_bytes); Ok(aad) } #[cfg(test)] #[allow(deprecated)] // Tests exercise from_bytes directly for parser coverage. mod tests { use super::*; use crate::error::Error; // Fingerprint constants for test AAD. const FP_A: [u8; 32] = [0xAAu8; 32]; const FP_B: [u8; 32] = [0xBBu8; 32]; // ── init guard tests ────────────────────────────────────────────── #[test] fn init_alice_rejects_zero_root_key() { let (ek_pk, ek_sk) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_alice([0u8; 32], [0x11; 32], FP_A, FP_B, ek_pk, ek_sk), Err(Error::InvalidData) )); } #[test] fn init_alice_rejects_zero_chain_key() { let (ek_pk, ek_sk) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_alice([0x11; 32], [0u8; 32], FP_A, FP_B, ek_pk, ek_sk), Err(Error::InvalidData) )); } #[test] fn init_alice_rejects_equal_fingerprints() { let (ek_pk, ek_sk) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_alice([0x11; 32], [0x22; 32], FP_A, FP_A, ek_pk, ek_sk), Err(Error::InvalidData) )); } #[test] fn init_bob_rejects_zero_root_key() { let (ek_pk, _) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_bob([0u8; 32], [0x11; 32], FP_B, FP_A, ek_pk), Err(Error::InvalidData) )); } #[test] fn init_bob_rejects_zero_chain_key() { let (ek_pk, _) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_bob([0x11; 32], [0u8; 32], FP_B, FP_A, ek_pk), Err(Error::InvalidData) )); } #[test] fn init_bob_rejects_equal_fingerprints() { let (ek_pk, _) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_bob([0x11; 32], [0x22; 32], FP_B, FP_B, ek_pk), Err(Error::InvalidData) )); } #[test] fn init_alice_rejects_zero_local_fp() { let (ek_pk, ek_sk) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_alice([0x11; 32], [0x22; 32], [0u8; 32], FP_B, ek_pk, ek_sk), Err(Error::InvalidData) )); } #[test] fn init_alice_rejects_zero_remote_fp() { let (ek_pk, ek_sk) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_alice([0x11; 32], [0x22; 32], FP_A, [0u8; 32], ek_pk, ek_sk), Err(Error::InvalidData) )); } #[test] fn init_bob_rejects_zero_local_fp() { let (ek_pk, _) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_bob([0x11; 32], [0x22; 32], [0u8; 32], FP_A, ek_pk), Err(Error::InvalidData) )); } #[test] fn init_bob_rejects_zero_remote_fp() { let (ek_pk, _) = xwing::keygen().unwrap(); assert!(matches!( RatchetState::init_bob([0x11; 32], [0x22; 32], FP_B, [0u8; 32], ek_pk), Err(Error::InvalidData) )); } // ── helpers ────────────────────────────────────────────────────── /// Set up a KEX-like pair: returns (alice_state, bob_state). fn make_pair() -> (RatchetState, RatchetState) { let (ek_pk, ek_sk) = xwing::keygen().unwrap(); let rk: [u8; 32] = random::random_array(); let ck: [u8; 32] = random::random_array(); let alice = RatchetState::init_alice(rk, ck, FP_A, FP_B, ek_pk.clone(), ek_sk).unwrap(); let bob = RatchetState::init_bob(rk, ck, FP_B, FP_A, ek_pk).unwrap(); (alice, bob) } /// Encrypt from alice, decrypt at bob. fn send_a_to_b( alice: &mut RatchetState, bob: &mut RatchetState, msg: &[u8], ) -> Zeroizing> { let enc = alice.encrypt(msg).unwrap(); bob.decrypt(&enc.header, &enc.ciphertext).unwrap() } /// Encrypt from bob, decrypt at alice. fn send_b_to_a( alice: &mut RatchetState, bob: &mut RatchetState, msg: &[u8], ) -> Zeroizing> { let enc = bob.encrypt(msg).unwrap(); alice.decrypt(&enc.header, &enc.ciphertext).unwrap() } // === 4A. Basic lifecycle === #[test] fn alice_bob_single_message() { let (mut alice, mut bob) = make_pair(); let pt = send_a_to_b(&mut alice, &mut bob, b"hello"); assert_eq!(&*pt, b"hello"); } #[test] fn alice_bob_bidirectional() { let (mut alice, mut bob) = make_pair(); let pt1 = send_a_to_b(&mut alice, &mut bob, b"from alice"); assert_eq!(&*pt1, b"from alice"); let pt2 = send_b_to_a(&mut alice, &mut bob, b"from bob"); assert_eq!(&*pt2, b"from bob"); } #[test] fn multiple_messages_same_direction() { let (mut alice, mut bob) = make_pair(); for i in 0..5u8 { let msg = [i; 16]; let pt = send_a_to_b(&mut alice, &mut bob, &msg); assert_eq!(&*pt, &msg); } } #[test] fn out_of_order_delivery() { let (mut alice, mut bob) = make_pair(); // Alice sends 3 messages. let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); let enc2 = alice.encrypt(b"msg2").unwrap(); // Bob receives in order 2, 0, 1. let pt2 = bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap(); assert_eq!(&*pt2, b"msg2"); let pt0 = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); assert_eq!(&*pt0, b"msg0"); let pt1 = bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); assert_eq!(&*pt1, b"msg1"); } #[test] fn decrypt_with_key_wrong_ciphertext_returns_aead_failed() { // Exercises out-of-order decryption via counter-mode: Bob decrypts // message 1 first, then attempts to decrypt message 0 with a tampered // ciphertext. The epoch key derives the correct message key but the // content is wrong → AeadFailed. State rollback on failure preserves // recv_seen so the genuine message 0 can still be decrypted afterwards. let (mut alice, mut bob) = make_pair(); let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); // Decrypt msg1 first; msg0 remains pending in the same epoch. bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); // Tampered ciphertext: key is in cache but content is wrong. let mut bad_ct = enc0.ciphertext.clone(); bad_ct[0] ^= 0xFF; assert!(matches!( bob.decrypt(&enc0.header, &bad_ct), Err(Error::AeadFailed) )); // State rollback on AEAD failure — genuine msg0 still decrypts. let pt = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); assert_eq!(&*pt, b"msg0"); } #[test] fn decrypt_out_of_order_then_replay_returns_duplicate() { // Out-of-order decrypt succeeds via counter-mode derivation. A second // attempt on the same counter returns DuplicateMessage (recv_seen tracks it). let (mut alice, mut bob) = make_pair(); let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); // Decrypt msg1 first — msg0 is still derivable via counter-mode. bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); // First decrypt of msg0: succeeds (counter-mode O(1) derivation). let pt = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); assert_eq!(&*pt, b"msg0"); // Second decrypt: counter is in recv_seen → DuplicateMessage. assert!(matches!( bob.decrypt(&enc0.header, &enc0.ciphertext), Err(Error::DuplicateMessage) )); } #[test] fn multiple_ratchet_steps() { let (mut alice, mut bob) = make_pair(); // A→B, B→A, A→B, B→A — each direction change triggers KEM ratchet. // Verify header fields: kem_ct, n, pn. // // init_alice: send_count=1, ratchet_pending=false, send_ratchet_pk=Some // init_bob: send_count=0, ratchet_pending=true // // Round 0: Alice sends. No ratchet (already has chain). n=1, pn=0, no kem_ct. // Round 1: Bob sends. Ratchet (pending). n=0, pn=0, kem_ct present. // Round 2: Alice sends. Ratchet (direction change). n=0, pn=2, kem_ct present. // Round 3: Bob sends. Ratchet (direction change). n=0, pn=1, kem_ct present. // Round 0: A→B let enc = alice.encrypt(&[0]).unwrap(); assert!(enc.header.kem_ct.is_none(), "round 0: no ratchet needed"); assert_eq!( enc.header.n, 1, "round 0: init_alice starts send_count at 1" ); assert_eq!(enc.header.pn, 0); bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); // Round 1: B→A (ratchet — Bob's ratchet_pending=true) let enc = bob.encrypt(&[1]).unwrap(); assert!( enc.header.kem_ct.is_some(), "round 1: Bob's first send triggers ratchet" ); assert_eq!(enc.header.n, 0); assert_eq!(enc.header.pn, 0, "round 1: Bob had no previous send chain"); alice.decrypt(&enc.header, &enc.ciphertext).unwrap(); // Round 2: A→B (ratchet — direction changed) let enc = alice.encrypt(&[2]).unwrap(); assert!( enc.header.kem_ct.is_some(), "round 2: direction change triggers ratchet" ); assert_eq!(enc.header.n, 0); assert_eq!( enc.header.pn, 2, "round 2: Alice sent 1 msg on previous chain (count 1→2)" ); bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); // Round 3: B→A (ratchet — direction changed) let enc = bob.encrypt(&[3]).unwrap(); assert!( enc.header.kem_ct.is_some(), "round 3: direction change triggers ratchet" ); assert_eq!(enc.header.n, 0); assert_eq!( enc.header.pn, 1, "round 3: Bob sent 1 msg on previous chain" ); alice.decrypt(&enc.header, &enc.ciphertext).unwrap(); } #[test] fn bob_first_send_triggers_kem_ratchet() { let (mut alice, mut bob) = make_pair(); // Bob's first encrypt should trigger KEM ratchet (ratchet_pending=true). let enc = bob.encrypt(b"bob first").unwrap(); assert!( enc.header.kem_ct.is_some(), "Bob's first send must include KEM ciphertext" ); let pt = alice.decrypt(&enc.header, &enc.ciphertext).unwrap(); assert_eq!(&*pt, b"bob first"); } // === 4B. First message === #[test] fn encrypt_decrypt_first_message() { let ck: [u8; 32] = random::random_array(); let aad = b"test aad"; let (payload, ck_enc) = RatchetState::encrypt_first_message(Zeroizing::new(ck), b"first", aad).unwrap(); let (pt, ck_dec) = RatchetState::decrypt_first_message(Zeroizing::new(ck), &payload, aad).unwrap(); assert_eq!(&*pt, b"first"); assert_eq!(*ck_enc, *ck_dec); } #[test] fn first_message_too_short() { let ck: [u8; 32] = random::random_array(); assert!(matches!( RatchetState::decrypt_first_message(Zeroizing::new(ck), &[0u8; 39], b"aad"), Err(Error::AeadFailed) )); } #[test] fn first_message_wrong_aad() { let ck: [u8; 32] = random::random_array(); let (payload, _) = RatchetState::encrypt_first_message(Zeroizing::new(ck), b"test", b"correct").unwrap(); assert!(matches!( RatchetState::decrypt_first_message(Zeroizing::new(ck), &payload, b"wrong"), Err(Error::AeadFailed) )); } #[test] fn first_message_tampered() { let ck: [u8; 32] = random::random_array(); let (mut payload, _) = RatchetState::encrypt_first_message(Zeroizing::new(ck), b"test", b"aad").unwrap(); payload[24] ^= 0xFF; // flip byte in ciphertext assert!(matches!( RatchetState::decrypt_first_message(Zeroizing::new(ck), &payload, b"aad"), Err(Error::AeadFailed) )); } #[test] fn first_message_epoch_key_passthrough() { let ck: [u8; 32] = random::random_array(); let (_, next_ek) = RatchetState::encrypt_first_message(Zeroizing::new(ck), b"test", b"aad").unwrap(); // Counter-mode: the epoch key passes through unchanged (no chain advancement). assert_eq!(&ck, &*next_ek); } // === RT-386: empty plaintext first message === #[test] fn first_message_empty_plaintext_round_trip() { let ck: [u8; 32] = random::random_array(); let (payload, next_ck) = RatchetState::encrypt_first_message(Zeroizing::new(ck), b"", b"aad").unwrap(); // Empty plaintext → 24-byte nonce + 16-byte AEAD tag = 40 bytes. assert_eq!(payload.len(), 40); let (plaintext, dec_next_ck) = RatchetState::decrypt_first_message(Zeroizing::new(ck), &payload, b"aad").unwrap(); assert!(plaintext.is_empty()); assert_eq!(*next_ck, *dec_next_ck); } // === 4C. Chain exhaustion === #[test] fn encrypt_chain_exhausted() { let (mut alice, _) = make_pair(); // Force send_count to u32::MAX. alice.send_count = u32::MAX; assert!(matches!(alice.encrypt(b"test"), Err(Error::ChainExhausted))); } #[test] fn encrypt_chain_exhausted_with_ratchet_pending() { // When ratchet_pending is true, perform_kem_ratchet_send would reset // send_count to 0. The ChainExhausted guard must fire BEFORE the pending // ratchet executes — otherwise a nonce with value u32::MAX would be used. let (mut alice, mut bob) = make_pair(); // Bob sends → triggers Alice's receive-side ratchet, setting ratchet_pending let enc_bob = bob.encrypt(b"from bob").unwrap(); alice.decrypt(&enc_bob.header, &enc_bob.ciphertext).unwrap(); assert!( alice.ratchet_pending, "ratchet_pending should be set after receive ratchet" ); // Force send_count to MAX with ratchet still pending alice.send_count = u32::MAX; assert!( matches!(alice.encrypt(b"test"), Err(Error::ChainExhausted)), "ChainExhausted must fire before pending ratchet resets send_count" ); } #[test] fn encrypt_at_send_count_max_minus_one_succeeds() { // Boundary: MAX-1 must succeed; the subsequent encrypt at MAX must fail. // An off-by-one in the guard (e.g. `>= MAX` instead of `== MAX`) would // pass encrypt_chain_exhausted but fail this boundary check. let (mut alice, _) = make_pair(); alice.send_count = u32::MAX - 1; assert!( alice.encrypt(b"last valid").is_ok(), "send_count == u32::MAX-1 must succeed" ); // After the successful encrypt, send_count == u32::MAX. assert!( matches!(alice.encrypt(b"over limit"), Err(Error::ChainExhausted)), "send_count == u32::MAX must return ChainExhausted" ); } #[test] fn double_consecutive_receive_ratchet() { // Alice sends two messages with different ratchet keys (no reply from Bob // in between). Bob receives both, performing two consecutive receive-side // KEM ratchets without intervening encrypt(). Then Bob sends, triggering a // send-side ratchet from the double-received state. let (mut alice, mut bob) = make_pair(); // Alice sends msg1 (her initial send chain) let enc1 = alice.encrypt(b"msg1").unwrap(); // Bob receives msg1 → first receive ratchet, sets ratchet_pending bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); // Alice receives nothing — she initiates a new ratchet by sending msg2 // (her encrypt triggers perform_kem_ratchet_send with fresh EK) let enc2 = alice.encrypt(b"msg2").unwrap(); // Bob receives msg2 with a different ratchet_pk → second consecutive // receive ratchet (no Bob encrypt between the two receives) bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap(); // Bob now sends — this triggers a send-side ratchet from the double-received state let enc_bob = bob.encrypt(b"reply").unwrap(); // Alice decrypts Bob's reply let pt = alice.decrypt(&enc_bob.header, &enc_bob.ciphertext).unwrap(); assert_eq!(&*pt, b"reply"); } #[test] fn decrypt_header_n_max() { let (mut alice, mut bob) = make_pair(); // Send a valid message to get a header template. let enc = alice.encrypt(b"test").unwrap(); // Modify header to have n=u32::MAX. let bad_header = RatchetHeader { ratchet_pk: enc.header.ratchet_pk, kem_ct: enc.header.kem_ct, n: u32::MAX, pn: enc.header.pn, }; assert!(matches!( bob.decrypt(&bad_header, &enc.ciphertext), Err(Error::ChainExhausted) )); } #[test] fn decrypt_header_n_tampered() { // Modifying the counter field n to a different valid value must cause // AEAD failure — n is bound into the AAD via the encoded ratchet header, // and is also used to derive the message key. Both mechanisms reject. let (mut alice, mut bob) = make_pair(); let enc = alice.encrypt(b"test").unwrap(); let bad_header = RatchetHeader { ratchet_pk: enc.header.ratchet_pk, kem_ct: enc.header.kem_ct, n: enc.header.n.wrapping_add(1), pn: enc.header.pn, }; assert!(matches!( bob.decrypt(&bad_header, &enc.ciphertext), Err(Error::AeadFailed) )); } #[test] fn decrypt_header_pn_max() { // With counter-mode, pn is informational only (included in AAD). // Modifying pn changes the AAD, causing AEAD authentication failure. let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"setup"); send_b_to_a(&mut alice, &mut bob, b"reply"); let enc = alice.encrypt(b"test").unwrap(); let bad_header = RatchetHeader { ratchet_pk: enc.header.ratchet_pk, kem_ct: enc.header.kem_ct, n: enc.header.n, pn: u32::MAX, }; assert!(matches!( bob.decrypt(&bad_header, &enc.ciphertext), Err(Error::AeadFailed) )); } // === 4D. Counter-mode out-of-order and duplicate detection === #[test] fn duplicate_message_rejected() { let (mut alice, mut bob) = make_pair(); let enc = alice.encrypt(b"once").unwrap(); bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); // Replay — counter is in recv_seen. assert!(matches!( bob.decrypt(&enc.header, &enc.ciphertext), Err(Error::DuplicateMessage) )); } #[test] fn out_of_order_counter_mode() { // Counter-mode allows O(1) derivation of any message key within an epoch. let (mut alice, mut bob) = make_pair(); let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); let enc2 = alice.encrypt(b"msg2").unwrap(); // Receive in reverse order — all must decrypt successfully. let pt2 = bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap(); let pt0 = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); let pt1 = bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); assert_eq!(&*pt0, b"msg0"); assert_eq!(&*pt1, b"msg1"); assert_eq!(&*pt2, b"msg2"); } #[test] fn out_of_order_replay_rejected() { let (mut alice, mut bob) = make_pair(); let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); // Receive msg1 first, then msg0. bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); // Replay msg0 — now in recv_seen. assert!(matches!( bob.decrypt(&enc0.header, &enc0.ciphertext), Err(Error::DuplicateMessage) )); } #[test] fn out_of_order_aead_failure_preserves_state() { // Tampered out-of-order message must not mark the counter as seen. let (mut alice, mut bob) = make_pair(); let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); // Receive msg1 first. bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); // Try msg0 with tampered ciphertext. let mut bad_ct = enc0.ciphertext.clone(); bad_ct[0] ^= 0xFF; assert!(matches!( bob.decrypt(&enc0.header, &bad_ct), Err(Error::AeadFailed) )); // Retry with correct ciphertext — must still work (counter not in recv_seen). let pt = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); assert_eq!(&*pt, b"msg0"); } // === 4E. Rollback on failure === #[test] fn rollback_on_aead_failure() { let (mut alice, mut bob) = make_pair(); // Send a valid message first. send_a_to_b(&mut alice, &mut bob, b"setup"); let enc = alice.encrypt(b"test").unwrap(); // Tamper with ciphertext. let mut bad_ct = enc.ciphertext.clone(); bad_ct[0] ^= 0xFF; assert!(matches!( bob.decrypt(&enc.header, &bad_ct), Err(Error::AeadFailed) )); // State should be unchanged — the valid message should still decrypt. let pt = bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); assert_eq!(&*pt, b"test"); } #[test] fn rollback_preserves_recv_seen() { // Failed KEM ratchet decrypt must not corrupt recv_seen. let (mut alice, mut bob) = make_pair(); // Out-of-order delivery to populate recv_seen. let enc0 = alice.encrypt(b"msg0").unwrap(); let enc2 = alice.encrypt(b"msg2").unwrap(); bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap(); // Bob replies, then Alice sends a new ratchet message that we tamper with. let enc_b = bob.encrypt(b"bob reply").unwrap(); alice.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap(); let enc_a2 = alice.encrypt(b"alice ratchet").unwrap(); assert!(enc_a2.header.kem_ct.is_some()); let mut bad_ct = enc_a2.ciphertext.clone(); bad_ct[0] ^= 0xFF; assert!(matches!( bob.decrypt(&enc_a2.header, &bad_ct), Err(Error::AeadFailed) )); // Out-of-order msg0 must still decrypt after rollback. let pt0 = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); assert_eq!(&*pt0, b"msg0"); // Valid ratchet message should also still work after rollback. let pt_a2 = bob.decrypt(&enc_a2.header, &enc_a2.ciphertext).unwrap(); assert_eq!(&*pt_a2, b"alice ratchet"); } #[test] fn decrypt_failure_preserves_send_state() { let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"setup"); let enc = alice.encrypt(b"test").unwrap(); let send_count_before = bob.send_count; let send_epoch_before = bob.send_epoch_key; let mut bad_ct = enc.ciphertext.clone(); bad_ct[0] ^= 0xFF; assert!(matches!( bob.decrypt(&enc.header, &bad_ct), Err(Error::AeadFailed) )); assert_eq!(bob.send_count, send_count_before); assert_eq!(bob.send_epoch_key, send_epoch_before); } #[test] fn decrypt_failure_preserves_recv_state() { let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"setup"); let enc = alice.encrypt(b"test").unwrap(); let recv_epoch_key_before = bob.recv_epoch_key; let recv_count_before = bob.recv_count; let mut bad_ct = enc.ciphertext.clone(); bad_ct[0] ^= 0xFF; assert!(matches!( bob.decrypt(&enc.header, &bad_ct), Err(Error::AeadFailed) )); assert_eq!( bob.recv_epoch_key, recv_epoch_key_before, "recv_epoch_key must be restored on rollback" ); assert_eq!( bob.recv_count, recv_count_before, "recv_count must be restored on rollback" ); let pt = bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); assert_eq!(&*pt, b"test"); } #[test] fn decrypt_failure_preserves_full_state_bytes() { // Extends decrypt_failure_preserves_recv_state: serialize the full ratchet // state before and after a failed decrypt and assert byte-for-byte identity. // This catches rollback gaps in any field (ratchet_pk, recv_seen, // prev_send_count, etc.) that field-level assertions would miss. let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"setup"); let enc = alice.encrypt(b"test").unwrap(); // to_bytes consumes — deserialize to get bob back for the decrypt attempt. let state_before = bob.to_bytes().unwrap().0; let mut bob = RatchetState::from_bytes(&state_before).unwrap(); let mut bad_ct = enc.ciphertext.clone(); bad_ct[0] ^= 0xFF; assert!(matches!( bob.decrypt(&enc.header, &bad_ct), Err(Error::AeadFailed) )); let state_after = bob.to_bytes().unwrap().0; // Epoch advances on each to_bytes() call, so compare version + post-epoch fields. assert_eq!(state_before[0], state_after[0], "version must match"); assert_eq!( state_before[9..], state_after[9..], "full serialized state (excluding epoch) must be identical after failed decrypt" ); // Epoch must advance by exactly 1 (the failed decrypt did not mutate state). let epoch_before = u64::from_be_bytes(state_before[1..9].try_into().unwrap()); let epoch_after = u64::from_be_bytes(state_after[1..9].try_into().unwrap()); assert_eq!(epoch_after, epoch_before + 1); // Confirm the ratchet is still operational after rollback. let mut bob = RatchetState::from_bytes(&state_after).unwrap(); let pt = bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); assert_eq!(&*pt, b"test"); } #[test] fn decrypt_ratchet_step_missing_kem_ct() { let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"setup"); send_b_to_a(&mut alice, &mut bob, b"reply"); // Alice sends with a new ratchet_pk (triggers ratchet on Bob's side). let enc = alice.encrypt(b"test").unwrap(); // Remove the KEM ciphertext — Bob needs it for the ratchet step. let bad_header = RatchetHeader { ratchet_pk: enc.header.ratchet_pk.clone(), kem_ct: None, n: enc.header.n, pn: enc.header.pn, }; assert!(matches!( bob.decrypt(&bad_header, &enc.ciphertext), Err(Error::InvalidData) )); // Bob should still be able to decrypt the valid message (state rolled back). let pt = bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); assert_eq!(&*pt, b"test"); } #[test] fn decrypt_ratchet_step_invalid_kem_ct() { let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"setup"); send_b_to_a(&mut alice, &mut bob, b"reply"); let enc = alice.encrypt(b"test").unwrap(); // Replace KEM ciphertext with garbage (valid size, wrong content). let bad_ct = xwing::Ciphertext::from_bytes(vec![0u8; constants::XWING_CIPHERTEXT_SIZE]).unwrap(); let bad_header = RatchetHeader { ratchet_pk: enc.header.ratchet_pk.clone(), kem_ct: Some(bad_ct), n: enc.header.n, pn: enc.header.pn, }; // ML-KEM implicit rejection: decapsulate() succeeds but produces a wrong shared // secret → wrong epoch keys → AEAD fails. assert!(matches!( bob.decrypt(&bad_header, &enc.ciphertext), Err(Error::AeadFailed) )); // State should be rolled back — valid message still works. let pt = bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); assert_eq!(&*pt, b"test"); } // === 4F. Serialization === #[test] fn serialization_round_trip() { let (mut alice, mut bob) = make_pair(); // Exchange a few messages. send_a_to_b(&mut alice, &mut bob, b"hello"); send_b_to_a(&mut alice, &mut bob, b"world"); // Serialize and deserialize. let alice_bytes = alice.to_bytes().unwrap().0; let mut alice2 = RatchetState::from_bytes(&alice_bytes).unwrap(); // Continue conversation with deserialized state. let pt = send_a_to_b(&mut alice2, &mut bob, b"after restore"); assert_eq!(&*pt, b"after restore"); } #[test] fn wrong_version() { let (alice, _) = make_pair(); let bytes = alice.to_bytes().unwrap().0; // Boundary versions most relevant for future version transitions. for version in [0x00, 0x02, 0x04, 0x06, 0xFF] { let mut blob = bytes.clone(); blob[0] = version; assert!( matches!( RatchetState::from_bytes(&blob), Err(Error::UnsupportedVersion) ), "version 0x{version:02X} should be rejected" ); } } #[test] fn truncated_data() { let (alice, _) = make_pair(); let bytes = alice.to_bytes().unwrap().0; assert!(matches!( RatchetState::from_bytes(&bytes[..10]), Err(Error::InvalidData) )); } #[test] fn trailing_bytes_rejected() { let (alice, _) = make_pair(); let mut bytes = alice.to_bytes().unwrap().0.to_vec(); bytes.push(0xFF); assert!(matches!( RatchetState::from_bytes(&bytes), Err(Error::InvalidData) )); } #[test] fn invalid_ratchet_pending_flag() { // Bob has ratchet_pending=true. Corrupt the flag byte to 0x02 (invalid). let (_, bob) = make_pair(); let mut bytes = bob.to_bytes().unwrap().0.to_vec(); // Bob v5: v(1) + epoch(8) + keys(96) + fps(64) = 169 // + sk=None(1) + pk=None(1) + recv_pk=Some(1219) + prev_ek=None(1) + prev_pk=None(1) // + counters(12) = 169 + 1 + 1 + 1219 + 1 + 1 + 12 = 1404 let pending_offset = 169 + 1 + 1 + 1219 + 1 + 1 + 12; assert_eq!(bytes[pending_offset], 0x01); // sanity check bytes[pending_offset] = 0x02; // invalid assert!(matches!( RatchetState::from_bytes(&bytes), Err(Error::InvalidData) )); } #[test] fn counter_u32_max() { // Alice v5: sk=Some(2435), pk=Some(1219), recv_pk=None(1), prev_ek=None(1), prev_pk=None(1) // counters start at 169 + 2435 + 1219 + 1 + 1 + 1 = 3826 let sc_offset = 169 + 2435 + 1219 + 1 + 1 + 1; let rc_offset = sc_offset + 4; let pc_offset = rc_offset + 4; // send_count = u32::MAX → InvalidData let (alice, _) = make_pair(); let mut bytes = alice.to_bytes().unwrap().0.to_vec(); bytes[sc_offset..sc_offset + 4].copy_from_slice(&u32::MAX.to_be_bytes()); assert!( matches!(RatchetState::from_bytes(&bytes), Err(Error::InvalidData)), "send_count=u32::MAX must be rejected" ); // recv_count = u32::MAX → InvalidData (same combined check, §6.8 guard 5) let (alice, _) = make_pair(); let mut bytes = alice.to_bytes().unwrap().0.to_vec(); bytes[rc_offset..rc_offset + 4].copy_from_slice(&u32::MAX.to_be_bytes()); assert!( matches!(RatchetState::from_bytes(&bytes), Err(Error::InvalidData)), "recv_count=u32::MAX must be rejected" ); // prev_send_count = u32::MAX → InvalidData (same combined check, §6.8 guard 5) let (alice, _) = make_pair(); let mut bytes = alice.to_bytes().unwrap().0.to_vec(); bytes[pc_offset..pc_offset + 4].copy_from_slice(&u32::MAX.to_be_bytes()); assert!( matches!(RatchetState::from_bytes(&bytes), Err(Error::InvalidData)), "prev_send_count=u32::MAX must be rejected" ); } #[test] fn decode_optional_bytes_zero_length_rejected() { // A present marker (0x01) with length=0 must be rejected as InvalidData. // Tests all three optional fields: send_ratchet_sk, send_ratchet_pk, recv_ratchet_pk. // send_ratchet_sk present-but-empty: 0x01 | epoch(8) | 96 keys | 64 fps | 0x01 0x00 0x00 | ... let mut blob = Vec::new(); blob.push(0x01); // version blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x42u8; 96]); // root + send_chain + recv_chain blob.extend_from_slice(&FP_A); // local_fp blob.extend_from_slice(&FP_B); // remote_fp blob.push(0x01); // send_ratchet_sk: present blob.extend_from_slice(&0u16.to_be_bytes()); // length = 0 → InvalidData assert!( matches!(RatchetState::from_bytes(&blob), Err(Error::InvalidData)), "send_ratchet_sk present-but-empty must be rejected" ); // send_ratchet_pk present-but-empty (sk is None): let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x42u8; 96]); blob.extend_from_slice(&FP_A); // local_fp blob.extend_from_slice(&FP_B); // remote_fp blob.push(0x00); // send_ratchet_sk: None blob.push(0x01); // send_ratchet_pk: present blob.extend_from_slice(&0u16.to_be_bytes()); // length = 0 → InvalidData assert!( matches!(RatchetState::from_bytes(&blob), Err(Error::InvalidData)), "send_ratchet_pk present-but-empty must be rejected" ); // recv_ratchet_pk present-but-empty (sk and pk are None): let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x42u8; 96]); blob.extend_from_slice(&FP_A); // local_fp blob.extend_from_slice(&FP_B); // remote_fp blob.push(0x00); // send_ratchet_sk: None blob.push(0x00); // send_ratchet_pk: None blob.push(0x01); // recv_ratchet_pk: present blob.extend_from_slice(&0u16.to_be_bytes()); // length = 0 → InvalidData assert!( matches!(RatchetState::from_bytes(&blob), Err(Error::InvalidData)), "recv_ratchet_pk present-but-empty must be rejected" ); } #[test] fn from_bytes_wrong_sized_xwing_sk_returns_invalid_data() { // A present send_ratchet_sk with the wrong byte length (not // XWING_SECRET_KEY_SIZE) must fail xwing::SecretKey::from_bytes and // map to InvalidData via the map_err path added in RT-77. let wrong_size = 64; // not 2432 let mut blob = Vec::new(); blob.push(0x01); // version blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x42u8; 96]); // root + send_chain + recv_chain blob.extend_from_slice(&FP_A); // local_fp blob.extend_from_slice(&FP_B); // remote_fp blob.push(0x01); // send_ratchet_sk: present blob.extend_from_slice(&u16::try_from(wrong_size).unwrap().to_be_bytes()); blob.extend_from_slice(&vec![0xAAu8; wrong_size]); // Remaining fields don't matter — parse fails at sk. assert!( matches!(RatchetState::from_bytes(&blob), Err(Error::InvalidData)), "wrong-sized X-Wing secret key must be rejected as InvalidData" ); } #[test] fn send_ratchet_sk_pk_copresence() { // Case 1: sk=None, pk=Some — pk without sk is invalid (§6.8 guard 2). let (alice, _) = make_pair(); let pk_bytes = alice.send_ratchet_pk.as_ref().unwrap().as_bytes().to_vec(); let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x11u8; 96]); // root + send_epoch + recv_epoch blob.extend_from_slice(&FP_A); blob.extend_from_slice(&FP_B); blob.push(0x00); // send_ratchet_sk: None blob.push(0x01); // send_ratchet_pk: Some blob.extend_from_slice(&u16::try_from(pk_bytes.len()).unwrap().to_be_bytes()); blob.extend_from_slice(&pk_bytes); blob.push(0x00); // recv_ratchet_pk: None blob.push(0x00); // prev_recv_epoch_key: None blob.push(0x00); // prev_recv_ratchet_pk: None blob.extend_from_slice(&0u32.to_be_bytes()); // send_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // recv_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // prev_send_count=0 blob.push(0x00); // ratchet_pending=false blob.extend_from_slice(&0u32.to_be_bytes()); // num_recv_seen=0 blob.extend_from_slice(&0u32.to_be_bytes()); // num_prev_recv_seen=0 assert!( matches!(RatchetState::from_bytes(&blob), Err(Error::InvalidData)), "sk=None/pk=Some must be rejected" ); // Case 2: sk=Some, pk=None — sk without pk is also invalid (§6.8 guard 2). let (alice, _) = make_pair(); let sk_bytes = alice.send_ratchet_sk.as_ref().unwrap().as_bytes().to_vec(); let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x11u8; 96]); blob.extend_from_slice(&FP_A); blob.extend_from_slice(&FP_B); blob.push(0x01); // send_ratchet_sk: Some blob.extend_from_slice(&u16::try_from(sk_bytes.len()).unwrap().to_be_bytes()); blob.extend_from_slice(&sk_bytes); blob.push(0x00); // send_ratchet_pk: None blob.push(0x00); // recv_ratchet_pk: None blob.push(0x00); // prev_recv_epoch_key: None blob.push(0x00); // prev_recv_ratchet_pk: None blob.extend_from_slice(&0u32.to_be_bytes()); // send_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // recv_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // prev_send_count=0 blob.push(0x00); // ratchet_pending=false blob.extend_from_slice(&0u32.to_be_bytes()); // num_recv_seen=0 blob.extend_from_slice(&0u32.to_be_bytes()); // num_prev_recv_seen=0 assert!( matches!(RatchetState::from_bytes(&blob), Err(Error::InvalidData)), "sk=Some/pk=None must be rejected" ); } #[test] fn invariant_a_pending_requires_pk_r() { // pending=true, recv_ratchet_pk=None → InvalidData let (alice, _) = make_pair(); let mut bytes = alice.to_bytes().unwrap().0.to_vec(); // Alice v5: v(1) + epoch(8) + keys(96) + fps(64) + sk(2435) + pk(1219) + recv(1) // + prev_ek(1) + prev_pk(1) + counters(12) = 3838 let pending_offset = 169 + 2435 + 1219 + 1 + 1 + 1 + 12; assert_eq!(bytes[pending_offset], 0x00); // Alice: pending=false bytes[pending_offset] = 0x01; assert!(matches!( RatchetState::from_bytes(&bytes), Err(Error::InvalidData) )); } #[test] fn invariant_b_send_count_requires_send_key() { // send_count > 0 with send_ratchet_sk=None → InvalidData let (_, bob) = make_pair(); let mut bytes = bob.to_bytes().unwrap().0.to_vec(); // Bob v5: sk=None(1), pk=None(1), recv_pk=Some(1219), prev_ek=None(1), prev_pk=None(1) // counters start at 169+1+1+1219+1+1=1392 bytes[1392..1396].copy_from_slice(&1u32.to_be_bytes()); assert!(matches!( RatchetState::from_bytes(&bytes), Err(Error::InvalidData) )); } #[test] fn invariant_c_recv_count_positive_no_pk() { // recv_count > 0 with recv_ratchet_pk=None → InvalidData let (alice, _) = make_pair(); let mut bytes = alice.to_bytes().unwrap().0.to_vec(); // Alice v5: sk=Some(2435), pk=Some(1219), recv=None(1), prev_ek=None(1), prev_pk=None(1) // counters at 169+2435+1219+1+1+1=3826 // recv_count at 3826+4=3830 let rc_offset = 3826 + 4; bytes[rc_offset..rc_offset + 4].copy_from_slice(&1u32.to_be_bytes()); assert!(matches!( RatchetState::from_bytes(&bytes), Err(Error::InvalidData) )); } #[test] fn invariant_c_recv_count_zero_with_pk() { // recv_count=0 with recv_ratchet_pk present is now VALID in v5 (transient // KEM ratchet state). This test verifies it parses successfully. let (_, bob) = make_pair(); let mut bytes = bob.to_bytes().unwrap().0.to_vec(); // Bob v5: sk=None(1), pk=None(1), recv_pk=Some(1219), prev_ek=None(1), prev_pk=None(1) // recv_count at 169+1+1+1219+1+1+4=1396 let rc_offset = 169 + 1 + 1 + 1219 + 1 + 1 + 4; bytes[rc_offset..rc_offset + 4].copy_from_slice(&0u32.to_be_bytes()); // In v5, recv_count=0 with recv_ratchet_pk present is valid // (transient state after KEM ratchet step, before any messages received). // The from_bytes should succeed or fail on a different guard. // Let's verify: this is accepted now. let result = RatchetState::from_bytes(&bytes); assert!( result.is_ok(), "recv_count=0 with recv_pk present is valid in v5" ); } #[test] fn invariant_d_send_key_without_count() { // send_count=0 with send_ratchet_sk present → InvalidData let (alice, _) = make_pair(); let mut bytes = alice.to_bytes().unwrap().0.to_vec(); // Alice v5: counters at 3826 let sc_offset = 3826; bytes[sc_offset..sc_offset + 4].copy_from_slice(&0u32.to_be_bytes()); assert!(matches!( RatchetState::from_bytes(&bytes), Err(Error::InvalidData) )); } #[test] fn invariant_e_unreachable_state() { // s=0, pending=false, pk_r set, no send key → InvalidData let (_, bob) = make_pair(); let mut bytes = bob.to_bytes().unwrap().0.to_vec(); // Bob v5: pending at 169+1+1+1219+1+1+12=1404 let pending_offset = 169 + 1 + 1 + 1219 + 1 + 1 + 12; assert_eq!(bytes[pending_offset], 0x01); bytes[pending_offset] = 0x00; assert!(matches!( RatchetState::from_bytes(&bytes), Err(Error::InvalidData) )); } #[test] fn all_default_rejected_by_root_key_check() { // v5 blob with all-zero keys — root_key == [0u8; 32] check fires first. let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0u8; 96]); // root + send_epoch + recv_epoch (all-zero) blob.extend_from_slice(&[0xAA; 32]); // local_fp blob.extend_from_slice(&[0xBB; 32]); // remote_fp blob.push(0x00); blob.push(0x00); blob.push(0x00); // sk, pk, recv_pk: None blob.push(0x00); blob.push(0x00); // prev epoch: None blob.extend_from_slice(&0u32.to_be_bytes()); // send_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // recv_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // prev_send_count=0 blob.push(0x00); // ratchet_pending=false blob.extend_from_slice(&0u32.to_be_bytes()); // num_recv_seen=0 blob.extend_from_slice(&0u32.to_be_bytes()); // num_prev_recv_seen=0 assert!(matches!( RatchetState::from_bytes(&blob), Err(Error::InvalidData) )); } #[test] fn all_default_structural_invariant_rejected() { // v5 blob with non-zero root_key but structurally impossible default state. let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x11; 32]); // root_key (non-zero) blob.extend_from_slice(&[0x22; 32]); // send_epoch_key blob.extend_from_slice(&[0x33; 32]); // recv_epoch_key blob.extend_from_slice(&[0xAA; 32]); // local_fp blob.extend_from_slice(&[0xBB; 32]); // remote_fp blob.push(0x00); blob.push(0x00); blob.push(0x00); // sk, pk, recv_pk: None blob.push(0x00); blob.push(0x00); // prev epoch: None blob.extend_from_slice(&0u32.to_be_bytes()); // send_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // recv_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // prev_send_count=0 blob.push(0x00); // ratchet_pending=false blob.extend_from_slice(&0u32.to_be_bytes()); // num_recv_seen=0 blob.extend_from_slice(&0u32.to_be_bytes()); // num_prev_recv_seen=0 assert!(matches!( RatchetState::from_bytes(&blob), Err(Error::InvalidData) )); } // === RT-884: all-zero recv_epoch_key with recv_ratchet_pk present === #[test] fn from_bytes_rejects_zero_recv_epoch_key_with_ratchet_pk() { // recv_count==0 with recv_ratchet_pk present means the session has // progressed past initialization (a KEM ratchet has occurred). // All-zero recv_epoch_key in this state produces publicly computable // message keys: HMAC([0;32], 0x01||counter). let (alice, _) = make_pair(); let pk_bytes = alice.send_ratchet_pk.as_ref().unwrap().as_bytes().to_vec(); let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x11; 32]); // root_key (non-zero) blob.extend_from_slice(&[0x22; 32]); // send_epoch_key (non-zero) blob.extend_from_slice(&[0x00; 32]); // recv_epoch_key: ALL-ZERO blob.extend_from_slice(&FP_A); blob.extend_from_slice(&FP_B); blob.push(0x00); // send_ratchet_sk: None blob.push(0x00); // send_ratchet_pk: None blob.push(0x01); // recv_ratchet_pk: Some blob.extend_from_slice(&u16::try_from(pk_bytes.len()).unwrap().to_be_bytes()); blob.extend_from_slice(&pk_bytes); blob.push(0x00); // prev_recv_epoch_key: None blob.push(0x00); // prev_recv_ratchet_pk: None blob.extend_from_slice(&0u32.to_be_bytes()); // send_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // recv_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // prev_send_count=0 blob.push(0x01); // ratchet_pending=true blob.extend_from_slice(&0u32.to_be_bytes()); // num_recv_seen=0 blob.extend_from_slice(&0u32.to_be_bytes()); // num_prev_recv_seen=0 assert!( matches!(RatchetState::from_bytes(&blob), Err(Error::InvalidData)), "all-zero recv_epoch_key with recv_ratchet_pk present must be rejected" ); } #[test] fn serialization_deterministic() { let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"hello"); // to_bytes consumes — round-trip through from_bytes to get the state back. let bytes1 = alice.to_bytes().unwrap().0; let alice = RatchetState::from_bytes(&bytes1).unwrap(); let bytes2 = alice.to_bytes().unwrap().0; // Epoch advances on each to_bytes() call (anti-rollback), so bytes // 1..9 (the epoch field) will differ. Everything else must be identical. assert_eq!(bytes1[0], bytes2[0]); // version assert_eq!(bytes1[9..], bytes2[9..]); // all fields after epoch // Epoch must strictly increase across serializations. let epoch1 = u64::from_be_bytes(bytes1[1..9].try_into().unwrap()); let epoch2 = u64::from_be_bytes(bytes2[1..9].try_into().unwrap()); assert_eq!(epoch2, epoch1 + 1); } #[test] fn recv_seen_cap_in_blob() { // A blob with num_recv_seen >= MAX_RECV_SEEN must be rejected. // We test that the count guard fires before reading entries. let mut blob = Vec::new(); blob.push(0x01); // version blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x42u8; 96]); // root + send_epoch + recv_epoch blob.extend_from_slice(&FP_A); blob.extend_from_slice(&FP_B); blob.push(0x00); // send_ratchet_sk: None blob.push(0x00); // send_ratchet_pk: None blob.push(0x01); // recv_ratchet_pk: present blob.extend_from_slice(&(1216u16).to_be_bytes()); blob.extend_from_slice(&[0x42u8; 1216]); blob.push(0x00); // prev_recv_epoch_key: None blob.push(0x00); // prev_recv_ratchet_pk: None blob.extend_from_slice(&0u32.to_be_bytes()); // send_count=0 blob.extend_from_slice(&1u32.to_be_bytes()); // recv_count=1 blob.extend_from_slice(&0u32.to_be_bytes()); // prev_send_count=0 blob.push(0x01); // ratchet_pending=true let too_many = constants::MAX_RECV_SEEN + 1; blob.extend_from_slice(&too_many.to_be_bytes()); // num_recv_seen // Count guard fires before reading entries. assert!(matches!( RatchetState::from_bytes(&blob), Err(Error::InvalidData) )); } // === 4G. Reset === #[test] fn reset_makes_state_unusable() { let (mut alice, _) = make_pair(); // Capture a header before reset for the decrypt probe. let probe = alice.encrypt(b"probe").unwrap(); alice.reset(); // After reset, root_key is all-zero — the session-dead guard // returns InvalidData immediately. assert!(matches!(alice.encrypt(b"test"), Err(Error::InvalidData))); assert!(matches!( alice.decrypt(&probe.header, b"test"), Err(Error::InvalidData) )); } #[test] fn reset_zeroes_all_fields() { let (mut alice, _) = make_pair(); // Encrypt a message to populate send-side state. let _ = alice.encrypt(b"populate").unwrap(); alice.reset(); // Verify every field individually — catches future field additions // that forget to update reset(). assert_eq!(alice.root_key, [0u8; 32]); assert_eq!(alice.send_epoch_key, [0u8; 32]); assert_eq!(alice.recv_epoch_key, [0u8; 32]); assert_eq!(alice.local_fp, [0u8; 32]); assert_eq!(alice.remote_fp, [0u8; 32]); assert!(alice.send_ratchet_sk.is_none()); assert!(alice.send_ratchet_pk.is_none()); assert!(alice.recv_ratchet_pk.is_none()); assert!(alice.prev_recv_epoch_key.is_none()); assert!(alice.prev_recv_ratchet_pk.is_none()); assert_eq!(alice.send_count, 0); assert_eq!(alice.recv_count, 0); assert_eq!(alice.prev_send_count, 0); assert!(!alice.ratchet_pending); assert!(alice.recv_seen.is_empty()); assert!(alice.prev_recv_seen.is_empty()); assert_eq!(alice.epoch, 0); } // === 4H. Private helpers === #[test] fn kdf_msg_key_kat() { let ek = [0x42u8; 32]; let mk = kdf_msg_key(&ek, 0); // Python reference: hmac.new(bytes([0x42]*32), bytes([0x01,0,0,0,0]), 'sha3_256') let expected = hex_literal::hex!("5ac7a1b8dd3103a3ef7bab0af995570a087b6a92b34d93bc8c88f3485e96054d"); assert_eq!(*mk, expected); } #[test] fn kdf_msg_key_different_counters_produce_different_keys() { let ek = [0x42u8; 32]; let mk0 = kdf_msg_key(&ek, 0); let mk1 = kdf_msg_key(&ek, 1); assert_ne!(*mk0, *mk1); } #[test] fn kdf_msg_key_different_epochs_produce_different_keys() { let ek1 = [0x42u8; 32]; let ek2 = [0x43u8; 32]; let mk1 = kdf_msg_key(&ek1, 0); let mk2 = kdf_msg_key(&ek2, 0); assert_ne!(*mk1, *mk2); } #[test] fn kdf_root_kat() { let rk = [0x01u8; 32]; let ss = [0x02u8; 32]; let (new_root, new_epoch) = kdf_root(&rk, &ss).unwrap(); // Python reference: HKDF-SHA3-256(salt=0x01*32, ikm=0x02*32, info=b"lo-ratchet-v1", L=64) let expected_root = hex_literal::hex!("36d288b05bf23bad5f1236f25654b26900bd57739dd0a592598d3cb4bdaf2c87"); let expected_epoch = hex_literal::hex!("cccd877f869a39899f87608dadb2f764c7aab9259867859090f3fe2c0c5fb07d"); assert_eq!(*new_root, expected_root); assert_eq!(*new_epoch, expected_epoch); } #[test] fn nonce_from_counter_values() { assert_eq!(nonce_from_counter(0), [0u8; 24]); assert_eq!(nonce_from_counter(1), { let mut expected = [0u8; 24]; expected[23] = 1; expected }); let max_nonce = nonce_from_counter(u32::MAX); assert_eq!(&max_nonce[20..], &u32::MAX.to_be_bytes()); assert_eq!(&max_nonce[..20], &[0u8; 20]); } #[test] fn counter_initialization_alice() { let (ek_pk, ek_sk) = xwing::keygen().unwrap(); let rk = [0x01u8; 32]; let ck = [0x02u8; 32]; let alice = RatchetState::init_alice(rk, ck, FP_A, FP_B, ek_pk, ek_sk).unwrap(); assert_eq!(alice.send_count, 1); assert_eq!(alice.recv_count, 0); assert_eq!(alice.prev_send_count, 0); assert!(!alice.ratchet_pending); assert!(alice.send_ratchet_sk.is_some()); assert!(alice.send_ratchet_pk.is_some()); assert!(alice.recv_ratchet_pk.is_none()); } #[test] fn counter_initialization_bob() { let (ek_pk, _) = xwing::keygen().unwrap(); let rk = [0x01u8; 32]; let ck = [0x02u8; 32]; let bob = RatchetState::init_bob(rk, ck, FP_B, FP_A, ek_pk).unwrap(); assert_eq!(bob.send_count, 0); assert_eq!(bob.recv_count, 1); assert_eq!(bob.prev_send_count, 0); assert!(bob.ratchet_pending); assert!(bob.send_ratchet_sk.is_none()); assert!(bob.send_ratchet_pk.is_none()); assert!(bob.recv_ratchet_pk.is_some()); } #[test] fn encode_ratchet_header_layout() { let (pk, _) = xwing::keygen().unwrap(); let (ct_pk, _) = xwing::keygen().unwrap(); let (ct, _) = xwing::encapsulate(&ct_pk).unwrap(); let header = RatchetHeader { ratchet_pk: pk.clone(), kem_ct: Some(ct.clone()), n: 42, pn: 7, }; let encoded = encode_ratchet_header(&header).unwrap(); let mut offset = 0; // ratchet_pk (1216) assert_eq!(&encoded[offset..offset + 1216], pk.as_bytes()); offset += 1216; // has_kem_ct = 0x01 assert_eq!(encoded[offset], 0x01); offset += 1; // len(kem_ct) = 1120 assert_eq!(&encoded[offset..offset + 2], &1120u16.to_be_bytes()); offset += 2; // kem_ct assert_eq!(&encoded[offset..offset + 1120], ct.as_bytes()); offset += 1120; // n = 42 assert_eq!(&encoded[offset..offset + 4], &42u32.to_be_bytes()); offset += 4; // pn = 7 assert_eq!(&encoded[offset..offset + 4], &7u32.to_be_bytes()); } #[test] fn build_ratchet_aad_structure() { let (pk, _) = xwing::keygen().unwrap(); let header = RatchetHeader { ratchet_pk: pk, kem_ct: None, n: 0, pn: 0, }; let aad = build_ratchet_aad(&FP_A, &FP_B, &header).unwrap(); let header_bytes = encode_ratchet_header(&header).unwrap(); let mut expected = Vec::new(); expected.extend_from_slice(constants::DM_AAD); expected.extend_from_slice(&FP_A); expected.extend_from_slice(&FP_B); expected.extend_from_slice(&header_bytes); assert_eq!(aad, expected); } #[test] fn previous_epoch_decrypt() { // After a KEM ratchet, messages from the previous epoch can still be // decrypted using prev_recv_epoch_key (one-epoch grace period). let (mut alice, mut bob) = make_pair(); // Alice sends 3 messages. let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); let enc2 = alice.encrypt(b"msg2").unwrap(); // Bob receives only enc2 (enc0, enc1 are delayed). bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap(); // Bob replies → triggers KEM ratchet on both sides. let enc_b = bob.encrypt(b"reply").unwrap(); alice.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap(); // Alice sends on the new epoch. let enc3 = alice.encrypt(b"msg3").unwrap(); bob.decrypt(&enc3.header, &enc3.ciphertext).unwrap(); // Now try the delayed messages from the OLD epoch — prev_recv_epoch_key // should still be able to derive them. let pt0 = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); let pt1 = bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); assert_eq!(&*pt0, b"msg0"); assert_eq!(&*pt1, b"msg1"); } // === 4I. RT-138: to_bytes ChainExhausted guard === #[test] fn to_bytes_rejects_send_count_max() { let (mut alice, _) = make_pair(); alice.send_count = u32::MAX; assert!(matches!(alice.to_bytes(), Err(Error::ChainExhausted))); } #[test] fn to_bytes_rejects_recv_count_max() { let (mut alice, _) = make_pair(); alice.recv_count = u32::MAX; assert!(matches!(alice.to_bytes(), Err(Error::ChainExhausted))); } #[test] fn to_bytes_rejects_prev_send_count_max() { let (mut alice, _) = make_pair(); alice.prev_send_count = u32::MAX; assert!(matches!(alice.to_bytes(), Err(Error::ChainExhausted))); } // === RT-389: epoch u64::MAX boundary === #[test] fn to_bytes_epoch_rejects_u64_max() { // At epoch == u64::MAX, to_bytes rejects with ChainExhausted // rather than silently wrapping to 0. let (mut alice, _) = make_pair(); alice.epoch = u64::MAX; assert!( matches!(alice.to_bytes(), Err(Error::ChainExhausted)), "to_bytes at epoch u64::MAX must return ChainExhausted" ); } // === RT-569: from_bytes rejects epoch u64::MAX === #[test] fn from_bytes_rejects_epoch_u64_max() { // A blob with epoch == u64::MAX creates an un-serializable state // (to_bytes would overflow on epoch+1). Reject at parse time. let (mut alice, _) = make_pair(); alice.epoch = u64::MAX - 1; // to_bytes stores epoch+1, so epoch u64::MAX-1 → stored as u64::MAX. // to_bytes itself succeeds because checked_add(u64::MAX-1 + 1) = u64::MAX. let (bytes, _) = alice.to_bytes().unwrap(); // from_bytes must reject the stored u64::MAX epoch. assert!( matches!(RatchetState::from_bytes(&bytes), Err(Error::ChainExhausted)), "from_bytes must reject epoch == u64::MAX" ); } // === 4J. RT-139: from_bytes zero/equal fingerprint rejection === #[test] fn from_bytes_rejects_zero_local_fp() { let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x11; 32]); // root_key (non-zero) blob.extend_from_slice(&[0x22; 32]); // send_epoch_key blob.extend_from_slice(&[0x33; 32]); // recv_epoch_key blob.extend_from_slice(&[0x00; 32]); // local_fp = all-zero blob.extend_from_slice(&[0xBB; 32]); // remote_fp blob.push(0x00); // send_ratchet_sk: None blob.push(0x00); // send_ratchet_pk: None blob.push(0x00); // recv_ratchet_pk: None blob.push(0x00); // prev_recv_epoch_key: None blob.push(0x00); // prev_recv_ratchet_pk: None blob.extend_from_slice(&1u32.to_be_bytes()); // send_count=1 blob.extend_from_slice(&0u32.to_be_bytes()); // recv_count=0 blob.extend_from_slice(&0u32.to_be_bytes()); // prev_send_count=0 blob.push(0x00); // ratchet_pending=false blob.extend_from_slice(&0u32.to_be_bytes()); // num_recv_seen=0 blob.extend_from_slice(&0u32.to_be_bytes()); // num_prev_recv_seen=0 assert!(matches!( RatchetState::from_bytes(&blob), Err(Error::InvalidData) )); } #[test] fn from_bytes_rejects_zero_remote_fp() { let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x11; 32]); // root_key blob.extend_from_slice(&[0x22; 32]); // send_epoch_key blob.extend_from_slice(&[0x33; 32]); // recv_epoch_key blob.extend_from_slice(&[0xAA; 32]); // local_fp blob.extend_from_slice(&[0x00; 32]); // remote_fp = all-zero blob.push(0x00); blob.push(0x00); blob.push(0x00); // sk, pk, recv_pk: None blob.push(0x00); blob.push(0x00); // prev epoch: None blob.extend_from_slice(&1u32.to_be_bytes()); blob.extend_from_slice(&0u32.to_be_bytes()); blob.extend_from_slice(&0u32.to_be_bytes()); blob.push(0x00); blob.extend_from_slice(&0u32.to_be_bytes()); // recv_seen blob.extend_from_slice(&0u32.to_be_bytes()); // prev_recv_seen assert!(matches!( RatchetState::from_bytes(&blob), Err(Error::InvalidData) )); } #[test] fn from_bytes_rejects_equal_fingerprints() { let mut blob = Vec::new(); blob.push(0x01); blob.extend_from_slice(&1u64.to_be_bytes()); // epoch blob.extend_from_slice(&[0x11; 32]); // root_key blob.extend_from_slice(&[0x22; 32]); // send_epoch_key blob.extend_from_slice(&[0x33; 32]); // recv_epoch_key blob.extend_from_slice(&[0xAA; 32]); // local_fp blob.extend_from_slice(&[0xAA; 32]); // remote_fp = same as local blob.push(0x00); blob.push(0x00); blob.push(0x00); blob.push(0x00); blob.push(0x00); blob.extend_from_slice(&1u32.to_be_bytes()); blob.extend_from_slice(&0u32.to_be_bytes()); blob.extend_from_slice(&0u32.to_be_bytes()); blob.push(0x00); blob.extend_from_slice(&0u32.to_be_bytes()); blob.extend_from_slice(&0u32.to_be_bytes()); assert!(matches!( RatchetState::from_bytes(&blob), Err(Error::InvalidData) )); } // === 4K. Rollback across KEM ratchet + AEAD failure === #[test] fn rollback_after_kem_ratchet_aead_failure() { let (mut alice, mut bob) = make_pair(); // Alice sends 3 messages, Bob receives the 3rd. let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); let enc2 = alice.encrypt(b"msg2").unwrap(); bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap(); // Bob replies → triggers KEM ratchet. let enc_b = bob.encrypt(b"bob-msg").unwrap(); alice.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap(); // Alice sends on the new epoch. let enc3 = alice.encrypt(b"msg3").unwrap(); let enc4 = alice.encrypt(b"msg4").unwrap(); // Tamper with enc4's ciphertext → KEM ratchet step + AEAD failure. let mut bad_ct = enc4.ciphertext.clone(); bad_ct[0] ^= 0xFF; assert!(bob.decrypt(&enc4.header, &bad_ct).is_err()); // State must be rolled back — valid messages still decrypt. bob.decrypt(&enc3.header, &enc3.ciphertext).unwrap(); bob.decrypt(&enc4.header, &enc4.ciphertext).unwrap(); // Previous-epoch messages (enc0, enc1) are still derivable via prev_recv_epoch_key. let pt0 = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); let pt1 = bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); assert_eq!(&*pt0, b"msg0"); assert_eq!(&*pt1, b"msg1"); } #[test] fn long_session_stress() { let (mut alice, mut bob) = make_pair(); // 120 messages with direction changes every 3-5 msgs → 24+ KEM ratchet steps. // Serialize/deserialize at multiple points to verify state persistence. for i in 0..120u32 { // Alternate directions every few messages to trigger frequent ratchets. if i % 5 < 3 { send_a_to_b(&mut alice, &mut bob, &i.to_be_bytes()); } else { send_b_to_a(&mut alice, &mut bob, &i.to_be_bytes()); } // Serialize/deserialize at 3 points spread across the session. if i == 30 || i == 70 || i == 110 { let a_bytes = alice.to_bytes().unwrap().0; let b_bytes = bob.to_bytes().unwrap().0; alice = RatchetState::from_bytes(&a_bytes).unwrap(); bob = RatchetState::from_bytes(&b_bytes).unwrap(); } } } proptest::proptest! { // Ratchet keygen is expensive — 32 cases provides adequate coverage // of the serialization parser state space without excessive test time. #![proptest_config(proptest::prelude::ProptestConfig::with_cases(32))] #[test] #[allow(clippy::cast_possible_truncation)] fn proptest_serialization_round_trip( msg_count in 0u32..100, msg_len in 0usize..512, ) { let (mut alice, mut bob) = make_pair(); // Drive the ratchet through a variable number of messages. for i in 0..msg_count { let payload = vec![(i as u8).wrapping_mul(37); msg_len]; if i % 2 == 0 { let enc = alice.encrypt(&payload).unwrap(); bob.decrypt(&enc.header, &enc.ciphertext).unwrap(); } else { let enc = bob.encrypt(&payload).unwrap(); alice.decrypt(&enc.header, &enc.ciphertext).unwrap(); } } // Round-trip both sides. let a_epoch_before = alice.epoch(); let a_bytes = alice.to_bytes().unwrap().0; let mut alice2 = RatchetState::from_bytes(&a_bytes).unwrap(); // Epoch must have advanced. proptest::prop_assert!(alice2.epoch() > a_epoch_before); let b_bytes = bob.to_bytes().unwrap().0; let mut bob2 = RatchetState::from_bytes(&b_bytes).unwrap(); // Functional verification: deserialized states can encrypt/decrypt. // A field-swap bug (e.g., root_key ↔ epoch_key) would cause AEAD // failure here even though structural round-trip succeeded. let enc_a = alice2.encrypt(b"post-roundtrip-a").unwrap(); let pt_a = bob2.decrypt(&enc_a.header, &enc_a.ciphertext).unwrap(); proptest::prop_assert_eq!(&*pt_a, b"post-roundtrip-a"); let enc_b = bob2.encrypt(b"post-roundtrip-b").unwrap(); let pt_b = alice2.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap(); proptest::prop_assert_eq!(&*pt_b, b"post-roundtrip-b"); // Re-serialize and verify deterministic output. let a_bytes2 = alice2.to_bytes().unwrap().0; let _ = RatchetState::from_bytes(&a_bytes2).unwrap(); } } // === RT-461: Two-KEM-ratchet prev_recv_epoch_key retention and duplicate detection === // // This test is empirical evidence for the intentional two-step receive-side // forward-secrecy delay described in Specification.md §14.17 and Abstract.md Theorem 4 // / Lemma 4b. After one receive-side KEM ratchet step, epoch N's key moves into // prev_recv_epoch_key (one-epoch grace period) — it is NOT yet gone. After a // second receive-side step it would be overwritten (Lemma 4b counterexample // becomes valid counterexample on step one, then succeeds on step two). // // What this test verifies: after two total KEM ratchet steps, an epoch-N replay // routes via the prev-epoch path (prev_recv_epoch_key still present → AEAD // succeeds), but is rejected by DuplicateMessage because counter n=0 is in // prev_recv_seen. This confirms both that prev_recv_epoch_key is intentionally // retained (not a bug) and that prev_recv_seen correctly guards against replay. #[test] fn two_kem_ratchets_expire_old_epoch() { let (mut alice, mut bob) = make_pair(); // Epoch N: Alice sends msg0. let enc0 = alice.encrypt(b"epoch-N").unwrap(); // Bob receives msg0 (establishes current epoch, recv_count → 1). bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); // Bob sends → Bob's send-side KEM ratchet step (N→N+1 on Bob's send side). send_b_to_a(&mut alice, &mut bob, b"ratchet-1"); // Alice sends → Alice's send-side KEM ratchet step. Bob RECEIVES → Bob's // receive-side KEM ratchet step: recv_epoch_key rotates to new epoch, // prev_recv_epoch_key = epoch-N key (retained), prev_recv_seen = {0}. send_a_to_b(&mut alice, &mut bob, b"ratchet-2"); // Bob sends → Bob's send-side ratchet step. Bob's receive-side state // unchanged: prev_recv_epoch_key still holds epoch-N key. send_b_to_a(&mut alice, &mut bob, b"ratchet-3"); // Replay enc0 (epoch N): ratchet_pk matches prev_recv_ratchet_pk → // prev-epoch path → AEAD succeeds (prev_recv_epoch_key is still present) → // counter n=0 in prev_recv_seen → DuplicateMessage. // Note: epoch-N key is NOT gone yet — it is still accessible via // prev_recv_epoch_key. It would be overwritten by a second receive-side // KEM ratchet step (another Alice send). See Abstract.md Lemma 4b. assert!( matches!( bob.decrypt(&enc0.header, &enc0.ciphertext), Err(Error::DuplicateMessage) ), "epoch-N replay must be caught as duplicate (prev_recv_seen)" ); } // === RT-462: recv_seen cap enforcement at runtime === #[test] fn recv_seen_cap_at_runtime() { let (mut alice, mut bob) = make_pair(); // Fill recv_seen to the runtime cap (MAX_RECV_SEEN - 1 entries) with // counters that won't collide with Alice's send_count (which starts at // 1). The next decrypt would push the set to MAX_RECV_SEEN, violating // the serialization invariant, so it must be rejected. bob.recv_seen = (100_000..100_000 + constants::MAX_RECV_SEEN - 1).collect(); bob.recv_count = 100_000 + constants::MAX_RECV_SEEN - 1; // Next decrypt should return ChainExhausted (not DuplicateMessage). let enc = alice.encrypt(b"over-cap").unwrap(); assert!( matches!( bob.decrypt(&enc.header, &enc.ciphertext), Err(Error::ChainExhausted) ), "decrypt must return ChainExhausted when recv_seen is at cap" ); } // === RT-463: prev_recv_seen cap enforcement at runtime === #[test] fn prev_recv_seen_cap_at_runtime() { let (mut alice, mut bob) = make_pair(); // Three exchanges to get Bob's prev_recv_epoch_key populated: // 1. A→B: Bob receives CurrentEpoch (recv_ratchet_pk already set from init). // 2. B→A: Bob sends (KEM ratchet), Alice receives NewEpoch (no prev — // her recv_ratchet_pk was None). Sets Alice's ratchet_pending. // 3. A→B: Alice sends with new ratchet key (ratchet_pending), Bob receives // NewEpoch — recv_ratchet_pk.is_some() so prev_recv_epoch_key is saved. send_a_to_b(&mut alice, &mut bob, b"setup"); let enc_old = alice.encrypt(b"will-be-prev").unwrap(); send_b_to_a(&mut alice, &mut bob, b"dir1"); send_a_to_b(&mut alice, &mut bob, b"dir2"); // enc_old is from Bob's previous recv epoch. assert!(bob.prev_recv_epoch_key.is_some()); // Fill prev_recv_seen to the runtime cap (MAX_RECV_SEEN - 1 entries) // with counters that won't collide with enc_old's counter (which is 2 — // setup=1, will-be-prev=2). bob.prev_recv_seen = (100_000..100_000 + constants::MAX_RECV_SEEN - 1).collect(); // Attempt to decrypt the old-epoch message. assert!( matches!( bob.decrypt(&enc_old.header, &enc_old.ciphertext), Err(Error::ChainExhausted) ), "decrypt must return ChainExhausted when prev_recv_seen is at cap" ); } // === RT-464: prev_recv_seen duplicate detection === #[test] fn prev_recv_seen_duplicate_rejected() { let (mut alice, mut bob) = make_pair(); // Alice sends two messages. let enc0 = alice.encrypt(b"msg0").unwrap(); let enc1 = alice.encrypt(b"msg1").unwrap(); // Bob receives msg0. bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap(); // Direction change → current epoch becomes prev. send_b_to_a(&mut alice, &mut bob, b"direction-change"); // Bob decrypts late msg1 via prev_recv_epoch_key. bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap(); // Replay msg1 again → DuplicateMessage. assert!( matches!( bob.decrypt(&enc1.header, &enc1.ciphertext), Err(Error::DuplicateMessage) ), "replayed prev-epoch message must return DuplicateMessage" ); } // === RT-475: serialization roundtrip with prev_recv_epoch_key === #[test] fn serialization_roundtrip_with_prev_epoch() { let (mut alice, mut bob) = make_pair(); // Three exchanges: A→B establishes Bob's recv epoch, B→A triggers // Alice's ratchet_pending, A→B with new ratchet key triggers Bob's // NewEpoch — saving his old recv_epoch_key as prev_recv_epoch_key. send_a_to_b(&mut alice, &mut bob, b"epoch-N"); let enc_late = alice.encrypt(b"late-msg").unwrap(); send_b_to_a(&mut alice, &mut bob, b"dir1"); send_a_to_b(&mut alice, &mut bob, b"dir2"); // Bob has prev_recv_epoch_key populated (old epoch N key). assert!(bob.prev_recv_epoch_key.is_some()); // Serialize and restore Bob. let (bob_bytes, epoch) = bob.to_bytes().unwrap(); let mut bob2 = RatchetState::from_bytes_with_min_epoch(&bob_bytes, epoch - 1).unwrap(); // Late message from epoch N must still decrypt via prev_recv_epoch_key. let pt = bob2 .decrypt(&enc_late.header, &enc_late.ciphertext) .unwrap(); assert_eq!(&*pt, b"late-msg"); } // === RT-476: from_bytes_with_min_epoch boundary cases === #[test] fn from_bytes_with_min_epoch_boundaries() { let (alice, _) = make_pair(); let (bytes, epoch) = alice.to_bytes().unwrap(); // epoch > min_epoch: success. assert!(RatchetState::from_bytes_with_min_epoch(&bytes, epoch - 1).is_ok()); // epoch == min_epoch: rejection (not strictly greater). assert!(matches!( RatchetState::from_bytes_with_min_epoch(&bytes, epoch), Err(Error::InvalidData) )); // epoch < min_epoch: rejection. assert!(matches!( RatchetState::from_bytes_with_min_epoch(&bytes, epoch + 1), Err(Error::InvalidData) )); } // === RT-477: encrypt works after recv_seen cap === #[test] fn encrypt_works_after_recv_seen_cap() { let (mut alice, _) = make_pair(); alice.recv_seen = (0..constants::MAX_RECV_SEEN - 1).collect(); alice.recv_count = constants::MAX_RECV_SEEN - 1; // Encrypt must still work — recv_seen cap only affects decrypt. assert!(alice.encrypt(b"still-works").is_ok()); } // === RT-478: recv_seen entries >= recv_count deserialization rejection === #[test] fn from_bytes_rejects_recv_seen_beyond_recv_count() { // Use actual message exchange to get valid state, then tamper. let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"msg"); // Bob now has recv_count=2, recv_seen={1}. // Tamper: add an entry beyond recv_count. bob.recv_seen.insert(999); let (bytes, _) = bob.to_bytes().unwrap(); // from_bytes should reject: entry 999 >= recv_count 2. assert!( matches!(RatchetState::from_bytes(&bytes), Err(Error::InvalidData)), "recv_seen entry >= recv_count must be rejected" ); } // === RT-479: prev_recv_seen without prev_recv_epoch_key rejection === #[test] fn from_bytes_rejects_orphaned_prev_recv_seen() { // to_bytes does not enforce the orphaned-prev_recv_seen invariant // (defense-in-depth lives in from_bytes only), so we can produce an // invalid blob by direct state manipulation and verify from_bytes // rejects it. let (mut state, _) = make_pair(); state.prev_recv_seen.insert(0); state.prev_recv_epoch_key = None; state.prev_recv_ratchet_pk = None; let (bytes, _) = state.to_bytes().unwrap(); assert!( matches!(RatchetState::from_bytes(&bytes), Err(Error::InvalidData)), "orphaned prev_recv_seen (no prev_recv_epoch_key) must be rejected" ); } // === RT-480: duplicate recv_seen entries deserialization rejection === #[test] fn from_bytes_rejects_duplicate_recv_seen() { // Use actual message exchange to get a valid serialized state. let (mut alice, mut bob) = make_pair(); send_a_to_b(&mut alice, &mut bob, b"msg0"); send_a_to_b(&mut alice, &mut bob, b"msg1"); // Bob has recv_seen={1,2}, recv_count=3. let (bytes, _) = bob.to_bytes().unwrap(); // Verify clean deserialization works. #[allow(deprecated)] let _ = RatchetState::from_bytes(&bytes).unwrap(); // Strict ascending order enforcement in from_bytes subsumes duplicate // rejection — equal values violate `n <= prev` just as descending ones do. } #[test] fn from_bytes_rejects_unsorted_recv_seen() { let (mut alice, mut bob) = make_pair(); // Two messages give Bob recv_seen = {1, 2}, serialized as [1, 2]. send_a_to_b(&mut alice, &mut bob, b"msg0"); send_a_to_b(&mut alice, &mut bob, b"msg1"); let (mut bytes, _) = bob.to_bytes().unwrap(); // Find the recv_seen count (0x00000002) in the blob and swap the two // 4-byte entries that follow to produce descending order [2, 1]. let count_needle = 2u32.to_be_bytes(); // Search from the end of the counters/flags region — recv_seen count // follows the flags byte. Scan backwards to avoid false matches. let mut found = false; for i in (0..bytes.len() - 12).rev() { if bytes[i..i + 4] == count_needle { // Swap the two 4-byte entries at i+4..i+8 and i+8..i+12. let (a, b_slice) = bytes[i + 4..i + 12].split_at_mut(4); a.swap_with_slice(b_slice); found = true; break; } } assert!(found, "could not find recv_seen count in blob"); #[allow(deprecated)] let result = RatchetState::from_bytes(&bytes); assert!(matches!(result, Err(Error::InvalidData))); } // === RT-481: all-zero prev_recv_epoch_key rejection === #[test] fn serialization_roundtrip_all_optionals() { // Deterministically drive the ratchet into a state where every optional // field is populated: send_ratchet_sk, send_ratchet_pk, recv_ratchet_pk, // prev_recv_epoch_key, prev_recv_ratchet_pk, recv_seen, prev_recv_seen. let (mut alice, mut bob) = make_pair(); // A→B: two messages so Bob has recv_seen = {1, 2}. send_a_to_b(&mut alice, &mut bob, b"msg-0"); let enc_late = alice.encrypt(b"late-from-epoch-N").unwrap(); send_a_to_b(&mut alice, &mut bob, b"msg-2"); // B→A: direction change triggers KEM ratchet. Both sides now have // send_ratchet_sk/pk (Bob generated a new pair). send_b_to_a(&mut alice, &mut bob, b"dir-change-1"); // A→B: another direction change triggers a new KEM ratchet on Bob's // receive side. Bob's old recv_epoch_key moves to prev_recv_epoch_key // and old recv_ratchet_pk moves to prev_recv_ratchet_pk. send_a_to_b(&mut alice, &mut bob, b"dir-change-2"); // Deliver the late message from epoch N — this decrypts via // prev_recv_epoch_key and populates prev_recv_seen. bob.decrypt(&enc_late.header, &enc_late.ciphertext).unwrap(); // Verify all optional fields are populated on Bob. assert!( bob.send_ratchet_sk.is_some(), "send_ratchet_sk must be Some" ); assert!( bob.send_ratchet_pk.is_some(), "send_ratchet_pk must be Some" ); assert!( bob.recv_ratchet_pk.is_some(), "recv_ratchet_pk must be Some" ); assert!( bob.prev_recv_epoch_key.is_some(), "prev_recv_epoch_key must be Some" ); assert!( bob.prev_recv_ratchet_pk.is_some(), "prev_recv_ratchet_pk must be Some" ); assert!(!bob.recv_seen.is_empty(), "recv_seen must be non-empty"); assert!( !bob.prev_recv_seen.is_empty(), "prev_recv_seen must be non-empty" ); // Serialize → deserialize → re-serialize must be deterministic. let (bytes1, epoch1) = bob.to_bytes().unwrap(); let bob2 = RatchetState::from_bytes_with_min_epoch(&bytes1, epoch1 - 1).unwrap(); let (bytes2, epoch2) = bob2.to_bytes().unwrap(); let bob3 = RatchetState::from_bytes_with_min_epoch(&bytes2, epoch2 - 1).unwrap(); let (bytes3, _) = bob3.to_bytes().unwrap(); // Second and third serializations must produce identical bytes // (epoch increments each time, so compare structure not raw bytes — // from_bytes_with_min_epoch validates all field parsing). // The fact that from_bytes succeeds on both re-serializations confirms // all optional fields survived the roundtrip correctly. assert_eq!( bytes2.len(), bytes3.len(), "re-serializations must have same length" ); } #[test] fn from_bytes_rejects_zero_prev_recv_epoch_key() { let (mut alice, mut bob) = make_pair(); // Three exchanges to get Bob's prev_recv_epoch_key and // prev_recv_ratchet_pk both populated (co-presence satisfied). send_a_to_b(&mut alice, &mut bob, b"setup"); send_b_to_a(&mut alice, &mut bob, b"dir1"); send_a_to_b(&mut alice, &mut bob, b"dir2"); assert!(bob.prev_recv_epoch_key.is_some()); assert!(bob.prev_recv_ratchet_pk.is_some()); // Tamper: zero out the key. The co-presence check passes (both // fields present), but the all-zero guard rejects. bob.prev_recv_epoch_key = Some(Zeroizing::new([0u8; 32])); let (bytes, _) = bob.to_bytes().unwrap(); assert!( matches!(RatchetState::from_bytes(&bytes), Err(Error::InvalidData)), "all-zero prev_recv_epoch_key must be rejected" ); } }