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
- 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).
- Apply the bounds check before calling
derive_message_key.
- Return a new
CryptoError::RatchetCounterOutOfRange { claimed, max } variant.
- 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));
}
Parent: #108
Problem
crates/crypto/src/lib.rs:240-245:derive_message_keyatcrates/crypto/src/lib.rs:173-185loops 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 junkSealedContentwith a hugeratchet_counterstalls the receiver for the full derivation.Measurements (reproduced)
Real timing test built against the crate:
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
sealed.ratchet_countermore than a small window above it (start withMAX_RATCHET_LOOKAHEAD = 1024).derive_message_key.CryptoError::RatchetCounterOutOfRange { claimed, max }variant.Suggested code
The current
open_contentcan remain as a thin wrapper callingopen_content_bounded(sealed, key, 0)if we need backwards compat, but the caller should be migrated to the bounded variant.Test