Skip to content

Commit 7db68b5

Browse files
authored
Going out of order is fine, maybe. (#155)
1 parent 46738bd commit 7db68b5

File tree

6 files changed

+457
-16
lines changed

6 files changed

+457
-16
lines changed

crates/mdk-core/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525

2626
## Unreleased
2727

28+
### Added
29+
30+
- Configurable `out_of_order_tolerance` and `maximum_forward_distance` in `MdkConfig` for MLS sender ratchet settings. Default `out_of_order_tolerance` increased from 5 to 100 for better handling of out-of-order message delivery on Nostr relays. ([`#155`](https://github.com/marmot-protocol/mdk/pull/155))
31+
2832
### Breaking changes
2933

3034
- **Unified Storage Architecture**: `MdkProvider` now uses the storage provider directly as the OpenMLS `StorageProvider`, instead of accessing it via `openmls_storage()`. This enables atomic transactions across MLS and MDK state for proper commit race resolution per MIP-03. Storage implementations must now directly implement `StorageProvider<1>`. ([#148](https://github.com/marmot-protocol/mdk/pull/148))

crates/mdk-core/src/groups.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,11 +988,16 @@ where
988988

989989
// Build the group config
990990
let capabilities = self.capabilities();
991+
let sender_ratchet_config = SenderRatchetConfiguration::new(
992+
self.config.out_of_order_tolerance,
993+
self.config.maximum_forward_distance,
994+
);
991995
let group_config = MlsGroupCreateConfig::builder()
992996
.ciphersuite(self.ciphersuite)
993997
.use_ratchet_tree_extension(true)
994998
.capabilities(capabilities)
995999
.with_group_context_extensions(extensions)?
1000+
.sender_ratchet_configuration(sender_ratchet_config)
9961001
.build();
9971002

9981003
let mut mls_group =

crates/mdk-core/src/lib.rs

Lines changed: 302 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ pub use mdk_storage_traits::GroupId;
5454
///
5555
/// // Custom configuration
5656
/// let config = MdkConfig {
57-
/// max_event_age_secs: 86400, // 1 day instead of 45
57+
/// max_event_age_secs: 86400, // 1 day instead of 45
58+
/// out_of_order_tolerance: 50, // Stricter forward secrecy
5859
/// ..Default::default()
5960
/// };
6061
/// ```
@@ -84,13 +85,44 @@ pub struct MdkConfig {
8485
///
8586
/// Default: 300 (5 minutes)
8687
pub max_future_skew_secs: u64,
88+
89+
/// Number of past message decryption secrets to retain for out-of-order delivery.
90+
///
91+
/// This controls how many past decryption secrets are kept to handle messages
92+
/// that arrive out of order. Nostr relays do not guarantee message ordering,
93+
/// so a higher value improves reliability when messages are reordered.
94+
///
95+
/// Default: 100
96+
///
97+
/// # Security Note
98+
/// Higher values reduce forward secrecy within an epoch, as more past secrets
99+
/// are retained in memory. The default of 100 balances reliability with security
100+
/// for typical Nostr relay behavior. Applications with stricter forward secrecy
101+
/// requirements may reduce this value.
102+
pub out_of_order_tolerance: u32,
103+
104+
/// Maximum number of messages that can be skipped before decryption fails.
105+
///
106+
/// This controls how far ahead the sender ratchet can advance when messages
107+
/// are dropped or lost. If more than this many messages are skipped,
108+
/// decryption will fail.
109+
///
110+
/// Default: 1000
111+
///
112+
/// # Security Note
113+
/// Higher values improve tolerance for dropped messages but require more
114+
/// computation to advance the ratchet when catching up. The default of 1000
115+
/// handles most message loss scenarios while keeping catch-up costs reasonable.
116+
pub maximum_forward_distance: u32,
87117
}
88118

89119
impl Default for MdkConfig {
90120
fn default() -> Self {
91121
Self {
92-
max_event_age_secs: 3888000, // 45 days
93-
max_future_skew_secs: 300, // 5 minutes
122+
max_event_age_secs: 3888000, // 45 days
123+
max_future_skew_secs: 300, // 5 minutes
124+
out_of_order_tolerance: 100, // 100 past messages
125+
maximum_forward_distance: 1000, // 1000 forward messages
94126
}
95127
}
96128
}
@@ -455,4 +487,271 @@ pub mod tests {
455487
// so we don't assert inequality. The test mainly verifies GREASE is being injected.
456488
}
457489
}
490+
491+
/// Tests for sender ratchet configuration (out_of_order_tolerance and maximum_forward_distance).
492+
///
493+
/// These settings control the MLS sender ratchet which handles message ordering:
494+
/// - `out_of_order_tolerance`: Number of past decryption secrets to keep for out-of-order messages
495+
/// - `maximum_forward_distance`: Maximum number of skipped messages before decryption fails
496+
///
497+
/// When messages arrive out of order beyond the tolerance, decryption fails.
498+
/// The default tolerance of 100 handles typical Nostr relay reordering scenarios.
499+
mod sender_ratchet_tests {
500+
use nostr::Keys;
501+
502+
use super::*;
503+
use crate::messages::MessageProcessingResult;
504+
use crate::test_util::{
505+
create_key_package_event, create_nostr_group_config_data, create_test_rumor,
506+
};
507+
508+
/// Test that custom MdkConfig is properly applied to groups.
509+
///
510+
/// This test verifies that the configuration is correctly passed through the
511+
/// group creation and joining process.
512+
#[test]
513+
fn test_custom_config_is_applied() {
514+
let config = MdkConfig {
515+
out_of_order_tolerance: 50,
516+
maximum_forward_distance: 500,
517+
max_event_age_secs: 86400,
518+
max_future_skew_secs: 120,
519+
};
520+
521+
let alice_keys = Keys::generate();
522+
let bob_keys = Keys::generate();
523+
524+
let alice_mdk = create_test_mdk_with_config(config.clone());
525+
let bob_mdk = create_test_mdk_with_config(config.clone());
526+
527+
// Verify configs are set correctly
528+
assert_eq!(alice_mdk.config.out_of_order_tolerance, 50);
529+
assert_eq!(alice_mdk.config.maximum_forward_distance, 500);
530+
assert_eq!(bob_mdk.config.out_of_order_tolerance, 50);
531+
assert_eq!(bob_mdk.config.maximum_forward_distance, 500);
532+
533+
let admins = vec![alice_keys.public_key(), bob_keys.public_key()];
534+
535+
// Bob creates key package in his MDK
536+
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
537+
538+
// Alice creates group and adds Bob
539+
let create_result = alice_mdk
540+
.create_group(
541+
&alice_keys.public_key(),
542+
vec![bob_key_package],
543+
create_nostr_group_config_data(admins),
544+
)
545+
.expect("Alice should create group");
546+
547+
let group_id = create_result.group.mls_group_id.clone();
548+
549+
alice_mdk
550+
.merge_pending_commit(&group_id)
551+
.expect("Alice should merge commit");
552+
553+
// Bob processes welcome and joins with his config
554+
let bob_welcome_rumor = &create_result.welcome_rumors[0];
555+
let bob_welcome = bob_mdk
556+
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
557+
.expect("Bob should process welcome");
558+
559+
bob_mdk
560+
.accept_welcome(&bob_welcome)
561+
.expect("Bob should accept welcome");
562+
563+
// Verify both clients have the same group
564+
assert_eq!(group_id, bob_welcome.mls_group_id);
565+
}
566+
567+
/// Test that high out_of_order_tolerance allows heavily reordered messages.
568+
///
569+
/// With tolerance of 100, receiving messages in reverse order (100 messages apart)
570+
/// should all decrypt successfully.
571+
#[test]
572+
fn test_high_tolerance_allows_reordered_messages() {
573+
// Default tolerance of 100
574+
let alice_keys = Keys::generate();
575+
let bob_keys = Keys::generate();
576+
577+
let alice_mdk = create_test_mdk();
578+
let bob_mdk = create_test_mdk();
579+
580+
let admins = vec![alice_keys.public_key(), bob_keys.public_key()];
581+
582+
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
583+
584+
let create_result = alice_mdk
585+
.create_group(
586+
&alice_keys.public_key(),
587+
vec![bob_key_package],
588+
create_nostr_group_config_data(admins),
589+
)
590+
.expect("Alice should create group");
591+
592+
let group_id = create_result.group.mls_group_id.clone();
593+
594+
alice_mdk
595+
.merge_pending_commit(&group_id)
596+
.expect("Alice should merge commit");
597+
598+
let bob_welcome_rumor = &create_result.welcome_rumors[0];
599+
let bob_welcome = bob_mdk
600+
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
601+
.expect("Bob should process welcome");
602+
603+
bob_mdk
604+
.accept_welcome(&bob_welcome)
605+
.expect("Bob should accept welcome");
606+
607+
// Alice sends 50 messages (within default tolerance of 100)
608+
let num_messages = 50;
609+
let mut message_events = Vec::new();
610+
611+
for i in 0..num_messages {
612+
let rumor = create_test_rumor(&alice_keys, &format!("Message {}", i));
613+
let msg_event = alice_mdk
614+
.create_message(&group_id, rumor)
615+
.expect("Alice should send message");
616+
message_events.push(msg_event);
617+
}
618+
619+
// Bob receives messages in extreme out-of-order pattern:
620+
// last, first, second-to-last, second, etc.
621+
let mut receive_order: Vec<usize> = Vec::new();
622+
for i in 0..num_messages / 2 {
623+
receive_order.push(num_messages - 1 - i); // from end
624+
receive_order.push(i); // from start
625+
}
626+
627+
for &idx in &receive_order {
628+
let msg_event = &message_events[idx];
629+
let result = bob_mdk
630+
.process_message(msg_event)
631+
.unwrap_or_else(|e| panic!("Bob should decrypt message {idx}: {e}"));
632+
633+
match result {
634+
MessageProcessingResult::ApplicationMessage(msg) => {
635+
assert_eq!(msg.content, format!("Message {}", idx));
636+
}
637+
other => panic!("Expected ApplicationMessage for message {idx}, got {other:?}"),
638+
}
639+
}
640+
}
641+
642+
/// Test that low out_of_order_tolerance causes decryption failures for distant messages.
643+
///
644+
/// With tolerance of 5, receiving message 19 first then trying to decrypt
645+
/// message 0 (which is 19 generations behind) should fail.
646+
#[test]
647+
fn test_low_tolerance_rejects_distant_messages() {
648+
// Very low tolerance
649+
let config = MdkConfig {
650+
out_of_order_tolerance: 5,
651+
..Default::default()
652+
};
653+
654+
let alice_keys = Keys::generate();
655+
let bob_keys = Keys::generate();
656+
657+
let alice_mdk = create_test_mdk_with_config(config.clone());
658+
let bob_mdk = create_test_mdk_with_config(config);
659+
660+
let admins = vec![alice_keys.public_key(), bob_keys.public_key()];
661+
662+
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
663+
664+
let create_result = alice_mdk
665+
.create_group(
666+
&alice_keys.public_key(),
667+
vec![bob_key_package],
668+
create_nostr_group_config_data(admins),
669+
)
670+
.expect("Alice should create group");
671+
672+
let group_id = create_result.group.mls_group_id.clone();
673+
674+
alice_mdk
675+
.merge_pending_commit(&group_id)
676+
.expect("Alice should merge commit");
677+
678+
let bob_welcome_rumor = &create_result.welcome_rumors[0];
679+
let bob_welcome = bob_mdk
680+
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
681+
.expect("Bob should process welcome");
682+
683+
bob_mdk
684+
.accept_welcome(&bob_welcome)
685+
.expect("Bob should accept welcome");
686+
687+
// Alice sends 20 messages
688+
let num_messages = 20;
689+
let mut message_events = Vec::new();
690+
691+
for i in 0..num_messages {
692+
let rumor = create_test_rumor(&alice_keys, &format!("Message {}", i));
693+
let msg_event = alice_mdk
694+
.create_message(&group_id, rumor)
695+
.expect("Alice should send message");
696+
message_events.push(msg_event);
697+
}
698+
699+
// Bob receives the LAST message first (message 19)
700+
// This advances his ratchet state to generation 19
701+
let last_msg = &message_events[num_messages - 1];
702+
let result = bob_mdk
703+
.process_message(last_msg)
704+
.expect("Bob should decrypt the latest message");
705+
706+
match result {
707+
MessageProcessingResult::ApplicationMessage(msg) => {
708+
assert_eq!(msg.content, format!("Message {}", num_messages - 1));
709+
}
710+
_ => panic!("Expected ApplicationMessage"),
711+
}
712+
713+
// Now Bob tries to decrypt message 0 (which is 19 generations behind)
714+
// With tolerance of 5, this should return Unprocessable because the
715+
// ratchet secret for generation 0 was not retained
716+
let first_msg = &message_events[0];
717+
let result = bob_mdk.process_message(first_msg);
718+
719+
match result {
720+
Ok(MessageProcessingResult::Unprocessable { .. }) => {
721+
// Expected - the message is too far in the past
722+
}
723+
Ok(MessageProcessingResult::ApplicationMessage(_)) => {
724+
panic!(
725+
"Message 0 should NOT decrypt after receiving message 19 with tolerance 5"
726+
);
727+
}
728+
Err(_) => {
729+
// Also acceptable - the processing failed entirely
730+
}
731+
other => {
732+
panic!("Unexpected result: {:?}", other);
733+
}
734+
}
735+
736+
// But messages within tolerance should still work
737+
// Messages 15, 16, 17, 18 are within 5 generations of message 19
738+
for (i, msg_event) in message_events
739+
.iter()
740+
.enumerate()
741+
.take(num_messages - 1)
742+
.skip(num_messages - 5)
743+
{
744+
let result = bob_mdk.process_message(msg_event).unwrap_or_else(|e| {
745+
panic!("Message {i} should decrypt (within tolerance): {e}")
746+
});
747+
748+
match result {
749+
MessageProcessingResult::ApplicationMessage(msg) => {
750+
assert_eq!(msg.content, format!("Message {}", i));
751+
}
752+
other => panic!("Expected ApplicationMessage for message {i}, got {other:?}"),
753+
}
754+
}
755+
}
756+
}
458757
}

crates/mdk-core/src/welcomes.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,13 @@ where
402402
_ => return Err(Error::InvalidWelcomeMessage),
403403
};
404404

405+
let sender_ratchet_config = SenderRatchetConfiguration::new(
406+
self.config.out_of_order_tolerance,
407+
self.config.maximum_forward_distance,
408+
);
405409
let mls_group_config = MlsGroupJoinConfig::builder()
406410
.use_ratchet_tree_extension(true)
411+
.sender_ratchet_configuration(sender_ratchet_config)
407412
.build();
408413

409414
let staged_welcome =

crates/mdk-uniffi/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@
3030
- **Security (Audit Issue M)**: Changed `get_message()` to require both `mls_group_id` and `event_id` parameters. This prevents messages from different groups from overwriting each other by scoping lookups to a specific group. ([#124](https://github.com/marmot-protocol/mdk/pull/124))
3131
- Changed `get_messages()` to accept optional `limit` and `offset` parameters for pagination control. Existing calls must be updated to pass `None, None` for default behavior (limit: 1000, offset: 0), or specify values for custom pagination. ([#111](https://github.com/marmot-protocol/mdk/pull/111))
3232
- Changed `get_pending_welcomes()` to accept optional `limit` and `offset` parameters for pagination control. Existing calls must be updated to pass `None, None` for default behavior (limit: 1000, offset: 0), or specify values for custom pagination. ([#119](https://github.com/marmot-protocol/mdk/pull/119))
33+
- Changed `new_mdk()`, `new_mdk_with_key()`, and `new_mdk_unencrypted()` to accept an optional `MdkConfig` parameter for customizing MDK behavior. Existing calls must be updated to pass `None` for default behavior. ([`#155`](https://github.com/marmot-protocol/mdk/pull/155))
3334

3435
### Changed
3536

3637
### Added
3738

39+
- Added `MdkConfig` record for configuring MDK behavior, including `out_of_order_tolerance` and `maximum_forward_distance` settings for MLS sender ratchet configuration. All fields are optional and default to sensible values. ([`#155`](https://github.com/marmot-protocol/mdk/pull/155))
3840
- Exposed pagination control for `get_messages()` to foreign language bindings via optional `limit` and `offset` parameters. ([#111](https://github.com/marmot-protocol/mdk/pull/111))
3941
- Exposed pagination control for `get_pending_welcomes()` to foreign language bindings via optional `limit` and `offset` parameters. ([#119](https://github.com/marmot-protocol/mdk/pull/119))
4042

@@ -52,4 +54,4 @@
5254

5355
## [0.5.3] - 2025-12-09
5456

55-
First bindings release ([commit](https://github.com/marmot-protocol/mdk/commit/8d05c9b499564277bdd1d1fe27fcc702eadf4d54))
57+
First bindings release ([commit](https://github.com/marmot-protocol/mdk/commit/8d05c9b499564277bdd1d1fe27fcc702eadf4d54))

0 commit comments

Comments
 (0)