Add ratchet counter DoS protection and RotateChannelKey authorization#134
Merged
intendednull merged 3 commits intoApr 11, 2026
Merged
Conversation
Closes #109. Before this, EventKind::RotateChannelKey fell into the catch-all `_ => None` arm of required_permission(), so any author — including a fresh outsider identity who had never interacted with the server — could emit a signed RotateChannelKey and inject arbitrary encrypted-key entries into ServerState.channel_keys. Map RotateChannelKey to Permission::ManageChannels so only admins and members with an explicit grant pass the check, and add a defense-in-depth membership assertion inside the apply_mutation arm. Regression coverage: - rotate_channel_key_by_outsider_is_rejected: fresh Mallory identity attempts injection; her key never appears in state.channel_keys. - rotate_channel_key_by_member_without_manage_channels_is_rejected: Bob has SendMessages but not ManageChannels; his rotate is rejected. - rotate_channel_key_by_admin_still_works: sanity check the legitimate admin rotation path remains unchanged. https://claude.ai/code/session_011cBQL95mF4j2DGCeLgxrsr
Post-#109, RotateChannelKey (the only encryption event) is permission-checked rather than unrestricted, so it no longer belongs in the fall-through comment. https://claude.ai/code/session_011cBQL95mF4j2DGCeLgxrsr
Closes #110. Before this, open_content called derive_message_key with a fully attacker-controlled ratchet_counter, which looped from 1 up to the claimed value performing two HKDF-Expand operations per step, all before AEAD verification. Measured cost: 1e6 counter = 1.0s, u64::MAX ≈ 584 000 years. Any peer subscribed to a channel topic could freeze every recipient with a single malformed packet. Introduce open_content_bounded(sealed, key, current_counter) which rejects sealed.ratchet_counter > current_counter + MAX_RATCHET_LOOKAHEAD (1024) before any HKDF work. The existing open_content becomes a thin shim that calls open_content_bounded with current_counter=0, so any packet with a counter above 1024 is rejected without the caller needing to track state. Callers that track per-channel ratchet state can migrate to open_content_bounded to widen the accepted window as they process messages. A new CryptoError::RatchetCounterOutOfRange { claimed, max } variant surfaces the rejection reason. The bounds check is skipped for ratchet_counter == 0 (legacy no-ratchet path) to preserve backwards compatibility with SealedContent that was never ratcheted. Regression coverage: - open_content_rejects_huge_ratchet_counter: u64::MAX counter is rejected with RatchetCounterOutOfRange and returns in well under 500ms (the unbounded path could not complete in any test timeout). - open_content_bounded_rejects_above_lookahead: verifies the exact error payload including claimed and max. - open_content_bounded_accepts_within_lookahead: counter=50 still decrypts fine from current_counter=0. - open_content_shim_rejects_counter_above_lookahead: the zero-arg shim rejects MAX_RATCHET_LOOKAHEAD+1. - open_content_bounded_counter_zero_bypasses_bounds_check: legacy ratchet_counter=0 path unaffected. No production code calls open_content today — the feature is dormant at the emission level — so no callers needed migration. https://claude.ai/code/session_011cBQL95mF4j2DGCeLgxrsr
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #109
Closes #110
Progresses #108
Summary
This PR addresses two critical security findings from the code quality review tracked in #108:
RotateChannelKeyevents to users withManageChannelspermission and adds defense-in-depth membership validation. Before this, any outsider with a valid signature could inject arbitrary encrypted-key bytes intoServerState.channel_keys.ratchet_counter = u64::MAXcould freeze every recipient's CPU for ~584 000 years on a single core.Key Changes
Crypto Module (
crates/crypto/src/lib.rs)RatchetCounterOutOfRange { claimed, max }to report when a sealed packet's counter exceeds acceptable boundsMAX_RATCHET_LOOKAHEAD = 1024defines the maximum counter advance a receiver will accept before AEAD verificationopen_content_bounded(sealed, key, current_counter)performs bounded decryption:ratchet_counter > current_counter + MAX_RATCHET_LOOKAHEADbefore any HKDF worku64::MAXiterations of key derivationratchet_counter == 0(backwards compatibility)open_content()now wrapsopen_content_bounded()withcurrent_counter = 0for backwards compatibilityState Module (
crates/state/src/materialize.rs)RotateChannelKeyto the list of events requiringManageChannelspermissionapply_mutationforRotateChannelKeyevents to reject non-members even if permission checks are bypassedState Tests (
crates/state/src/tests.rs)rotate_channel_key_by_outsider_is_rejected: Verifies outsiders cannot inject key materialrotate_channel_key_by_member_without_manage_channels_is_rejected: Verifies permission enforcementrotate_channel_key_by_admin_still_works: Sanity check that legitimate admins can still rotate keysImplementation Details
saturating_addto safely handlecurrent_counternearu64::MAXrequired_permission()provides the primary gate, and membership validation provides defense-in-depthTest plan
cargo fmt --check— cleancargo clippy --workspace -- -D warnings— cleancargo test --workspace— 0 failures (30 crypto tests, 155 state tests, all other crates green)cargo check --target wasm32-unknown-unknown— clean for all library cratesu64::MAX)https://claude.ai/code/session_011cBQL95mF4j2DGCeLgxrsr