version bumps, MAX_RECV_SEEN patch, FIPS interop, doc updates
All checks were successful
CI / test (push) Successful in 11m10s
CI / test-python (push) Successful in 1m10s
CI / test-zig (push) Successful in 1m8s
CI / test-wasm (push) Successful in 1m29s
CI / miri (push) Successful in 10m17s
CI / build (push) Successful in 1m5s
CI / lint (push) Successful in 1m17s
CI / fuzz-regression (push) Successful in 1h37m6s
CI / publish-python (push) Successful in 1m33s
CI / publish-wasm (push) Successful in 1m25s
CI / publish (push) Successful in 1m43s

This commit is contained in:
Kamal Tufekcic 2026-09-09 00:59:39 +03:00
commit cb5a7cef57
No known key found for this signature in database
17 changed files with 584 additions and 437 deletions

View file

@ -562,15 +562,15 @@ impl RatchetState {
// through key derivation + AEAD. Duplicates still succeed AEAD
// (same key, nonce, ciphertext) but the plaintext is discarded.
//
// Enforce MAX_RECV_SEEN cap at runtime, not just serialization time.
// Without this, an authenticated peer sending >65536 messages in one
// epoch would grow the set unboundedly and make the state unserializable.
// 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() >= constants::MAX_RECV_SEEN as usize {
if self.prev_recv_seen.len() + 1 >= constants::MAX_RECV_SEEN as usize {
return Err(Error::ChainExhausted);
}
self.prev_recv_seen.insert(header.n);
@ -579,7 +579,7 @@ impl RatchetState {
if self.recv_seen.contains(&header.n) {
return Err(Error::DuplicateMessage);
}
if self.recv_seen.len() >= constants::MAX_RECV_SEEN as usize {
if self.recv_seen.len() + 1 >= constants::MAX_RECV_SEEN as usize {
return Err(Error::ChainExhausted);
}
self.recv_seen.insert(header.n);
@ -862,9 +862,10 @@ impl RatchetState {
// 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 exceeding MAX_RECV_SEEN.
// decrypt_inner enforces this cap at runtime, so these checks are
// defense-in-depth against a future refactor removing the runtime cap.
// 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
}
@ -1054,8 +1055,8 @@ impl RatchetState {
// recv_seen — sorted for deterministic output.
let mut sorted_seen: Vec<u32> = self.recv_seen.iter().copied().collect();
sorted_seen.sort_unstable();
// Consistent with decrypt_inner's runtime cap (`>= MAX_RECV_SEEN`):
// the set can hold at most MAX_RECV_SEEN - 1 entries (65535).
// 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);
}
@ -1069,7 +1070,7 @@ impl RatchetState {
// 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();
// Consistent with decrypt_inner's runtime cap (`>= MAX_RECV_SEEN`).
// Mirrors decrypt_inner's runtime cap.
if sorted_prev_seen.len() >= constants::MAX_RECV_SEEN as usize {
return Err(Error::InvalidData);
}
@ -1286,8 +1287,8 @@ impl RatchetState {
// recv_seen set.
let num_seen =
u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?);
// Consistent with decrypt_inner's runtime cap (`>= MAX_RECV_SEEN`):
// the set can hold at most MAX_RECV_SEEN - 1 entries.
// 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);
}
@ -1311,7 +1312,7 @@ impl RatchetState {
// prev_recv_seen set.
let num_prev_seen =
u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?);
// Consistent with decrypt_inner's runtime cap (`>= MAX_RECV_SEEN`).
// Mirrors decrypt_inner's runtime cap.
if num_prev_seen >= constants::MAX_RECV_SEEN {
return Err(Error::InvalidData);
}
@ -3316,11 +3317,12 @@ mod tests {
#[test]
fn recv_seen_cap_at_runtime() {
let (mut alice, mut bob) = make_pair();
// Fill recv_seen to capacity with counters that won't collide
// with Alice's send_count (which starts at 1).
// Use range 100_000..100_000+MAX_RECV_SEEN to avoid collision.
bob.recv_seen = (100_000..100_000 + constants::MAX_RECV_SEEN).collect();
bob.recv_count = 100_000 + constants::MAX_RECV_SEEN;
// 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!(
@ -3349,9 +3351,10 @@ mod tests {
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 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).collect();
// 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!(
@ -3435,8 +3438,8 @@ mod tests {
#[test]
fn encrypt_works_after_recv_seen_cap() {
let (mut alice, _) = make_pair();
alice.recv_seen = (0..constants::MAX_RECV_SEEN).collect();
alice.recv_count = constants::MAX_RECV_SEEN;
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());
}