version bumps, MAX_RECV_SEEN patch, FIPS interop, doc updates
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
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
This commit is contained in:
parent
89f5be4f40
commit
6853b30036
16 changed files with 582 additions and 435 deletions
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue