mdk-core 0.8.0

A simplified interface to build secure messaging apps on nostr with MLS.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
//! MDK messages
//!
//! This module provides functionality for creating, processing, and managing encrypted
//! messages in MLS groups. It handles:
//! - Message creation and encryption
//! - Message processing and decryption
//! - Message state tracking
//! - Integration with Nostr events
//!
//! Messages in MDK are wrapped in Nostr events (kind:445) for relay transmission.
//! The message content is encrypted using both MLS group keys (MLS layer) and
//! ChaCha20-Poly1305 (outer layer per MIP-03, no AAD).
//! Message state is tracked to handle processing status and failure scenarios.

mod application;
mod commit;
mod create;
pub use create::EventTag;
pub(crate) mod crypto;
mod decryption;
mod error_handling;
mod process;
mod proposal;
mod validation;

use mdk_storage_traits::groups::types as group_types;
use mdk_storage_traits::groups::{MessageSortOrder, Pagination};
use mdk_storage_traits::messages::types as message_types;
use mdk_storage_traits::{GroupId, MdkStorageProvider};
use nostr::{EventId, Timestamp};

use sha2::{Digest, Sha256};

use crate::MDK;
use crate::error::Error;
use crate::groups::UpdateGroupResult;

// Internal Result type alias for this module
pub(crate) type Result<T> = std::result::Result<T, Error>;

/// Compute the SHA-256 hash of the outer event content for ciphertext deduplication.
///
/// Used by the epoch snapshot system to detect re-wrapped kind:445 events that
/// carry identical ciphertext in different outer wrappers.
pub(crate) fn content_hash(content: &str) -> [u8; 32] {
    Sha256::digest(content.as_bytes()).into()
}

// =============================================================================
// Helper Functions for ProcessedMessage Creation
// =============================================================================

/// Creates a ProcessedMessage record with common defaults
///
/// This helper reduces boilerplate across the many places that create
/// ProcessedMessage records. The `processed_at` timestamp is automatically
/// set to the current time.
pub(crate) fn create_processed_message_record(
    wrapper_event_id: EventId,
    message_event_id: Option<EventId>,
    epoch: Option<u64>,
    mls_group_id: Option<GroupId>,
    state: message_types::ProcessedMessageState,
    failure_reason: Option<String>,
) -> message_types::ProcessedMessage {
    message_types::ProcessedMessage {
        wrapper_event_id,
        message_event_id,
        processed_at: Timestamp::now(),
        epoch,
        mls_group_id,
        state,
        failure_reason,
    }
}

/// Default number of epochs to look back when trying to decrypt messages with older exporter secrets
pub(crate) const DEFAULT_EPOCH_LOOKBACK: u64 = 5;

/// Additional context captured while processing an MLS message.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MessageProcessingContext {
    /// The MLS sender leaf index when the sender is a group member.
    pub sender_leaf_index: Option<u32>,
}

/// Message-processing result paired with transient MLS context.
#[derive(Debug)]
pub struct MessageProcessingOutcome {
    /// The primary processing result.
    pub result: MessageProcessingResult,
    /// Additional transient context captured during processing.
    pub context: MessageProcessingContext,
}

impl MessageProcessingOutcome {
    pub(crate) fn new(result: MessageProcessingResult, sender_leaf_index: Option<u32>) -> Self {
        Self {
            result,
            context: MessageProcessingContext { sender_leaf_index },
        }
    }

    pub(crate) fn without_context(result: MessageProcessingResult) -> Self {
        Self::new(result, None)
    }
}

/// MessageProcessingResult covers the full spectrum of responses that we can get back from attempting to process a message
pub enum MessageProcessingResult {
    /// An application message (this is usually a message in a chat)
    ApplicationMessage(message_types::Message),
    /// Proposal message that was auto-committed (self-remove proposals when receiver is admin)
    Proposal(UpdateGroupResult),
    /// Pending proposal message stored but not committed
    ///
    /// For add/remove member proposals, these are always stored as pending so that
    /// admins can approve them through a manual commit. For self-remove (leave) proposals,
    /// these are stored as pending when the receiver is not an admin.
    PendingProposal {
        /// The MLS group ID this pending proposal belongs to
        mls_group_id: GroupId,
    },
    /// Proposal was ignored and not stored
    ///
    /// This occurs for proposals that should not be processed, such as:
    /// - Extension/ciphersuite change proposals (admins should create commits directly)
    /// - Other unsupported proposal types
    IgnoredProposal {
        /// The MLS group ID this proposal was for
        mls_group_id: GroupId,
        /// Reason the proposal was ignored
        reason: String,
    },
    /// External Join Proposal
    ExternalJoinProposal {
        /// The MLS group ID this proposal belongs to
        mls_group_id: GroupId,
    },
    /// Commit message
    Commit {
        /// The MLS group ID this commit applies to
        mls_group_id: GroupId,
    },
    /// Unprocessable message
    Unprocessable {
        /// 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 {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ApplicationMessage(msg) => f
                .debug_struct("ApplicationMessage")
                .field("id", &msg.id)
                .field("pubkey", &msg.pubkey)
                .field("kind", &msg.kind)
                .field("mls_group_id", &"[REDACTED]")
                .field("created_at", &msg.created_at)
                .field("state", &msg.state)
                .finish(),
            Self::Proposal(result) => f
                .debug_struct("Proposal")
                .field("evolution_event_id", &result.evolution_event.id)
                .field("mls_group_id", &"[REDACTED]")
                .finish(),
            Self::PendingProposal { .. } => f
                .debug_struct("PendingProposal")
                .field("mls_group_id", &"[REDACTED]")
                .finish(),
            Self::IgnoredProposal { reason, .. } => f
                .debug_struct("IgnoredProposal")
                .field("mls_group_id", &"[REDACTED]")
                .field("reason", reason)
                .finish(),
            Self::ExternalJoinProposal { .. } => f
                .debug_struct("ExternalJoinProposal")
                .field("mls_group_id", &"[REDACTED]")
                .finish(),
            Self::Commit { .. } => f
                .debug_struct("Commit")
                .field("mls_group_id", &"[REDACTED]")
                .finish(),
            Self::Unprocessable { .. } => f
                .debug_struct("Unprocessable")
                .field("mls_group_id", &"[REDACTED]")
                .finish(),
            Self::PreviouslyFailed => f.debug_struct("PreviouslyFailed").finish(),
        }
    }
}

impl<Storage> MDK<Storage>
where
    Storage: MdkStorageProvider,
{
    /// Retrieves a message by its Nostr event ID within a specific group
    ///
    /// This function looks up a message in storage using its associated Nostr event ID
    /// and MLS group ID. The message must have been previously processed and stored.
    /// Requiring both the event ID and group ID prevents messages from different groups
    /// from overwriting each other.
    ///
    /// # Arguments
    ///
    /// * `mls_group_id` - The MLS group ID the message belongs to
    /// * `event_id` - The Nostr event ID to look up
    ///
    /// # Returns
    ///
    /// * `Ok(Some(Message))` - The message if found
    /// * `Ok(None)` - If no message exists with the given event ID in the specified group
    /// * `Err(Error)` - If there is an error accessing storage
    pub fn get_message(
        &self,
        mls_group_id: &GroupId,
        event_id: &EventId,
    ) -> Result<Option<message_types::Message>> {
        self.storage()
            .find_message_by_event_id(mls_group_id, event_id)
            .map_err(|_e| Error::Message("Storage error while finding message".to_string()))
    }

    /// Retrieves messages for a specific MLS group with optional pagination
    ///
    /// This function returns messages that have been processed and stored for a group,
    /// ordered by creation time (descending). If no pagination is specified, uses default
    /// pagination (1000 messages, offset 0).
    ///
    /// # Arguments
    ///
    /// * `mls_group_id` - The MLS group ID to get messages for
    /// * `pagination` - Optional pagination parameters. If `None`, uses default limit and offset.
    ///
    /// # Returns
    ///
    /// * `Ok(Vec<Message>)` - List of messages for the group (up to limit)
    /// * `Err(Error)` - If there is an error accessing storage
    ///
    /// # Examples
    ///
    /// ```ignore
    /// // Get messages with default pagination (1000 messages, offset 0)
    /// let messages = mdk.get_messages(&group_id, None)?;
    ///
    /// // Get first 100 messages
    /// use mdk_storage_traits::groups::Pagination;
    /// let messages = mdk.get_messages(&group_id, Some(Pagination::new(Some(100), Some(0))))?;
    ///
    /// // Get next 100 messages
    /// let messages = mdk.get_messages(&group_id, Some(Pagination::new(Some(100), Some(100))))?;
    /// ```
    pub fn get_messages(
        &self,
        mls_group_id: &GroupId,
        pagination: Option<Pagination>,
    ) -> Result<Vec<message_types::Message>> {
        self.storage()
            .messages(mls_group_id, pagination)
            .map_err(|_e| Error::Message("Storage error while getting messages".to_string()))
    }

    /// Returns the most recent message in a group according to the given sort order.
    ///
    /// This is useful for clients that use [`MessageSortOrder::ProcessedAtFirst`] and
    /// need a "last message" value that is consistent with their [`get_messages()`](Self::get_messages)
    /// ordering. The cached [`Group::last_message_id`](group_types::Group::last_message_id) always
    /// reflects [`MessageSortOrder::CreatedAtFirst`], so clients using a different sort order
    /// can call this method instead.
    ///
    /// # Arguments
    ///
    /// * `mls_group_id` - The MLS group ID
    /// * `sort_order` - The sort order to use when determining the "last" message
    ///
    /// # Returns
    ///
    /// * `Ok(Some(Message))` - The most recent message under the given ordering
    /// * `Ok(None)` - If the group has no messages
    /// * `Err(Error)` - If the group does not exist or a storage error occurs
    pub fn get_last_message(
        &self,
        mls_group_id: &GroupId,
        sort_order: MessageSortOrder,
    ) -> Result<Option<message_types::Message>> {
        self.storage()
            .last_message(mls_group_id, sort_order)
            .map_err(|_e| Error::Message("Storage error while getting last message".to_string()))
    }

    // =========================================================================
    // Storage Save Helpers
    // =========================================================================

    /// Saves a message record to storage with standardized error handling
    pub(crate) fn save_message_record(&self, message: message_types::Message) -> Result<()> {
        self.storage()
            .save_message(message)
            .map_err(|_e| Error::Message("Storage error while saving message".to_string()))
    }

    /// Saves a processed message record to storage with standardized error handling
    pub(crate) fn save_processed_message_record(
        &self,
        processed_message: message_types::ProcessedMessage,
    ) -> Result<()> {
        self.storage()
            .save_processed_message(processed_message)
            .map_err(|_e| {
                Error::Message("Storage error while saving processed message".to_string())
            })
    }

    /// Saves a group record to storage with standardized error handling
    pub(crate) fn save_group_record(&self, group: group_types::Group) -> Result<()> {
        self.storage()
            .save_group(group)
            .map_err(|_e| Error::Group("Storage error while saving group".to_string()))
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;

    use mdk_storage_traits::groups::Pagination;
    use nostr::EventId;

    use crate::test_util::*;
    use crate::tests::create_test_mdk;

    #[test]
    fn test_get_message_not_found() {
        let mdk = create_test_mdk();
        let (creator, members, admins) = create_test_group_members();
        let group_id = create_test_group(&mdk, &creator, &members, &admins);
        let non_existent_event_id = EventId::all_zeros();

        let result = mdk.get_message(&group_id, &non_existent_event_id);
        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[test]
    fn test_get_messages_empty_group() {
        let mdk = create_test_mdk();
        let (creator, members, admins) = create_test_group_members();
        let group_id = create_test_group(&mdk, &creator, &members, &admins);

        let messages = mdk
            .get_messages(&group_id, None)
            .expect("Failed to get messages");
        assert!(messages.is_empty());
    }

    #[test]
    fn test_get_messages_with_pagination() {
        let mdk = create_test_mdk();
        let (creator, members, admins) = create_test_group_members();
        let group_id = create_test_group(&mdk, &creator, &members, &admins);

        // Create 15 messages
        for i in 0..15 {
            let rumor = create_test_rumor(&creator, &format!("Message {}", i));
            mdk.create_message(&group_id, rumor, None)
                .expect("Failed to create message");
        }

        // Test 1: Get first page (10 messages)
        let page1 = mdk
            .get_messages(&group_id, Some(Pagination::new(Some(10), Some(0))))
            .expect("Failed to get first page");
        assert_eq!(page1.len(), 10, "First page should have 10 messages");

        // Test 2: Get second page (5 messages)
        let page2 = mdk
            .get_messages(&group_id, Some(Pagination::new(Some(10), Some(10))))
            .expect("Failed to get second page");
        assert_eq!(page2.len(), 5, "Second page should have 5 messages");

        // Test 3: Verify no duplicates between pages
        let page1_ids: HashSet<_> = page1.iter().map(|m| m.id).collect();
        let page2_ids: HashSet<_> = page2.iter().map(|m| m.id).collect();
        assert!(
            page1_ids.is_disjoint(&page2_ids),
            "Pages should not have duplicate messages"
        );

        // Test 4: Get all messages with default pagination
        let all_messages = mdk
            .get_messages(&group_id, None)
            .expect("Failed to get all messages");
        assert_eq!(
            all_messages.len(),
            15,
            "Should get all 15 messages with default pagination"
        );

        // Test 5: Request beyond available messages
        let page3 = mdk
            .get_messages(&group_id, Some(Pagination::new(Some(10), Some(20))))
            .expect("Failed to get third page");
        assert!(
            page3.is_empty(),
            "Should return empty when offset exceeds message count"
        );

        // Test 6: Small page size
        let small_page = mdk
            .get_messages(&group_id, Some(Pagination::new(Some(3), Some(0))))
            .expect("Failed to get small page");
        assert_eq!(small_page.len(), 3, "Should respect small page size");
    }

    #[test]
    fn test_get_messages_for_group() {
        let mdk = create_test_mdk();
        let (creator, members, admins) = create_test_group_members();
        let group_id = create_test_group(&mdk, &creator, &members, &admins);

        // Create multiple messages
        let rumor1 = create_test_rumor(&creator, "First message");
        let rumor2 = create_test_rumor(&creator, "Second message");

        let _event1 = mdk
            .create_message(&group_id, rumor1, None)
            .expect("Failed to create first message");
        let _event2 = mdk
            .create_message(&group_id, rumor2, None)
            .expect("Failed to create second message");

        // Get all messages for the group
        let messages = mdk
            .get_messages(&group_id, None)
            .expect("Failed to get messages");

        assert_eq!(messages.len(), 2);

        // Verify message contents
        let contents: Vec<&str> = messages.iter().map(|m| m.content.as_str()).collect();
        assert!(contents.contains(&"First message"));
        assert!(contents.contains(&"Second message"));

        // Verify all messages belong to the correct group
        for message in &messages {
            assert_eq!(message.mls_group_id, group_id.clone());
        }
    }

    /// Test getting messages for non-existent group
    #[test]
    fn test_get_messages_nonexistent_group() {
        let mdk = create_test_mdk();
        let non_existent_group_id = crate::GroupId::from_slice(&[9, 9, 9, 9]);

        let result = mdk.get_messages(&non_existent_group_id, None);

        // Both storage implementations should return error for non-existent group
        assert!(
            result.is_err(),
            "Should return error for non-existent group"
        );
    }

    /// Test getting single message that doesn't exist
    #[test]
    fn test_get_nonexistent_message() {
        let mdk = create_test_mdk();
        let (creator, members, admins) = create_test_group_members();
        let group_id = create_test_group(&mdk, &creator, &members, &admins);
        let non_existent_id = nostr::EventId::all_zeros();

        let result = mdk.get_message(&group_id, &non_existent_id);

        assert!(result.is_ok(), "Should succeed");
        assert!(
            result.unwrap().is_none(),
            "Should return None for non-existent message"
        );
    }
}