diff --git a/src/client.rs b/src/client.rs index ed2c65b6e..c46ae886f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -85,6 +85,30 @@ impl Drop for DecryptedPayloadLease { } } +/// Lease that keeps per-`` decrypt-failure events enabled for one consumer. +/// +/// Dropping the final lease disables forwarding. The lease holds only a weak +/// client reference, so it cannot keep the client alive. +#[must_use = "dropping the lease immediately releases enc-decrypt-failure forwarding"] +pub struct EncDecryptFailedLease { + client: std::sync::Weak, +} + +impl Drop for EncDecryptFailedLease { + fn drop(&mut self) { + let Some(client) = self.client.upgrade() else { + return; + }; + let previous = client + .enc_decrypt_failed_forwarding + .fetch_sub(1, Ordering::Relaxed); + debug_assert!( + previous > 0, + "enc-decrypt-failure forwarding lease underflow" + ); + } +} + /// Lease that keeps raw decoded stanza events enabled for one consumer. /// /// Dropping the final lease disables forwarding. The lease holds only a weak @@ -1566,6 +1590,12 @@ pub struct Client { /// forwarding. decrypted_payload_forwarding: AtomicUsize, + /// Number of consumers currently requesting `Event::EncDecryptFailed` + /// forwarding. Counted apart from `decrypted_payload_forwarding` so a + /// consumer that only watches failures does not turn on payload cloning, + /// and one that only watches successes pays nothing on the failure paths. + enc_decrypt_failed_forwarding: AtomicUsize, + /// Gate and publisher for `Event::SentFrame`. Behind an `Arc` because the /// noise sender task reads it; see [`SentFrameTap`]. pub(crate) sent_frame_tap: Arc, diff --git a/src/client/accessors.rs b/src/client/accessors.rs index 6020430fd..ba60ea889 100644 --- a/src/client/accessors.rs +++ b/src/client/accessors.rs @@ -88,6 +88,39 @@ impl Client { self.decrypted_payload_forwarding.load(Ordering::Relaxed) != 0 } + /// Acquire per-`` decrypt-failure forwarding for one consumer. + /// + /// [`Event::EncDecryptFailed`] stays enabled until every acquired lease is + /// dropped. While none is held nothing is emitted and nothing is built: each + /// failure branch costs one relaxed atomic load. + /// + /// Separate from + /// [`acquire_decrypted_payload_forwarding`](Self::acquire_decrypted_payload_forwarding) + /// on purpose — a consumer that wants both halves of a stanza's decryption + /// holds both leases, and one that wants only failures does not make the + /// success path clone plaintext. + /// + /// [`Event::EncDecryptFailed`]: wacore::types::events::Event::EncDecryptFailed + pub fn acquire_enc_decrypt_failed_forwarding(self: &Arc) -> EncDecryptFailedLease { + let incremented = self + .enc_decrypt_failed_forwarding + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| { + count.checked_add(1) + }) + .is_ok(); + assert!( + incremented, + "enc-decrypt-failure forwarding lease counter overflow" + ); + EncDecryptFailedLease { + client: Arc::downgrade(self), + } + } + + pub(crate) fn enc_decrypt_failed_forwarding_enabled(&self) -> bool { + self.enc_decrypt_failed_forwarding.load(Ordering::Relaxed) != 0 + } + /// Acquire sent-frame forwarding for one consumer. /// /// [`Event::SentFrame`] stays enabled until every acquired lease is dropped. diff --git a/src/client/lifecycle.rs b/src/client/lifecycle.rs index 7a5dce053..81c87b49f 100644 --- a/src/client/lifecycle.rs +++ b/src/client/lifecycle.rs @@ -510,6 +510,7 @@ impl Client { alloc_meter: std::sync::OnceLock::new(), raw_node_forwarding: AtomicUsize::new(0), decrypted_payload_forwarding: AtomicUsize::new(0), + enc_decrypt_failed_forwarding: AtomicUsize::new(0), sent_frame_tap, stanza_interceptors: std::sync::RwLock::new(Arc::new(Vec::new())), stanza_interceptor_count: AtomicUsize::new(0), diff --git a/src/lib.rs b/src/lib.rs index 367e86a00..70e863f04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -128,7 +128,7 @@ pub use client::{ pub use client::{CallError, Voip}; pub use client::{ Client, ClientBuild, ClientBuilder, ClientBuilderError, Connection, DecryptedPayloadLease, - RawNodeLease, SentFrameLease, + EncDecryptFailedLease, RawNodeLease, SentFrameLease, }; #[cfg(feature = "client-lifecycle")] #[cfg_attr(docsrs, doc(cfg(feature = "client-lifecycle")))] @@ -249,7 +249,7 @@ pub mod prelude { pub use crate::bot::{Bot, BotBuilder, BotHandle, EventDelivery, MessageContext}; pub use crate::client::{ Client, ClientBuilder, ClientBuilderError, ClientError, Connection, DecryptedPayloadLease, - RawNodeLease, SentFrameLease, + EncDecryptFailedLease, RawNodeLease, SentFrameLease, }; #[cfg(feature = "client-lifecycle")] #[cfg_attr(docsrs, doc(cfg(feature = "client-lifecycle")))] diff --git a/src/message.rs b/src/message.rs index e6076c019..d499e0662 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1,4 +1,5 @@ use crate::client::Client; +use crate::types::events::EncDecryptFailureReason; use crate::types::events::Event; use crate::types::message::MessageInfo; use log::{debug, warn}; @@ -154,8 +155,16 @@ enum MigrationDecryptResult { Decrypted, /// Server redelivered an already-processed message. Duplicate, - /// Migration didn't apply or still failed; caller sends a retry receipt. - NotDecrypted, + /// Migration didn't apply, or applied and the retry still failed; the + /// caller sends a retry receipt either way. + /// + /// `Some` carries the terminal cause when a migration actually ran and its + /// retry decrypt failed. Without it the caller would report the error that + /// sent it here — typically `NoSession` — for a message whose session was + /// in fact found and whose retry then failed a MAC or a store read. + /// `None` means nothing was migrated, so the caller's own error still is + /// the terminal one. + NotDecrypted(Option), } #[derive(Clone, Copy, Debug, Default)] @@ -262,6 +271,85 @@ fn decrypt_fail_log_level(mode: crate::types::events::DecryptFailMode) -> log::L } } +/// Errors libsignal raises while turning bytes into a message of their declared +/// type: too short to hold the signature, a version this build predates or does +/// not know, or a body that is not the protobuf it claims. No key material is +/// used to reach any of them. +/// +/// Shared by the session and group arms so one libsignal error cannot be +/// reported as a malformed envelope on one path and an unclassified failure on +/// the other. `UnrecognizedMessageVersion` is deliberately absent: it is the +/// *state* mismatch `group_decrypt` raises after parsing, not a parse failure — +/// `UnrecognizedCiphertextVersion` is that one. +fn is_malformed_envelope_error(e: &SignalProtocolError) -> bool { + matches!( + e, + SignalProtocolError::CiphertextMessageTooShort(_) + | SignalProtocolError::LegacyCiphertextVersion(_) + | SignalProtocolError::UnrecognizedCiphertextVersion(_) + | SignalProtocolError::InvalidProtobufEncoding + ) +} + +/// Cause reported for a libsignal error the decrypt arms do not name themselves. +/// +/// Shared by the session catch-all and the group arm so the same error cannot be +/// classified two ways. `BackendError` is local storage failing to answer — the +/// store adapter wraps every backend error in it — and not the ciphertext +/// failing: reporting it as a cryptographic error would blame the peer for our +/// own disk, and corrupt any per-peer health signal built on this event. +fn signal_error_reason(e: &SignalProtocolError) -> EncDecryptFailureReason { + if is_malformed_envelope_error(e) { + EncDecryptFailureReason::MalformedCiphertext + } else if matches!(e, SignalProtocolError::BackendError(_, _)) { + EncDecryptFailureReason::StorageFailure + } else if matches!(e, SignalProtocolError::KeyAgreementFailed(_)) { + // "the active crypto provider failed the key agreement" — our provider, + // not the sender's bytes, which were never judged. + EncDecryptFailureReason::LocalCryptoFailure + } else if e.is_stored_session_corruption() { + // A stored `SessionRecord` that decoded and then would not yield usable + // state. The predicate lives in libsignal because the distinction is + // drawn on `InvalidSessionStructure`'s message, and only the crate that + // writes those messages can keep the two in step. + EncDecryptFailureReason::StorageFailure + } else if matches!(e, SignalProtocolError::InvalidSenderKeySession) { + // A sender-key record that loaded but does not hold usable state: no + // chain key, a signing key that will not parse, or a chain whose derived + // key/IV the cipher rejects. Every site `group_decrypt` can reach it + // from is reading our stored record, which is why libsignal's own log + // there says the state is corrupt. The peer's copy is judged by + // `SignatureValidationFailed` and `InvalidMessage` instead. + EncDecryptFailureReason::StorageFailure + } else if matches!(e, SignalProtocolError::UnrecognizedMessageVersion(_)) { + // The group arm reaches this one through `group_decrypt_retry_reason` + // and calls it an invalid message. Naming it here too keeps a session + // `` and an `skmsg` from reporting the same rejection differently. + EncDecryptFailureReason::InvalidMessage + } else { + EncDecryptFailureReason::SignalError + } +} + +/// Cause for a terminal error on the 1:1 session path, matching what the arms +/// of `process_session_enc_batch` report for the same libsignal errors. +/// +/// Used where an error reaches a reporting site that has no arm of its own — +/// the PN→LID migration's retry decrypt — so a migrated session that then fails +/// a MAC is not reported under the error that opened the migration. +fn session_error_reason(e: &SignalProtocolError) -> EncDecryptFailureReason { + match e { + SignalProtocolError::SessionNotFound(_) => EncDecryptFailureReason::NoSession, + SignalProtocolError::BadMac(_) => EncDecryptFailureReason::BadMac, + SignalProtocolError::InvalidMessage(_, _) => EncDecryptFailureReason::InvalidMessage, + SignalProtocolError::InvalidPreKeyId | SignalProtocolError::InvalidSignedPreKeyId => { + EncDecryptFailureReason::UnknownPreKey + } + SignalProtocolError::UntrustedIdentity(_) => EncDecryptFailureReason::UntrustedIdentity, + other => signal_error_reason(other), + } +} + /// WA Web treats every `SignalDecryptionError` as `SignalRetryable`, so a /// sender-key desync must request a resend rather than NACK (which stops the /// server retransmitting). `None` = keep the NACK (genuinely non-Signal error). diff --git a/src/message/msg_secret.rs b/src/message/msg_secret.rs index 4ec332b95..0a874e25c 100644 --- a/src/message/msg_secret.rs +++ b/src/message/msg_secret.rs @@ -2,6 +2,29 @@ use super::*; +/// Map a bot-payload failure onto the cause reported for its ``. +/// +/// The stage comes from [`BotMessageError::stage`] rather than from matching +/// variants here, so a new variant upstream cannot quietly land in the wrong +/// bucket. An envelope rejected on shape never reached the cipher, so calling +/// it a MAC failure would make malformed wire data count as an authentication +/// failure against the peer. +/// +/// A `Secret` stage is `StorageFailure`, not `NoMessageSecret`: we found a row +/// and it would not yield a key. `NoMessageSecret` is for the lookups that came +/// back empty, which is the companion's state; a stored secret of the wrong +/// length is ours. +pub(super) fn msmsg_failure_reason( + error: &wacore::bot_message::BotMessageError, +) -> EncDecryptFailureReason { + use wacore::bot_message::BotMessageFailure; + match error.stage() { + BotMessageFailure::Envelope => EncDecryptFailureReason::MalformedCiphertext, + BotMessageFailure::Secret => EncDecryptFailureReason::StorageFailure, + BotMessageFailure::Authentication => EncDecryptFailureReason::BadMac, + } +} + impl Client { /// Capture embedded `MessageContextInfo.message_secret` for add-on /// decrypts. Bot DMs keep the legacy LID key as a second entry. @@ -485,6 +508,12 @@ impl Client { "[msg:{}] failed to decode MessageSecretMessage: {e:?}", info.id ); + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::MalformedCiphertext, + ); self.spawn_nack(info, NackReason::ParsingError, None); return; } @@ -496,6 +525,12 @@ impl Client { "[msg:{}] MessageSecretMessage missing enc_iv/enc_payload", info.id ); + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::MalformedCiphertext, + ); self.spawn_nack(info, NackReason::ParsingError, None); return; }; @@ -507,6 +542,12 @@ impl Client { Some(j) => j, None => { log::warn!("[msg:{}] msmsg: no target_sender resolvable", info.id); + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::NoMessageSecret, + ); self.spawn_nack(info, NackReason::MissingMessageSecret, None); return; } @@ -533,6 +574,12 @@ impl Client { "[msg:{}] msmsg: missing target_id; cannot look up secret", info.id ); + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::NoMessageSecret, + ); self.spawn_nack(info, NackReason::MissingMessageSecret, None); return; } @@ -547,6 +594,12 @@ impl Client { // Store lookup: primary, then the LID/PN alternate. A backend error is // logged and treated as a miss (not a hard nack) so the resolver still // gets a chance — mirrors the secret-encrypted edit path. + // + // Treated as a miss for control flow, but not for reporting: "the store + // would not answer" is ours and "no secret here" is the companion's, + // and by the time the cause is named nothing else remembers which of + // the two emptied the lookup. + let mut lookup_failed = false; let buffered = self .msg_secret_buffer .lookup(&chat_for_lookup, &target_sender_str, target_id) @@ -570,6 +623,7 @@ impl Client { Ok(found) => found, Err(e) => { log::warn!("[msg:{}] msmsg: alternate lookup failed: {e:?}", info.id); + lookup_failed = true; None } }, @@ -578,6 +632,7 @@ impl Client { "[msg:{}] backend error reading message_secret: {e:?}", info.id ); + lookup_failed = true; None } }, @@ -585,12 +640,26 @@ impl Client { let secret = match store_secret { Some(s) => s, None => { - let alternate = self + let alternate = match self .alternate_msg_secret_jid(&backend, &target_sender) .await - .ok() - .flatten() - .map(|j| j.to_non_ad_string()); + { + Ok(jid) => jid.map(|j| j.to_non_ad_string()), + Err(e) => { + // The resolver still gets its chance without the + // alternate identity, so this stays a miss for control + // flow. But it is a store that would not answer, and the + // reported cause has to say so: without this the resolver + // returning `None` would be named `NoMessageSecret`, + // blaming the companion for our own mapping table. + log::warn!( + "[msg:{}] msmsg: alternate jid lookup failed: {e:?}", + info.id + ); + lookup_failed = true; + None + } + }; match self .resolve_msg_secret_via_app( &chat_for_lookup, @@ -619,6 +688,16 @@ impl Client { "[msg:{}] msmsg: no message_secret stored for target_id={target_id} (primary or alternate)", info.id ); + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + if lookup_failed { + EncDecryptFailureReason::StorageFailure + } else { + EncDecryptFailureReason::NoMessageSecret + }, + ); self.spawn_nack(info, NackReason::MissingMessageSecret, None); return; } @@ -681,6 +760,15 @@ impl Client { "[msg:{}] msmsg AES-GCM open failed both attempts (primary={primary_err:?}, fallback={fallback_err:?})", info.id ); + // Both attempts see the same iv/payload, so a shape + // rejection is identical on either and the primary + // error decides. + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + msmsg_failure_reason(&primary_err), + ); self.spawn_nack(info, NackReason::MissingMessageSecret, None); return; } @@ -690,6 +778,12 @@ impl Client { "[msg:{}] msmsg AES-GCM open failed and no fallback msg_id: {primary_err:?}", info.id ); + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + msmsg_failure_reason(&primary_err), + ); self.spawn_nack(info, NackReason::MissingMessageSecret, None); return; } @@ -719,6 +813,12 @@ impl Client { "[msg:{}] msmsg plaintext is not a Message proto: {e:?}", info.id ); + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::PlaintextUnusable, + ); self.spawn_nack(info, NackReason::ParsingError, None); return; } diff --git a/src/message/receive.rs b/src/message/receive.rs index 954a9acfb..df33c1bf4 100644 --- a/src/message/receive.rs +++ b/src/message/receive.rs @@ -266,6 +266,12 @@ impl Client { Some(t) => t, None => { log::warn!("Enc node missing 'type' attribute, skipping"); + self.report_raw_enc_decrypt_failure( + &info, + enc_index, + None, + EncDecryptFailureReason::MalformedNode, + ); had_unknown_enc = true; continue; } @@ -303,6 +309,12 @@ impl Client { // Either way the stanza needs the fallback ack or the server replays. if EncType::from_wire(enc_type.as_ref()).is_none() { log::warn!("Enc node has unknown type: {enc_type}"); + self.report_raw_enc_decrypt_failure( + &info, + enc_index, + Some(enc_type.as_ref()), + EncDecryptFailureReason::UnsupportedEncType, + ); had_unknown_enc = true; continue; } @@ -311,6 +323,12 @@ impl Client { Some(p) => p, None => { log::warn!("Enc node {enc_type} has no content"); + self.report_raw_enc_decrypt_failure( + &info, + enc_index, + Some(enc_type.as_ref()), + EncDecryptFailureReason::MalformedNode, + ); had_unknown_enc = true; continue; } @@ -428,6 +446,25 @@ impl Client { "Connection torn down while awaiting the processing permit; leaving message {} for redelivery", info.id ); + // Every `` still queued here is abandoned without being tried. + // Reported before the bail, because classification already reported + // any node it set aside: staying silent would leave a stanza whose + // malformed `` was reported and whose decryptable siblings + // were not, which is the one shape this event promises not to + // produce. The stanza is unacked and will come back, and the event + // repeats with it. + for payload in session_payloads + .iter() + .chain(&group_payloads) + .chain(&bot_payloads) + { + self.report_enc_decrypt_failure( + &info, + payload.enc_index, + payload.enc_type.as_wire_str(), + EncDecryptFailureReason::NotAttempted, + ); + } return; } @@ -459,6 +496,14 @@ impl Client { session_payload_count, sender_encryption_jid.observe() ); + for payload in &session_payloads { + self.report_enc_decrypt_failure( + &info, + payload.enc_index, + payload.enc_type.as_wire_str(), + EncDecryptFailureReason::NotAttempted, + ); + } } SessionBatchOutcome::default() }; @@ -508,6 +553,24 @@ impl Client { } } } else { + // Reported outside the duplicate guard below, unlike the log. + // The duplicate rule that keeps redelivery out of this event does + // not reach here: `should_process_skmsg_after_session` already + // returns true for a batch whose session `` were duplicates + // and nothing else, so that stanza never takes this branch. What + // does take it with `session_had_duplicates` set is a batch that + // ALSO had a genuine failure — and that failure skipped this + // skmsg on the first delivery too, so it has produced no + // plaintext on any delivery and there is no earlier success for + // the redelivery to stand in for. + for payload in &group_payloads { + self.report_enc_decrypt_failure( + &info, + payload.enc_index, + payload.enc_type.as_wire_str(), + EncDecryptFailureReason::NotAttempted, + ); + } // Only show warning if session messages actually FAILED (not duplicates) if !session_had_duplicates { if info.is_expired_status() { @@ -543,8 +606,12 @@ impl Client { // tell the server we processed it, incrementing the offline counter. // The transport is sufficient for acknowledgment. } - // If session_had_duplicates is true, we silently skip (no warning, no event) - // because the message was already processed in a previous session + // If session_had_duplicates is true, we skip the warning and the + // UndecryptableMessage because the message was already processed + // in a previous session. The per-`` reports above are not + // skipped with them: they answer whether an index produced + // plaintext, and the skipped skmsg produced none on this + // delivery or the first. } } else if !session_decrypted_successfully && !session_had_duplicates @@ -719,6 +786,12 @@ impl Client { // |= so a later dedup'd return (false) can't clobber a true // set by a prior iteration in this batch. outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type_str, + EncDecryptFailureReason::MalformedCiphertext, + ); outcome.undecryptable |= self .dispatch_undecryptable_event( Arc::clone(info), @@ -826,6 +899,12 @@ impl Client { wacore::types::jid::observe_protocol_address(address) ); outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::StorageFailure, + ); continue; } // Flush immediately so the backend is updated BEFORE the retry decrypt below. @@ -836,6 +915,12 @@ impl Client { wacore::types::jid::observe_protocol_address(address) ); outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::StorageFailure, + ); continue; } log::info!( @@ -933,7 +1018,7 @@ impl Client { MigrationDecryptResult::Duplicate => { outcome.duplicate = true; } - MigrationDecryptResult::NotDecrypted => { + MigrationDecryptResult::NotDecrypted(terminal) => { log::debug!( "[msg:{}] InvalidPreKeyId after identity change for {}. \ Sending retry receipt with fresh keys.", @@ -941,6 +1026,14 @@ impl Client { address ); outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + terminal.unwrap_or( + EncDecryptFailureReason::UnknownPreKey, + ), + ); outcome.undecryptable |= self .handle_decrypt_failure( info, @@ -960,6 +1053,28 @@ impl Client { // Send retry receipt so the sender resends with a PreKeySignalMessage // to establish a new session with the new identity outcome.had_failure = true; + // The retry can fail for a reason that has + // nothing to do with the identity that sent + // it here — a MAC that would not verify, a + // store that would not answer, a signed + // pre-key we no longer hold. `session_error_reason` + // names those the way the sibling arms do. + // What it leaves unclassified keeps + // `UntrustedIdentity`, which on this path is + // the more specific of the two: the retry + // ran after the identity was cleared, and + // nothing else explains it failing. + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + match session_error_reason(&retry_err) { + EncDecryptFailureReason::SignalError => { + EncDecryptFailureReason::UntrustedIdentity + } + reason => reason, + }, + ); outcome.undecryptable |= self .handle_decrypt_failure( info, @@ -994,7 +1109,10 @@ impl Client { } // Try PN→LID session migration before sending retry receipt if let SignalProtocolError::SessionNotFound(_) = e { - match self + // `Some` only when a migration ran and its retry decrypt + // failed: then that failure is the terminal one, not the + // error that opened the migration. + let terminal = match self .try_pn_to_lid_migration_decrypt( sender_encryption_jid, &signal_address, @@ -1019,8 +1137,8 @@ impl Client { outcome.duplicate = true; continue; } - MigrationDecryptResult::NotDecrypted => {} - } + MigrationDecryptResult::NotDecrypted(reason) => reason, + }; debug!( "[msg:{}] No session found for {} message from {}. Sending retry receipt to request session establishment.", @@ -1029,6 +1147,12 @@ impl Client { info.source.sender.observe() ); outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + terminal.unwrap_or(EncDecryptFailureReason::NoSession), + ); outcome.undecryptable |= self .handle_decrypt_failure(info, RetryReason::NoSession, decrypt_fail_mode) .await; @@ -1039,7 +1163,10 @@ impl Client { ) { // whatsmeow migrates PN sessions before decrypt; a fresh // LID record can otherwise shadow the sender's PN ratchet. - match self + // `Some` only when a migration ran and its retry decrypt + // failed: then that failure is the terminal one, not the + // error that opened the migration. + let terminal = match self .try_pn_to_lid_migration_decrypt( sender_encryption_jid, &signal_address, @@ -1064,16 +1191,25 @@ impl Client { outcome.duplicate = true; continue; } - MigrationDecryptResult::NotDecrypted => {} - } + MigrationDecryptResult::NotDecrypted(reason) => reason, + }; // WAWebMsgProcessingDecryptionHandler classifies both as // SignalRetryable -> sendRetryReceipt only, with no delete. - let (reason, label) = if matches!(e, SignalProtocolError::BadMac(_)) { - (RetryReason::BadMac, "BadMac") - } else { - (RetryReason::InvalidMessage, "InvalidMessage") - }; + let (reason, label, failure) = + if matches!(e, SignalProtocolError::BadMac(_)) { + ( + RetryReason::BadMac, + "BadMac", + EncDecryptFailureReason::BadMac, + ) + } else { + ( + RetryReason::InvalidMessage, + "InvalidMessage", + EncDecryptFailureReason::InvalidMessage, + ) + }; log::log!( decrypt_fail_log_level(decrypt_fail_mode), "[msg:{}] Decryption failed for {} message from {} due to {label}. \ @@ -1084,6 +1220,12 @@ impl Client { ); outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + terminal.unwrap_or(failure), + ); outcome.undecryptable |= self .handle_decrypt_failure(info, reason, decrypt_fail_mode) .await; @@ -1093,7 +1235,10 @@ impl Client { // session exists under a PN address (legacy migration). // Migrating lets Signal use the existing ratchet state // instead of looking up the consumed one-time prekey. - match self + // `Some` only when a migration ran and its retry decrypt + // failed: then that failure is the terminal one, not the + // error that opened the migration. + let terminal = match self .try_pn_to_lid_migration_decrypt( sender_encryption_jid, &signal_address, @@ -1118,8 +1263,8 @@ impl Client { outcome.duplicate = true; continue; } - MigrationDecryptResult::NotDecrypted => {} - } + MigrationDecryptResult::NotDecrypted(reason) => reason, + }; log::debug!( "[msg:{}] Decryption failed for {} message from {} due to InvalidPreKeyId. \ @@ -1132,6 +1277,12 @@ impl Client { // Send retry receipt with fresh prekeys outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + terminal.unwrap_or(EncDecryptFailureReason::UnknownPreKey), + ); outcome.undecryptable |= self .handle_decrypt_failure( info, @@ -1153,6 +1304,12 @@ impl Client { ); outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::UnknownPreKey, + ); outcome.undecryptable |= self .handle_decrypt_failure( info, @@ -1171,6 +1328,12 @@ impl Client { e ); outcome.had_failure = true; + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + signal_error_reason(&e), + ); outcome.undecryptable |= self .dispatch_undecryptable_event( Arc::clone(info), @@ -1217,6 +1380,16 @@ impl Client { ); outcome.plaintext_failed = true; outcome.had_failure = true; + // The one report that can follow a DecryptedPayload for the + // same enc: the bytes existed, they just could not be made + // into a message. Unpadding fails ahead of that event, so + // there this is the only signal. + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::PlaintextUnusable, + ); outcome.undecryptable |= self.handle_plaintext_failure(info, decrypt_fail_mode).await; } @@ -1260,6 +1433,7 @@ impl Client { let ciphertext = &payload.ciphertext[..]; let padding_version = payload.padding_version; let enc_index = payload.enc_index; + let enc_type = payload.enc_type.as_wire_str(); log::debug!( "Looking up sender key for group {} with sender address {} (from sender JID: {})", @@ -1299,6 +1473,12 @@ impl Client { .await { log::warn!("Failed processing group plaintext (batch): {e:?}"); + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::PlaintextUnusable, + ); } } Err(SignalProtocolError::DuplicatedMessage(iteration, counter)) => { @@ -1318,6 +1498,14 @@ impl Client { } } Err(SignalProtocolError::NoSenderKeyState(msg)) => { + // Reported before the expired-status early return: the enc + // failed either way, and only what happens next differs. + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + EncDecryptFailureReason::NoSenderKey, + ); if info.is_expired_status() { log::debug!( "[msg:{}] Skipping retry for expired status from {}", @@ -1349,6 +1537,23 @@ impl Client { .await; } Err(e) => { + // Envelope and storage failures are named by the shared + // classifier; only what it leaves unnamed is refined by the + // same predicate the retry decision uses, so the reported + // cause and the recovery the client chose cannot disagree. + self.report_enc_decrypt_failure( + info, + enc_index, + enc_type, + match signal_error_reason(&e) { + EncDecryptFailureReason::SignalError + if group_decrypt_retry_reason(&e).is_some() => + { + EncDecryptFailureReason::InvalidMessage + } + reason => reason, + }, + ); if info.is_expired_status() { log::debug!( "[msg:{}] Ignoring decrypt error for expired status from {}: {:?}", @@ -1668,11 +1873,11 @@ impl Client { deferred: &mut Vec, ) -> MigrationDecryptResult { if !parsed_message.is_available() || !sender_jid.is_lid() { - return MigrationDecryptResult::NotDecrypted; + return MigrationDecryptResult::NotDecrypted(None); } let Some(pn) = self.lid_pn_cache.get_phone_number(&sender_jid.user).await else { - return MigrationDecryptResult::NotDecrypted; + return MigrationDecryptResult::NotDecrypted(None); }; // Release the address lock so the migration loop can acquire it for @@ -1696,7 +1901,7 @@ impl Client { info.id, info.source.sender.observe() ); - return MigrationDecryptResult::NotDecrypted; + return MigrationDecryptResult::NotDecrypted(None); } match decrypt_session_message(parsed_message, signal_address, adapter, rng).await { @@ -1737,7 +1942,9 @@ impl Client { "[msg:{}] Decryption still failed after PN→LID migration: {retry_err:?}", info.id ); - MigrationDecryptResult::NotDecrypted + // A session was found and moved; whatever failed now is the + // terminal cause, not the error that opened the migration. + MigrationDecryptResult::NotDecrypted(Some(session_error_reason(&retry_err))) } } } diff --git a/src/message/retry.rs b/src/message/retry.rs index 4574f1966..2c881e8ec 100644 --- a/src/message/retry.rs +++ b/src/message/retry.rs @@ -52,6 +52,84 @@ impl Client { .await } + /// Report that one `` of a stanza produced no plaintext. + /// + /// Pure observation: it neither decides nor reflects what the receive path + /// does next (retry receipt, nack, ack, or nothing). Every branch that + /// abandons an `` **this client was going to decrypt** calls this + /// exactly once for it, so a consumer holding the lease can pair each + /// [`Event::DecryptedPayload`] with the failure of every sibling that + /// produced none. + /// + /// Two kinds of `` are outside that pairing on purpose. A duplicate + /// was decrypted on an earlier delivery, so neither event fires. And an + /// `` claimed by a registered `EncHandler` is not this client's to + /// decrypt at all: the consumer that registered the handler already sees + /// its own `Err` directly, the handler runs in a detached task, and + /// reporting from there would break the one ordering this event does + /// promise — that a stanza's events all come from its receive task. + /// + /// Deliberately *not* deduplicated: `UndecryptableMessage` is single-flight + /// per `(chat, id)` because a UI must not show two placeholders for one + /// message, and that is exactly what makes it silent on the second delivery + /// of a stanza that keeps failing. This one reports each delivery. + pub(crate) fn report_enc_decrypt_failure( + &self, + info: &Arc, + enc_index: usize, + enc_type: &'static str, + reason: EncDecryptFailureReason, + ) { + if !self.enc_decrypt_failed_forwarding_enabled() { + return; + } + self.dispatch_enc_decrypt_failure( + info, + enc_index, + Some(std::borrow::Cow::Borrowed(enc_type)), + reason, + ); + } + + /// Same, for a classification-time failure where the `type` attribute is + /// whatever the wire carried — a type this build does not implement, or + /// none at all. The copy is made past the gate, so an unheld lease still + /// costs one atomic load. + pub(crate) fn report_raw_enc_decrypt_failure( + &self, + info: &Arc, + enc_index: usize, + enc_type: Option<&str>, + reason: EncDecryptFailureReason, + ) { + if !self.enc_decrypt_failed_forwarding_enabled() { + return; + } + self.dispatch_enc_decrypt_failure( + info, + enc_index, + enc_type.map(|enc_type| std::borrow::Cow::Owned(enc_type.to_owned())), + reason, + ); + } + + fn dispatch_enc_decrypt_failure( + &self, + info: &Arc, + enc_index: usize, + enc_type: Option>, + reason: EncDecryptFailureReason, + ) { + self.core.event_bus.dispatch(Event::EncDecryptFailed( + crate::types::events::EncDecryptFailed::builder() + .info(Arc::clone(info)) + .enc_index(enc_index) + .maybe_enc_type(enc_type) + .reason(reason) + .build(), + )); + } + /// Dispatch an `UndecryptableMessage` event at most once per `(chat, id)` /// via the single-flight `get_with` semantic on `undecryptable_dispatched`. /// The atomic arm avoids the get-then-insert race where two concurrent diff --git a/src/message/tests.rs b/src/message/tests.rs index ef3a944cc..6b4423551 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -13273,3 +13273,1366 @@ async fn forwarding_stops_when_the_last_lease_drops() { "the last lease dropping turns it back off" ); } + +// --- per-`` decrypt-failure reporting ---------------------------------- + +use crate::message::msg_secret::msmsg_failure_reason; +use wacore::types::events::{EncDecryptFailed, EncDecryptFailureReason}; + +/// Every `EncDecryptFailed` a client emitted, in dispatch order, plus the +/// `enc_index` of every `DecryptedPayload` so a test can assert that the two +/// events number one stanza and not two. +#[derive(Default)] +struct EncOutcomeRecorder { + failures: std::sync::Mutex>, + decrypted: std::sync::Mutex>, +} + +impl EventHandler for EncOutcomeRecorder { + fn handle_event(&self, event: Arc) { + match &*event { + Event::EncDecryptFailed(failed) => { + self.failures.lock().unwrap().push(failed.clone()); + } + Event::DecryptedPayload(payload) => self + .decrypted + .lock() + .unwrap() + .push((payload.enc_index, payload.enc_type)), + _ => {} + } + } +} + +impl EncOutcomeRecorder { + /// `(enc_index, enc_type, reason)` for each failure, in dispatch order. + fn failures(&self) -> Vec<(usize, Option, EncDecryptFailureReason)> { + self.failures + .lock() + .unwrap() + .iter() + .map(|f| { + ( + f.enc_index, + f.enc_type.as_ref().map(|t| t.to_string()), + f.reason, + ) + }) + .collect() + } + + fn decrypted(&self) -> Vec<(usize, &'static str)> { + self.decrypted.lock().unwrap().clone() + } +} + +/// Subscribe a recorder and hold both forwarding leases, so one test can watch +/// a stanza's successes and failures together. +fn watch_enc_outcomes( + client: &Arc, +) -> ( + Arc, + ( + crate::client::EncDecryptFailedLease, + crate::client::DecryptedPayloadLease, + ), +) { + let recorder = Arc::new(EncOutcomeRecorder::default()); + client.subscribe_handler(recorder.clone()).detach(); + ( + recorder, + ( + client.acquire_enc_decrypt_failed_forwarding(), + client.acquire_decrypted_payload_forwarding(), + ), + ) +} + +fn enc_payload_at(enc_type: &str, bytes: Vec, enc_index: usize) -> EncPayload { + let enc = NodeBuilder::new("enc") + .attr("type", enc_type) + .bytes(bytes) + .build(); + EncPayload::from_node_ref(&enc.as_node_ref(), enc_index).expect("payload") +} + +fn dm_info(msg_id: &str, sender: &Jid) -> Arc { + Arc::new(MessageInfo { + id: msg_id.to_string(), + source: crate::types::message::MessageSource { + sender: sender.clone(), + chat: sender.clone(), + ..Default::default() + }, + ..Default::default() + }) +} + +/// Bytes that parse as neither a `SignalMessage` nor a `PreKeySignalMessage`: +/// too short to hold a MAC, so the envelope is rejected before any key +/// material is touched. +const UNPARSEABLE_ENVELOPE: [u8; 3] = [0x33, 0x01, 0x02]; + +async fn classified(client: &Arc, node: wacore_binary::Node) -> Option { + client.classify_incoming_message(&node_to_arc(node)).await +} + +/// The three ways an `` dies before any decryption: no `type`, a `type` +/// this build does not implement, and a body that is not there. All three are +/// reported, each against the position it occupied in the stanza, and all three +/// say the client never reached a decryption attempt. +#[tokio::test] +async fn classification_reports_every_enc_it_sets_aside() { + let (client, _transport) = capturing_client("enc_fail_classify").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let node = NodeBuilder::new("message") + .attr("from", "5511777776666@s.whatsapp.net") + .attr("id", "ENCFAIL_CLASSIFY") + .attr("type", "text") + .children([ + // 0: no `type` at all. + NodeBuilder::new("enc").bytes(vec![1u8; 8]).build(), + // 1: a type this build has no path for. + NodeBuilder::new("enc") + .attr("type", "frskmsg") + .bytes(vec![2u8; 8]) + .build(), + // 2: a known type with nothing to decrypt. + NodeBuilder::new("enc").attr("type", "msg").build(), + // 3: usable, so classification does not bail before reporting. + NodeBuilder::new("enc") + .attr("type", "pkmsg") + .bytes(vec![4u8; 8]) + .build(), + ]) + .build(); + + let result = classified(&client, node).await.expect("one usable payload"); + assert_eq!( + result + .session_payloads + .iter() + .map(|p| p.enc_index) + .collect::>(), + [3], + "the usable enc keeps its stanza position", + ); + + assert_eq!( + recorder.failures(), + vec![ + (0, None, EncDecryptFailureReason::MalformedNode), + ( + 1, + Some("frskmsg".to_string()), + EncDecryptFailureReason::UnsupportedEncType + ), + ( + 2, + Some("msg".to_string()), + EncDecryptFailureReason::MalformedNode + ), + ], + ); + assert!( + recorder + .failures + .lock() + .unwrap() + .iter() + .all(|f| !f.reason.decryption_was_attempted()), + "none of these reached a decryption; that is what separates them from a failed one", + ); +} + +/// A stanza whose every `` is unusable is transport-acked and classified +/// away — and must still report each one. This is the path where nothing +/// downstream ever sees the stanza again. +#[tokio::test] +async fn an_all_unusable_stanza_still_reports_each_enc() { + let (client, transport) = capturing_client("enc_fail_all_unknown").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let node = NodeBuilder::new("message") + .attr("from", "5511777776666@s.whatsapp.net") + .attr("id", "ENCFAIL_ALL_UNKNOWN") + .attr("type", "text") + .children([ + NodeBuilder::new("enc") + .attr("type", "frskmsg") + .bytes(vec![1u8; 8]) + .build(), + NodeBuilder::new("enc") + .attr("type", "frskmsg") + .bytes(vec![2u8; 8]) + .build(), + ]) + .build(); + + assert!( + classified(&client, node).await.is_none(), + "nothing usable, so the stanza is acked away" + ); + assert_eq!( + recorder + .failures() + .iter() + .map(|(index, _, reason)| (*index, *reason)) + .collect::>(), + [ + (0, EncDecryptFailureReason::UnsupportedEncType), + (1, EncDecryptFailureReason::UnsupportedEncType), + ], + ); + + // The ack is what makes this the terminal path: without it the server + // replays the stanza forever, and the two reports above would repeat with + // it. Assert the ack the doc claims, and that reporting did not also add a + // nack for a stanza the client chose to drop quietly. + crate::test_utils::poll_until("the unusable stanza to be transport-acked", || { + find_message_ack_for(&transport.sent(), "ENCFAIL_ALL_UNKNOWN").is_some() + }) + .await; + crate::test_utils::wait_for_outbound_tasks(&client).await; + let frames = transport.sent(); + assert_eq!( + message_acks_for(&frames, "ENCFAIL_ALL_UNKNOWN"), + 1, + "exactly one transport ack", + ); + assert_eq!( + find_message_nack_error(&frames, "ENCFAIL_ALL_UNKNOWN"), + None, + "observing the failures must not turn the drop into a nack", + ); +} + +/// An envelope that does not parse never reaches a cipher, and is reported as +/// such — distinct from the ciphertext that parses and then fails. +#[tokio::test] +async fn a_session_envelope_that_does_not_parse_reports_malformed_ciphertext() { + let client = create_test_client_for_retry_with_id("enc_fail_envelope").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let sender: Jid = "5511900000001@s.whatsapp.net".parse().unwrap(); + let info = dm_info("ENCFAIL_ENVELOPE", &sender); + client + .clone() + .process_session_enc_batch( + vec![enc_payload_at("msg", UNPARSEABLE_ENVELOPE.to_vec(), 4)], + &info, + &sender, + DecryptFailMode::Show, + ) + .await; + + assert_eq!( + recorder.failures(), + vec![( + 4, + Some("msg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + )], + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// The common DM failure: a well-formed `SignalMessage` for a session this +/// device has never had. +#[tokio::test] +async fn a_session_enc_with_no_session_reports_no_session() { + use wacore::libsignal::protocol::{IdentityKeyPair, KeyPair, SignalMessage}; + let client = create_test_client_for_retry_with_id("enc_fail_nosession").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let sender: Jid = "5511900000002@s.whatsapp.net".parse().unwrap(); + let info = dm_info("ENCFAIL_NOSESSION", &sender); + let mut rng = rand::make_rng::(); + let signal_message = SignalMessage::new( + 4, + &[0u8; 32], + KeyPair::generate(&mut rng).public_key, + 0, + 0, + b"test", + IdentityKeyPair::generate(&mut rng).identity_key(), + IdentityKeyPair::generate(&mut rng).identity_key(), + ) + .expect("valid inputs"); + + client + .clone() + .process_session_enc_batch( + vec![enc_payload_at( + "msg", + signal_message.serialized().to_vec(), + 2, + )], + &info, + &sender, + DecryptFailMode::Show, + ) + .await; + + assert_eq!( + recorder.failures(), + vec![( + 2, + Some("msg".to_string()), + EncDecryptFailureReason::NoSession + )], + ); + assert!( + recorder.failures.lock().unwrap()[0] + .reason + .decryption_was_attempted(), + "the client did run a decrypt for this one", + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A tampered MAC on a real session: parses, then fails to authenticate. +#[tokio::test] +async fn a_tampered_session_enc_reports_bad_mac() { + let client = crate::test_utils::create_test_client_with_name("enc_fail_badmac").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let mut alice = AlicePeer::new("1111111111112@s.whatsapp.net").await; + let (bob_bundle, _) = bobs_prekey_bundle(&client).await; + let bob_addr = { + let snapshot = client.persistence_manager.get_device_snapshot(); + snapshot + .lid + .as_ref() + .or(snapshot.pn.as_ref()) + .expect("own jid") + .to_protocol_address() + }; + alice.install_bob_session(&bob_addr, &bob_bundle).await; + let pkmsg = alice.encrypt_text(&bob_addr, "hello").await; + let (established, _, _, _) = submit_and_check_session(&client, &alice.jid, &pkmsg).await; + assert!( + established, + "the session must exist before we tamper with it" + ); + + if let Some(record) = alice.sessions.0.get_mut(&bob_addr) + && let Some(state) = record.session_state_mut() + { + state.clear_unacknowledged_pre_key_message(); + } + let mut bytes = match alice.encrypt_text(&bob_addr, "world").await { + CiphertextMessage::SignalMessage(m) => m.serialized().to_vec(), + _ => panic!("expected a SignalMessage"), + }; + let last = bytes.len() - 1; + bytes[last] ^= 0xFF; + + let info = dm_info("ENCFAIL_BADMAC", &alice.jid); + client + .clone() + .process_session_enc_batch( + vec![enc_payload_at("msg", bytes, 1)], + &info, + &alice.jid, + DecryptFailMode::Show, + ) + .await; + + assert_eq!( + recorder.failures(), + vec![(1, Some("msg".to_string()), EncDecryptFailureReason::BadMac)], + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// Signal decrypts, and the bytes are not a message this build can read. The +/// one case where an `` gets both events: the payload was real, so +/// `DecryptedPayload` carries it, and it was unusable, so this reports why. +#[tokio::test] +async fn a_plaintext_that_will_not_decode_reports_plaintext_unusable() { + let client = crate::test_utils::create_test_client_with_name("enc_fail_plaintext").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let mut alice = AlicePeer::new("1111111111113@s.whatsapp.net").await; + let (bob_bundle, _) = bobs_prekey_bundle(&client).await; + let bob_addr = { + let snapshot = client.persistence_manager.get_device_snapshot(); + snapshot + .lid + .as_ref() + .or(snapshot.pn.as_ref()) + .expect("own jid") + .to_protocol_address() + }; + alice.install_bob_session(&bob_addr, &bob_bundle).await; + // Field 1 of `Message` is a string; a varint there fails the decode while + // the padding stays valid, so the plaintext exists and cannot be used. + let undecodable = MessageUtils::pad_message_v2(vec![0x08, 0x01]); + let ciphertext = alice.encrypt(&bob_addr, &undecodable).await; + + let info = dm_info("ENCFAIL_PLAINTEXT", &alice.jid); + let outcome = client + .clone() + .process_session_enc_batch( + vec![enc_payload_at("pkmsg", ciphertext.serialize().to_vec(), 6)], + &info, + &alice.jid, + DecryptFailMode::Show, + ) + .await; + assert!(outcome.decrypted, "Signal itself succeeded"); + + assert_eq!( + recorder.failures(), + vec![( + 6, + Some("pkmsg".to_string()), + EncDecryptFailureReason::PlaintextUnusable + )], + ); + assert_eq!( + recorder.decrypted(), + [(6, "pkmsg")], + "the same enc also produced bytes, and both events point at it", + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A group `` for a chain this device holds no sender key for. +#[tokio::test] +async fn a_group_enc_without_a_sender_key_reports_no_sender_key() { + let client = create_test_client_for_retry_with_id("enc_fail_nosk").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let group: Jid = "120363000000000002@g.us".parse().unwrap(); + let participant: Jid = "5511900000003:1@s.whatsapp.net".parse().unwrap(); + let info = Arc::new(MessageInfo { + id: "ENCFAIL_NOSK".to_string(), + source: crate::types::message::MessageSource { + sender: participant, + chat: group.clone(), + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + + // Version 3 + protobuf + a 64-byte signature: parses as a SenderKeyMessage, + // so the lookup for the chain is what fails. + let mut skmsg = vec![0x33, 0x08, 0x01, 0x10, 0x01, 0x1A, 0x00]; + skmsg.extend(vec![0u8; 64]); + + client + .clone() + .process_classified_message( + ClassifiedMessage { + info, + sender_encryption_jid: group, + session_payloads: vec![], + group_payloads: vec![enc_payload_at("skmsg", skmsg, 5)], + bot_payloads: vec![], + max_sender_retry_count: 0, + decrypt_fail_mode: DecryptFailMode::Show, + }, + client.connection_generation.load(Ordering::Acquire), + ) + .await; + + assert_eq!( + recorder.failures(), + vec![( + 5, + Some("skmsg".to_string()), + EncDecryptFailureReason::NoSenderKey + )], + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// The skmsg the client deliberately does not try, because the session `` +/// carrying its sender key failed first. Reported as recognized-not-attempted, +/// which is what separates it from a decryption that was run and lost. +#[tokio::test] +async fn a_skmsg_skipped_after_a_session_failure_reports_not_attempted() { + let client = create_test_client_for_retry_with_id("enc_fail_skipped").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let group: Jid = "120363000000000003@g.us".parse().unwrap(); + let participant: Jid = "5511900000004@s.whatsapp.net".parse().unwrap(); + let info = Arc::new(MessageInfo { + id: "ENCFAIL_SKIPPED".to_string(), + source: crate::types::message::MessageSource { + sender: participant.clone(), + chat: group, + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + + client + .clone() + .process_classified_message( + ClassifiedMessage { + info, + sender_encryption_jid: participant, + session_payloads: vec![enc_payload_at("pkmsg", UNPARSEABLE_ENVELOPE.to_vec(), 0)], + group_payloads: vec![enc_payload_at("skmsg", vec![0u8; 71], 1)], + bot_payloads: vec![], + max_sender_retry_count: 0, + decrypt_fail_mode: DecryptFailMode::Show, + }, + client.connection_generation.load(Ordering::Acquire), + ) + .await; + + assert_eq!( + recorder.failures(), + vec![ + ( + 0, + Some("pkmsg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + ), + ( + 1, + Some("skmsg".to_string()), + EncDecryptFailureReason::NotAttempted + ), + ], + "the skmsg is never decrypted, and saying so is the point", + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A session `` on a stanza addressed from a group has no 1:1 session to +/// use, so the client drops it without trying. Before this event that drop was +/// a debug line and nothing else. +#[tokio::test] +async fn session_encs_from_a_group_sender_report_not_attempted() { + let client = create_test_client_for_retry_with_id("enc_fail_groupsender").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let group: Jid = "120363000000000004@g.us".parse().unwrap(); + let participant: Jid = "5511900000005@s.whatsapp.net".parse().unwrap(); + let info = Arc::new(MessageInfo { + id: "ENCFAIL_GROUPSENDER".to_string(), + source: crate::types::message::MessageSource { + sender: participant, + chat: group.clone(), + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + + client + .clone() + .process_classified_message( + ClassifiedMessage { + info, + sender_encryption_jid: group, + session_payloads: vec![enc_payload_at("msg", vec![0xFF, 0x00, 0x03], 0)], + group_payloads: vec![], + bot_payloads: vec![], + max_sender_retry_count: 0, + decrypt_fail_mode: DecryptFailMode::Show, + }, + client.connection_generation.load(Ordering::Acquire), + ) + .await; + + assert_eq!( + recorder.failures(), + vec![( + 0, + Some("msg".to_string()), + EncDecryptFailureReason::NotAttempted + )], + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A bot reply whose `messageSecret` this device does not hold. +#[tokio::test] +async fn a_bot_enc_without_its_secret_reports_no_message_secret() { + let (client, _transport) = capturing_client("enc_fail_msmsg").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", "ENCFAIL_MSMSG") + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", "OUT_MISSING") + .attr("target_sender_jid", "5511900000006@s.whatsapp.net") + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(encode_message_secret_message(&[7u8; 12], &[9u8; 32])) + .build(), + ]) + .build(); + client + .clone() + .handle_incoming_message(node_to_arc(node)) + .await; + + assert_eq!( + recorder.failures(), + vec![( + 0, + Some("msmsg".to_string()), + EncDecryptFailureReason::NoMessageSecret + )], + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// Fan-out: one stanza, several ``, some decrypting and some not. Each +/// event must name the node it belongs to, and the numbering must be the one +/// `DecryptedPayload` uses — two numberings across this pair of events would be +/// worse than having no failure event at all. +#[tokio::test] +async fn a_mixed_stanza_numbers_successes_and_failures_the_same_way() { + let client = crate::test_utils::create_test_client_with_name("enc_fail_fanout").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let mut alice = AlicePeer::new("1111111111114@s.whatsapp.net").await; + let (bob_bundle, _) = bobs_prekey_bundle(&client).await; + let bob_addr = { + let snapshot = client.persistence_manager.get_device_snapshot(); + snapshot + .lid + .as_ref() + .or(snapshot.pn.as_ref()) + .expect("own jid") + .to_protocol_address() + }; + alice.install_bob_session(&bob_addr, &bob_bundle).await; + let good = alice.encrypt_text(&bob_addr, "the one that works").await; + + let info = dm_info("ENCFAIL_FANOUT", &alice.jid); + // Positions 0 and 2 are unusable, 1 decrypts. Feeding them in one batch is + // the shape a fan-out stanza takes once classification has bucketed it. + let outcome = client + .clone() + .process_session_enc_batch( + vec![ + enc_payload_at("msg", UNPARSEABLE_ENVELOPE.to_vec(), 0), + enc_payload_at("pkmsg", good.serialize().to_vec(), 1), + enc_payload_at("msg", UNPARSEABLE_ENVELOPE.to_vec(), 2), + ], + &info, + &alice.jid, + DecryptFailMode::Show, + ) + .await; + assert!(outcome.decrypted, "the middle enc must actually decrypt"); + + assert_eq!( + recorder.failures(), + vec![ + ( + 0, + Some("msg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + ), + ( + 2, + Some("msg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + ), + ], + "each failure names its own node, and the one that worked is not among them", + ); + assert_eq!( + recorder.decrypted(), + [(1, "pkmsg")], + "the success carries the same numbering the failures do", + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// The gap this event fills at the other end: `UndecryptableMessage` is +/// single-flight per `(chat, id)`, so the second delivery of a stanza that +/// keeps failing produces nothing. This one reports both deliveries. +#[tokio::test] +async fn a_redelivered_stanza_reports_its_failure_again() { + let client = create_test_client_for_retry_with_id("enc_fail_redeliver").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + let undecryptable = Arc::new(EventRecorder::default()); + client.subscribe_handler(undecryptable.clone()).detach(); + + let sender: Jid = "5511900000007@s.whatsapp.net".parse().unwrap(); + let info = dm_info("ENCFAIL_REDELIVERED", &sender); + + for _ in 0..2 { + client + .clone() + .process_session_enc_batch( + vec![enc_payload_at("msg", UNPARSEABLE_ENVELOPE.to_vec(), 0)], + &info, + &sender, + DecryptFailMode::Show, + ) + .await; + } + + assert_eq!( + recorder.failures(), + vec![ + ( + 0, + Some("msg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + ), + ( + 0, + Some("msg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + ), + ], + "once per delivery", + ); + assert_eq!( + undecryptable.undecryptable().len(), + 1, + "the per-message event stays deduplicated; this test would be pointless otherwise", + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// Nothing is built or dispatched while no consumer asks, and the last lease +/// dropping turns it back off. +#[tokio::test] +async fn enc_failures_are_not_reported_without_a_lease() { + let client = create_test_client_for_retry_with_id("enc_fail_lease").await; + let recorder = Arc::new(EncOutcomeRecorder::default()); + client.subscribe_handler(recorder.clone()).detach(); + + let sender: Jid = "5511900000008@s.whatsapp.net".parse().unwrap(); + let info = dm_info("ENCFAIL_LEASE", &sender); + let run = || { + let client = client.clone(); + let info = info.clone(); + let sender = sender.clone(); + async move { + client + .process_session_enc_batch( + vec![enc_payload_at("msg", UNPARSEABLE_ENVELOPE.to_vec(), 0)], + &info, + &sender, + DecryptFailMode::Show, + ) + .await; + } + }; + + assert!( + !client.enc_decrypt_failed_forwarding_enabled(), + "no lease, no gate", + ); + run().await; + assert!( + recorder.failures().is_empty(), + "nothing is emitted while no lease is held", + ); + + let first = client.acquire_enc_decrypt_failed_forwarding(); + let second = client.acquire_enc_decrypt_failed_forwarding(); + run().await; + assert_eq!(recorder.failures().len(), 1); + + drop(first); + run().await; + assert_eq!( + recorder.failures().len(), + 2, + "one lease still holds it open" + ); + + drop(second); + assert!(!client.enc_decrypt_failed_forwarding_enabled()); + run().await; + assert_eq!( + recorder.failures().len(), + 2, + "the last lease dropping turns it back off", + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A `DecryptedPayload` lease alone must not switch failure reporting on: the +/// two are counted apart so neither consumer pays for the other's event. +#[tokio::test] +async fn the_two_forwarding_gates_are_independent() { + let client = create_test_client_for_retry_with_id("enc_fail_gates").await; + let payload_lease = client.acquire_decrypted_payload_forwarding(); + assert!(client.decrypted_payload_forwarding_enabled()); + assert!( + !client.enc_decrypt_failed_forwarding_enabled(), + "asking for successes must not turn on failures", + ); + + let failure_lease = client.acquire_enc_decrypt_failed_forwarding(); + drop(payload_lease); + assert!( + !client.decrypted_payload_forwarding_enabled(), + "and dropping one must not keep the other's gate open", + ); + assert!(client.enc_decrypt_failed_forwarding_enabled()); + drop(failure_lease); +} + +/// A group envelope libsignal could not even parse never reached a cipher. +/// Reporting it as an unclassified Signal error would put a malformed-wire +/// event in the same bucket as a real cryptographic failure. +#[tokio::test] +async fn a_group_envelope_that_does_not_parse_reports_malformed_ciphertext() { + let client = create_test_client_for_retry_with_id("enc_fail_skmsg_parse").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let group: Jid = "120363000000000005@g.us".parse().unwrap(); + let participant: Jid = "5511900000009@s.whatsapp.net".parse().unwrap(); + let info = Arc::new(MessageInfo { + id: "ENCFAIL_SKMSG_PARSE".to_string(), + source: crate::types::message::MessageSource { + sender: participant, + chat: group.clone(), + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + + client + .clone() + .process_classified_message( + ClassifiedMessage { + info, + sender_encryption_jid: group, + session_payloads: vec![], + // Too short to hold the trailing signature, so + // `SenderKeyMessage::try_from` rejects it before the chain is + // ever looked up. + group_payloads: vec![enc_payload_at("skmsg", vec![0x33, 0x01], 0)], + bot_payloads: vec![], + max_sender_retry_count: 0, + decrypt_fail_mode: DecryptFailMode::Show, + }, + client.connection_generation.load(Ordering::Acquire), + ) + .await; + + assert_eq!( + recorder.failures(), + vec![( + 0, + Some("skmsg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + )], + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A bot payload too short to hold its GCM tag is rejected on shape, before any +/// key is derived. It must not be reported as a failed authentication — that +/// would let malformed wire data count against the peer's session health. +#[tokio::test] +async fn a_bot_envelope_too_short_for_its_tag_is_not_reported_as_bad_mac() { + use crate::store::commands::DeviceCommand; + let (client, _transport) = capturing_client("enc_fail_msmsg_short").await; + client + .persistence_manager + .process_command(DeviceCommand::SetLid(Some( + "999888777666554:0@lid".parse().unwrap(), + ))) + .await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let bot_chat: Jid = "867051314767696@bot".parse().unwrap(); + let sender_identity = client + .dm_sender_identity_for(&bot_chat) + .await + .expect("LID seeded"); + client + .persist_outbound_msg_secret( + &bot_chat, + &sender_identity, + "OUT_SHORT", + &[0x5Au8; 32], + wacore::msg_secret::RetentionClass::Bot, + crate::send::SendInstant::now(), + ) + .await; + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", "ENCFAIL_MSMSG_SHORT") + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", "OUT_SHORT") + .attr("target_sender_jid", "999888777666554@lid") + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + // A valid 12-byte IV, and four bytes where a 16-byte tag has to + // be: the secret is found, and there is nothing to authenticate. + .bytes(encode_message_secret_message(&[3u8; 12], &[9u8; 4])) + .build(), + ]) + .build(); + client + .clone() + .handle_incoming_message(node_to_arc(node)) + .await; + + assert_eq!( + recorder.failures(), + vec![( + 0, + Some("msmsg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + )], + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A signed pre-key we no longer hold, reported as such. The identity-change +/// retry reaches the same libsignal error by a different route and now names +/// the same cause, so a consumer keying a resync off `UnknownPreKey` sees both. +#[tokio::test] +async fn a_rotated_out_signed_prekey_reports_unknown_prekey() { + let client = crate::test_utils::create_test_client_with_name("enc_fail_spk").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let (bundle, bob_jid) = bobs_prekey_bundle_with_spk_id(&client, 4243).await; + let bob_addr = bob_jid.to_protocol_address(); + let mut alice = AlicePeer::new("15550002003@s.whatsapp.net").await; + alice.install_bob_session(&bob_addr, &bundle).await; + let pkmsg = alice.encrypt_text(&bob_addr, "rotated out").await; + let bytes = match &pkmsg { + CiphertextMessage::PreKeySignalMessage(m) => m.serialized().to_vec(), + _ => panic!("must be a pkmsg so decrypt looks up the signed prekey"), + }; + + let info = dm_info("ENCFAIL_SPK", &alice.jid); + let outcome = client + .clone() + .process_session_enc_batch( + vec![enc_payload_at("pkmsg", bytes, 2)], + &info, + &alice.jid, + DecryptFailMode::Show, + ) + .await; + assert!(!outcome.decrypted, "the signed prekey is missing"); + + assert_eq!( + recorder.failures(), + vec![( + 2, + Some("pkmsg".to_string()), + EncDecryptFailureReason::UnknownPreKey + )], + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A stanza abandoned by a teardown reports every `` it never tried. +/// +/// The bail happens after classification, so its failures are already out. If +/// the queued payloads stayed silent, a mixed stanza would report the malformed +/// index and nothing for the decryptable siblings — the one shape the event +/// promises not to produce. The stanza is unacked and comes back; the event +/// repeats with it. +#[tokio::test] +async fn a_stanza_abandoned_by_a_teardown_reports_what_it_never_tried() { + let client = crate::test_utils::create_test_client_with_name("enc_fail_teardown").await; + let (recorder, _leases) = watch_enc_outcomes(&client); + + let sender: Jid = "15550002005@s.whatsapp.net".parse().unwrap(); + let info = dm_info("ENCFAIL_TEARDOWN", &sender); + + // The generation this stanza was classified under, before teardown bumps it. + let stale_generation = client.connection_generation.load(Ordering::Acquire); + client.connection_generation.fetch_add(1, Ordering::AcqRel); + + client + .clone() + .process_classified_message( + ClassifiedMessage { + info, + sender_encryption_jid: sender.clone(), + session_payloads: vec![enc_payload_at("msg", vec![1u8; 40], 1)], + group_payloads: vec![enc_payload_at("skmsg", vec![2u8; 40], 2)], + bot_payloads: vec![], + max_sender_retry_count: 0, + decrypt_fail_mode: DecryptFailMode::Show, + }, + stale_generation, + ) + .await; + + assert_eq!( + recorder.failures(), + vec![ + ( + 1, + Some("msg".to_string()), + EncDecryptFailureReason::NotAttempted + ), + ( + 2, + Some("skmsg".to_string()), + EncDecryptFailureReason::NotAttempted + ), + ], + "every queued index is reported as never tried, none is decrypted", + ); + assert!( + recorder.decrypted().is_empty(), + "the teardown bailed before any decrypt could run", + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// A duplicate alongside a genuine failure does not buy the `skmsg` its silence. +/// +/// `should_process_skmsg_after_session` already lets a batch through when its +/// session `` were duplicates and nothing else, so the only way a duplicate +/// reaches the skip branch is beside an `` that really failed — and that +/// failure skipped this `skmsg` on the first delivery too. There is no earlier +/// success for the redelivery to stand in for, so the skipped index is reported +/// and the duplicate itself still is not. +#[tokio::test] +async fn a_skmsg_skipped_beside_a_duplicate_is_still_reported() { + let client = crate::test_utils::create_test_client_with_name("enc_fail_dup_skip").await; + + let mut alice = AlicePeer::new("15550002004@s.whatsapp.net").await; + let (bundle, bob_jid) = bobs_prekey_bundle(&client).await; + let bob_addr = bob_jid.to_protocol_address(); + alice.install_bob_session(&bob_addr, &bundle).await; + let pkmsg = alice.encrypt_text(&bob_addr, "establish").await; + let (established, _, _, _) = submit_and_check_session(&client, &alice.jid, &pkmsg).await; + assert!(established, "the session must exist first"); + + // Force a plain SignalMessage, whose replay libsignal answers with + // `DuplicatedMessage` off the message-key cache. + if let Some(record) = alice.sessions.0.get_mut(&bob_addr) + && let Some(state) = record.session_state_mut() + { + state.clear_unacknowledged_pre_key_message(); + } + let bytes = match alice.encrypt_text(&bob_addr, "first delivery").await { + CiphertextMessage::SignalMessage(m) => m.serialized().to_vec(), + _ => panic!("expected a SignalMessage"), + }; + + let first = dm_info("ENCFAIL_DUP_FIRST", &alice.jid); + let outcome = client + .clone() + .process_session_enc_batch( + vec![enc_payload_at("msg", bytes.clone(), 0)], + &first, + &alice.jid, + DecryptFailMode::Show, + ) + .await; + assert!(outcome.decrypted, "the first delivery must decrypt"); + + // Only now start watching, so the first delivery's events are not counted. + let (recorder, _leases) = watch_enc_outcomes(&client); + + // The redelivery: the same ciphertext (a duplicate) alongside one that + // genuinely fails, which is what drives `should_process_skmsg_after_session` + // to skip the group payload. + let group: Jid = "120363000000000006@g.us".parse().unwrap(); + let redelivered = Arc::new(MessageInfo { + id: "ENCFAIL_DUP_REDELIVERED".to_string(), + source: crate::types::message::MessageSource { + sender: alice.jid.clone(), + chat: group, + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + client + .clone() + .process_classified_message( + ClassifiedMessage { + info: redelivered, + sender_encryption_jid: alice.jid.clone(), + session_payloads: vec![ + enc_payload_at("msg", bytes, 0), + enc_payload_at("msg", UNPARSEABLE_ENVELOPE.to_vec(), 1), + ], + group_payloads: vec![enc_payload_at("skmsg", vec![0u8; 71], 2)], + bot_payloads: vec![], + max_sender_retry_count: 0, + decrypt_fail_mode: DecryptFailMode::Show, + }, + client.connection_generation.load(Ordering::Acquire), + ) + .await; + + assert_eq!( + recorder.failures(), + vec![ + ( + 1, + Some("msg".to_string()), + EncDecryptFailureReason::MalformedCiphertext + ), + ( + 2, + Some("skmsg".to_string()), + EncDecryptFailureReason::NotAttempted + ), + ], + "the duplicate reports nothing, the enc that really failed does, and so \ + does the skmsg that failure skipped", + ); + crate::test_utils::wait_for_outbound_tasks(&client).await; +} + +/// The shared classifier for libsignal errors the decrypt arms do not name +/// themselves. A store that could not answer is local, not cryptographic: +/// reporting it as `SignalError` would blame the peer for our own disk, and +/// both the session catch-all and the group arm read this one function so they +/// cannot drift apart. +#[test] +fn a_backend_error_is_storage_not_a_signal_failure() { + use wacore::libsignal::protocol::SignalProtocolError; + + #[derive(Debug)] + struct StoreDown; + impl std::fmt::Display for StoreDown { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("store down") + } + } + impl std::error::Error for StoreDown {} + + assert_eq!( + signal_error_reason(&SignalProtocolError::BackendError( + "backend", + Box::new(StoreDown) + )), + EncDecryptFailureReason::StorageFailure, + ); + assert_eq!( + signal_error_reason(&SignalProtocolError::CiphertextMessageTooShort(3)), + EncDecryptFailureReason::MalformedCiphertext, + "an envelope that would not parse stays malformed", + ); + assert_eq!( + signal_error_reason(&SignalProtocolError::InvalidSenderKeySession), + EncDecryptFailureReason::StorageFailure, + "a sender-key record that loaded without usable state is our row, not the \ + peer's ciphertext — libsignal only raises this while reading it", + ); + assert_eq!( + signal_error_reason(&SignalProtocolError::InvalidSessionStructure( + "cannot decrypt without remote identity key" + )), + EncDecryptFailureReason::StorageFailure, + "a session record that decoded without usable state is our row too", + ); + assert_eq!( + signal_error_reason(&SignalProtocolError::InvalidSessionStructure( + "receiver chain is closed" + )), + EncDecryptFailureReason::SignalError, + "but a chain we closed is a fact about the message — libsignal's \ + `is_stored_session_corruption` draws that line and this must honour it", + ); + assert_eq!( + signal_error_reason(&SignalProtocolError::SignatureValidationFailed), + EncDecryptFailureReason::SignalError, + "and anything this build does not classify stays in the named catch-all", + ); +} + +/// A row we stored that no longer converts back into a record must not be +/// reported as a malformed ciphertext. The conversion raises +/// `InvalidProtobufEncoding` — the same variant a peer's malformed envelope +/// raises — so the store boundary is the only place that still knows the bytes +/// were ours. +#[tokio::test] +async fn a_corrupt_stored_prekey_is_not_blamed_on_the_peer() { + use wacore::libsignal::protocol::{PreKeyStore, SignalProtocolError}; + use wacore::libsignal::store::PreKeyStore as WacorePreKeyStore; + + let client = crate::test_utils::create_test_client_with_name("enc_fail_corrupt_row").await; + let device = client.persistence_manager.get_device_arc().await; + + // A stored structure with no key material: what a truncated or partially + // written row deserializes into. + let corrupt = waproto::whatsapp::PreKeyRecordStructure { + id: Some(7), + public_key: None, + private_key: None, + }; + { + let guard = device.read().await; + WacorePreKeyStore::store_prekey(&*guard, 7, corrupt, false) + .await + .expect("stored"); + } + drop(device); + + let adapter = client.signal_adapter(); + let err = adapter + .pre_key_store + .get_pre_key(7u32.into()) + .await + .expect_err("a keyless row cannot become a record"); + + assert!( + matches!(err, SignalProtocolError::BackendError(context, _) if context == "stored record"), + "the boundary must mark it as ours, got {err:?}", + ); + assert_eq!( + signal_error_reason(&err), + EncDecryptFailureReason::StorageFailure, + "and so the receive path reports storage, not a malformed ciphertext", + ); +} + +/// The same libsignal rejection must not carry two names depending on which +/// `` type reached it. `UnrecognizedMessageVersion` arrives at the group +/// arm through `group_decrypt_retry_reason` as an invalid message; the session +/// catch-all has to agree. +#[test] +fn a_version_mismatch_is_an_invalid_message_on_both_paths() { + use wacore::libsignal::protocol::SignalProtocolError; + + let mismatch = SignalProtocolError::UnrecognizedMessageVersion(9); + assert_eq!( + signal_error_reason(&mismatch), + EncDecryptFailureReason::InvalidMessage, + ); + assert!( + group_decrypt_retry_reason(&mismatch).is_some(), + "the group arm still recognizes it, so both now say the same thing", + ); +} + +/// A PN→LID migration that finds a session and then fails the retry must report +/// what the retry failed on, not the error that opened the migration. Reporting +/// `NoSession` for a session that was found and then failed its MAC is the kind +/// of miscount this event exists to avoid. +#[test] +fn a_migrated_session_reports_the_retry_failure_not_the_one_that_opened_it() { + use wacore::libsignal::protocol::{CiphertextMessageType, SignalProtocolError}; + + assert_eq!( + session_error_reason(&SignalProtocolError::BadMac(CiphertextMessageType::Whisper)), + EncDecryptFailureReason::BadMac, + ); + assert_eq!( + session_error_reason(&SignalProtocolError::InvalidMessage( + CiphertextMessageType::Whisper, + "bad" + )), + EncDecryptFailureReason::InvalidMessage, + ); + assert_eq!( + session_error_reason(&SignalProtocolError::InvalidSignedPreKeyId), + EncDecryptFailureReason::UnknownPreKey, + ); + let address: Jid = "12025550101@s.whatsapp.net".parse().unwrap(); + assert_eq!( + session_error_reason(&SignalProtocolError::SessionNotFound( + address.to_protocol_address() + )), + EncDecryptFailureReason::NoSession, + "and the error that opened the migration still maps to itself", + ); +} + +/// A stored identity row whose key is the wrong length is our corruption, not +/// the peer's. `from_djb_public_key_bytes` reports `BadKeyLength` either way, so +/// like the pre-key row it has to be marked at the store boundary. +#[tokio::test] +async fn a_corrupt_stored_identity_is_not_blamed_on_the_peer() { + use wacore::libsignal::protocol::{IdentityKeyStore, SignalProtocolError}; + + let client = crate::test_utils::create_test_client_with_name("enc_fail_corrupt_ident").await; + let peer: Jid = "12025550102@s.whatsapp.net".parse().unwrap(); + let address = peer.to_protocol_address(); + + // Half a key: what a truncated write leaves behind. + client + .signal_cache + .put_identity(&address, &[0x11u8; 16]) + .await; + + let adapter = client.signal_adapter(); + let err = adapter + .identity_store + .get_identity(&address) + .await + .expect_err("a 16-byte key cannot become an identity"); + + assert!( + matches!(err, SignalProtocolError::BackendError(context, _) if context == "stored record"), + "the boundary must mark it as ours, got {err:?}", + ); + assert_eq!( + signal_error_reason(&err), + EncDecryptFailureReason::StorageFailure, + ); +} + +/// A bot secret we stored that will not produce a key is our corrupt row, not a +/// companion that never had one. `NoMessageSecret` is reserved for the lookups +/// that came back empty; anything the store answered with and we could not use +/// is storage. +#[test] +fn a_stored_bot_secret_that_will_not_derive_is_storage_not_a_missing_secret() { + use wacore::bot_message::BotMessageError; + + assert_eq!( + msmsg_failure_reason(&BotMessageError::InvalidSecretLength { + expected: 32, + got: 20 + }), + EncDecryptFailureReason::StorageFailure, + ); + assert_eq!( + msmsg_failure_reason(&BotMessageError::AuthenticationFailed), + EncDecryptFailureReason::BadMac, + "and a real tag failure is still the peer's", + ); + assert_eq!( + msmsg_failure_reason(&BotMessageError::PayloadTooShort { need: 16, got: 4 }), + EncDecryptFailureReason::MalformedCiphertext, + "and a body too short for its tag is still malformed wire", + ); +} + +/// `KeyAgreementFailed` is libsignal saying our active crypto provider failed, +/// not a verdict on the sender's bytes — which were never judged. Counting it +/// against the peer is the same mistake as counting a failed disk read. +#[test] +fn a_local_key_agreement_failure_is_not_the_peers() { + use wacore::libsignal::crypto::CryptoProviderError; + use wacore::libsignal::protocol::SignalProtocolError; + + assert_eq!( + signal_error_reason(&SignalProtocolError::KeyAgreementFailed( + CryptoProviderError::BackendFailed + )), + EncDecryptFailureReason::LocalCryptoFailure, + ); +} diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 52aae866f..439afee1d 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -34,8 +34,8 @@ use waproto::whatsapp::Message; use crate::Client; use crate::client::interceptor::{Interception, InterceptorHandle, StanzaInterceptor}; use crate::client::{ - ClientLifecycle, ConnectionScope, ConnectionScopeState, DecryptedPayloadLease, RawNodeLease, - SentFrameLease, + ClientLifecycle, ConnectionScope, ConnectionScopeState, DecryptedPayloadLease, + EncDecryptFailedLease, RawNodeLease, SentFrameLease, }; use crate::request::IqError; use crate::send::{SendError, SendResult}; @@ -494,6 +494,7 @@ struct GatedForwarding { raw_node: Option, decrypted_payload: Option, sent_frame: Option, + enc_decrypt_failed: Option, } impl GatedForwarding { @@ -505,6 +506,7 @@ impl GatedForwarding { (interest.wants(EventKind::RawNode) && self.raw_node.is_none()) || (interest.wants(EventKind::DecryptedPayload) && self.decrypted_payload.is_none()) || (interest.wants(EventKind::SentFrame) && self.sent_frame.is_none()) + || (interest.wants(EventKind::EncDecryptFailed) && self.enc_decrypt_failed.is_none()) } /// Acquire what `interest` needs and this does not hold yet. @@ -520,6 +522,9 @@ impl GatedForwarding { .then(|| client.acquire_decrypted_payload_forwarding()), sent_frame: (interest.wants(EventKind::SentFrame) && self.sent_frame.is_none()) .then(|| client.acquire_sent_frame_forwarding()), + enc_decrypt_failed: (interest.wants(EventKind::EncDecryptFailed) + && self.enc_decrypt_failed.is_none()) + .then(|| client.acquire_enc_decrypt_failed_forwarding()), } } @@ -528,6 +533,10 @@ impl GatedForwarding { self.raw_node = self.raw_node.take().or(acquired.raw_node); self.decrypted_payload = self.decrypted_payload.take().or(acquired.decrypted_payload); self.sent_frame = self.sent_frame.take().or(acquired.sent_frame); + self.enc_decrypt_failed = self + .enc_decrypt_failed + .take() + .or(acquired.enc_decrypt_failed); } /// Give up what `interest` no longer asks for. @@ -546,6 +555,9 @@ impl GatedForwarding { sent_frame: (!interest.wants(EventKind::SentFrame)) .then(|| self.sent_frame.take()) .flatten(), + enc_decrypt_failed: (!interest.wants(EventKind::EncDecryptFailed)) + .then(|| self.enc_decrypt_failed.take()) + .flatten(), } } } @@ -6004,6 +6016,10 @@ mod tests { !client.raw_node_forwarding_enabled(), "the kind that is no longer wanted releases its own lease" ); + assert!( + !client.enc_decrypt_failed_forwarding_enabled(), + "the success half of a decrypt must not turn on the failure half" + ); // All at once, then each removed on its own. assert!( @@ -6012,12 +6028,14 @@ mod tests { EventKind::RawNode, EventKind::DecryptedPayload, EventKind::SentFrame, + EventKind::EncDecryptFailed, ])) .expect("interest update") ); assert!(client.raw_node_forwarding_enabled()); assert!(client.decrypted_payload_forwarding_enabled()); assert!(client.sent_frame_forwarding_enabled()); + assert!(client.enc_decrypt_failed_forwarding_enabled()); assert!( subscription @@ -6027,6 +6045,7 @@ mod tests { assert!(client.raw_node_forwarding_enabled(), "kept"); assert!(!client.decrypted_payload_forwarding_enabled(), "released"); assert!(!client.sent_frame_forwarding_enabled(), "released"); + assert!(!client.enc_decrypt_failed_forwarding_enabled(), "released"); assert!( subscription @@ -6036,10 +6055,23 @@ mod tests { assert!(client.sent_frame_forwarding_enabled()); assert!(!client.raw_node_forwarding_enabled(), "released"); + assert!( + subscription + .update_interest(EventInterest::of(&[EventKind::EncDecryptFailed])) + .expect("interest update") + ); + assert!(client.enc_decrypt_failed_forwarding_enabled()); + assert!(!client.sent_frame_forwarding_enabled(), "released"); + assert!( + !client.decrypted_payload_forwarding_enabled(), + "and the failure half must not drag the success half back in" + ); + assert!(subscription.unsubscribe()); assert!(!client.raw_node_forwarding_enabled()); assert!(!client.decrypted_payload_forwarding_enabled()); assert!(!client.sent_frame_forwarding_enabled()); + assert!(!client.enc_decrypt_failed_forwarding_enabled()); } #[tokio::test] diff --git a/src/store/signal_adapter.rs b/src/store/signal_adapter.rs index cb957eee7..254dd3a69 100644 --- a/src/store/signal_adapter.rs +++ b/src/store/signal_adapter.rs @@ -23,6 +23,18 @@ where move |e| SignalProtocolError::BackendError(context, e.into()) } +/// A row we stored that no longer converts back into a record. +/// +/// Rebranded as a backend error rather than propagated as-is: the conversion +/// reports `InvalidProtobufEncoding`, the same variant a peer's malformed +/// envelope produces, and by the time it reaches the receive path nothing can +/// tell the two apart. Only this boundary knows the bytes were ours, and +/// calling it a malformed ciphertext would blame the peer for our own corrupt +/// row. The receiving arm is unchanged either way — both land in its catch-all. +fn record_read_err(e: SignalProtocolError) -> SignalProtocolError { + SignalProtocolError::BackendError("stored record", Box::new(e)) +} + /// Boxed future with the exact shape `#[async_trait]` expects, so the hot /// methods below can be hand-desugared: a cache hit completes synchronously /// and boxes only a tiny `Ready` instead of the full async state machine. @@ -378,13 +390,19 @@ impl IdentityKeyStore for IdentityAdapter { /// Decode the cache's raw 32-byte DJB public key bytes; empty/absent = no /// identity (mirrors the previous inline match in `get_identity`). +/// +/// Every caller feeds this bytes we stored, so a decode failure is a corrupt +/// row of ours — `record_read_err` says so rather than letting `BadKeyLength` +/// reach the receive path, where it is indistinguishable from a peer sending a +/// bad key and would be reported against them. fn parse_cached_identity( data: Option>, ) -> Result, SignalProtocolError> { match data { Some(data) if !data.is_empty() => { let public_key = - wacore::libsignal::protocol::PublicKey::from_djb_public_key_bytes(&data)?; + wacore::libsignal::protocol::PublicKey::from_djb_public_key_bytes(&data) + .map_err(|e| record_read_err(e.into()))?; Ok(Some(IdentityKey::new(public_key))) } _ => Ok(None), @@ -400,7 +418,9 @@ impl PreKeyStore for PreKeyAdapter { .await .map_err(signal_err("backend"))? .ok_or(SignalProtocolError::InvalidPreKeyId) - .and_then(wacore_record::prekey_structure_to_record) + .and_then(|structure| { + wacore_record::prekey_structure_to_record(structure).map_err(record_read_err) + }) } async fn save_pre_key( &mut self, @@ -484,7 +504,9 @@ impl SignedPreKeyStore for SignedPreKeyAdapter { ); SignalProtocolError::InvalidSignedPreKeyId }) - .and_then(wacore_record::signed_prekey_structure_to_record) + .and_then(|structure| { + wacore_record::signed_prekey_structure_to_record(structure).map_err(record_read_err) + }) } async fn save_signed_pre_key( &mut self, diff --git a/wacore/libsignal/src/protocol/error.rs b/wacore/libsignal/src/protocol/error.rs index d6125b923..7075aa447 100644 --- a/wacore/libsignal/src/protocol/error.rs +++ b/wacore/libsignal/src/protocol/error.rs @@ -117,10 +117,63 @@ impl From for SignalProtocolError { } } +/// The one [`SignalProtocolError::InvalidSessionStructure`] that is not this +/// device's stored state failing. +/// +/// `session_cipher` raises it when a message arrives on a receiver chain we +/// closed — a fact about the message, not about the row it was decrypted +/// against. Both raise sites use this constant so the string cannot drift away +/// from the predicate that reads it. +pub(crate) const CLOSED_RECEIVER_CHAIN: &str = "receiver chain is closed"; + +impl SignalProtocolError { + /// Whether this error is a stored session record that decoded and then would + /// not yield usable state, rather than a verdict on the message. + /// + /// Every producer of [`Self::InvalidSessionStructure`] is reading persisted + /// session state — most through `InvalidSessionError`, which exists, in its + /// own words, "to keep from accidentally propagating deserialization + /// errors" — with the single exception of a closed receiver chain. + /// + /// Callers use this to avoid reporting our own corrupt state against the + /// peer who sent the message. It answers only that question: it says nothing + /// about whether the state is recoverable, and it is not a check for every + /// kind of local storage trouble (a store that cannot be read at all + /// surfaces as [`Self::BackendError`] instead). + pub fn is_stored_session_corruption(&self) -> bool { + matches!(self, Self::InvalidSessionStructure(what) if *what != CLOSED_RECEIVER_CHAIN) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_closed_receiver_chain_is_not_stored_corruption() { + assert!( + !SignalProtocolError::InvalidSessionStructure(CLOSED_RECEIVER_CHAIN) + .is_stored_session_corruption(), + "a chain we closed is a fact about the message, not a corrupt row", + ); + assert!( + SignalProtocolError::InvalidSessionStructure( + "cannot decrypt without remote identity key" + ) + .is_stored_session_corruption(), + "session state missing its remote identity key is ours", + ); + assert!( + SignalProtocolError::InvalidSessionStructure("invalid receiver chain message keys") + .is_stored_session_corruption(), + "message keys the cipher rejects are ours — libsignal logs the state as corrupt", + ); + assert!( + !SignalProtocolError::InvalidSenderKeySession.is_stored_session_corruption(), + "the sender-key variant is classified on its own, not through this one", + ); + } + #[derive(Debug, thiserror::Error)] #[error("synthetic backend failure: {code}")] struct DummyBackendError { diff --git a/wacore/libsignal/src/protocol/session_cipher.rs b/wacore/libsignal/src/protocol/session_cipher.rs index 09e081446..9eaa354fa 100644 --- a/wacore/libsignal/src/protocol/session_cipher.rs +++ b/wacore/libsignal/src/protocol/session_cipher.rs @@ -1444,7 +1444,7 @@ fn decrypt_with_pending_state( if let Some(ReceiverChainState::Closed { next_index }) = receiver_chain { if counter >= next_index { return Err(SignalProtocolError::InvalidSessionStructure( - "receiver chain is closed", + crate::protocol::error::CLOSED_RECEIVER_CHAIN, )); } let Some(message_key_gen) = state.get_message_keys(their_ephemeral, counter)? else { @@ -1593,7 +1593,7 @@ fn get_or_create_chain_key( Some(ReceiverChainState::Open(chain)) => return Ok((chain, None)), Some(ReceiverChainState::Closed { .. }) => { return Err(SignalProtocolError::InvalidSessionStructure( - "receiver chain is closed", + crate::protocol::error::CLOSED_RECEIVER_CHAIN, )); } None => {} diff --git a/wacore/src/bot_message.rs b/wacore/src/bot_message.rs index 9957c57e4..4cf7de660 100644 --- a/wacore/src/bot_message.rs +++ b/wacore/src/bot_message.rs @@ -24,6 +24,60 @@ const GCM_TAG_SIZE: usize = 16; const KEY_SIZE: usize = 32; const BOT_MESSAGE_INFO: &[u8] = b"Bot Message"; +/// Why [`decrypt_bot_message`] refused a bot payload. +/// +/// Typed rather than a flat message because "the envelope was the wrong shape" +/// and "the tag did not verify" are different events: only the second says +/// anything about keys. Reporting both as an authentication failure would let +/// malformed wire data count against a peer's session health. Read +/// [`stage`](Self::stage) rather than matching variants when all a caller needs +/// is which of the two happened. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum BotMessageError { + /// Our stored `messageSecret` is not a 32-byte key. + #[error("invalid messageSecret length: expected {expected}, got {got}")] + InvalidSecretLength { expected: usize, got: usize }, + /// `enc_iv` is not a 12-byte GCM nonce. + #[error("invalid enc_iv length: expected {expected}, got {got}")] + InvalidIvLength { expected: usize, got: usize }, + /// `enc_payload` cannot even hold the 16-byte tag, let alone a ciphertext. + #[error("enc_payload too short: need at least {need} bytes for tag, got {got}")] + PayloadTooShort { need: usize, got: usize }, + /// HKDF failed while deriving the per-message key. + #[error("HKDF expand failed: {0}")] + KeyDerivation(String), + /// The GCM tag did not verify: wrong secret, wrong context, or tampering. + #[error("bot message GCM tag verification failed")] + AuthenticationFailed, +} + +/// Which stage of [`decrypt_bot_message`] rejected the payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BotMessageFailure { + /// The wire inputs were rejected on shape, before any key was derived. + Envelope, + /// The secret this device holds could not produce a key. + Secret, + /// A key was derived and the ciphertext did not authenticate under it. + Authentication, +} + +impl BotMessageError { + /// Classify the failure for a caller that reports causes rather than + /// rendering messages. Kept here, beside the code that produces each + /// variant, so a new variant cannot be silently misfiled at a call site. + pub fn stage(&self) -> BotMessageFailure { + match self { + Self::InvalidIvLength { .. } | Self::PayloadTooShort { .. } => { + BotMessageFailure::Envelope + } + Self::InvalidSecretLength { .. } | Self::KeyDerivation(_) => BotMessageFailure::Secret, + Self::AuthenticationFailed => BotMessageFailure::Authentication, + } + } +} + /// Inputs needed to derive the per-message bot key + AAD. /// /// `msg_id` is the wire `id` of the bot reply, OR `bot_info.edit_target_id` @@ -41,16 +95,16 @@ pub struct BotMessageContext<'a> { } /// Pass 1: base bot key. -fn derive_base_bot_key(message_secret: &[u8]) -> Result<[u8; KEY_SIZE]> { +fn derive_base_bot_key(message_secret: &[u8]) -> Result<[u8; KEY_SIZE], BotMessageError> { if message_secret.len() != KEY_SIZE { - return Err(anyhow!( - "invalid messageSecret length: expected {KEY_SIZE}, got {}", - message_secret.len() - )); + return Err(BotMessageError::InvalidSecretLength { + expected: KEY_SIZE, + got: message_secret.len(), + }); } let mut out = [0u8; KEY_SIZE]; crate::crypto::hkdf_sha256_into(message_secret, None, BOT_MESSAGE_INFO, &mut out) - .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; + .map_err(|e| BotMessageError::KeyDerivation(e.to_string()))?; Ok(out) } @@ -89,18 +143,19 @@ pub fn decrypt_bot_message( enc_iv: &[u8], enc_payload: &[u8], ctx: &BotMessageContext<'_>, -) -> Result> { - let nonce: &[u8; GCM_IV_SIZE] = enc_iv.try_into().map_err(|_| { - anyhow!( - "invalid enc_iv length: expected {GCM_IV_SIZE}, got {}", - enc_iv.len() - ) - })?; +) -> Result, BotMessageError> { + let nonce: &[u8; GCM_IV_SIZE] = + enc_iv + .try_into() + .map_err(|_| BotMessageError::InvalidIvLength { + expected: GCM_IV_SIZE, + got: enc_iv.len(), + })?; if enc_payload.len() < GCM_TAG_SIZE { - return Err(anyhow!( - "enc_payload too short: need at least {GCM_TAG_SIZE} bytes for tag, got {}", - enc_payload.len() - )); + return Err(BotMessageError::PayloadTooShort { + need: GCM_TAG_SIZE, + got: enc_payload.len(), + }); } let base = derive_base_bot_key(message_secret)?; let key = derive_per_message_key(&base, ctx); @@ -108,7 +163,7 @@ pub fn decrypt_bot_message( let mut out = Vec::with_capacity(enc_payload.len().saturating_sub(GCM_TAG_SIZE)); aes_256_gcm_decrypt(&key, nonce, &aad, enc_payload, &mut out) - .map_err(|_| anyhow!("bot message GCM tag verification failed"))?; + .map_err(|_| BotMessageError::AuthenticationFailed)?; Ok(out) } diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 79c3fa692..239554067 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -6,6 +6,7 @@ use bytes::Bytes; use chrono::{DateTime, Duration, Utc}; use portable_atomic::{AtomicU64, Ordering}; use serde::Serialize; +use std::borrow::Cow; use std::fmt; use std::sync::{Arc, OnceLock, RwLock}; use wacore_binary::Node; @@ -281,6 +282,7 @@ pub enum EventKind { QuickReplyUpdate, DisableLinkPreviewsUpdate, ContactRemoved, + EncDecryptFailed, // When adding a variant, mind the 128-kind ceiling below (EventInterest packs // each discriminant as a bit in a u128) and keep the guard pointing at the // last variant. @@ -294,7 +296,7 @@ impl EventKind { // Build-time tripwire: a new variant that would overflow EventInterest's bitmask // fails compilation instead of silently corrupting the mask at runtime. -const _: () = assert!((EventKind::ContactRemoved as u8) < EventKind::CAPACITY); +const _: () = assert!((EventKind::EncDecryptFailed as u8) < EventKind::CAPACITY); /// A set of [`EventKind`]s a handler wants delivered. Producers can query the /// aggregate interest before building expensive payloads, and dispatch avoids @@ -1021,6 +1023,17 @@ pub enum Event { /// [`ContactUpdate`]: the mutation arrives as a syncd `Remove`, carries no /// meaningful action payload, and means the contact left the address book. ContactRemoved(ContactRemoved), + + /// One `` that produced no plaintext, and why. + /// + /// The per-`` counterpart of [`Event::DecryptedPayload`]. Library + /// extension — no WA Web equivalent. Gated by + /// `Client::acquire_enc_decrypt_failed_forwarding()` so nothing is built + /// while unused. + /// + /// Last, like every new variant: a binary `Serialize` format writes the + /// variant index, so inserting in the middle renumbers everything after it. + EncDecryptFailed(EncDecryptFailed), } /// Payload for [`Event::PairPasskeyRequest`]. @@ -1113,6 +1126,7 @@ impl Event { Event::QuickReplyUpdate(_) => EventKind::QuickReplyUpdate, Event::DisableLinkPreviewsUpdate(_) => EventKind::DisableLinkPreviewsUpdate, Event::ContactRemoved(_) => EventKind::ContactRemoved, + Event::EncDecryptFailed(_) => EventKind::EncDecryptFailed, Event::HistorySync(_) => EventKind::HistorySync, Event::OfflineSyncPreview(_) => EventKind::OfflineSyncPreview, Event::OfflineSyncCompleted(_) => EventKind::OfflineSyncCompleted, @@ -1722,6 +1736,198 @@ pub struct DecryptedPayload { pub payload: Bytes, } +/// Why one `` produced no plaintext. +/// +/// Every variant names a branch the receive path actually takes; there is no +/// catch-all "other" standing in for code nobody wrote. New branches append new +/// variants, so this is `#[non_exhaustive]` and a match on it needs a `_` arm. +/// +/// This is the client's own classification of where *it* stopped, not something +/// the server sends and not a statement about the sender's copy. Two builds can +/// classify the same ciphertext differently as branches are refined; the pairing +/// of a reason with a specific `` is the stable part, the exact variant is +/// not. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub enum EncDecryptFailureReason { + /// The node is unusable as a node: no `type` attribute, or no content to + /// decrypt. Nothing about it named a decryption to attempt. + MalformedNode, + /// The `type` is one this build does not implement. Recognized as an + /// ``, but no path here could ever decrypt it. + UnsupportedEncType, + /// A type this build handles, whose body is not a well-formed envelope for + /// it — so it never reached a cipher. Distinct from + /// [`InvalidMessage`](Self::InvalidMessage), which is the cryptographic + /// layer rejecting an envelope it did parse. + MalformedCiphertext, + /// No Signal session for the sender's address. Usually recoverable: the + /// client asks the sender to re-establish one. + NoSession, + /// No sender-key state for this `(group, sender)` chain — typically an + /// `skmsg` whose distribution message was never received or was lost. + NoSenderKey, + /// The sender encrypted to a one-time or signed pre-key of ours that this + /// device no longer holds. + UnknownPreKey, + /// The sender's identity key is not the one this device trusts for them. + /// + /// Usually reported after the client cleared the stored identity and + /// retried, and the retry still did not produce plaintext — so the identity + /// change is what is left explaining it. Also reported where libsignal + /// raises the untrusted identity directly and no retry ran, such as the + /// decrypt that follows a PN→LID session migration. + UntrustedIdentity, + /// Authentication failed: the ciphertext did not verify under the key the + /// client derived for it. Covers both the Signal MAC and the AES-GCM tag of + /// a bot (`msmsg`) payload. + BadMac, + /// The envelope parsed and the cryptographic layer rejected its contents — + /// a version that does not match the state it was decrypted against, a + /// signature that did not verify, or a body the cipher would not accept + /// under keys that were themselves sound. + /// + /// Not the same as state that was never sound: a sender-key or session + /// record that will not yield usable keys is + /// [`StorageFailure`](Self::StorageFailure), because nothing about the + /// message was judged. + InvalidMessage, + /// A bot (`msmsg`) payload whose `messageSecret` this device does not hold, + /// or whose `` does not say which secret to look up. Expected on a + /// companion for a group bot invocation the primary device sent. + NoMessageSecret, + /// The local cryptographic provider failed a key agreement. Ours, like + /// [`StorageFailure`](Self::StorageFailure): the peer's ciphertext was never + /// judged, so a per-peer health signal should exclude this too. + LocalCryptoFailure, + /// The cryptographic layer failed for a reason this build does not classify + /// further. A reason that shows up in volume here deserves a variant. + SignalError, + /// Local state was the problem, not the ciphertext: a store that would not + /// answer, a row that came back and would not yield what it should hold, or + /// state that could not be made durable. + /// + /// Not confined to Signal state, though that is where most of it comes + /// from — a corrupt pre-key, identity, session or sender-key record, or a + /// durability failure, in which case the decrypt was abandoned rather than + /// advancing a ratchet no crash could recover. A bot (`msmsg`) payload + /// reaches it too: a message-secret lookup that *errored* rather than came + /// back empty, or a stored `messageSecret` that will not derive a key. + /// A secret this device genuinely does not hold is + /// [`NoMessageSecret`](Self::NoMessageSecret) instead — that is the + /// companion's state, this is ours. + /// + /// Says nothing about the peer. A per-peer health signal built on this + /// event should exclude it. + /// + /// Says nothing about recovery either. What the client does next is the + /// branch's decision, not the reason's: some leave the stanza queued for + /// redelivery, others nack it. Like every reason here, this one names where + /// the client stopped — see the "not a loss report" note on + /// [`EncDecryptFailed`]. + StorageFailure, + /// The `` decrypted, and the bytes could not be turned into a message: + /// padding this build could not strip, or a payload it could not decode. + /// + /// The one reason that can accompany a [`DecryptedPayload`] for the same + /// `` — when the bytes existed but were unusable, both are emitted. + PlaintextUnusable, + /// Never attempted. The client recognized the node and did not try it: an + /// `skmsg` whose stanza's session `` failed first (the sender key it + /// needed came in that one), a session `` on a stanza addressed from a + /// group, which has no 1:1 session to use, or a stanza abandoned when the + /// connection was torn down before its turn to decrypt came. + /// + /// Says nothing about the ciphertext, which was never read. The last case + /// is not even about this stanza — it is about when it arrived. + NotAttempted, +} + +impl EncDecryptFailureReason { + /// Whether the client entered its decryption path for this `` at all. + /// + /// This is the line between *tried and failed* and *recognized and not + /// handled*. `false` means the node was set aside before any decryption was + /// attempted, so nothing here says whether its ciphertext was good. `true` + /// spans everything from an envelope that would not parse to a MAC that + /// would not verify — the attempt happened and did not produce plaintext. + pub fn decryption_was_attempted(self) -> bool { + !matches!( + self, + Self::MalformedNode | Self::UnsupportedEncType | Self::NotAttempted + ) + } +} + +/// Payload of [`Event::EncDecryptFailed`]: one `` of a stanza that +/// produced no plaintext, and why. +/// +/// The failing half of what [`DecryptedPayload`] reports for the succeeding +/// half, at the same granularity and under the same numbering. A stanza can +/// carry one `` per device; without a per-node signal a consumer watching +/// decryption can say *this `` produced these bytes* but not *this `` +/// failed for this reason*, and on a fan-out not even which one failed. +/// +/// Reasons to want it: attributing a failure inside a fan-out, driving a retry +/// or resync policy off the reason, and measuring session health per peer +/// rather than per message. +/// +/// # What it does not say +/// +/// - **It is not a display signal.** Whether to show the user a placeholder is +/// [`Event::UndecryptableMessage`], which is per *message*, deduplicated by +/// `(chat, id)`, and carries the server's `decrypt-fail` hint. This event is +/// per ``, is not deduplicated, and answers a different question. +/// - **It is not a loss report.** Most reasons are recoverable — the client may +/// already have asked the sender to resend — and this event says nothing +/// about whether a retry went out or whether one succeeded later. +/// - **It repeats.** A redelivered stanza that fails again emits it again, once +/// per `` per delivery. Correlate on `info.id` if you want at-most-once. +/// - **A duplicate is not a failure.** An `` the server redelivered that +/// this device already processed emits neither this nor [`DecryptedPayload`]: +/// its plaintext was reported the first time round, and calling that a +/// failure would put two meanings in one event. The silence covers the +/// duplicate `` itself and nothing more: a stanza whose session `` +/// were duplicates and nothing else has its `skmsg` decrypted normally, and +/// one where a duplicate arrives beside an `` that genuinely failed +/// skips that `skmsg` on every delivery — so the skip is reported as +/// [`NotAttempted`](EncDecryptFailureReason::NotAttempted), because no +/// delivery ever produced its plaintext. +/// - **Order is `enc_index`, not arrival.** The client decrypts a stanza's +/// `` nodes in per-kind passes (session, then group, then bot), so +/// neither these events nor [`DecryptedPayload`]s arrive in stanza order, and +/// a failure for a later `` can precede a success for an earlier one. +/// Within one stanza both kinds come from the same receive task, so they are +/// totally ordered relative to each other — just not by position. +/// +/// Gated by `Client::acquire_enc_decrypt_failed_forwarding()`: nothing is +/// emitted, and nothing is built, while no consumer holds a lease. +#[derive(Debug, Clone, Serialize, bon::Builder)] +#[non_exhaustive] +pub struct EncDecryptFailed { + /// Which message this `` belongs to. + pub info: Arc, + /// Which `` of the stanza this was, counting from zero in the order + /// the client enumerates them — the same numbering as + /// [`DecryptedPayload::enc_index`], produced by the same enumeration, so + /// the two events index one stanza and not two. + /// + /// That order is the stanza's direct `` children first, then the ones + /// under `` addressed to this device. It is *not* a child + /// index. + pub enc_index: usize, + /// The `type` attribute the `` carried: `msg`, `pkmsg`, `skmsg`, … + /// + /// `None` only when the node carried no `type` at all, which is also the + /// one thing [`MalformedNode`](EncDecryptFailureReason::MalformedNode) can + /// mean here that a present type does not. Borrowed for the types this + /// build knows, owned for a `type` it does not. + #[serde(skip_serializing_if = "Option::is_none")] + pub enc_type: Option>, + /// Where the client stopped. + pub reason: EncDecryptFailureReason, +} + /// Payload of [`Event::SentFrame`]: one marshaled stanza, exactly as it was /// handed to the noise frame encryption. ///