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