Skip to content

[crypto] Bound ratchet_counter in open_content to fix CPU DoS #110

@intendednull

Description

@intendednull

Parent: #108

Problem

crates/crypto/src/lib.rs:240-245:

pub fn open_content(sealed: &SealedContent, key: &ChannelKey) -> Result<Content, CryptoError> {
    let decrypt_key = if sealed.ratchet_counter > 0 {
        derive_message_key(key, sealed.key_epoch, sealed.ratchet_counter)
    } else {
        key.clone()
    };
    ...

derive_message_key at crates/crypto/src/lib.rs:173-185 loops from counter=1 to the attacker-supplied counter, doing two HKDF-Expand ops per step, and it runs before AEAD verification. So the attacker doesn't even need a valid ciphertext — any junk SealedContent with a huge ratchet_counter stalls the receiver for the full derivation.

Measurements (reproduced)

Real timing test built against the crate:

ratchet_counter open_content time
1 16 µs
1 000 976 µs
10 000 9.87 ms
100 000 98.8 ms
1 000 000 1.00 s

u64::MAX would take ~584 000 years on a single core. Any peer subscribed to a channel topic can freeze every recipient by publishing one garbage packet.

Fix

  1. Track per-channel current ratchet state (or at least the highest counter the receiver has derived so far) and reject any sealed.ratchet_counter more than a small window above it (start with MAX_RATCHET_LOOKAHEAD = 1024).
  2. Apply the bounds check before calling derive_message_key.
  3. Return a new CryptoError::RatchetCounterOutOfRange { claimed, max } variant.
  4. See #TBD (separate issue) for the follow-up caching work to make legitimate large-counter derivations O(1) instead of O(n).

Suggested code

pub const MAX_RATCHET_LOOKAHEAD: u64 = 1024;

pub fn open_content_bounded(
    sealed: &SealedContent,
    key: &ChannelKey,
    current_counter: u64,
) -> Result<Content, CryptoError> {
    if sealed.ratchet_counter > current_counter.saturating_add(MAX_RATCHET_LOOKAHEAD) {
        return Err(CryptoError::RatchetCounterOutOfRange {
            claimed: sealed.ratchet_counter,
            max: current_counter + MAX_RATCHET_LOOKAHEAD,
        });
    }
    // existing logic
}

The current open_content can remain as a thin wrapper calling open_content_bounded(sealed, key, 0) if we need backwards compat, but the caller should be migrated to the bounded variant.

Test

#[test]
fn open_content_rejects_huge_ratchet_counter() {
    let key = generate_channel_key();
    let content = Content::Text { body: "x".into() };
    let mut sealed = seal_content_with_counter(&content, &key, 0, 1).unwrap();
    sealed.ratchet_counter = u64::MAX;
    let start = std::time::Instant::now();
    let result = open_content_bounded(&sealed, &key, 1);
    assert!(result.is_err());
    assert!(start.elapsed() < std::time::Duration::from_millis(10));
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions