# 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` — Widened `CryptoError::ReplayDetected { seq }` from `u16` to `u32` to match v2 `MediaHeader::seq`.
- `crates/wzp-crypto/src/anti_replay.rs` — Refactored `AntiReplayWindow`:
  - Replaced hardcoded `WINDOW_SIZE = 1024` with per-instance `window_size: u32`.
  - Changed internal sequence type from `u16` to `u32`.
  - Added `with_window(size: usize) -> Self` constructor.
  - Updated wrapping arithmetic (`0x8000_0000` boundary) for `u32`.
  - Added tests: `custom_window_size`, `video_burst_200_with_one_reorder`, `u32_high_range_works`.
- `crates/wzp-crypto/src/session.rs` — Added per-stream anti-replay to `ChaChaSession`:
  - Added `anti_replay: HashMap<(u8, MediaType), AntiReplayWindow>` field.
  - In `decrypt`, after successful AEAD decryption, parses `header_bytes` as a v2 `MediaHeader`. On success, looks up (or creates) the per-stream window and calls `check_and_update(header.seq)`. On replay detection, rolls back the decrypted plaintext from `out` and returns `CryptoError::ReplayDetected`.
  - Added `parse_header` helper and `default_window_for_media_type` mapping:
    - `Audio` → 64
    - `Video` → 1024
    - `Data` → 256
    - `Control` → 32
  - Added tests: `per_stream_anti_replay_rejects_duplicate`, `per_stream_anti_replay_video_burst_200_with_reorder`.

## 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

```bash
$ 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
```

```bash
$ 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_size`
  - `anti_replay::tests::video_burst_200_with_one_reorder`
  - `anti_replay::tests::u32_high_range_works`
  - `session::tests::per_stream_anti_replay_rejects_duplicate`
  - `session::tests::per_stream_anti_replay_video_burst_200_with_reorder`
- Tests modified: 2 (`wrapping_works`, `u32_high_range_works` — updated for `u32` semantics)
- 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 in `wzp-codec` + `warzone-protocol` (see PROTOCOL-AUDIT.md)
- `cargo fmt --all -- --check`: pass

## Risks / follow-ups

- The `ChaChaSession::decrypt` nonce scheme still uses a monotonic `recv_seq` counter, 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 use `MediaHeader::seq` directly, enabling true out-of-order tolerance.
- `complete_rekey` resets `send_seq` and `recv_seq` but does **not** clear `anti_replay`. This is intentional: replay protection is stream-scoped, not key-scoped. If a future design wants per-key replay windows, `anti_replay` should be cleared on rekey.
- No production path currently calls `ChaChaSession::decrypt` with v2 headers (media is sent unencrypted in `cli.rs`). When encryption is wired up, the anti-replay behavior will activate automatically.

## Reviewer checklist (filled in by reviewer)

- [x] Code matches PRD intent — per-stream + per-MediaType windows, configurable sizes, u32 seq width
- [x] Verification output is real — re-ran `cargo test -p wzp-crypto anti_replay` (12 pass) and full `cargo test -p wzp-crypto` (69 pass); clippy clean on `wzp-proto` + `wzp-crypto`
- [x] No backward-incompat surprises — non-v2 header bytes gracefully skip anti-replay (legacy tests unaffected)
- [x] Tests cover the new behavior — including the exact W11 scenario (`video_burst_200_with_one_reorder`)
- [x] 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_replay` on rekey, reasoning that replay protection is stream-scoped, not key-scoped. I agree with the choice.

**Honest risks the agent flagged:**

1. `ChaChaSession::decrypt` nonce derivation still uses a monotonic `recv_seq` counter, 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 use `MediaHeader::seq` directly — that unlocks real out-of-order tolerance. Pre-existing limitation, not introduced by T1.8.
2. 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_header` is a free function in `session.rs`; could naturally be a method on `MediaHeader`. Minor; the underlying `read_from` is used correctly.
- The `default_window_for_media_type` size matrix lives inside `wzp-crypto`. Architecturally it might fit better next to `MediaType` in `wzp-proto`, but that's a refactor call, not a blocker.
