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
427impl MlsError {
428 /// A fixed, identifier-free classification of this error, safe to place in
429 /// a field that reaches a [`TelemetrySink`][sink].
430 ///
431 /// [sink]: https://docs.rs/offline-protocol
432 ///
433 /// # Why this exists
434 ///
435 /// The protocol crate's telemetry scrubber hashes the *named* actor fields
436 /// of an event (`peer_id`, `group_id`, `sender`, …) and ships free-text
437 /// `reason`/`detail` fields verbatim — deliberately, since scrubbing
438 /// payload is a separate concern from scrubbing identity. Rendering an
439 /// `MlsError` into one of those fields therefore hands a sink running
440 /// `scrub_ids: true` exactly what the setting exists to withhold, and it
441 /// does so by *two* routes:
442 ///
443 /// - **Identifiers this device holds.** A 1:1 slot id is
444 /// `session:<a>:<b>`, so a single rendered slot carries two addresses —
445 /// neither of which is the event's `peer_id`, so neither is hashed, and
446 /// one of which may be a third party who is not a participant in the
447 /// exchange at all. Storage keys, group ids and user ids render the same
448 /// way.
449 /// - **Text the wire chose.** [`Self::WelcomeIdentityMismatch::found`] and
450 /// [`Self::WelcomeGroupIdMismatch::embedded`] are attacker-controlled by
451 /// construction — bounding what they may contain is the entire premise of
452 /// the checks that raise them. The embedded id is the worse of the two:
453 /// it is `from_utf8_lossy` over raw `GroupContext` bytes, so unlike the
454 /// wire field it passes through neither charset validation nor
455 /// [`GroupId::MAX_LEN`][crate::GroupId::MAX_LEN].
456 ///
457 /// # Contract
458 ///
459 /// Two properties are load-bearing and neither is decoration:
460 ///
461 /// 1. The return type is `&'static str`, so a caller *cannot* interpolate a
462 /// value into it. Widening this to `String` would restore the leak by
463 /// the front door.
464 /// 2. The match below is **exhaustive**, which is legal despite
465 /// `#[non_exhaustive]` only because it lives in the defining crate. That
466 /// is the point: a new variant added above fails to compile *here*,
467 /// in the same file, forcing the privacy decision at the moment the
468 /// variant is written. **Do not add a `_ =>` arm** — a wildcard turns
469 /// this from a guarantee back into the per-site opt-in it replaced.
470 ///
471 /// Callers keep the full error where it is useful and safe: the `warn!`
472 /// or `error!` at the refusal site, which goes to the device log rather
473 /// than to a sink.
474 pub fn privacy_safe_reason(&self) -> &'static str {
475 match self {
476 // Storage keys are raw user ids, group ids and session slots.
477 Self::Storage(_) => "Secure storage operation failed",
478 Self::CryptoGeneration(_) => "Cryptographic key generation failed",
479 Self::CredentialCreation(_) => "MLS credential creation failed",
480 Self::KeyPackageCreation(_) => "Key package creation failed",
481 Self::InvalidKeyPackage(_) => "The peer's key package was malformed or unusable",
482 Self::GroupCreation(_) => "MLS group creation failed",
483 Self::GroupNotFound(_) => "No local MLS group state for the requested conversation",
484 Self::AddMember(_) => "Adding a member to the MLS group failed",
485 Self::RemoveMember(_) => "Removing a member from the MLS group failed",
486 Self::Encryption(_) => "MLS encryption failed",
487 Self::Decryption(_) => "MLS decryption failed",
488 Self::SessionDesync(_) => {
489 "The session is out of sync with the peer's epoch (recoverable by re-keying)"
490 }
491 Self::WelcomeProcessing(_) => "The Welcome message could not be processed",
492 Self::Serialization(_) => "Serialization failed",
493 Self::Deserialization(_) => "Deserialization failed",
494 Self::NotInitialized => "MLS is not initialized on this device",
495 Self::NoKeyPackage(_) => "No key package is available for the peer",
496 Self::SessionNotFound(_) => "No local MLS session with the peer",
497 Self::CommitNotAuthorized { .. } => {
498 "A membership commit was refused: its committer is not an admin of this group"
499 }
500 Self::InvalidMessage(_) => "The MLS message was malformed",
501 Self::UserNotInGroup(_) => "The user is not a member of this group",
502 Self::OpenMls(_) => "The MLS library reported an error",
503 Self::Signing(_) => "Signing failed",
504 Self::VerificationFailed(_) => "Signature verification failed",
505 Self::InvalidPublicKey(_) => "The public key was malformed",
506 Self::IdentityAddressMismatch { .. } => {
507 "The stored identity key does not derive to this device's own address"
508 }
509 Self::InvalidGroupId(_) => "The group id failed validation",
510 Self::InvalidUserId(_) => "The user id failed validation",
511 Self::CredentialIdentityMismatch { .. } => {
512 "A key package's credential names a different identity than the one it was \
513 claimed for"
514 }
515 Self::KeyPackageAddressMismatch { .. } => {
516 "A key package's signature key does not derive to the address it claims"
517 }
518 Self::LeafAddressMismatch { .. } => {
519 "A ratchet-tree leaf carries an identity its own signature key does not derive to"
520 }
521 Self::UnsupportedSender { .. } => {
522 "The message was authenticated to a sender role this SDK does not accept"
523 }
524 Self::SenderIdentityMismatch { .. } => {
525 "The MLS-authenticated sender does not match the sender the transport attributed \
526 the message to"
527 }
528 Self::SessionIdentityMismatch { .. } => {
529 "The envelope names a session slot that is not the one shared with the claimed \
530 sender"
531 }
532 Self::WelcomeIdentityMismatch { .. } => {
533 "The Welcome names a session slot that is not the one shared with its inviter"
534 }
535 Self::ReservedSessionNamespace { .. } => {
536 "A group Welcome targeted the reserved 1:1 session namespace"
537 }
538 Self::WelcomeGroupIdMismatch { .. } => {
539 "The Welcome's embedded group id does not match the slot it was delivered under"
540 }
541 }
542 }
543}
544
545#[cfg(test)]
546mod privacy_safe_reason_tests {
547 use super::*;
548 use crate::storage::StorageError;
549
550 /// A stand-in for an address the sink must not learn, and for wire-chosen
551 /// text. Both are checked because the two leak routes are different: an
552 /// address is an identifier this device holds, while `MARKER` stands for a
553 /// string an attacker put on the wire — which need not look like an
554 /// address, or like anything at all.
555 const ADDR: &str = "off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzzz";
556 const MARKER: &str = "ZZ-WIRE-CHOSEN-ZZ";
557
558 /// Every variant that renders an identifier or wire-chosen text must
559 /// classify to a string carrying neither.
560 ///
561 /// The list is deliberately built from *populated* variants rather than
562 /// asserted structurally: the property under test is about what the
563 /// rendering contains, so a variant whose payload is empty would pass
564 /// vacuously.
565 #[test]
566 fn identifier_bearing_variants_classify_without_their_payload() {
567 let slot = format!("session:{ADDR}:{ADDR}");
568 let cases = vec![
569 MlsError::Storage(StorageError::KeyNotFound(slot.clone())),
570 MlsError::GroupNotFound(slot.clone()),
571 MlsError::NoKeyPackage(ADDR.to_string()),
572 MlsError::SessionNotFound(ADDR.to_string()),
573 MlsError::UserNotInGroup(ADDR.to_string()),
574 MlsError::InvalidGroupId(format!("{slot} {MARKER}")),
575 MlsError::InvalidUserId(format!("{ADDR} {MARKER}")),
576 MlsError::CommitNotAuthorized {
577 committer: ADDR.to_string(),
578 added: vec![ADDR.to_string()],
579 removed: vec![ADDR.to_string()],
580 },
581 MlsError::IdentityAddressMismatch {
582 expected: ADDR.to_string(),
583 derived: ADDR.to_string(),
584 },
585 MlsError::CredentialIdentityMismatch {
586 expected: ADDR.to_string(),
587 found: MARKER.to_string(),
588 },
589 MlsError::KeyPackageAddressMismatch {
590 claimed: ADDR.to_string(),
591 derived: ADDR.to_string(),
592 },
593 MlsError::LeafAddressMismatch {
594 claimed: ADDR.to_string(),
595 derived: ADDR.to_string(),
596 context: LeafSource::WelcomeTree,
597 },
598 MlsError::UnsupportedSender {
599 detail: format!("{ADDR} {MARKER}"),
600 },
601 MlsError::SenderIdentityMismatch {
602 claimed: MARKER.to_string(),
603 authenticated: ADDR.to_string(),
604 },
605 MlsError::SessionIdentityMismatch {
606 expected: slot.clone(),
607 found: MARKER.to_string(),
608 },
609 // The two variants issue #346 turns on: reachable from the session
610 // Welcome failure arms, and each rendering both a session slot (two
611 // addresses, one of them possibly a third party's) and a string the
612 // sender chose.
613 MlsError::WelcomeIdentityMismatch {
614 expected: slot.clone(),
615 found: MARKER.to_string(),
616 },
617 MlsError::ReservedSessionNamespace {
618 group_id: format!("{slot} {MARKER}"),
619 },
620 MlsError::WelcomeGroupIdMismatch {
621 expected: slot,
622 embedded: MARKER.to_string(),
623 },
624 ];
625
626 for err in cases {
627 // Premise guard: without this the assertions below could pass
628 // against a variant that never carried the payload in the first
629 // place, which is exactly the vacuous test this is replacing.
630 let rendered = err.to_string();
631 assert!(
632 rendered.contains(ADDR) || rendered.contains(MARKER),
633 "test premise: {err:?} was expected to render its payload, got {rendered:?}"
634 );
635
636 let reason = err.privacy_safe_reason();
637 assert!(
638 !reason.contains(ADDR),
639 "an address reached a privacy-safe reason: {reason:?} (from {err:?})"
640 );
641 assert!(
642 !reason.contains("off1"),
643 "an address prefix reached a privacy-safe reason: {reason:?} (from {err:?})"
644 );
645 assert!(
646 !reason.contains(MARKER),
647 "wire-chosen text reached a privacy-safe reason: {reason:?} (from {err:?})"
648 );
649 assert!(
650 !reason.is_empty(),
651 "a classified reason must still say something: {err:?}"
652 );
653 }
654 }
655}