Skip to main content

offline_protocol_mls/
error.rs

1//! Error types for MLS operations.
2
3use thiserror::Error;
4
5/// Result type alias for MLS operations.
6pub type Result<T> = std::result::Result<T, MlsError>;
7
8/// Which validation gate refused a leaf in [`MlsError::LeafAddressMismatch`].
9///
10/// RFC 9420 §7.3 names two moments at which a client validates a ratchet tree
11/// — joining, and processing a Commit — and the SDK adds a third at use time
12/// (the sender's own leaf, checked where the message is attributed). They fail
13/// for the same reason but mean different things operationally: a Welcome
14/// refusal is an invite that was never joined, a commit refusal is a member of
15/// a group you are already in forging identities, and a sender refusal on a
16/// leaf that passed the first two means local group state was written behind
17/// the SDK's back.
18// Adding a variant is a breaking change without this attribute.
19#[non_exhaustive]
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LeafSource {
22    /// A leaf in the ratchet tree of a Welcome being joined.
23    WelcomeTree,
24    /// A leaf added by an Add proposal in a staged commit.
25    CommitAdd,
26    /// A leaf replaced by an Update proposal in a staged commit.
27    CommitUpdate,
28    /// The committer's own leaf, carried in the commit's update path.
29    CommitPath,
30    /// The leaf a decrypted message is attributed to.
31    MessageSender,
32    /// A leaf read back out of stored group state to build a roster.
33    RosterEntry,
34}
35
36impl std::fmt::Display for LeafSource {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        let name = match self {
39            Self::WelcomeTree => "Welcome ratchet-tree",
40            Self::CommitAdd => "committed Add proposal's",
41            Self::CommitUpdate => "committed Update proposal's",
42            Self::CommitPath => "commit update-path",
43            Self::MessageSender => "message sender's",
44            Self::RosterEntry => "stored roster",
45        };
46        f.write_str(name)
47    }
48}
49
50/// Errors that can occur during MLS operations.
51// Adding a variant to a public error enum is a breaking change without
52// this attribute; downstream crates must carry a wildcard arm.
53#[non_exhaustive]
54#[derive(Debug, Error)]
55pub enum MlsError {
56    /// Storage operation failed.
57    #[error("Storage error: {0}")]
58    Storage(#[from] crate::storage::StorageError),
59
60    /// Failed to generate cryptographic material.
61    #[error("Crypto generation failed: {0}")]
62    CryptoGeneration(String),
63
64    /// Failed to create a credential.
65    #[error("Credential creation failed: {0}")]
66    CredentialCreation(String),
67
68    /// Failed to create a key package.
69    #[error("Key package creation failed: {0}")]
70    KeyPackageCreation(String),
71
72    /// Invalid key package received.
73    #[error("Invalid key package: {0}")]
74    InvalidKeyPackage(String),
75
76    /// A peer's key package is well formed, but this device's clock falls
77    /// outside its validity window: before its `not_before`, or after its
78    /// `not_after`.
79    ///
80    /// Split from [`Self::InvalidKeyPackage`] because it has a different
81    /// remedy, and that remedy is usually a clock rather than the peer. The
82    /// window is judged against this device's own time, and a package starts
83    /// only an hour before it was minted, so a device whose clock runs more
84    /// than an hour behind its peer's refuses every package that peer sends.
85    /// Past the 30-day lifetime the peer refuses this device's packages too,
86    /// and no session forms from either side. Reported to the application as
87    /// `KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW`; before that it was a debug line,
88    /// and two phones a year apart sat on a pending connection request with
89    /// nothing anywhere to say why.
90    ///
91    /// `expired` says which end refused it, because the two have different
92    /// remedies. A window that has not started (`false`) is a clock, this
93    /// device's or the peer's, and the package becomes valid once the clocks
94    /// agree. A window that has closed (`true`) is this device's clock running
95    /// ahead *or* a package that is simply old: a relay can hold a package for
96    /// days, and the receiver's cached expiry is anchored to when the frame
97    /// arrived, not to when the package was minted. Blaming the clock for the
98    /// second, and keeping the package, would fail every attempt with a
99    /// correct clock until the cached expiry.
100    #[error(
101        "Key package is outside its validity window by this device's clock: {}",
102        if *.expired { "it has already closed" } else { "it has not started yet" }
103    )]
104    KeyPackageOutsideValidityWindow {
105        /// `true` when the window closed before this device's `now`, `false`
106        /// when it has not opened yet.
107        expired: bool,
108    },
109
110    /// Failed to create a group.
111    #[error("Group creation failed: {0}")]
112    GroupCreation(String),
113
114    /// Group not found.
115    #[error("Group not found: {0}")]
116    GroupNotFound(String),
117
118    /// Failed to add member to group.
119    #[error("Failed to add member: {0}")]
120    AddMember(String),
121
122    /// Failed to remove member from group.
123    #[error("Failed to remove member: {0}")]
124    RemoveMember(String),
125
126    /// Encryption failed.
127    #[error("Encryption failed: {0}")]
128    Encryption(String),
129
130    /// Decryption failed.
131    #[error("Decryption failed: {0}")]
132    Decryption(String),
133
134    /// Decryption failed because the local session is out of sync with the
135    /// sender's epoch (the two sides disagree on the MLS epoch), as opposed to
136    /// a ciphertext that failed authentication. This is a *recoverable* failure:
137    /// tearing down and re-establishing the 1:1 session restores the channel. It
138    /// is deliberately distinct from [`MlsError::Decryption`] so the protocol
139    /// layer can withhold the delivery ACK and trigger a re-key instead of
140    /// silently dropping the message. Message-specific ratchet failures (a
141    /// discarded past generation) and AEAD/corrupt failures are NOT classified
142    /// here — re-keying would not help, and routing them into a re-key would add
143    /// a second, unbounded storm vector on top of the one below.
144    ///
145    /// **This classification does not mean the frame was authentic.** The epoch
146    /// is read during framing validation, before any AEAD, sender-data or
147    /// signature check, so a frame forged with no key material at all lands here
148    /// (`manager.rs::test_forged_frame_reaches_session_desync_without_any_key_material`).
149    /// Callers must treat it as an unauthenticated hint and keep whatever they
150    /// hang off it bounded and non-destructive; see the SECURITY block on
151    /// `OfflineProtocol::schedule_session_rekey` in the `offline-protocol` crate.
152    #[error("Session out of sync: {0}")]
153    SessionDesync(String),
154
155    /// Failed to process Welcome message.
156    #[error("Welcome processing failed: {0}")]
157    WelcomeProcessing(String),
158
159    /// Failed to serialize data.
160    #[error("Serialization failed: {0}")]
161    Serialization(String),
162
163    /// Failed to deserialize data.
164    #[error("Deserialization failed: {0}")]
165    Deserialization(String),
166
167    /// MLS manager not initialized.
168    #[error("MLS not initialized")]
169    NotInitialized,
170
171    /// No key package available for the user.
172    #[error("No key package available for user: {0}")]
173    NoKeyPackage(String),
174
175    /// Session not found for 1:1 messaging.
176    #[error("Session not found for user: {0}")]
177    SessionNotFound(String),
178
179    /// A membership commit was refused before merging because the local
180    /// admin policy did not authorize its committer or proposal senders.
181    ///
182    /// Only produced when commit enforcement is explicitly enabled
183    /// (`GroupConfig::enforce_admin_commits`, default off) — see
184    /// `GroupManager::authorize_membership_commit` for the fail-open rules
185    /// that keep an incomplete local admin view from rejecting a legitimate
186    /// commit and forking the group. Permanent: the same commit can never
187    /// become authorized, so callers must not buffer and retry it.
188    #[error("Commit not authorized: {committer} is not an admin of this group")]
189    CommitNotAuthorized {
190        /// The MLS-authenticated committer whose commit was refused.
191        committer: String,
192        /// User ids the refused commit would have added, sorted.
193        added: Vec<String>,
194        /// User ids the refused commit would have removed, sorted.
195        removed: Vec<String>,
196    },
197
198    /// Invalid message format.
199    #[error("Invalid message format: {0}")]
200    InvalidMessage(String),
201
202    /// User not found in group.
203    #[error("User not found in group: {0}")]
204    UserNotInGroup(String),
205
206    /// OpenMLS library error.
207    #[error("OpenMLS error: {0}")]
208    OpenMls(String),
209
210    /// Signing operation failed.
211    #[error("Signing failed: {0}")]
212    Signing(String),
213
214    /// Signature verification failed.
215    #[error("Signature verification failed: {0}")]
216    VerificationFailed(String),
217
218    /// Invalid public key format.
219    #[error("Invalid public key: {0}")]
220    InvalidPublicKey(String),
221
222    /// The stored identity key does not derive to the address this manager
223    /// was constructed for.
224    ///
225    /// Under self-certifying addressing the address *is* the identity key's
226    /// hash, so these two can only disagree if the wrong storage namespace was
227    /// opened for this profile, or a stored keypair was replaced. Continuing
228    /// would build a credential claiming an address this device cannot prove
229    /// it owns — every peer would reject it at the derivation check, so the
230    /// failure is raised here where it is still legible.
231    ///
232    /// Only reachable for address-shaped ids: a manager constructed with a
233    /// legacy/nickname id has no derivation to check against.
234    #[error("Stored identity key derives to '{derived}', but this manager is '{expected}'")]
235    IdentityAddressMismatch {
236        /// The address this manager was constructed for.
237        expected: String,
238        /// The address the stored identity key actually derives to.
239        derived: String,
240    },
241
242    /// Group id failed storage-key validation (empty, oversized, or
243    /// containing path-traversal / storage-hostile characters).
244    #[error("Invalid group id: {0}")]
245    InvalidGroupId(String),
246
247    /// User id failed storage-key validation (empty, or containing
248    /// path-traversal / storage-hostile characters).
249    #[error("Invalid user id: {0}")]
250    InvalidUserId(String),
251
252    /// A key package's embedded credential identity does not match the
253    /// user id it was claimed to belong to.
254    #[error(
255        "Credential identity mismatch: expected '{expected}', key package credential is '{found}'"
256    )]
257    CredentialIdentityMismatch {
258        /// The user id the key package was claimed to belong to.
259        expected: String,
260        /// The identity actually embedded in the key package credential.
261        found: String,
262    },
263
264    /// A key package's leaf signature key does not derive to the address it
265    /// claims to belong to.
266    ///
267    /// Distinct from [`MlsError::CredentialIdentityMismatch`], and it has to
268    /// be: credentials here are MLS *basic* credentials, whose content is a
269    /// bare self-asserted identity string. Anyone can generate a signature
270    /// keypair and stamp an address on it, so the identity check catches only a
271    /// careless substitution. This one recomputes the address *from the key*,
272    /// and so catches a deliberate one.
273    ///
274    /// RFC 9420 leaves credential validation to the application's
275    /// Authentication Service. For this SDK that service is a pure derivation:
276    /// an address *is* `bech32m(0x01 ‖ SHA-256(signature_key)[..20])`, so the
277    /// claim carries its own proof and needs no prior contact to check. This is
278    /// where that verdict is enforced at key-package use time.
279    #[error(
280        "Key package signature key derives to '{derived}', not the claimed address '{claimed}'"
281    )]
282    KeyPackageAddressMismatch {
283        /// The address the key package was claimed to belong to.
284        claimed: String,
285        /// The address its leaf signature key actually derives to.
286        derived: String,
287    },
288
289    /// A leaf node in a ratchet tree carries a credential its own signature
290    /// key does not derive to.
291    ///
292    /// The tree-side sibling of [`MlsError::KeyPackageAddressMismatch`]. That
293    /// one fires on a key package *this device supplied* — imported for a
294    /// contact, read back from the contact cache, or handed to
295    /// `add_group_member`. This one fires on a leaf that entered the tree some
296    /// other way: a Welcome's ratchet tree, or an Add/Update another member
297    /// committed. The two are kept separate so the error text says which gate
298    /// refused, because the recovery differs — a bad key package means
299    /// re-exchange, a bad leaf means someone in the group is forging identities.
300    ///
301    /// RFC 9420 §5.3.1 puts this check on the application: the Authentication
302    /// Service must verify "that the credential's presented identifiers are
303    /// correctly associated with the `signature_key` field in the member's
304    /// LeafNode", and §7.3 applies that "when a client validates a ratchet
305    /// tree, e.g., when joining a group or after processing a Commit". OpenMLS
306    /// does not do it for you — its own external-commit validation says so in
307    /// as many words ("This MUST be checked by the application"), and
308    /// `StagedCommit::credentials_to_verify` exists precisely to hand the
309    /// application the credentials it must judge.
310    ///
311    /// Without this, SEC-M1 ([`MlsError::SenderIdentityMismatch`]) is checking
312    /// a wire-claimed sender against a *self-asserted* string: a member who
313    /// commits a leaf whose credential names someone else is then attributed as
314    /// that someone else, which on the `__GROUP_MSG__` data-plane path needs no
315    /// signature from anyone.
316    #[error(
317        "{context} leaf signature key derives to '{derived}', not the address its credential claims ('{claimed}')"
318    )]
319    LeafAddressMismatch {
320        /// The identity the leaf's credential asserts.
321        claimed: String,
322        /// The address the leaf's own signature key derives to, or a rendering
323        /// of why no address could be derived from it.
324        derived: String,
325        /// Which gate refused, so a report names the path rather than only the
326        /// verdict (see [`LeafSource`]).
327        context: LeafSource,
328    },
329
330    /// A message, or a commit, was authenticated to a sender that is not a
331    /// member of the group.
332    ///
333    /// MLS admits three such senders: an external joiner committing itself in
334    /// (`NewMemberCommit`), a would-be member proposing its own Add
335    /// (`NewMemberProposal`), and a party authorized by the group's
336    /// `ExternalSenders` extension. This SDK issues none of them and configures
337    /// no external senders, so any of the three arriving is either a peer
338    /// running something else or an attacker.
339    ///
340    /// They are refused rather than ignored because each one is a leaf, or a
341    /// signing key, that [`MlsError::LeafAddressMismatch`] cannot judge the
342    /// usual way: an external joiner's leaf has no prior entry in the tree to
343    /// compare against, and an `ExternalSenders` credential is not a leaf at
344    /// all. Refusing keeps the tree to identities that entered through a gate
345    /// this SDK actually operates.
346    ///
347    /// One further condition shares this variant because it shares the
348    /// disposition exactly — permanent, unattributable, never buffered: a
349    /// sender index that resolves to no leaf at all. That is a tree-integrity
350    /// fault rather than an unsupported role, and it cannot happen for a
351    /// message OpenMLS just authenticated against that leaf, so it is
352    /// defensive; its `detail` is prefixed `tree integrity:` to keep the two
353    /// readable apart in a log.
354    ///
355    /// **Contract for anything added here.** Three separate dispositions key
356    /// off the bare variant — `process_commit_core`'s `is_permanent`,
357    /// `is_media_security_rejection`, and the `__MLS_ENC__` intercept in
358    /// `handle_encrypted_message` — so a new condition mapped to this variant
359    /// silently inherits *permanent, un-ACKed, and reported as a security
360    /// warning*. Only map conditions that genuinely share all three: the frame
361    /// can never become processable, the sender must learn nothing from an ACK,
362    /// and no honest peer produces it. Anything recoverable needs its own
363    /// variant.
364    #[error("Unsupported sender: {detail}")]
365    UnsupportedSender {
366        /// Which unsupported sender shape arrived, and where.
367        detail: String,
368    },
369
370    /// The MLS-authenticated sender of a decrypted message does not match
371    /// the sender the transport layer attributed the message to.
372    #[error(
373        "Sender identity mismatch: claimed '{claimed}', MLS-authenticated sender is '{authenticated}'"
374    )]
375    SenderIdentityMismatch {
376        /// The sender the wire envelope claimed.
377        claimed: String,
378        /// The identity authenticated by the MLS credential.
379        authenticated: String,
380    },
381
382    /// A 1:1 envelope's `group_id` does not name the session slot shared with
383    /// the sender the transport layer attributed it to.
384    ///
385    /// This is the **failure-path** counterpart to
386    /// [`MlsError::SenderIdentityMismatch`], and the two are not
387    /// interchangeable: SEC-M1 binds the wire sender to the MLS credential, but
388    /// that credential only exists once `process_message` *succeeds*. Every
389    /// pre-authentication verdict — most importantly the framing checks OpenMLS
390    /// runs before any AEAD (group id, then epoch, yielding
391    /// [`MlsError::SessionDesync`]) — is reached with the wire sender still
392    /// entirely unverified. Since a 1:1 slot id is `session:<a>:<b>` over two
393    /// public user ids, anything keyed off such a verdict would otherwise be
394    /// reachable by a stranger naming any session and any sender, with no key
395    /// material at all. Requiring the envelope's slot to be the one shared with
396    /// the claimed sender is the only binding that covers those paths, so it
397    /// runs before the group is loaded.
398    #[error("Session identity mismatch: envelope names '{found}', sender's session slot is '{expected}'")]
399    SessionIdentityMismatch {
400        /// The session slot shared with the claimed sender.
401        expected: String,
402        /// The slot the envelope's `group_id` actually named.
403        found: String,
404    },
405
406    /// A 1:1-session Welcome's `group_id` — the session storage slot it would
407    /// install into — does not match the slot for the (local user,
408    /// authenticated inviter) pair. Rejecting this stops an authenticated peer
409    /// from installing or overwriting a *third* party's 1:1 session slot, which
410    /// would hijack the victim's session so their outbound messages encrypt to
411    /// the attacker's group. This guards the session-Welcome (`join_session`)
412    /// half of SEC-M6; the group-Welcome half is guarded by
413    /// [`MlsError::ReservedSessionNamespace`].
414    #[error(
415        "Welcome session-slot mismatch: inviter maps to slot '{expected}', Welcome claims '{found}'"
416    )]
417    WelcomeIdentityMismatch {
418        /// The only session slot the authenticated inviter may write.
419        expected: String,
420        /// The slot the Welcome's `group_id` actually named.
421        found: String,
422    },
423
424    /// A group Welcome named a `group_id` in the reserved `session:` namespace.
425    /// That namespace is owned exclusively by identity-bound 1:1 sessions
426    /// (installed only via `join_session`, guarded by
427    /// [`MlsError::WelcomeIdentityMismatch`]). A group Welcome carries an
428    /// attacker-controllable `group_id` with no (self, inviter) binding and
429    /// installs into the *same* storage/OpenMLS keyspace, so allowing one to
430    /// name a session slot would let an authenticated peer seed or overwrite a
431    /// third party's 1:1 session — the identical hijack SEC-M6 blocks on the
432    /// session-Welcome path, reached through the group path. This is the
433    /// group-Welcome half of that binding.
434    #[error("Group Welcome may not target the reserved 'session:' namespace: '{group_id}'")]
435    ReservedSessionNamespace {
436        /// The reserved-namespace slot the group Welcome tried to install into.
437        group_id: String,
438    },
439
440    /// A Welcome's embedded MLS group id (the id OpenMLS actually persists the
441    /// joined group under) does not match the wire `group_id` the caller
442    /// authenticated and keys storage by. The embedded id lives in the
443    /// Welcome's `GroupContext` and is chosen freely by the inviter at group
444    /// creation (`new_with_group_id`); every SEC-M5/M6 binding validates only
445    /// the wire field. Left unchecked, `into_group` would persist the group
446    /// under the attacker-chosen embedded id — seeding or overwriting an
447    /// arbitrary session/group slot the wire-id checks never inspected, the
448    /// same hijack SEC-M6 blocks, reached through the identifier it does not
449    /// cover. Rejecting binds the two before any state is written.
450    #[error(
451        "Welcome group-id mismatch: wire slot '{expected}', Welcome installs under '{embedded}'"
452    )]
453    WelcomeGroupIdMismatch {
454        /// The wire `group_id` the caller authenticated and keys storage by.
455        expected: String,
456        /// The group id embedded in the Welcome's GroupContext (attacker-chosen).
457        embedded: String,
458    },
459}
460
461/// Carries a sealed-layer failure into this crate's error type.
462///
463/// Each arm maps to the variant the moved code used to raise, and passes the
464/// inner string through rather than the rendered `Display`, so the text a
465/// caller sees is byte-identical to what it was before the move. Two test
466/// suites assert on substrings of it.
467///
468/// [`SealedError`](offline_protocol_sealed::SealedError) is deliberately not
469/// `#[non_exhaustive]`, so this match is
470/// exhaustive and a new variant over there is a compile error here until
471/// someone decides what it means. A wildcard arm would render it as some
472/// other error's text instead.
473impl From<offline_protocol_sealed::SealedError> for MlsError {
474    fn from(err: offline_protocol_sealed::SealedError) -> Self {
475        use offline_protocol_sealed::SealedError;
476        match err {
477            SealedError::Serialization(msg) => Self::Serialization(msg),
478            SealedError::Deserialization(msg) => Self::Deserialization(msg),
479            SealedError::InvalidGroupId(msg) => Self::InvalidGroupId(msg),
480            SealedError::InvalidPublicKey(msg) => Self::InvalidPublicKey(msg),
481            // No `Serialization` sibling to pass through: this one renders its
482            // own message, and `Serialization`'s prefix is what the MLS-crate
483            // caller printed before the construction moved.
484            SealedError::FieldTooLarge(_) => Self::Serialization(err.to_string()),
485        }
486    }
487}
488
489impl MlsError {
490    /// A fixed, identifier-free classification of this error, safe to place in
491    /// a field that reaches a [`TelemetrySink`][sink].
492    ///
493    /// [sink]: https://docs.rs/offline-protocol
494    ///
495    /// # Why this exists
496    ///
497    /// The protocol crate's telemetry scrubber hashes the *named* actor fields
498    /// of an event (`peer_id`, `group_id`, `sender`, …) and ships free-text
499    /// `reason`/`detail` fields verbatim — deliberately, since scrubbing
500    /// payload is a separate concern from scrubbing identity. Rendering an
501    /// `MlsError` into one of those fields therefore hands a sink running
502    /// `scrub_ids: true` exactly what the setting exists to withhold, and it
503    /// does so by *two* routes:
504    ///
505    /// - **Identifiers this device holds.** A 1:1 slot id is
506    ///   `session:<a>:<b>`, so a single rendered slot carries two addresses —
507    ///   neither of which is the event's `peer_id`, so neither is hashed, and
508    ///   one of which may be a third party who is not a participant in the
509    ///   exchange at all. Storage keys, group ids and user ids render the same
510    ///   way.
511    /// - **Text the wire chose.** [`Self::WelcomeIdentityMismatch::found`] and
512    ///   [`Self::WelcomeGroupIdMismatch::embedded`] are attacker-controlled by
513    ///   construction — bounding what they may contain is the entire premise of
514    ///   the checks that raise them. The embedded id is the worse of the two:
515    ///   it is `from_utf8_lossy` over raw `GroupContext` bytes, so unlike the
516    ///   wire field it passes through neither charset validation nor
517    ///   [`GroupId::MAX_LEN`][crate::GroupId::MAX_LEN].
518    ///
519    /// # Contract
520    ///
521    /// Two properties are load-bearing and neither is decoration:
522    ///
523    /// 1. The return type is `&'static str`, so a caller *cannot* interpolate a
524    ///    value into it. Widening this to `String` would restore the leak by
525    ///    the front door.
526    /// 2. The match below is **exhaustive**, which is legal despite
527    ///    `#[non_exhaustive]` only because it lives in the defining crate. That
528    ///    is the point: a new variant added above fails to compile *here*,
529    ///    in the same file, forcing the privacy decision at the moment the
530    ///    variant is written. **Do not add a `_ =>` arm** — a wildcard turns
531    ///    this from a guarantee back into the per-site opt-in it replaced.
532    ///
533    /// Callers keep the full error where it is useful and safe: the `warn!`
534    /// or `error!` at the refusal site, which goes to the device log rather
535    /// than to a sink.
536    pub fn privacy_safe_reason(&self) -> &'static str {
537        match self {
538            // Storage keys are raw user ids, group ids and session slots.
539            Self::Storage(_) => "Secure storage operation failed",
540            Self::CryptoGeneration(_) => "Cryptographic key generation failed",
541            Self::CredentialCreation(_) => "MLS credential creation failed",
542            Self::KeyPackageCreation(_) => "Key package creation failed",
543            Self::InvalidKeyPackage(_) => "The peer's key package was malformed or unusable",
544            Self::KeyPackageOutsideValidityWindow { .. } => {
545                "The peer's key package is not valid at this device's time"
546            }
547            Self::GroupCreation(_) => "MLS group creation failed",
548            Self::GroupNotFound(_) => "No local MLS group state for the requested conversation",
549            Self::AddMember(_) => "Adding a member to the MLS group failed",
550            Self::RemoveMember(_) => "Removing a member from the MLS group failed",
551            Self::Encryption(_) => "MLS encryption failed",
552            Self::Decryption(_) => "MLS decryption failed",
553            Self::SessionDesync(_) => {
554                "The session is out of sync with the peer's epoch (recoverable by re-keying)"
555            }
556            Self::WelcomeProcessing(_) => "The Welcome message could not be processed",
557            Self::Serialization(_) => "Serialization failed",
558            Self::Deserialization(_) => "Deserialization failed",
559            Self::NotInitialized => "MLS is not initialized on this device",
560            Self::NoKeyPackage(_) => "No key package is available for the peer",
561            Self::SessionNotFound(_) => "No local MLS session with the peer",
562            Self::CommitNotAuthorized { .. } => {
563                "A membership commit was refused: its committer is not an admin of this group"
564            }
565            Self::InvalidMessage(_) => "The MLS message was malformed",
566            Self::UserNotInGroup(_) => "The user is not a member of this group",
567            Self::OpenMls(_) => "The MLS library reported an error",
568            Self::Signing(_) => "Signing failed",
569            Self::VerificationFailed(_) => "Signature verification failed",
570            Self::InvalidPublicKey(_) => "The public key was malformed",
571            Self::IdentityAddressMismatch { .. } => {
572                "The stored identity key does not derive to this device's own address"
573            }
574            Self::InvalidGroupId(_) => "The group id failed validation",
575            Self::InvalidUserId(_) => "The user id failed validation",
576            Self::CredentialIdentityMismatch { .. } => {
577                "A key package's credential names a different identity than the one it was \
578                 claimed for"
579            }
580            Self::KeyPackageAddressMismatch { .. } => {
581                "A key package's signature key does not derive to the address it claims"
582            }
583            Self::LeafAddressMismatch { .. } => {
584                "A ratchet-tree leaf carries an identity its own signature key does not derive to"
585            }
586            Self::UnsupportedSender { .. } => {
587                "The message was authenticated to a sender role this SDK does not accept"
588            }
589            Self::SenderIdentityMismatch { .. } => {
590                "The MLS-authenticated sender does not match the sender the transport attributed \
591                 the message to"
592            }
593            Self::SessionIdentityMismatch { .. } => {
594                "The envelope names a session slot that is not the one shared with the claimed \
595                 sender"
596            }
597            Self::WelcomeIdentityMismatch { .. } => {
598                "The Welcome names a session slot that is not the one shared with its inviter"
599            }
600            Self::ReservedSessionNamespace { .. } => {
601                "A group Welcome targeted the reserved 1:1 session namespace"
602            }
603            Self::WelcomeGroupIdMismatch { .. } => {
604                "The Welcome's embedded group id does not match the slot it was delivered under"
605            }
606        }
607    }
608}
609
610#[cfg(test)]
611mod privacy_safe_reason_tests {
612    use super::*;
613    use crate::storage::StorageError;
614
615    /// A stand-in for an address the sink must not learn, and for wire-chosen
616    /// text. Both are checked because the two leak routes are different: an
617    /// address is an identifier this device holds, while `MARKER` stands for a
618    /// string an attacker put on the wire — which need not look like an
619    /// address, or like anything at all.
620    const ADDR: &str = "off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzzz";
621    const MARKER: &str = "ZZ-WIRE-CHOSEN-ZZ";
622
623    /// Every variant that renders an identifier or wire-chosen text must
624    /// classify to a string carrying neither.
625    ///
626    /// The list is deliberately built from *populated* variants rather than
627    /// asserted structurally: the property under test is about what the
628    /// rendering contains, so a variant whose payload is empty would pass
629    /// vacuously.
630    #[test]
631    fn identifier_bearing_variants_classify_without_their_payload() {
632        let slot = format!("session:{ADDR}:{ADDR}");
633        let cases = vec![
634            MlsError::Storage(StorageError::KeyNotFound(slot.clone())),
635            MlsError::GroupNotFound(slot.clone()),
636            MlsError::NoKeyPackage(ADDR.to_string()),
637            MlsError::SessionNotFound(ADDR.to_string()),
638            MlsError::UserNotInGroup(ADDR.to_string()),
639            MlsError::InvalidGroupId(format!("{slot} {MARKER}")),
640            MlsError::InvalidUserId(format!("{ADDR} {MARKER}")),
641            MlsError::CommitNotAuthorized {
642                committer: ADDR.to_string(),
643                added: vec![ADDR.to_string()],
644                removed: vec![ADDR.to_string()],
645            },
646            MlsError::IdentityAddressMismatch {
647                expected: ADDR.to_string(),
648                derived: ADDR.to_string(),
649            },
650            MlsError::CredentialIdentityMismatch {
651                expected: ADDR.to_string(),
652                found: MARKER.to_string(),
653            },
654            MlsError::KeyPackageAddressMismatch {
655                claimed: ADDR.to_string(),
656                derived: ADDR.to_string(),
657            },
658            MlsError::LeafAddressMismatch {
659                claimed: ADDR.to_string(),
660                derived: ADDR.to_string(),
661                context: LeafSource::WelcomeTree,
662            },
663            MlsError::UnsupportedSender {
664                detail: format!("{ADDR} {MARKER}"),
665            },
666            MlsError::SenderIdentityMismatch {
667                claimed: MARKER.to_string(),
668                authenticated: ADDR.to_string(),
669            },
670            MlsError::SessionIdentityMismatch {
671                expected: slot.clone(),
672                found: MARKER.to_string(),
673            },
674            // The two variants issue #346 turns on: reachable from the session
675            // Welcome failure arms, and each rendering both a session slot (two
676            // addresses, one of them possibly a third party's) and a string the
677            // sender chose.
678            MlsError::WelcomeIdentityMismatch {
679                expected: slot.clone(),
680                found: MARKER.to_string(),
681            },
682            MlsError::ReservedSessionNamespace {
683                group_id: format!("{slot} {MARKER}"),
684            },
685            MlsError::WelcomeGroupIdMismatch {
686                expected: slot,
687                embedded: MARKER.to_string(),
688            },
689        ];
690
691        for err in cases {
692            // Premise guard: without this the assertions below could pass
693            // against a variant that never carried the payload in the first
694            // place, which is exactly the vacuous test this is replacing.
695            let rendered = err.to_string();
696            assert!(
697                rendered.contains(ADDR) || rendered.contains(MARKER),
698                "test premise: {err:?} was expected to render its payload, got {rendered:?}"
699            );
700
701            let reason = err.privacy_safe_reason();
702            assert!(
703                !reason.contains(ADDR),
704                "an address reached a privacy-safe reason: {reason:?} (from {err:?})"
705            );
706            assert!(
707                !reason.contains("off1"),
708                "an address prefix reached a privacy-safe reason: {reason:?} (from {err:?})"
709            );
710            assert!(
711                !reason.contains(MARKER),
712                "wire-chosen text reached a privacy-safe reason: {reason:?} (from {err:?})"
713            );
714            assert!(
715                !reason.is_empty(),
716                "a classified reason must still say something: {err:?}"
717            );
718        }
719    }
720}