Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
49920f5
feat(client): report which <enc> failed to decrypt, and why
claude Aug 9, 2026
91f4e2a
style: rustfmt the shortened test helper signature
claude Aug 9, 2026
9c626bb
fix(client): name the terminal cause on three misclassified failure b…
claude Aug 9, 2026
2c2339f
fix(client): keep the redelivery path and local storage errors out of…
claude Aug 9, 2026
65fb7f6
docs(events): stop StorageFailure promising a redelivery it does not …
claude Aug 9, 2026
f93f919
fix(store): mark a corrupt stored record as ours, not as a bad cipher…
claude Aug 9, 2026
cbaf386
fix(client): route three more terminal errors through the shared clas…
claude Aug 9, 2026
b1014d8
fix(client): report what the migration retry failed on, not what open…
claude Aug 9, 2026
22db49d
fix(client): classify the identity retry with the session mapping
claude Aug 9, 2026
2a4fe67
fix(store): mark a corrupt stored identity as ours too
claude Aug 9, 2026
0adc457
fix(client): keep two more local failures off the peer's ledger
claude Aug 9, 2026
b179607
fix(client): stop losing three failure attributions
claude Aug 9, 2026
9e6bf2b
fix(signal): mark a corrupt stored session as ours, not the peer's
claude Aug 9, 2026
66d6296
docs(events): correct two reasons my own changes made wrong
claude Aug 9, 2026
7e7e065
docs(events): the duplicate rule stops at the duplicate
claude Aug 9, 2026
91417db
docs(events): UntrustedIdentity has a second producer
claude Aug 9, 2026
d20f3b9
fix(client): report the encs a teardown abandons
claude Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ impl Drop for DecryptedPayloadLease {
}
}

/// Lease that keeps per-`<enc>` 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<Client>,
}

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
Expand Down Expand Up @@ -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<SentFrameTap>,
Expand Down
33 changes: 33 additions & 0 deletions src/client/accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,39 @@ impl Client {
self.decrypted_payload_forwarding.load(Ordering::Relaxed) != 0
}

/// Acquire per-`<enc>` 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<Self>) -> 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.
Expand Down
1 change: 1 addition & 0 deletions src/client/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")))]
Expand Down Expand Up @@ -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")))]
Expand Down
38 changes: 38 additions & 0 deletions src/message.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -262,6 +263,43 @@ 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) {
Comment thread
jlucaso1 marked this conversation as resolved.
EncDecryptFailureReason::MalformedCiphertext
} else if matches!(e, SignalProtocolError::BackendError(_, _)) {
EncDecryptFailureReason::StorageFailure
} else {
EncDecryptFailureReason::SignalError
Comment thread
jlucaso1 marked this conversation as resolved.
Comment thread
jlucaso1 marked this conversation as resolved.
Comment thread
jlucaso1 marked this conversation as resolved.
}
}

/// 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).
Expand Down
67 changes: 67 additions & 0 deletions src/message/msg_secret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

use super::*;

/// Map a bot-payload failure onto the cause reported for its `<enc>`.
///
/// 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.
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::NoMessageSecret,
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
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.
Expand Down Expand Up @@ -485,6 +501,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;
}
Expand All @@ -496,6 +518,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;
};
Expand All @@ -507,6 +535,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;
}
Expand All @@ -533,6 +567,12 @@ impl Client {
"[msg:{}] msmsg: <meta> 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;
}
Expand Down Expand Up @@ -619,6 +659,12 @@ 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,
EncDecryptFailureReason::NoMessageSecret,
);
Comment thread
jlucaso1 marked this conversation as resolved.
self.spawn_nack(info, NackReason::MissingMessageSecret, None);
return;
}
Expand Down Expand Up @@ -681,6 +727,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;
}
Expand All @@ -690,6 +745,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;
}
Expand Down Expand Up @@ -719,6 +780,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;
}
Expand Down
Loading
Loading