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
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
// Copyright (c) 2024-2025 Jeff Gardner
// Copyright (c) 2025 Rust Nostr Developers
// Distributed under the MIT software license

//! MDK errors

use std::string::FromUtf8Error;
use std::{fmt, str};

use nostr::types::url;
use nostr::{Kind, PublicKey, SignerError, event, key};
use openmls::credentials::errors::BasicCredentialError;
use openmls::error::LibraryError;
use openmls::extensions::errors::InvalidExtensionError;
use openmls::framing::errors::ProtocolMessageError;
use openmls::group::{
    AddMembersError, CommitToPendingProposalsError, CreateGroupContextExtProposalError,
    CreateMessageError, ExportSecretError, MergePendingCommitError, NewGroupError,
    ProcessMessageError, SelfUpdateError, WelcomeError,
};
use openmls::key_packages::errors::{KeyPackageNewError, KeyPackageVerifyError};
use openmls::prelude::{MlsGroupStateError, ProposalType, ValidationError};
use openmls_traits::types::CryptoError;

#[cfg(feature = "mip05")]
use crate::mip05::Mip05Error;

/// MDK error
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum Error {
    /// Hex error
    #[error(transparent)]
    Hex(#[from] hex::FromHexError),
    /// Keys error
    #[error(transparent)]
    Keys(#[from] key::Error),
    /// Event error
    #[error(transparent)]
    Event(#[from] event::Error),
    /// Event Builder error
    #[error(transparent)]
    EventBuilder(#[from] event::builder::Error),
    /// Nostr Signer error
    #[error(transparent)]
    Signer(#[from] SignerError),
    /// Relay URL error
    #[error(transparent)]
    RelayUrl(#[from] url::Error),
    /// TLS error
    #[error(transparent)]
    Tls(#[from] tls_codec::Error),
    /// UTF8 error
    #[error(transparent)]
    Utf8(#[from] str::Utf8Error),
    /// Crypto error
    #[error(transparent)]
    Crypto(#[from] CryptoError),
    /// Storage error
    #[error(transparent)]
    Storage(#[from] mdk_storage_traits::MdkStorageError),
    /// Generic OpenMLS error
    #[error(transparent)]
    OpenMlsGeneric(#[from] LibraryError),
    /// Invalid extension error
    #[error(transparent)]
    InvalidExtension(#[from] InvalidExtensionError),
    /// Create message error
    #[error(transparent)]
    CreateMessage(#[from] CreateMessageError),
    /// Export secret error
    #[error(transparent)]
    ExportSecret(#[from] ExportSecretError),
    /// Basic credential error
    #[error(transparent)]
    BasicCredential(#[from] BasicCredentialError),
    /// Process message error - epoch mismatch
    ///
    /// Fields: `(msg_epoch, is_commit)`. The `is_commit` flag indicates whether
    /// the stale message was a Commit (true) or a non-commit type such as a
    /// Proposal or ApplicationMessage (false). Only commits should trigger the
    /// rollback/`is_better_candidate` path.
    #[error("Message epoch differs from the group's epoch")]
    ProcessMessageWrongEpoch(u64, bool),
    /// Process message error - wrong group ID
    #[error("Wrong group ID")]
    ProcessMessageWrongGroupId,
    /// Process message error - use after eviction
    #[error("Use after eviction")]
    ProcessMessageUseAfterEviction,
    /// Process message error - other
    #[error("{0}")]
    ProcessMessageOther(String),
    /// Protocol message error
    #[error("{0}")]
    ProtocolMessage(String),
    /// Key package error
    #[error("{0}")]
    KeyPackage(String),
    /// Group error
    #[error("{0}")]
    Group(String),
    /// An invitee's KeyPackage advertises a `LeafNode.capabilities.proposals`
    /// set that does not cover the group's `RequiredCapabilities`. OpenMLS
    /// rejects the add at leaf-node validation; MDK surfaces it as this
    /// typed variant so callers can distinguish legacy-joiner rejection
    /// from other `add_members` failures.
    #[error("invitee KeyPackage is missing a proposal type required by the group")]
    InviteeMissingRequiredProposal,
    /// Caller tried an admin-only operation without admin rights.
    #[error("only admins can perform this operation")]
    NotAdmin,
    /// Caller passed an empty set where at least one proposal type was
    /// expected (e.g. `upgrade_group_capabilities` with nothing to add).
    #[error("upgrade requires at least one proposal type")]
    EmptyUpgradeSet,
    /// Caller requested a proposal type that MDK does not advertise in
    /// `SUPPORTED_PROPOSALS`.
    #[error("proposal type {0:?} is not in SUPPORTED_PROPOSALS")]
    ProposalNotInSupportedSet(ProposalType),
    /// Caller requested adding a proposal type that is already in the
    /// group's `RequiredCapabilities`. Usually means the client's cached
    /// status is stale because another admin upgraded the group first.
    #[error("proposal type {0:?} is already required by this group")]
    ProposalAlreadyRequired(ProposalType),
    /// Caller requested adding a proposal type that is no longer safe to
    /// require: at least one member's leaf doesn't advertise it. The
    /// `blockers` list identifies them so the client can update its UI
    /// without re-fetching status.
    #[error(
        "proposal type {proposal:?} can't be upgraded: {} member(s) don't advertise it",
        blockers.len()
    )]
    ProposalNotAvailableForUpgrade {
        /// The blocking proposal type.
        proposal: ProposalType,
        /// Nostr public keys of members whose leaves don't advertise the
        /// proposal, ordered by leaf index.
        blockers: Vec<PublicKey>,
    },
    /// Group exporter secret not found
    #[error("group exporter secret not found")]
    GroupExporterSecretNotFound,
    /// Message error
    #[error("{0}")]
    Message(String),
    /// MIP-05 error
    #[cfg(feature = "mip05")]
    #[cfg_attr(docsrs, doc(cfg(feature = "mip05")))]
    #[error(transparent)]
    Mip05(#[from] Mip05Error),
    /// Cannot decrypt own message
    #[error("cannot decrypt own message")]
    CannotDecryptOwnMessage,
    /// Merge pending commit error
    #[error("{0}")]
    MergePendingCommit(String),
    /// Commit to pending proposal
    #[error("unable to commit to pending proposal")]
    CommitToPendingProposalsError,
    /// Self update error
    #[error("{0}")]
    SelfUpdate(String),
    /// Welcome error
    #[error("{0}")]
    Welcome(String),
    /// Welcome previously failed to process (retries are not supported)
    #[error("welcome previously failed to process: {0}")]
    WelcomePreviouslyFailed(String),
    /// Processed welcome not found
    #[error("processed welcome not found")]
    ProcessedWelcomeNotFound,
    /// Provider error
    #[error("{0}")]
    Provider(String),
    /// Group not found
    #[error("group not found")]
    GroupNotFound,
    /// Protocol message group ID doesn't match the current group ID
    #[error("protocol message group ID doesn't match the current group ID")]
    ProtocolGroupIdMismatch,
    /// Own leaf not found
    #[error("own leaf not found")]
    OwnLeafNotFound,
    /// Failed to load signer
    #[error("can't load signer")]
    CantLoadSigner,
    /// Invalid Welcome message
    #[error("invalid welcome message")]
    InvalidWelcomeMessage,
    /// Unexpected event
    #[error("unexpected event kind: expected={expected}, received={received}")]
    UnexpectedEvent {
        /// Expected event kind
        expected: Kind,
        /// Received event kind
        received: Kind,
    },
    /// Unexpected extension type
    #[error("Unexpected extension type")]
    UnexpectedExtensionType,
    /// Nostr group data extension not found
    #[error("Nostr group data extension not found")]
    NostrGroupDataExtensionNotFound,
    /// Message from a non-member of a group
    #[error("Message received from non-member")]
    MessageFromNonMember,
    /// Code path is not yet implemented
    #[error("{0}")]
    NotImplemented(String),
    /// Stored message not found
    #[error("stored message not found")]
    MessageNotFound,
    /// Commit message received from a non-admin
    #[error("not processing commit from non-admin")]
    CommitFromNonAdmin,
    /// Own commit pending merge
    #[error("own commit pending merge")]
    OwnCommitPending,
    /// Error when updating group context extensions
    #[error("Error when updating group context extensions {0}")]
    UpdateGroupContextExts(String),
    /// Invalid image hash length
    #[error("invalid image hash length")]
    InvalidImageHashLength,
    /// Invalid image key length
    #[error("invalid image key length")]
    InvalidImageKeyLength,
    /// Invalid image nonce length
    #[error("invalid image nonce length")]
    InvalidImageNonceLength,
    /// Invalid image upload key length
    #[error("invalid image upload key length")]
    InvalidImageUploadKeyLength,
    /// Invalid extension version
    #[error("invalid extension version: {0}")]
    InvalidExtensionVersion(u16),
    /// Extension format error
    #[error("extension format error: {0}")]
    ExtensionFormatError(String),
    /// Rumor pubkey does not match MLS sender credential
    #[error("author mismatch: rumor pubkey does not match MLS sender")]
    AuthorMismatch,
    /// Key package identity binding mismatch - credential identity doesn't match event signer
    #[error(
        "key package identity mismatch: credential identity {credential_identity} doesn't match event signer {event_signer}"
    )]
    KeyPackageIdentityMismatch {
        /// The identity claimed in the BasicCredential
        credential_identity: String,
        /// The public key that signed the event
        event_signer: String,
    },
    /// Identity change attempted in proposal or commit - MIP-00 requires immutable identity
    #[error(
        "identity change not allowed: proposal attempts to change identity from {original_identity} to {new_identity}"
    )]
    IdentityChangeNotAllowed {
        /// The original identity of the member
        original_identity: String,
        /// The new identity attempted in the proposal
        new_identity: String,
    },
    /// Rumor event is missing its ID
    #[error("rumor event is missing its ID")]
    MissingRumorEventId,
    /// Event timestamp is invalid (too far in future or past)
    #[error("event timestamp is invalid: {0}")]
    InvalidTimestamp(String),
    /// Missing required group ID tag
    #[error("missing required group ID tag (h tag)")]
    MissingGroupIdTag,
    /// Invalid group ID format in tag
    #[error("invalid group ID format: {0}")]
    InvalidGroupIdFormat(String),
    /// Multiple group ID tags found (MIP-03 requires exactly one)
    #[error("multiple group ID tags found: expected exactly one h tag, found {0}")]
    MultipleGroupIdTags(usize),
    /// Failed to create epoch snapshot for commit race resolution
    #[error("failed to create epoch snapshot: {0}")]
    SnapshotCreationFailed(String),
}

impl From<FromUtf8Error> for Error {
    fn from(e: FromUtf8Error) -> Self {
        Self::Utf8(e.utf8_error())
    }
}

/// Convert a concrete error type to an `Error` variant via `to_string()`.
macro_rules! impl_from_display_error {
    ($($source:ty => $variant:ident),+ $(,)?) => {
        $(
            impl From<$source> for Error {
                fn from(e: $source) -> Self {
                    Self::$variant(e.to_string())
                }
            }
        )+
    };
}

/// Convert a generic `ErrorType<T: Display>` to an `Error` variant via `to_string()`.
macro_rules! impl_from_generic_display_error {
    ($($source:ident => $variant:ident),+ $(,)?) => {
        $(
            impl<T: fmt::Display> From<$source<T>> for Error {
                fn from(e: $source<T>) -> Self {
                    Self::$variant(e.to_string())
                }
            }
        )+
    };
}

impl_from_display_error! {
    ProtocolMessageError => ProtocolMessage,
    KeyPackageNewError   => KeyPackage,
    KeyPackageVerifyError => KeyPackage,
}

impl_from_generic_display_error! {
    NewGroupError                      => Group,
    AddMembersError                    => Group,
    MergePendingCommitError            => MergePendingCommit,
    SelfUpdateError                    => SelfUpdate,
    WelcomeError                       => Welcome,
    CreateGroupContextExtProposalError => UpdateGroupContextExts,
}

impl<T: fmt::Display> From<CommitToPendingProposalsError<T>> for Error {
    fn from(_e: CommitToPendingProposalsError<T>) -> Self {
        Self::CommitToPendingProposalsError
    }
}

/// Convert ProcessMessageError to our structured error variants
impl<T> From<ProcessMessageError<T>> for Error
where
    T: fmt::Display,
{
    fn from(e: ProcessMessageError<T>) -> Self {
        match e {
            ProcessMessageError::ValidationError(validation_error) => match validation_error {
                ValidationError::WrongGroupId => Self::ProcessMessageWrongGroupId,
                ValidationError::CannotDecryptOwnMessage => Self::CannotDecryptOwnMessage,
                _ => Self::ProcessMessageOther(validation_error.to_string()),
            },
            ProcessMessageError::GroupStateError(group_state_error) => match group_state_error {
                MlsGroupStateError::UseAfterEviction => Self::ProcessMessageUseAfterEviction,
                _ => Self::ProcessMessageOther(group_state_error.to_string()),
            },
            _ => Self::ProcessMessageOther(e.to_string()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nostr::Kind;

    /// Test that all error variants can be constructed and display correctly
    #[test]
    fn test_error_display_messages() {
        // Test simple message errors
        let error = Error::ProcessMessageWrongEpoch(5, true);
        assert_eq!(
            error.to_string(),
            "Message epoch differs from the group's epoch"
        );

        let error = Error::ProcessMessageWrongGroupId;
        assert_eq!(error.to_string(), "Wrong group ID");

        let error = Error::ProcessMessageUseAfterEviction;
        assert_eq!(error.to_string(), "Use after eviction");

        let error = Error::ProcessMessageOther("custom error".to_string());
        assert_eq!(error.to_string(), "custom error");

        let error = Error::ProtocolMessage("protocol error".to_string());
        assert_eq!(error.to_string(), "protocol error");

        let error = Error::KeyPackage("key package error".to_string());
        assert_eq!(error.to_string(), "key package error");

        let error = Error::Group("group error".to_string());
        assert_eq!(error.to_string(), "group error");

        let error = Error::GroupExporterSecretNotFound;
        assert_eq!(error.to_string(), "group exporter secret not found");

        let error = Error::Message("message error".to_string());
        assert_eq!(error.to_string(), "message error");

        let error = Error::CannotDecryptOwnMessage;
        assert_eq!(error.to_string(), "cannot decrypt own message");

        let error = Error::MergePendingCommit("merge error".to_string());
        assert_eq!(error.to_string(), "merge error");

        let error = Error::CommitToPendingProposalsError;
        assert_eq!(error.to_string(), "unable to commit to pending proposal");

        let error = Error::SelfUpdate("self update error".to_string());
        assert_eq!(error.to_string(), "self update error");

        let error = Error::Welcome("welcome error".to_string());
        assert_eq!(error.to_string(), "welcome error");

        let error = Error::WelcomePreviouslyFailed("original error reason".to_string());
        assert_eq!(
            error.to_string(),
            "welcome previously failed to process: original error reason"
        );

        let error = Error::ProcessedWelcomeNotFound;
        assert_eq!(error.to_string(), "processed welcome not found");

        let error = Error::Provider("provider error".to_string());
        assert_eq!(error.to_string(), "provider error");

        let error = Error::GroupNotFound;
        assert_eq!(error.to_string(), "group not found");

        let error = Error::ProtocolGroupIdMismatch;
        assert_eq!(
            error.to_string(),
            "protocol message group ID doesn't match the current group ID"
        );

        let error = Error::OwnLeafNotFound;
        assert_eq!(error.to_string(), "own leaf not found");

        let error = Error::CantLoadSigner;
        assert_eq!(error.to_string(), "can't load signer");

        let error = Error::InvalidWelcomeMessage;
        assert_eq!(error.to_string(), "invalid welcome message");

        let error = Error::UnexpectedExtensionType;
        assert_eq!(error.to_string(), "Unexpected extension type");

        let error = Error::NostrGroupDataExtensionNotFound;
        assert_eq!(error.to_string(), "Nostr group data extension not found");

        let error = Error::MessageFromNonMember;
        assert_eq!(error.to_string(), "Message received from non-member");

        let error = Error::NotImplemented("feature X".to_string());
        assert_eq!(error.to_string(), "feature X");

        let error = Error::MessageNotFound;
        assert_eq!(error.to_string(), "stored message not found");

        let error = Error::CommitFromNonAdmin;
        assert_eq!(error.to_string(), "not processing commit from non-admin");

        let error = Error::UpdateGroupContextExts("context error".to_string());
        assert_eq!(
            error.to_string(),
            "Error when updating group context extensions context error"
        );

        let error = Error::InvalidImageHashLength;
        assert_eq!(error.to_string(), "invalid image hash length");

        let error = Error::InvalidImageKeyLength;
        assert_eq!(error.to_string(), "invalid image key length");

        let error = Error::InvalidImageNonceLength;
        assert_eq!(error.to_string(), "invalid image nonce length");

        let error = Error::InvalidImageUploadKeyLength;
        assert_eq!(error.to_string(), "invalid image upload key length");

        let error = Error::InvalidExtensionVersion(99);
        assert_eq!(error.to_string(), "invalid extension version: 99");

        let error = Error::AuthorMismatch;
        assert_eq!(
            error.to_string(),
            "author mismatch: rumor pubkey does not match MLS sender"
        );

        let error = Error::MissingRumorEventId;
        assert_eq!(error.to_string(), "rumor event is missing its ID");
    }

    /// Test UnexpectedEvent error variant with Kind values
    #[test]
    fn test_unexpected_event_error() {
        let error = Error::UnexpectedEvent {
            expected: Kind::MlsGroupMessage,
            received: Kind::TextNote,
        };

        let msg = error.to_string();
        assert!(msg.contains("unexpected event kind"));
        assert!(msg.contains("expected="));
        assert!(msg.contains("received="));
    }

    /// Test KeyPackageIdentityMismatch error variant
    #[test]
    fn test_key_package_identity_mismatch_error() {
        let error = Error::KeyPackageIdentityMismatch {
            credential_identity: "abc123".to_string(),
            event_signer: "def456".to_string(),
        };

        let msg = error.to_string();
        assert!(msg.contains("key package identity mismatch"));
        assert!(msg.contains("abc123"));
        assert!(msg.contains("def456"));
    }

    /// Test IdentityChangeNotAllowed error variant
    #[test]
    fn test_identity_change_not_allowed_error() {
        let error = Error::IdentityChangeNotAllowed {
            original_identity: "original_id".to_string(),
            new_identity: "new_id".to_string(),
        };

        let msg = error.to_string();
        assert!(msg.contains("identity change not allowed"));
        assert!(msg.contains("original_id"));
        assert!(msg.contains("new_id"));
    }

    /// Test error equality (PartialEq implementation)
    #[test]
    fn test_error_equality() {
        let error1 = Error::GroupNotFound;
        let error2 = Error::GroupNotFound;
        let error3 = Error::OwnLeafNotFound;

        assert_eq!(error1, error2);
        assert_ne!(error1, error3);

        let error1 = Error::Message("test".to_string());
        let error2 = Error::Message("test".to_string());
        let error3 = Error::Message("different".to_string());

        assert_eq!(error1, error2);
        assert_ne!(error1, error3);
    }

    /// Test From<FromUtf8Error> conversion
    #[test]
    fn test_from_utf8_error_conversion() {
        let invalid_bytes = vec![0xff, 0xfe];
        let utf8_result = String::from_utf8(invalid_bytes);
        assert!(utf8_result.is_err());

        let error: Error = utf8_result.unwrap_err().into();
        assert!(matches!(error, Error::Utf8(_)));
    }

    /// Test Debug implementation
    #[test]
    fn test_error_debug() {
        let error = Error::GroupNotFound;
        let debug_str = format!("{:?}", error);
        assert!(debug_str.contains("GroupNotFound"));

        let error = Error::UnexpectedEvent {
            expected: Kind::MlsGroupMessage,
            received: Kind::TextNote,
        };
        let debug_str = format!("{:?}", error);
        assert!(debug_str.contains("UnexpectedEvent"));
    }

    /// Test ProcessMessageWrongEpoch error variant preserves epoch for internal use
    #[test]
    fn test_process_message_wrong_epoch() {
        // Epoch value is preserved for internal rollback logic but not exposed in message
        let error = Error::ProcessMessageWrongEpoch(42, true);
        assert_eq!(
            error.to_string(),
            "Message epoch differs from the group's epoch"
        );

        // Different epoch values produce same message (epoch used internally only)
        let error2 = Error::ProcessMessageWrongEpoch(100, false);
        assert_eq!(error.to_string(), error2.to_string());
    }

    /// Test OwnCommitPending error variant
    #[test]
    fn test_own_commit_pending() {
        let error = Error::OwnCommitPending;
        assert_eq!(error.to_string(), "own commit pending merge");
    }

    /// Test SnapshotCreationFailed error variant
    #[test]
    fn test_snapshot_creation_failed() {
        let error = Error::SnapshotCreationFailed("storage unavailable".to_string());
        assert_eq!(
            error.to_string(),
            "failed to create epoch snapshot: storage unavailable"
        );
    }

    /// Test Storage error conversion
    #[test]
    fn test_storage_error_conversion() {
        use mdk_storage_traits::MdkStorageError;

        let storage_error = MdkStorageError::NotFound("group not found".to_string());
        let error: Error = storage_error.into();

        assert!(matches!(error, Error::Storage(_)));
        let msg = error.to_string();
        assert!(msg.contains("not found"));
    }
}