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}