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}