Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/mdk-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

### Added

- **`PreviouslyFailed` Result Variant**: Added `MessageProcessingResult::PreviouslyFailed` variant to handle cases where a previously failed message arrives again but the MLS group ID cannot be extracted. This prevents crashes in client applications by returning a result instead of throwing an error. ([#160](https://github.com/marmot-protocol/mdk/pull/160), fixes [#154](https://github.com/marmot-protocol/mdk/issues/154), [#159](https://github.com/marmot-protocol/mdk/issues/159))
- **Message Retry Support**: Implemented better handling for retryable message states. When a message fails processing, it now preserves the `message_event_id` and other context. Added logic to allow reprocessing of messages marked as `Retryable`, with automatic state recovery to `Processed` upon success. ([#161](https://github.com/marmot-protocol/mdk/pull/161))
- 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))

Expand Down
177 changes: 119 additions & 58 deletions crates/mdk-core/src/messages/error_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ mod tests {
/// of previously failed events, mitigating DoS attacks.
///
/// When a previously failed message cannot provide a valid group_id (missing or
/// malformed h-tag), we return an error to be explicit about the failure.
/// malformed h-tag), we return PreviouslyFailed to avoid crashing client apps.
#[test]
fn test_repeated_validation_failure_rejected_immediately() {
let mdk = create_test_mdk();
Expand All @@ -520,18 +520,15 @@ mod tests {
assert!(result1.is_err(), "First attempt should fail validation");

// Second attempt - should be rejected immediately via deduplication
// Returns error because group_id cannot be extracted from malformed event
// Returns PreviouslyFailed because group_id cannot be extracted from malformed event
let result2 = mdk.process_message(&event);
assert!(
result2.is_err(),
"Second attempt should return error for malformed event without valid h-tag"
result2.is_ok(),
"Second attempt should return Ok(PreviouslyFailed), not error"
);
assert!(
result2
.unwrap_err()
.to_string()
.contains("Message processing previously failed"),
"Should indicate message previously failed"
matches!(result2.unwrap(), MessageProcessingResult::PreviouslyFailed),
"Should return PreviouslyFailed variant"
);
}

Expand Down Expand Up @@ -584,7 +581,7 @@ mod tests {
///
/// This test verifies the deduplication mechanism works for decryption failures,
/// preventing expensive repeated decryption attempts. When the group doesn't exist
/// in storage, we return an error since we can't determine the MLS group ID.
/// in storage, we return PreviouslyFailed to avoid crashing client apps.
#[test]
fn test_repeated_decryption_failure_rejected_immediately() {
let mdk = create_test_mdk();
Expand All @@ -602,26 +599,23 @@ mod tests {
let result1 = mdk.process_message(&event);
assert!(result1.is_err(), "First attempt should fail decryption");

// Second attempt - should return error because group isn't in storage
// Second attempt - should return PreviouslyFailed because group isn't in storage
// (we can't determine the MLS group ID from just the Nostr group ID)
let result2 = mdk.process_message(&event);
assert!(
result2.is_err(),
"Second attempt should return error when group not in storage"
result2.is_ok(),
"Second attempt should return Ok(PreviouslyFailed), not error"
);
assert!(
result2
.unwrap_err()
.to_string()
.contains("Message processing previously failed"),
"Should indicate message previously failed"
matches!(result2.unwrap(), MessageProcessingResult::PreviouslyFailed),
"Should return PreviouslyFailed variant"
);
}

/// Test that previously failed message without group in storage returns error
/// Test that previously failed message without group in storage returns PreviouslyFailed
///
/// This test verifies that when a previously failed message has a valid h-tag
/// but the group doesn't exist in storage, we return an error since we can't
/// but the group doesn't exist in storage, we return PreviouslyFailed since we can't
/// determine the MLS group ID (Nostr group ID != MLS group ID).
#[test]
fn test_previously_failed_message_without_group_in_storage() {
Expand Down Expand Up @@ -655,26 +649,23 @@ mod tests {
"State should be Failed"
);

// Second attempt - should return error because we can't determine MLS group ID
// Second attempt - should return PreviouslyFailed because we can't determine MLS group ID
let result2 = mdk.process_message(&event);
assert!(
result2.is_err(),
"Second attempt should return error when group not in storage"
result2.is_ok(),
"Second attempt should return Ok(PreviouslyFailed), not error"
);
assert!(
result2
.unwrap_err()
.to_string()
.contains("Message processing previously failed"),
"Should indicate message previously failed"
matches!(result2.unwrap(), MessageProcessingResult::PreviouslyFailed),
"Should return PreviouslyFailed variant"
);
}

/// Test that previously failed message with oversized hex in h-tag returns error
/// Test that previously failed message with oversized hex in h-tag returns PreviouslyFailed
///
/// This test verifies that when a previously failed message has an oversized hex string
/// in the h-tag (potential DoS vector), the size check prevents decoding and returns
/// an explicit error.
/// PreviouslyFailed to avoid crashing client apps.
#[test]
fn test_previously_failed_message_with_oversized_hex() {
let mdk = create_test_mdk();
Expand Down Expand Up @@ -703,25 +694,23 @@ mod tests {
message_types::ProcessedMessageState::Failed
);

// Second attempt - should return error due to malformed h-tag
// Second attempt - should return PreviouslyFailed due to malformed h-tag
let result2 = mdk.process_message(&event);
assert!(
result2.is_err(),
"Second attempt should return error for oversized hex"
result2.is_ok(),
"Second attempt should return Ok(PreviouslyFailed), not error"
);
assert!(
result2
.unwrap_err()
.to_string()
.contains("Message processing previously failed"),
"Should indicate message previously failed"
matches!(result2.unwrap(), MessageProcessingResult::PreviouslyFailed),
"Should return PreviouslyFailed variant"
);
}

/// Test that previously failed message with undersized hex in h-tag returns error
/// Test that previously failed message with undersized hex in h-tag returns PreviouslyFailed
///
/// This test verifies that when a previously failed message has an undersized hex string
/// in the h-tag, the size check prevents decoding and returns an explicit error.
/// in the h-tag, the size check prevents decoding and returns PreviouslyFailed to avoid
/// crashing client apps.
#[test]
fn test_previously_failed_message_with_undersized_hex() {
let mdk = create_test_mdk();
Expand Down Expand Up @@ -750,18 +739,15 @@ mod tests {
message_types::ProcessedMessageState::Failed
);

// Second attempt - should return error due to malformed h-tag
// Second attempt - should return PreviouslyFailed due to malformed h-tag
let result2 = mdk.process_message(&event);
assert!(
result2.is_err(),
"Second attempt should return error for undersized hex"
result2.is_ok(),
"Second attempt should return Ok(PreviouslyFailed), not error"
);
assert!(
result2
.unwrap_err()
.to_string()
.contains("Message processing previously failed"),
"Should indicate message previously failed"
matches!(result2.unwrap(), MessageProcessingResult::PreviouslyFailed),
"Should return PreviouslyFailed variant"
);
}

Expand Down Expand Up @@ -826,10 +812,10 @@ mod tests {
}
}

/// Test that previously failed message with invalid hex characters returns error
/// Test that previously failed message with invalid hex characters returns PreviouslyFailed
///
/// This test verifies that when hex::decode fails due to invalid characters,
/// the code returns an explicit error.
/// the code returns PreviouslyFailed to avoid crashing client apps.
#[test]
fn test_previously_failed_message_with_invalid_hex_chars() {
let mdk = create_test_mdk();
Expand Down Expand Up @@ -864,18 +850,15 @@ mod tests {
message_types::ProcessedMessageState::Failed
);

// Second attempt - should return error due to invalid hex
// Second attempt - should return PreviouslyFailed due to invalid hex
let result2 = mdk.process_message(&event);
assert!(
result2.is_err(),
"Second attempt should return error for invalid hex chars"
result2.is_ok(),
"Second attempt should return Ok(PreviouslyFailed), not error"
);
assert!(
result2
.unwrap_err()
.to_string()
.contains("Message processing previously failed"),
"Should indicate message previously failed"
matches!(result2.unwrap(), MessageProcessingResult::PreviouslyFailed),
"Should return PreviouslyFailed variant"
);
}

Expand Down Expand Up @@ -1007,6 +990,84 @@ mod tests {
}
}

/// Test that sanitize_error_reason covers all explicitly mapped error variants
/// and falls back to "processing_failed" for unmapped variants
#[test]
fn test_sanitize_error_reason_all_variants() {
use crate::MDK;
use mdk_memory_storage::MdkMemoryStorage;
use nostr::Kind;

// Test explicitly mapped error variants
assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::UnexpectedEvent {
expected: Kind::MlsGroupMessage,
received: Kind::TextNote,
}),
"invalid_event_type"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::MissingGroupIdTag),
"invalid_event_format"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::InvalidGroupIdFormat(
"bad format".to_string()
)),
"invalid_event_format"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::MultipleGroupIdTags(3)),
"invalid_event_format"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::InvalidTimestamp(
"future timestamp".to_string()
)),
"invalid_event_format"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::GroupNotFound),
"group_not_found"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::CannotDecryptOwnMessage),
"own_message"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::AuthorMismatch),
"authentication_failed"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::CommitFromNonAdmin),
"authorization_failed"
);

// Test catch-all for unmapped variants (should return "processing_failed")
assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::MessageNotFound),
"processing_failed"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::OwnLeafNotFound),
"processing_failed"
);

assert_eq!(
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::ProcessMessageWrongEpoch(5)),
"processing_failed"
);
}

#[test]
fn test_record_failure_preserves_message_event_id() {
let mdk = create_test_mdk();
Expand Down
7 changes: 7 additions & 0 deletions crates/mdk-core/src/messages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ pub enum MessageProcessingResult {
/// The MLS group ID of the message that could not be processed
mls_group_id: GroupId,
},
/// Message was previously marked as failed and cannot be reprocessed
///
/// This variant is returned when a message that previously failed processing
/// is received again. Unlike `Unprocessable`, this does not require an MLS group ID
/// because the group ID may not be extractable from malformed messages.
PreviouslyFailed,
}

impl std::fmt::Debug for MessageProcessingResult {
Expand Down Expand Up @@ -145,6 +151,7 @@ impl std::fmt::Debug for MessageProcessingResult {
.debug_struct("Unprocessable")
.field("mls_group_id", &"[REDACTED]")
.finish(),
Self::PreviouslyFailed => f.debug_struct("PreviouslyFailed").finish(),
}
}
}
Expand Down
27 changes: 13 additions & 14 deletions crates/mdk-core/src/messages/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,30 +279,22 @@ where
processed.state == message_types::ProcessedMessageState::EpochInvalidated;

if is_failed || is_epoch_invalidated {
let mls_group_id = match self.extract_mls_group_id_from_event(event) {
Some(id) => {
match self.extract_mls_group_id_from_event(event) {
Some(mls_group_id) => {
tracing::debug!(
target: "mdk_core::messages::process_message",
"Returning Unprocessable for previously failed/invalidated message with extracted group_id"
);
id
return Ok(MessageProcessingResult::Unprocessable { mls_group_id });
}
None => {
tracing::debug!(
target: "mdk_core::messages::process_message",
"Cannot extract group_id from previously failed/invalidated message (missing or malformed h-tag)"
"Returning PreviouslyFailed for message without extractable group_id"
);
// Return the appropriate error message based on the prior state
let error_msg = if is_failed {
"Message processing previously failed"
} else {
"Message epoch was invalidated"
};
return Err(Error::Message(error_msg.to_string()));
return Ok(MessageProcessingResult::PreviouslyFailed);
}
};

return Ok(MessageProcessingResult::Unprocessable { mls_group_id });
}
}

// Allow Retryable messages to be reprocessed after rollback
Expand Down Expand Up @@ -431,6 +423,8 @@ mod tests {
let pending_proposal_result = MessageProcessingResult::PendingProposal {
mls_group_id: test_group_id.clone(),
};
// PreviouslyFailed: for when a message that previously failed cannot provide a group_id
let previously_failed_result = MessageProcessingResult::PreviouslyFailed;

// Test that we can match on variants
match app_result {
Expand All @@ -457,6 +451,11 @@ mod tests {
MessageProcessingResult::PendingProposal { .. } => {}
_ => panic!("Expected PendingProposal variant"),
}

match previously_failed_result {
MessageProcessingResult::PreviouslyFailed => {}
_ => panic!("Expected PreviouslyFailed variant"),
}
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/mdk-uniffi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@

### Added

- **`PreviouslyFailed` Result Variant**: Added `ProcessMessageResult.PreviouslyFailed` enum variant to handle cases where a previously failed message arrives again but the MLS group ID cannot be extracted. This prevents crashes in client applications (fixes [#153](https://github.com/marmot-protocol/mdk/issues/153)) by returning a result instead of throwing an exception. ([#160](https://github.com/marmot-protocol/mdk/pull/160), fixes [#154](https://github.com/marmot-protocol/mdk/issues/154), [#159](https://github.com/marmot-protocol/mdk/issues/159))
- 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))
- 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))
- 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))
Expand Down
Loading