libsoliton/soliton/src/ratchet/mod.rs
Kamal Tufekcic 6853b30036
Some checks failed
CI / lint (push) Failing after 52s
CI / test-python (push) Successful in 1m40s
CI / test (push) Has been cancelled
CI / test-wasm (push) Has been cancelled
CI / publish-wasm (push) Has been cancelled
CI / miri (push) Has been cancelled
CI / fuzz-regression (push) Has been cancelled
CI / build (push) Has been cancelled
CI / publish (push) Has been cancelled
CI / publish-python (push) Has been cancelled
CI / test-zig (push) Has been cancelled
version bumps, MAX_RECV_SEEN patch, FIPS interop, doc updates
2026-09-09 00:49:53 +03:00

3621 lines
154 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<xwing::Ciphertext>,
/// 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<xwing::SecretKey>,
/// Local X-Wing public key for the current send ratchet epoch.
send_ratchet_pk: Option<xwing::PublicKey>,
/// Peer's X-Wing public key for the current receive ratchet epoch.
recv_ratchet_pk: Option<xwing::PublicKey>,
/// 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<Zeroizing<[u8; 32]>>,
/// Previous receive ratchet public key — identifies which epoch a
/// late-arriving message belongs to.
prev_recv_ratchet_pk: Option<xwing::PublicKey>,
/// 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<u32>,
/// 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<u32>,
/// 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<u8>,
}
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<Self> {
// 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<Self> {
// 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<EncryptedMessage> {
// 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<Zeroizing<Vec<u8>>> {
// 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<Zeroizing<Vec<u8>>> {
// 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<u8>, 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<Vec<u8>>, 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<Vec<u8>>, 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<xwing::Ciphertext> {
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<SecretKey> 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<Zeroizing<Vec<u8>>> {
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<crate::call::CallKeys> {
// 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<Vec<u8>>, 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<u32> = 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<u32> = 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<Self> {
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<u32> = 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<u32> = 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<Self> {
#[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<u8>, 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<Option<&'a [u8]>> {
// 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<RootKdfOutput> {
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<Vec<u8>> {
// 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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"
);
}
}