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
3621 lines
154 KiB
Rust
3621 lines
154 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|