Files
wz-phone/docs/PRD/reports/T1.8-report.md

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

$ 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_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)

  • 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 full cargo test -p wzp-crypto (69 pass); clippy clean on wzp-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_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.