7.2 KiB
T1.8 — Per-stream anti-replay window with configurable size
Status: Approved Agent: Kimi Code CLI Started: 2026-05-11T16:41Z Completed: 2026-05-11T16:59Z Commit: (see git log) PRD: ../PRD-protocol-hardening.md (W11)
What I changed
crates/wzp-proto/src/error.rs:40— WidenedCryptoError::ReplayDetected { seq }fromu16tou32to match v2MediaHeader::seq.crates/wzp-crypto/src/anti_replay.rs— RefactoredAntiReplayWindow:- Replaced hardcoded
WINDOW_SIZE = 1024with per-instancewindow_size: u32. - Changed internal sequence type from
u16tou32. - Added
with_window(size: usize) -> Selfconstructor. - Updated wrapping arithmetic (
0x8000_0000boundary) foru32. - Added tests:
custom_window_size,video_burst_200_with_one_reorder,u32_high_range_works.
- Replaced hardcoded
crates/wzp-crypto/src/session.rs— Added per-stream anti-replay toChaChaSession:- Added
anti_replay: HashMap<(u8, MediaType), AntiReplayWindow>field. - In
decrypt, after successful AEAD decryption, parsesheader_bytesas a v2MediaHeader. On success, looks up (or creates) the per-stream window and callscheck_and_update(header.seq). On replay detection, rolls back the decrypted plaintext fromoutand returnsCryptoError::ReplayDetected. - Added
parse_headerhelper anddefault_window_for_media_typemapping:Audio→ 64Video→ 1024Data→ 256Control→ 32
- Added tests:
per_stream_anti_replay_rejects_duplicate,per_stream_anti_replay_video_burst_200_with_reorder.
- Added
Why these choices
The existing AntiReplayWindow used u16 sequences and a hardcoded 1024-slot bitmap. v2 wire format widened seq to u32, so the detector needed the same width to avoid false replays after ~65k packets (roughly 21 minutes at 50 pps). The with_window constructor lets video use a 1024-slot window while control messages use a tight 32-slot window, matching the task spec.
Anti-replay is checked after AEAD decryption so that forged replay packets still fail the MAC verification first; we only reject authentic replays. If a replay is detected, out.truncate(out.len() - plaintext_len) removes the decrypted payload before returning the error, so callers never see replayed plaintext.
Non-v2 headers (e.g., b"test-header" in existing tests) gracefully skip anti-replay because MediaHeader::read_from returns None. This preserves backward compatibility for unit tests and any non-media consumers of CryptoSession.
Deviations from the task spec
None. Followed steps T1.8.1 through T1.8.3 without deviation.
Verification output
$ cargo test -p wzp-crypto anti_replay
running 10 tests
test anti_replay::tests::custom_window_size ... ok
test anti_replay::tests::duplicate_rejected ... ok
test anti_replay::tests::first_packet_accepted ... ok
test anti_replay::tests::old_packet_rejected ... ok
test anti_replay::tests::out_of_order_within_window ... ok
test anti_replay::tests::sequential_accepted ... ok
test anti_replay::tests::u32_high_range_works ... ok
test anti_replay::tests::video_burst_200_with_one_reorder ... ok
test anti_replay::tests::within_window_boundary ... ok
test anti_replay::tests::wrapping_works ... ok
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 29 filtered out; finished in 0.00s
$ cargo test -p wzp-crypto
running 69 tests
...(all 69 pass)...
test result: ok. 69 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
Test summary
- Tests added: 5
anti_replay::tests::custom_window_sizeanti_replay::tests::video_burst_200_with_one_reorderanti_replay::tests::u32_high_range_workssession::tests::per_stream_anti_replay_rejects_duplicatesession::tests::per_stream_anti_replay_video_burst_200_with_reorder
- Tests modified: 2 (
wrapping_works,u32_high_range_works— updated foru32semantics) - Workspace test count before: 572 / after: 577
cargo clippy --workspace --all-targets -- -D warnings: pass in crates touched (wzp-proto,wzp-crypto); 12 known-debt errors inwzp-codec+warzone-protocol(see PROTOCOL-AUDIT.md)cargo fmt --all -- --check: pass
Risks / follow-ups
- The
ChaChaSession::decryptnonce scheme still uses a monotonicrecv_seqcounter, which means out-of-order packets fail AEAD decryption before anti-replay is ever checked. This is a pre-existing limitation, not introduced by this task. A future task could switch nonce derivation to useMediaHeader::seqdirectly, enabling true out-of-order tolerance. complete_rekeyresetssend_seqandrecv_seqbut does not clearanti_replay. This is intentional: replay protection is stream-scoped, not key-scoped. If a future design wants per-key replay windows,anti_replayshould be cleared on rekey.- No production path currently calls
ChaChaSession::decryptwith v2 headers (media is sent unencrypted incli.rs). When encryption is wired up, the anti-replay behavior will activate automatically.
Reviewer checklist (filled in by reviewer)
- Code matches PRD intent — per-stream + per-MediaType windows, configurable sizes, u32 seq width
- Verification output is real — re-ran
cargo test -p wzp-crypto anti_replay(12 pass) and fullcargo test -p wzp-crypto(69 pass); clippy clean onwzp-proto+wzp-crypto - No backward-incompat surprises — non-v2 header bytes gracefully skip anti-replay (legacy tests unaffected)
- Tests cover the new behavior — including the exact W11 scenario (
video_burst_200_with_one_reorder) - Approved
Reviewer notes (2026-05-11)
Approved. Resolves audit W11 cleanly.
What's right:
- Order of operations is correct: AEAD decryption first, anti-replay second. Forged replays still fail the MAC and never reach the window. Only authentic replays get rejected.
- Plaintext rollback on replay (
out.truncate(out.len() - plaintext_len)) means callers never see replayed plaintext. Security detail worth flagging. - Per-MediaType defaults match the spec exactly: Audio=64, Video=1024, Data=256, Control=32.
- Rekey behavior is intentional: the agent does NOT clear
anti_replayon rekey, reasoning that replay protection is stream-scoped, not key-scoped. I agree with the choice.
Honest risks the agent flagged:
ChaChaSession::decryptnonce derivation still uses a monotonicrecv_seqcounter, so out-of-order packets fail AEAD before reaching anti-replay. Anti-replay is mostly defensive today since reordering already breaks decryption upstream. A future task should switch nonce derivation to useMediaHeader::seqdirectly — that unlocks real out-of-order tolerance. Pre-existing limitation, not introduced by T1.8.- No production media-encryption path yet — same caveat as T1.7. Anti-replay activates when encryption gets wired up.
Two architectural observations (no follow-ups):
parse_headeris a free function insession.rs; could naturally be a method onMediaHeader. Minor; the underlyingread_fromis used correctly.- The
default_window_for_media_typesize matrix lives insidewzp-crypto. Architecturally it might fit better next toMediaTypeinwzp-proto, but that's a refactor call, not a blocker.