-
Notifications
You must be signed in to change notification settings - Fork 0
docs: document Event::EncDecryptFailed (whatsapp-rust#1261) #504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
8b234bd
186d042
f9a782c
a1ea9c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2065,6 +2065,45 @@ while let Ok(event) = event_rx.recv().await { | |
| // Drop `_lease` to stop forwarding. | ||
| ``` | ||
|
|
||
| ### acquire_enc_decrypt_failed_forwarding | ||
|
|
||
| ```rust | ||
| pub fn acquire_enc_decrypt_failed_forwarding(self: &Arc<Self>) -> EncDecryptFailedLease | ||
| ``` | ||
|
|
||
| Acquire a lease that keeps [`Event::EncDecryptFailed`](/concepts/events#encdecryptfailed) enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its `interest()` away from the default `EventInterest::ALL` also needs `EventKind::EncDecryptFailed` added back in, or it won't see the event even while a lease is held. This is the failing counterpart of [`acquire_decrypted_payload_forwarding`](#acquire_decrypted_payload_forwarding) — same per-`<enc>` granularity, same `enc_index` numbering — but tracked by a separate counter on purpose: a consumer that wants both halves of a stanza's decryption holds both leases, one that wants only failures does not make the success path clone plaintext, and one that wants only successes pays nothing extra on the failure paths. | ||
|
|
||
| <ResponseField name="EncDecryptFailedLease" type="EncDecryptFailedLease"> | ||
| RAII lease. `Event::EncDecryptFailed` stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive. | ||
| </ResponseField> | ||
|
|
||
| <Note> | ||
| While no lease is held, nothing is emitted and nothing is built — each failure branch costs one relaxed atomic load. | ||
| </Note> | ||
|
|
||
| **Example:** | ||
| ```rust | ||
| use wacore::types::events::{ChannelEventHandler, Event}; | ||
|
|
||
| let _lease = client.acquire_enc_decrypt_failed_forwarding(); | ||
|
|
||
| let (handler, event_rx) = ChannelEventHandler::new(); | ||
| client.register_handler(handler); | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If an inbound Useful? React with 👍 / 👎. |
||
|
|
||
| while let Ok(event) = event_rx.recv().await { | ||
| if let Event::EncDecryptFailed(failed) = &*event { | ||
| // Not a display signal and not a loss report — see the caveats on | ||
| // `Event::EncDecryptFailed` — but useful for attributing a failure | ||
| // inside a fan-out or measuring session health per peer. | ||
| println!( | ||
| "enc #{} ({:?}) for {} failed: {:?}", | ||
| failed.enc_index, failed.enc_type, failed.info.id, failed.reason, | ||
| ); | ||
| } | ||
| } | ||
| // Drop `_lease` to stop forwarding. | ||
| ``` | ||
|
|
||
| ### acquire_sent_frame_forwarding | ||
|
|
||
| ```rust | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,8 @@ As of PR #1195, `ErrorChainExt` also answers `http_status()` — the HTTP status | |
|
|
||
| As of PR #1257, `IqError::ServerError` also carries `response: RejectionStanza` — the `type="error"` stanza itself, handed over whole the same way a `type="result"` response already was, alongside the four fields this crate parses off it (`code`, `text`, `error_type`, `backoff`). See [`RejectionStanza`](#rejectionstanza) below. | ||
|
|
||
| As of PR #1261, `wacore::bot_message::decrypt_bot_message` (and its private decryption helper) return `Result<T, BotMessageError>` instead of `anyhow::Result<T>`. Unlike the entries above, this is **a breaking change to `wacore`'s public API**, not an additive one — a caller matching on the previous `anyhow::Error` needs to switch to the typed enum. See [`BotMessageError`](#botmessageerror) below. | ||
|
|
||
| ## Error hierarchy | ||
|
|
||
| ``` | ||
|
|
@@ -57,6 +59,13 @@ IqError (IQ request failures — embedded by most domain errors) | |
| ├── DuplicateRequestId(String) | ||
| ├── EncodeError | ||
| └── ParseError | ||
|
|
||
| BotMessageError (wacore::bot_message::decrypt_bot_message — wacore crate, not whatsapp_rust) | ||
| ├── InvalidSecretLength | ||
| ├── InvalidIvLength | ||
| ├── PayloadTooShort | ||
| ├── KeyDerivation | ||
| └── AuthenticationFailed | ||
| ``` | ||
|
|
||
| Domain errors that embed `IqError` or `ClientError` via `#[from]` propagate those failures automatically via `?`. Some errors (e.g. `AppStateError`, `SignalError`) use internal `anyhow::Error` wrapping instead and do not have `Iq` or `Client` variants. | ||
|
|
@@ -218,6 +227,7 @@ Rendering changed alongside the `source()` fix: a wrapping variant's `Display` s | |
| | `ConnectError` | `Client::connect`, `Client::wait_for_socket`, `Client::wait_for_connected` | `whatsapp_rust` | | ||
| | `SignalMaintenanceError` | `Client::rotate_signed_pre_key`, `Client::flush_pending_signal_state` | `whatsapp_rust` | | ||
| | `MessageEditError` | `EncryptedEdit::original_sender_jid`, `SecretEncrypted::original_sender_jid`, `SecretEncrypted::original_sender_for_dispatch` | `whatsapp_rust` | | ||
| | `BotMessageError` | `wacore::bot_message::decrypt_bot_message` | `wacore` | | ||
|
|
||
| ## Type definitions | ||
|
|
||
|
|
@@ -569,6 +579,44 @@ pub enum MessageEditError { | |
|
|
||
| Both variants mean the peer sent a target message key that cannot be attributed — retrying the same envelope yields the same result. `MessageEditError` is re-exported from the crate root as `whatsapp_rust::MessageEditError`. | ||
|
|
||
| ### BotMessageError | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Format the type name as code. Use 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| Returned by `wacore::bot_message::decrypt_bot_message` and its private decryption helper. Added in PR #1261, replacing bare `anyhow::Result`. | ||
|
|
||
| <Note> | ||
| Unlike every other error type on this page, `BotMessageError` lives in the `wacore` crate, not `whatsapp_rust` — and its introduction is **a breaking change to `wacore`'s public API**: `decrypt_bot_message`'s return type changed from `anyhow::Result<T>` to `Result<T, BotMessageError>`, so a caller matching on the previous `anyhow::Error` needs to switch to this typed enum. | ||
| </Note> | ||
|
|
||
| ```rust | ||
| #[non_exhaustive] | ||
| pub enum BotMessageError { | ||
| InvalidSecretLength, | ||
| InvalidIvLength, | ||
| PayloadTooShort, | ||
| KeyDerivation, | ||
| AuthenticationFailed, | ||
| } | ||
| ``` | ||
|
|
||
| **Variants:** | ||
| - `InvalidSecretLength` — the bot message secret is not the expected size. | ||
| - `InvalidIvLength` — the IV carried by the payload is not the expected size. | ||
| - `PayloadTooShort` — the payload is too short to contain what it claims to. | ||
| - `KeyDerivation` — deriving the decryption key from the secret failed. | ||
| - `AuthenticationFailed` — the ciphertext did not verify (AES-GCM tag mismatch). | ||
|
|
||
| `BotMessageError::stage(&self) -> BotMessageFailure` classifies which stage of decryption a failure belongs to, without matching every variant by name: | ||
|
|
||
| ```rust | ||
| pub enum BotMessageFailure { | ||
| Envelope, | ||
| Secret, | ||
| Authentication, | ||
| } | ||
| ``` | ||
|
|
||
| Use `stage()` when you only care whether the envelope, the secret, or the authentication step failed — for metrics or a coarse retry policy — rather than the specific `BotMessageError` variant. | ||
|
|
||
| ### ClientError (base type) | ||
|
|
||
| ```rust | ||
|
|
@@ -846,3 +894,28 @@ let err: IqError = wacore_err.into(); | |
| // After — pass the response the classification failure was read from | ||
| let err = IqError::from_response(wacore_err, &response); | ||
| ``` | ||
|
|
||
| ### From PR #1261: `decrypt_bot_message` returns a typed error | ||
|
|
||
| If you were matching on `anyhow::Error` from `wacore::bot_message::decrypt_bot_message`, switch to [`BotMessageError`](#botmessageerror): | ||
|
|
||
| ```rust | ||
| // Before | ||
| match decrypt_bot_message(&payload, &secret) { | ||
| Ok(plaintext) => { /* ... */ } | ||
| Err(e) => eprintln!("bot message decrypt failed: {e}"), | ||
| } | ||
|
|
||
| // After — match specific variants, or classify by stage | ||
| use wacore::bot_message::{BotMessageError, BotMessageFailure}; | ||
|
|
||
| match decrypt_bot_message(&payload, &secret) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The documented low-level signature is Useful? React with 👍 / 👎. |
||
| Ok(plaintext) => { /* ... */ } | ||
| Err(e @ BotMessageError::AuthenticationFailed) => eprintln!("tampered or wrong secret: {e}"), | ||
| Err(e) => match e.stage() { | ||
| BotMessageFailure::Envelope => eprintln!("malformed envelope: {e}"), | ||
| BotMessageFailure::Secret => eprintln!("bad secret: {e}"), | ||
| BotMessageFailure::Authentication => eprintln!("authentication failed: {e}"), | ||
| }, | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -224,6 +224,9 @@ pub enum Event { | |
|
|
||
| // Sent frame (opt-in) | ||
| SentFrame(SentFrame), | ||
|
|
||
| // Enc decrypt failure (opt-in) | ||
| EncDecryptFailed(EncDecryptFailed), | ||
| } | ||
| ``` | ||
|
|
||
|
|
@@ -2895,6 +2898,106 @@ Event::SentFrame(frame) => { | |
|
|
||
| See [`acquire_sent_frame_forwarding`](/api/client#acquire_sent_frame_forwarding) for the lease API, and [WebSocket & Noise Protocol Handling](/advanced/websocket-handling#noisesocket) for how `SendObservers` wires it into the noise sender. | ||
|
|
||
| ## Enc decrypt failure events | ||
|
|
||
| ### `EncDecryptFailed` | ||
|
|
||
| **Emitted:** One `<enc>` that produced no plaintext, and why — the failing half of what [`DecryptedPayload`](#decryptedpayload) reports for the succeeding half, at the same granularity (per `<enc>`, not per message) and under the same numbering (`enc_index` comes from the same enumeration as `DecryptedPayload::enc_index`, so the two events index one stanza and not two). To receive this event, hold a lease from `client.acquire_enc_decrypt_failed_forwarding()` and include `EventKind::EncDecryptFailed` in your handler's `interest()`. While no lease is held, nothing is emitted and nothing is built. | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When decoding fails after successful decryption, Useful? React with 👍 / 👎. |
||
|
|
||
| ```rust | ||
| #[derive(Debug, Clone, Serialize, bon::Builder)] | ||
| #[non_exhaustive] | ||
| pub struct EncDecryptFailed { | ||
| pub info: Arc<MessageInfo>, | ||
| pub enc_index: usize, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub enc_type: Option<Cow<'static, str>>, | ||
| pub reason: EncDecryptFailureReason, | ||
| } | ||
|
|
||
| Event::EncDecryptFailed(EncDecryptFailed) | ||
| ``` | ||
|
|
||
| **Fields:** | ||
| - `info` — Which message this `<enc>` belongs to. | ||
| - `enc_index` — Which `<enc>` of the stanza this was, counting from zero in the order the client enumerates them — the stanza's direct `<enc>` children first, then the ones under `<participants><to>` addressed to this device. Not a child index. | ||
| - `enc_type` — The `type` attribute the `<enc>` carried: `msg`, `pkmsg`, `skmsg`, … `None` only when the node carried no `type` attribute at all — the one thing `MalformedNode` can mean here that a present type does not. | ||
| - `reason` — Where the client stopped. See [`EncDecryptFailureReason`](#encdecryptfailurereason) below. | ||
|
|
||
| This is a library extension with no WhatsApp Web equivalent — WhatsApp's own client does not surface this. Reasons to want it: attributing a failure inside a fan-out (a stanza can carry one `<enc>` per device), driving a retry or resync policy off the specific reason, and measuring session health per peer rather than per message. | ||
|
|
||
| #### EncDecryptFailureReason | ||
|
|
||
| Why one `<enc>` produced no plaintext. 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. It is `#[non_exhaustive]` — a `match` needs a `_` arm, since new branches append new variants as the receive path is refined. | ||
|
|
||
| ```rust | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] | ||
| #[non_exhaustive] | ||
| pub enum EncDecryptFailureReason { | ||
| MalformedNode, | ||
| UnsupportedEncType, | ||
| MalformedCiphertext, | ||
| NoSession, | ||
| NoSenderKey, | ||
| UnknownPreKey, | ||
| UntrustedIdentity, | ||
| BadMac, | ||
| InvalidMessage, | ||
| NoMessageSecret, | ||
| LocalCryptoFailure, | ||
| SignalError, | ||
| StorageFailure, | ||
| PlaintextUnusable, | ||
| NotAttempted, | ||
| } | ||
| ``` | ||
|
|
||
| | Variant | Meaning | | ||
| |---|---| | ||
| | `MalformedNode` | No `type` attribute, or no content to decrypt — nothing about the node named a decryption to attempt. | | ||
| | `UnsupportedEncType` | Recognized as an `<enc>`, but the `type` is one this build does not implement. | | ||
| | `MalformedCiphertext` | A `type` this build handles, whose body is not a well-formed envelope for it — never reached a cipher. Distinct from `InvalidMessage`, which is the cryptographic layer rejecting an envelope it did parse. | | ||
| | `NoSession` | No Signal session for the sender's address. Usually recoverable — the client asks the sender to re-establish one. | | ||
| | `NoSenderKey` | No sender-key state for this `(group, sender)` chain — typically an `skmsg` whose distribution message was never received or was lost. | | ||
| | `UnknownPreKey` | The sender encrypted to a one-time or signed pre-key of ours that this device no longer holds. | | ||
| | `UntrustedIdentity` | The sender's identity key is not the one this device trusts for them. Usually reported after a stored-identity retry still didn't produce plaintext; also reported where libsignal raises it directly, as with a PN→LID session migration. | | ||
| | `BadMac` | 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. | | ||
| | `InvalidMessage` | The envelope parsed and the cryptographic layer rejected its contents: a version mismatch, a bad signature, or a body the cipher would not accept under sound keys. Not the same as state that was never sound — that's `StorageFailure`. | | ||
| | `NoMessageSecret` | A bot (`msmsg`) payload whose `messageSecret` this device does not hold, or whose `<meta>` doesn't say which secret to look up. Expected on a companion for a group bot invocation the primary device sent. | | ||
| | `LocalCryptoFailure` | The local cryptographic provider failed a key agreement. Ours — like `StorageFailure`, the peer's ciphertext was never judged. | | ||
| | `SignalError` | The cryptographic layer failed for a reason this build does not classify further. | | ||
| | `StorageFailure` | Local state was the problem, not the ciphertext — a store that wouldn't answer, a corrupt pre-key/identity/session/sender-key record, or a durability failure. Says nothing about the peer, and nothing about recovery (some branches leave the stanza queued for redelivery, others nack it). | | ||
| | `PlaintextUnusable` | The `<enc>` decrypted, but the bytes couldn't be turned into a message (padding it couldn't strip, or an undecodable payload). The one reason that can accompany a `DecryptedPayload` for the same `<enc>` — when the bytes existed but were unusable, both are emitted. | | ||
| | `NotAttempted` | Never attempted: an `skmsg` whose stanza's session `<enc>` failed first (the sender key it needed came in that one), a session `<enc>` on a stanza addressed from a group, or a stanza abandoned when the connection tore down before its turn. Says nothing about the ciphertext, which was never read. | | ||
|
|
||
| `EncDecryptFailureReason::decryption_was_attempted(self) -> bool` reports whether the client entered its decryption path at all — `false` for `MalformedNode`, `UnsupportedEncType`, and `NotAttempted`; `true` for every other variant, spanning everything from an envelope that wouldn't parse to a MAC that wouldn't verify. | ||
|
|
||
| #### What it does not say | ||
|
|
||
| <Note> | ||
| - **Not a display signal.** Whether to show the user a placeholder is [`UndecryptableMessage`](#undecryptablemessage), which is per *message*, deduplicated by `(chat, id)`, and carries the server's `decrypt-fail` hint. `EncDecryptFailed` is per `<enc>`, is not deduplicated, and answers a different question. | ||
| - **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 succeeded later. | ||
| - **Repeats.** A redelivered stanza that fails again emits it again, once per `<enc>` per delivery. Correlate on `info.id` if you want at-most-once. | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a stanza containing multiple failing Useful? React with 👍 / 👎. |
||
| - **A duplicate is not a failure.** An `<enc>` the server redelivered that this device already processed emits neither this event nor [`DecryptedPayload`](#decryptedpayload) — its plaintext was reported the first time round. A stanza whose only duplicate `<enc>` sits beside one that genuinely fails still reports that other `<enc>` as `NotAttempted`, on every delivery, since no delivery ever produced its plaintext. | ||
| - **Order is `enc_index`, not arrival.** The client decrypts a stanza's `<enc>` 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 `<enc>` can precede a success for an earlier one. | ||
| </Note> | ||
|
|
||
| **Example:** | ||
| ```rust | ||
| let _lease = client.acquire_enc_decrypt_failed_forwarding(); | ||
|
|
||
| // In your event handler: | ||
| Event::EncDecryptFailed(failed) => { | ||
| println!( | ||
| "enc #{} ({:?}) for {} failed: {:?}", | ||
| failed.enc_index, failed.enc_type, failed.info.id, failed.reason, | ||
| ); | ||
| } | ||
| // Drop `_lease` to stop forwarding. | ||
| ``` | ||
|
|
||
| See [`acquire_enc_decrypt_failed_forwarding`](/api/client#acquire_enc_decrypt_failed_forwarding) for the lease API. | ||
|
|
||
| ## Event handler patterns | ||
|
|
||
| ### Bot builder pattern | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Use direct reader guidance and split the contract details.
Lines 2074-2081 use third-person consumers. They also combine activation, event scope, numbering, and cost behavior in long sentences. Use “you” and separate each guarantee into its own sentence.
Proposed wording
As per coding guidelines, use active voice and second-person wording, and keep one idea per sentence.
🤖 Prompt for AI Agents
Source: Coding guidelines