Skip to main content

wacore/send/
encrypt.rs

1//! Per-device Signal encryption fanout and the bounded spawn helper.
2
3use super::*;
4use anyhow::Context;
5
6/// Caller must hold `SenderKeyStore::sender_key_lock` for `sender_key_name`
7/// across the surrounding SKDM creation + this encrypt, so a concurrent send
8/// can't split the key between the SKDM and the skmsg.
9#[cfg_attr(
10    feature = "tracing",
11    tracing::instrument(name = "wa.send.encrypt_group", level = "debug", skip_all, err(Debug))
12)]
13pub async fn encrypt_group_message<S, R>(
14    sender_key_store: &mut S,
15    sender_key_name: &SenderKeyName,
16    plaintext: &[u8],
17    csprng: &mut R,
18) -> Result<SenderKeyMessage>
19where
20    S: SenderKeyStore + ?Sized,
21    R: Rng + CryptoRng,
22{
23    // Delegate to the libsignal primitive so the sender-key advance, the wire
24    // gate, and the iteration lease live in exactly one place. `.context` keeps
25    // the concrete SignalProtocolError as the source, so callers can still
26    // downcast NoSenderKeyState to clear stale tracking and retry with SKDM
27    // redistribution.
28    crate::libsignal::protocol::group_encrypt(sender_key_store, sender_key_name, plaintext, csprng)
29        .await
30        .context("group encrypt failed")
31}
32
33/// Object-safe `SessionStore` that can clone itself into an owned box. The
34/// encrypt fan-out hands each spawned task its own owned store handle; erasing
35/// the concrete type here (instead of a generic `S: Clone`) keeps the whole
36/// encrypt tree as a single instantiation instead of one ~60 KiB copy per
37/// concrete adapter set. Blanket-impl'd for every `Clone` store, so concrete
38/// adapters need no changes.
39pub trait CloneableSessionStore: crate::libsignal::protocol::SessionStore {
40    fn clone_box(&self) -> Box<dyn CloneableSessionStore + Send + Sync>;
41}
42
43impl<T> CloneableSessionStore for T
44where
45    T: crate::libsignal::protocol::SessionStore + Clone + Send + Sync + 'static,
46{
47    fn clone_box(&self) -> Box<dyn CloneableSessionStore + Send + Sync> {
48        Box::new(self.clone())
49    }
50}
51
52/// See [`CloneableSessionStore`].
53pub trait CloneableIdentityStore: crate::libsignal::protocol::IdentityKeyStore {
54    fn clone_box(&self) -> Box<dyn CloneableIdentityStore + Send + Sync>;
55}
56
57impl<T> CloneableIdentityStore for T
58where
59    T: crate::libsignal::protocol::IdentityKeyStore + Clone + Send + Sync + 'static,
60{
61    fn clone_box(&self) -> Box<dyn CloneableIdentityStore + Send + Sync> {
62        Box::new(self.clone())
63    }
64}
65
66/// Borrowed handles to the Signal stores for one send. The stores are
67/// type-erased (`dyn`) so the encrypt/session functions compile to a single
68/// instantiation rather than one per concrete adapter set.
69pub struct SignalStores<'a> {
70    pub sender_key_store: &'a mut (dyn SenderKeyStore + Send + Sync),
71    pub session_store: &'a mut (dyn CloneableSessionStore + Send + Sync),
72    pub identity_store: &'a mut (dyn CloneableIdentityStore + Send + Sync),
73    pub prekey_store: &'a mut (dyn crate::libsignal::protocol::PreKeyStore + Send + Sync),
74    pub signed_prekey_store: &'a (dyn crate::libsignal::protocol::SignedPreKeyStore + Send + Sync),
75}
76
77/// Check if an anyhow error is a 406 "not-acceptable" server error (device unregistered).
78/// Uses typed downcast to `ServerErrorCode` — the shared error type that the
79/// `SendContextResolver` impl wraps server errors in.
80/// The `<error code>` the server attaches to a device it no longer knows.
81pub(crate) const UNREGISTERED_DEVICE_CODE: u16 = 406;
82
83pub(crate) fn is_device_unregistered_error(err: &anyhow::Error) -> bool {
84    crate::request::ServerErrorCode::from_anyhow(err)
85        .is_some_and(|e| e.code == UNREGISTERED_DEVICE_CODE)
86}
87
88pub struct EncryptResult {
89    pub participant_nodes: Vec<Node>,
90    pub includes_prekey_message: bool,
91    pub encrypted_devices: Vec<Jid>,
92    /// True if any device returned 406 (unregistered) during prekey fetch.
93    pub had_unregistered_device: bool,
94    /// The devices the server rejected by name, when it named them. Empty for a
95    /// batch-wide failure, which names nobody.
96    pub rejected_devices: Vec<Jid>,
97}
98
99pub(crate) struct EncryptAttempt {
100    pub result: EncryptResult,
101    pub first_error: Option<anyhow::Error>,
102}
103
104/// One device's encrypted ciphertext, node-agnostic. The DM/peer paths map this
105/// into a `<to><enc>` node; the voip offer maps it into an `<enc>` per device.
106pub struct EncryptedDevice {
107    pub device_jid: Jid,
108    /// `pkmsg` or `msg`.
109    pub enc_type: &'static str,
110    pub is_prekey: bool,
111    pub ciphertext: Vec<u8>,
112}
113
114/// Node-agnostic encrypt fan-out result: the per-device ciphertexts plus the
115/// two batch flags the message path threads into its stanza.
116pub struct EncryptForDevicesRaw {
117    pub devices: Vec<EncryptedDevice>,
118    pub includes_prekey_message: bool,
119    /// True if any device returned 406 (unregistered) during prekey fetch.
120    pub had_unregistered_device: bool,
121    /// See [`EncryptResult::rejected_devices`].
122    pub rejected_devices: Vec<Jid>,
123}
124
125struct RawEncryptAttempt {
126    result: EncryptForDevicesRaw,
127    first_error: Option<anyhow::Error>,
128}
129
130/// Resolve the `<device-identity>` blob a stanza must carry. A pkmsg recipient
131/// validates our identity from it; without it a pkmsg advances the sender chain
132/// while the peer can't consume the pre-key message (the linked-device deadlock).
133///
134/// - `includes_prekey && account.is_some()` -> `Ok(Some(encoded))`
135/// - `includes_prekey && account.is_none()` -> `Err` (refuse before send)
136/// - `!includes_prekey` -> `Ok(None)`
137pub fn needs_device_identity(
138    includes_prekey: bool,
139    account: Option<&wa::ADVSignedDeviceIdentity>,
140) -> Result<Option<Vec<u8>>> {
141    if !includes_prekey {
142        return Ok(None);
143    }
144    let acc = account
145        .ok_or_else(|| anyhow!("pkmsg requires <device-identity> but no ADV account is present"))?;
146    Ok(Some(waproto::codec::adv_signed_device_identity_to_vec(acc)))
147}
148
149/// Maximum number of concurrent per-device crypto tasks during group send
150/// fan-out. Picked from the `perf-audit` benchmark: speedup plateaus around
151/// 16 on Oracle ARM64; 32 gives only ~10% more for double the task overhead.
152const ENCRYPT_FANOUT_CONCURRENCY: usize = 16;
153
154/// Per-task encrypt result, shipped from a spawned task back to the orchestrator.
155struct EncryptOneResult {
156    enc_type: &'static str,
157    is_prekey: bool,
158    ciphertext: Vec<u8>,
159}
160
161/// Surfaces a spawned task that didn't deliver its result — either the task
162/// itself panicked or the runtime tore it down (e.g., during shutdown).
163/// Surfacing this as an Err lets the encrypt fan-out fall through to its
164/// existing log+skip path instead of propagating a panic.
165#[derive(Debug, thiserror::Error)]
166#[error("spawned task did not produce a result (panic or runtime shutdown)")]
167struct SpawnCanceled;
168
169/// Future returned by [`spawn_oneshot`]. Holds the spawned task's
170/// [`AbortHandle`] until the result is received, so dropping the future mid-
171/// flight (e.g., the outer send was cancelled by a timeout) cancels the
172/// in-flight crypto work instead of orphaning it.
173struct Spawned<T> {
174    rx: futures::channel::oneshot::Receiver<T>,
175    abort: Option<AbortHandle>,
176}
177
178impl<T> Future for Spawned<T> {
179    type Output = std::result::Result<T, SpawnCanceled>;
180
181    fn poll(
182        mut self: std::pin::Pin<&mut Self>,
183        cx: &mut std::task::Context<'_>,
184    ) -> std::task::Poll<Self::Output> {
185        match std::pin::Pin::new(&mut self.rx).poll(cx) {
186            std::task::Poll::Ready(Ok(value)) => {
187                // Result delivered: disarm so Drop doesn't try to abort an
188                // already-completed task.
189                if let Some(handle) = self.abort.take() {
190                    handle.detach();
191                }
192                std::task::Poll::Ready(Ok(value))
193            }
194            std::task::Poll::Ready(Err(_)) => {
195                if let Some(handle) = self.abort.take() {
196                    handle.detach();
197                }
198                std::task::Poll::Ready(Err(SpawnCanceled))
199            }
200            std::task::Poll::Pending => std::task::Poll::Pending,
201        }
202    }
203}
204
205impl<T> Drop for Spawned<T> {
206    fn drop(&mut self) {
207        // If the future was dropped before completion, abort the spawned
208        // task to stop the wasted CPU work. AbortHandle::abort is a no-op
209        // after the task has already finished, so this is always safe.
210        if let Some(handle) = self.abort.take() {
211            handle.abort();
212        }
213    }
214}
215
216/// Spawn `fut` on the runtime and return a future that resolves to its
217/// output. Cancellation propagates: dropping the returned future aborts
218/// the spawned task. A spawned-task panic surfaces as `Err(SpawnCanceled)`
219/// rather than a panic on `rx.await`.
220#[cfg(not(target_arch = "wasm32"))]
221fn spawn_oneshot<F, T>(
222    rt: &dyn Runtime,
223    fut: F,
224) -> impl Future<Output = std::result::Result<T, SpawnCanceled>> + Send + 'static
225where
226    F: Future<Output = T> + Send + 'static,
227    T: Send + 'static,
228{
229    let (tx, rx) = futures::channel::oneshot::channel();
230    let abort = rt.spawn(Box::pin(async move {
231        let _ = tx.send(fut.await);
232    }));
233    Spawned {
234        rx,
235        abort: Some(abort),
236    }
237}
238
239#[cfg(target_arch = "wasm32")]
240fn spawn_oneshot<F, T>(
241    rt: &dyn Runtime,
242    fut: F,
243) -> impl Future<Output = std::result::Result<T, SpawnCanceled>> + 'static
244where
245    F: Future<Output = T> + 'static,
246    T: 'static,
247{
248    let (tx, rx) = futures::channel::oneshot::channel();
249    let abort = rt.spawn(Box::pin(async move {
250        let _ = tx.send(fut.await);
251    }));
252    Spawned {
253        rx,
254        abort: Some(abort),
255    }
256}
257
258/// Encrypt padded plaintext for each device JID, producing participant `<to>` nodes.
259///
260/// Encrypt the plaintext for one device's Signal session. Shared by the
261/// single-device fast path and the parallel fan-out so both behave identically.
262async fn encrypt_one_device(
263    plaintext: &[u8],
264    addr: &ProtocolAddress,
265    session_store: &mut dyn crate::libsignal::protocol::SessionStore,
266    identity_store: &mut dyn crate::libsignal::protocol::IdentityKeyStore,
267    device_jid: Jid,
268) -> (Jid, Result<Option<EncryptOneResult>>) {
269    match message_encrypt(plaintext, addr, session_store, identity_store).await {
270        Ok(encrypted_payload) => {
271            let Some((enc_type, is_prekey, serialized_bytes)) =
272                extract_ciphertext(encrypted_payload)
273            else {
274                return (device_jid, Ok(None));
275            };
276            (
277                device_jid,
278                Ok(Some(EncryptOneResult {
279                    enc_type,
280                    is_prekey,
281                    // Box<[u8]> -> Vec<u8> reuses the allocation (no copy).
282                    ciphertext: serialized_bytes.into(),
283                })),
284            )
285        }
286        Err(error) => (
287            device_jid,
288            Err(anyhow::Error::new(error).context(format!("failed to encrypt for {addr}"))),
289        ),
290    }
291}
292
293/// Append one encrypt result to the raw fan-out output: an [`EncryptedDevice`]
294/// on success, a logged skip on failure. Node-agnostic so both the message
295/// `<to>` map and the voip offer share it.
296fn push_raw_result(
297    (device_jid, res): (Jid, Result<Option<EncryptOneResult>>),
298    devices: &mut Vec<EncryptedDevice>,
299    includes_prekey_message: &mut bool,
300    first_error: &mut Option<anyhow::Error>,
301) {
302    match res {
303        Ok(Some(one)) => {
304            *includes_prekey_message |= one.is_prekey;
305            devices.push(EncryptedDevice {
306                device_jid,
307                enc_type: one.enc_type,
308                is_prekey: one.is_prekey,
309                ciphertext: one.ciphertext,
310            });
311        }
312        Ok(None) => {}
313        Err(error) => {
314            log::warn!("Failed to encrypt for device: {error:#}. Skipping.");
315            if first_error.is_none() {
316                *first_error = Some(error);
317            }
318        }
319    }
320}
321
322/// Map one [`EncryptedDevice`] to the message path's `<to><enc>` participant
323/// node, applying the batch-level `mediatype` and `decrypt-fail` attrs. This is
324/// exactly the wire shape the DM/peer fan-out produced before the raw split.
325fn encrypted_device_to_participant_node(
326    one: EncryptedDevice,
327    mediatype: Option<&str>,
328    hide_decrypt_fail: bool,
329) -> Node {
330    let mut enc_builder = NodeBuilder::new("enc")
331        .attr("v", stanza::ENC_VERSION)
332        .attr("type", one.enc_type);
333    // `mediatype` is batch-level (same for every device) and originates as
334    // a `&'static str`, so it's threaded here instead of cloned per result.
335    if let Some(mt) = mediatype {
336        enc_builder = enc_builder.attr("mediatype", mt);
337    }
338    if hide_decrypt_fail {
339        enc_builder = enc_builder.attr("decrypt-fail", "hide");
340    }
341    let enc_node = enc_builder.bytes(one.ciphertext).build();
342    NodeBuilder::new("to")
343        .attr("jid", one.device_jid)
344        .children([enc_node])
345        .build()
346}
347
348/// Per-device Signal sessions are independent (different ratchet state per
349/// recipient), so this fans the encrypt loop out across tokio tasks bounded
350/// by `ENCRYPT_FANOUT_CONCURRENCY`. Each task clones the store handles
351/// (Arc bumps under the hood); the shared cache provides interior mutability.
352///
353/// Composition of [`ensure_sessions_for_devices`] (network: prekey fetch +
354/// X3DH for missing sessions) and [`encrypt_for_devices_with_sessions`]
355/// (CPU: the pairwise encrypt fan-out). Callers that must not hold a lock
356/// across network I/O (the group sender-key chain lock) call the two phases
357/// directly with the lock taken only around the second.
358///
359/// Callers must hold per-device session locks before calling this function —
360/// concurrent ratchet mutations will corrupt Signal session state.
361pub async fn encrypt_for_devices(
362    runtime: &dyn Runtime,
363    stores: &mut SignalStores<'_>,
364    resolver: &dyn SendContextResolver,
365    devices: &[Jid],
366    plaintext_to_encrypt: &[u8],
367    hide_decrypt_fail: bool,
368    mediatype: Option<&str>,
369) -> Result<EncryptResult> {
370    let plan = ensure_sessions_for_devices(runtime, stores, resolver, devices).await?;
371    encrypt_for_devices_with_sessions(
372        runtime,
373        stores,
374        devices,
375        plaintext_to_encrypt,
376        hide_decrypt_fail,
377        mediatype,
378        plan,
379    )
380    .await
381}
382
383/// What a fan-out reports back when its `<to><enc>` nodes went straight into
384/// the caller's stanza buffer instead of a per-fan-out [`EncryptResult`].
385pub struct EncryptFanoutSummary {
386    pub includes_prekey_message: bool,
387    /// True if any device returned 406 (unregistered) during prekey fetch.
388    pub had_unregistered_device: bool,
389}
390
391/// [`encrypt_for_devices`] for a caller that already owns the buffer the nodes
392/// belong in.
393///
394/// [`EncryptResult`] is shaped for the group path, which needs the encrypted
395/// device list to tell a partial SKDM distribution from a complete one. A DM
396/// never asks that question and knows up front how many participants it can
397/// have, so it sizes one vector and lets each fan-out append into it: the
398/// per-fan-out node vector and the device list it would otherwise carry are
399/// both work done only to be moved and dropped.
400#[allow(clippy::too_many_arguments)]
401pub async fn encrypt_for_devices_into(
402    runtime: &dyn Runtime,
403    stores: &mut SignalStores<'_>,
404    resolver: &dyn SendContextResolver,
405    devices: &[Jid],
406    plaintext_to_encrypt: &[u8],
407    hide_decrypt_fail: bool,
408    mediatype: Option<&str>,
409    participant_nodes: &mut Vec<Node>,
410) -> Result<EncryptFanoutSummary> {
411    let plan = ensure_sessions_for_devices(runtime, stores, resolver, devices).await?;
412    // `first_error` is dropped here exactly as `encrypt_for_devices` drops it:
413    // a DM reports failure through the empty-participants check, not per device.
414    let RawEncryptAttempt { result: raw, .. } = encrypt_for_devices_with_sessions_raw_detailed(
415        runtime,
416        stores,
417        devices,
418        plaintext_to_encrypt,
419        plan,
420    )
421    .await?;
422
423    participant_nodes.reserve(raw.devices.len());
424    for one in raw.devices {
425        participant_nodes.push(encrypted_device_to_participant_node(
426            one,
427            mediatype,
428            hide_decrypt_fail,
429        ));
430    }
431
432    Ok(EncryptFanoutSummary {
433        includes_prekey_message: raw.includes_prekey_message,
434        had_unregistered_device: raw.had_unregistered_device,
435    })
436}
437
438/// Session material prepared for one encrypt fan-out: per-index LID
439/// encryption overrides (mirroring the `devices` slice it was built from)
440/// plus whether any device 406'd during prekey fetch. Produced only by
441/// [`ensure_sessions_for_devices`]; consumed by
442/// [`encrypt_for_devices_with_sessions`] over the same `devices` slice.
443pub struct SessionPlan {
444    /// Device count the plan was built for. Kept alongside the (possibly empty)
445    /// override map so the "same slice" invariant is still checkable now that an
446    /// override-free plan carries no vector at all.
447    device_count: usize,
448    /// Empty means "no device is overridden"; otherwise one slot per device.
449    /// See [`record_encryption_override`].
450    encryption_overrides: Vec<Option<Jid>>,
451    pub had_unregistered_device: bool,
452    /// Devices the server rejected *by name*. Empty when the whole batch
453    /// failed, since a batch-wide answer names nobody.
454    ///
455    /// Kept apart from the flag because they call for different recoveries: a
456    /// named set says exactly which device lists are stale, while a batch-wide
457    /// failure leaves the caller to infer it from what went unencrypted -- and
458    /// inferring it when the server did name the devices would sweep in every
459    /// device that merely lacked a bundle or failed session setup, refreshing
460    /// unrelated users for no reason.
461    pub rejected_devices: Vec<Jid>,
462    first_error: Option<anyhow::Error>,
463}
464
465impl SessionPlan {
466    /// A plan that performs no LID override and no prekey fetch: each device is
467    /// encrypted against its own address as-is. For callers that already ensured
468    /// sessions out-of-band (the voip offer asserts sessions before encrypting)
469    /// and must not touch the network during the encrypt fan-out.
470    pub fn assume_ready(device_count: usize) -> Self {
471        Self {
472            device_count,
473            encryption_overrides: Vec::new(),
474            had_unregistered_device: false,
475            rejected_devices: Vec::new(),
476            first_error: None,
477        }
478    }
479}
480
481/// The LID address recorded for `index`, or `None` when that device encrypts
482/// against its own JID. Indexing tolerates the empty (no-override) map, which
483/// is what a warm send carries.
484fn encryption_override_at(overrides: &[Option<Jid>], index: usize) -> Option<&Jid> {
485    overrides.get(index).and_then(Option::as_ref)
486}
487
488/// Record a per-index LID override, materializing the map on its first entry.
489///
490/// A steady-state send overrides nothing, so the all-`None` vector it would
491/// otherwise allocate (once per encrypt fan-out, twice per DM that also has
492/// companion devices) never exists.
493fn record_encryption_override(
494    overrides: &mut Vec<Option<Jid>>,
495    device_count: usize,
496    index: usize,
497    jid: Jid,
498) {
499    if overrides.is_empty() {
500        overrides.resize(device_count, None);
501    }
502    overrides[index] = Some(jid);
503}
504
505/// Resolve LID overrides and establish missing Signal sessions (prekey
506/// fetch + X3DH) for `devices`. This is the network half of the encrypt
507/// fan-out and touches only session/identity state — never a sender-key
508/// chain — so group sends run it before taking the chain lock.
509#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.ensure_sessions", level = "debug", skip_all, fields(count = devices.len()), err(Debug)))]
510pub async fn ensure_sessions_for_devices(
511    runtime: &dyn Runtime,
512    stores: &mut SignalStores<'_>,
513    resolver: &dyn SendContextResolver,
514    devices: &[Jid],
515) -> Result<SessionPlan> {
516    // Per-device LID upgrade map: encryption_overrides[i] mirrors devices[i].
517    // None = use devices[i] as-is; Some(jid) = use this LID-upgraded version.
518    // The Vec replaces a HashMap<&Jid, Jid> that paid hash + alloc per insert
519    // and per get (~666 of each on a large group). Plain Vec<Option<Jid>> is
520    // direct indexing and contiguous memory. Both vectors stay unallocated
521    // until something is actually recorded in them, which on a warm send is
522    // never.
523    let mut encryption_overrides: Vec<Option<Jid>> = Vec::new();
524    // Indices into `devices` for those needing prekey fetch.
525    let mut indices_needing_prekeys: Vec<usize> = Vec::new();
526    let mut had_406 = false;
527    let mut rejected_devices: Vec<Jid> = Vec::new();
528    let mut first_error = None;
529
530    let mut reusable_addr = crate::types::jid::make_reusable_protocol_address();
531
532    for (idx, device_jid) in devices.iter().enumerate() {
533        // WhatsApp Web's SignalAddress.toString() normalizes PN → LID before
534        // creating signal addresses. We do the same: check LID session FIRST.
535        // This prevents using stale PN sessions when a newer LID session exists.
536        if device_jid.is_pn()
537            && let Some(lid_user) = resolver.get_lid_for_phone(&device_jid.user).await
538        {
539            // Construct the LID JID with the same device ID
540            let lid_jid = Jid::lid_device(lid_user, device_jid.device);
541            lid_jid.reset_protocol_address(&mut reusable_addr);
542
543            if wacore_libsignal::protocol::has_session(stores.session_store, &reusable_addr).await?
544            {
545                log::debug!(
546                    "Using LID session {} for PN {} (LID-first lookup)",
547                    lid_jid.observe(),
548                    device_jid.observe()
549                );
550                record_encryption_override(&mut encryption_overrides, devices.len(), idx, lid_jid);
551                continue;
552            }
553        }
554
555        device_jid.reset_protocol_address(&mut reusable_addr);
556        if wacore_libsignal::protocol::has_session(stores.session_store, &reusable_addr).await? {
557            continue;
558        }
559
560        // No session found - need to fetch prekeys and create session.
561        // Keep device_jid for prekey fetch (server returns bundles keyed by this),
562        // but normalize to LID for the actual session creation.
563        if device_jid.is_pn()
564            && let Some(lid_user) = resolver.get_lid_for_phone(&device_jid.user).await
565        {
566            let lid_jid = Jid::lid_device(lid_user, device_jid.device);
567            log::debug!(
568                "Will create LID session {} for PN {} (no existing session)",
569                lid_jid.observe(),
570                device_jid.observe()
571            );
572            record_encryption_override(&mut encryption_overrides, devices.len(), idx, lid_jid);
573        }
574        indices_needing_prekeys.push(idx);
575    }
576
577    if !indices_needing_prekeys.is_empty() {
578        log::debug!(
579            "Fetching prekeys for {} devices without sessions",
580            indices_needing_prekeys.len()
581        );
582        // Materialize the Jid slice for the resolver call. fetch_prekeys
583        // wants &[Jid]; same per-device clone count as the previous Vec
584        // model, just sourced from the indices.
585        let jids_for_fetch: Vec<Jid> = indices_needing_prekeys
586            .iter()
587            .map(|&i| devices[i].clone())
588            .collect();
589        // A batch-wide 406 is all-or-nothing — per-device retries just wasted
590        // N·RTT with the same failure. Mark `had_406` so the caller invalidates
591        // the users and the next send re-fetches. Matches WA Web's
592        // `GroupSkmsgJob`: log, continue without those devices.
593        //
594        // A per-device rejection is the better-informed case: the server names
595        // the device in its own `<user>`, so it sets the same flag without
596        // condemning the rest of the batch.
597        let prekey_bundles = match resolver
598            .fetch_prekeys_for_identity_check(&jids_for_fetch)
599            .await
600        {
601            Ok(outcome) => {
602                rejected_devices.extend(
603                    outcome
604                        .rejected
605                        .iter()
606                        .filter(|device| device.code == UNREGISTERED_DEVICE_CODE)
607                        .map(|device| device.jid.clone()),
608                );
609                if !rejected_devices.is_empty() {
610                    log::debug!(
611                        "prekey fetch rejected {} of {} device(s) by name",
612                        rejected_devices.len(),
613                        jids_for_fetch.len()
614                    );
615                    had_406 = true;
616                }
617                outcome.bundles
618            }
619            Err(e) if is_device_unregistered_error(&e) => {
620                // No server prekeys for these devices this round; the next send
621                // re-fetches. Debug, not warn — a batch 406 would otherwise flood the log.
622                log::debug!(
623                    "Prekey fetch returned 406 for {} device(s); skipping them this round",
624                    jids_for_fetch.len()
625                );
626                had_406 = true;
627                // Best-effort callers still skip these devices, while a required
628                // distribution can surface the typed server failure without
629                // reconstructing it or reducing the source chain to a string.
630                first_error = Some(e);
631                std::collections::HashMap::new()
632            }
633            Err(e) => return Err(e),
634        };
635
636        // Parallel session establishment via process_prekey_bundle. Each
637        // recipient device has an independent Signal session and an
638        // independent prekey bundle, so the X3DH derivation runs on a
639        // separate task per device, bounded at ENCRYPT_FANOUT_CONCURRENCY.
640        // Spawning goes through `Runtime::spawn` (the platform-agnostic
641        // abstraction) plus a oneshot channel for result delivery —
642        // `FuturesUnordered` handles the in-flight window.
643        let prekey_bundles = std::sync::Arc::new(prekey_bundles);
644        let total = indices_needing_prekeys.len();
645        let mut next_spawn = 0usize;
646
647        let make_session_task = |spawn_idx: usize| {
648            let idx = indices_needing_prekeys[spawn_idx];
649            let lookup_jid = devices[idx].clone();
650            let encryption_jid = encryption_override_at(&encryption_overrides, idx)
651                .cloned()
652                .unwrap_or_else(|| lookup_jid.clone());
653
654            let bundles = prekey_bundles.clone();
655            let mut session_store = stores.session_store.clone_box();
656            let mut identity_store = stores.identity_store.clone_box();
657
658            spawn_oneshot(runtime, async move {
659                let mut addr = crate::types::jid::make_reusable_protocol_address();
660                encryption_jid.reset_protocol_address(&mut addr);
661
662                let Some(bundle) = bundles.get(&lookup_jid) else {
663                    // No key material this round (usually the 406 cascade); the next
664                    // send re-fetches. Debug avoids one warn per skipped device.
665                    log::debug!(
666                        "No pre-key bundle returned for device {}. This device will be skipped for encryption.",
667                        addr
668                    );
669                    return Ok::<Option<Jid>, anyhow::Error>(None);
670                };
671
672                let mut rng = rand::make_rng::<rand::rngs::StdRng>();
673                // No UntrustedIdentity recovery: WA Web's isTrustedIdentity is
674                // unconditional Ok(true) (TOFU), and save_identity inside
675                // process_prekey_bundle persists rotations transparently.
676                match process_prekey_bundle(
677                    &addr,
678                    &mut *session_store,
679                    &mut *identity_store,
680                    bundle,
681                    &mut rng,
682                    UsePQRatchet::No,
683                )
684                .await
685                {
686                    // Surface a replaced identity so the caller can react
687                    // (resolver has no 'static handle into this spawned task).
688                    Ok(IdentityChange::ReplacedExisting) => Ok(Some(encryption_jid)),
689                    Ok(IdentityChange::NewOrUnchanged) => Ok(None),
690                    Err(error) => Err(anyhow::Error::new(error)
691                        .context(format!("failed to process pre-key bundle for {addr}"))),
692                }
693            })
694        };
695
696        let mut in_flight: FuturesUnordered<_> = FuturesUnordered::new();
697        while next_spawn < total && in_flight.len() < ENCRYPT_FANOUT_CONCURRENCY {
698            in_flight.push(make_session_task(next_spawn));
699            next_spawn += 1;
700        }
701        while let Some(spawn_result) = in_flight.next().await {
702            match spawn_result {
703                // Some(jid) => establishing this session replaced a stored
704                // identity; notify the client so it can react off-path.
705                Ok(Ok(Some(changed_jid))) => resolver.on_local_identity_change(&changed_jid),
706                Ok(Ok(None)) => {}
707                // Isolate the failure to this device so one participant can't abort
708                // the cohort's SKDM (matching WA Web GroupKeyDistributionMsg's
709                // per-device try/catch). The sessionless device is dropped by the
710                // fan-out below.
711                Ok(Err(e)) => {
712                    log::warn!("Group session setup failed for a device, skipping it: {e}");
713                    if first_error.is_none() {
714                        first_error = Some(e);
715                    }
716                }
717                Err(error) => {
718                    log::warn!(
719                        "Session-establishment task did not deliver a result; skipping device."
720                    );
721                    if first_error.is_none() {
722                        first_error = Some(anyhow::Error::new(error));
723                    }
724                }
725            }
726            if next_spawn < total {
727                in_flight.push(make_session_task(next_spawn));
728                next_spawn += 1;
729            }
730        }
731    }
732
733    Ok(SessionPlan {
734        device_count: devices.len(),
735        encryption_overrides,
736        had_unregistered_device: had_406,
737        rejected_devices,
738        first_error,
739    })
740}
741
742/// CPU half of the encrypt fan-out: pairwise-encrypt `plaintext_to_encrypt`
743/// for each device using sessions prepared by [`ensure_sessions_for_devices`]
744/// over the same `devices` slice. No resolver, no network — safe to run
745/// under locks that must not span I/O. A device whose session is still
746/// missing (e.g. its bundle was absent) fails its encrypt and is skipped,
747/// matching the combined path's behavior.
748pub async fn encrypt_for_devices_with_sessions(
749    runtime: &dyn Runtime,
750    stores: &mut SignalStores<'_>,
751    devices: &[Jid],
752    plaintext_to_encrypt: &[u8],
753    hide_decrypt_fail: bool,
754    mediatype: Option<&str>,
755    plan: SessionPlan,
756) -> Result<EncryptResult> {
757    Ok(encrypt_for_devices_with_sessions_detailed(
758        runtime,
759        stores,
760        devices,
761        plaintext_to_encrypt,
762        hide_decrypt_fail,
763        mediatype,
764        plan,
765    )
766    .await?
767    .result)
768}
769
770#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.encrypt_fanout", level = "debug", skip_all, fields(count = devices.len()), err(Debug)))]
771pub(crate) async fn encrypt_for_devices_with_sessions_detailed(
772    runtime: &dyn Runtime,
773    stores: &mut SignalStores<'_>,
774    devices: &[Jid],
775    plaintext_to_encrypt: &[u8],
776    hide_decrypt_fail: bool,
777    mediatype: Option<&str>,
778    plan: SessionPlan,
779) -> Result<EncryptAttempt> {
780    let RawEncryptAttempt {
781        result: raw,
782        first_error,
783    } = encrypt_for_devices_with_sessions_raw_detailed(
784        runtime,
785        stores,
786        devices,
787        plaintext_to_encrypt,
788        plan,
789    )
790    .await?;
791
792    // Map each ciphertext to the message path's `<to><enc>` node, preserving the
793    // raw fan-out order so the wire output is identical to the pre-split path.
794    // `encrypted_devices` mirrors `participant_nodes` index-for-index, as before.
795    let mut participant_nodes = Vec::with_capacity(raw.devices.len());
796    let mut encrypted_devices = Vec::with_capacity(raw.devices.len());
797    for one in raw.devices {
798        encrypted_devices.push(one.device_jid.clone());
799        participant_nodes.push(encrypted_device_to_participant_node(
800            one,
801            mediatype,
802            hide_decrypt_fail,
803        ));
804    }
805
806    Ok(EncryptAttempt {
807        result: EncryptResult {
808            participant_nodes,
809            includes_prekey_message: raw.includes_prekey_message,
810            encrypted_devices,
811            had_unregistered_device: raw.had_unregistered_device,
812            rejected_devices: raw.rejected_devices.clone(),
813        },
814        first_error,
815    })
816}
817
818/// Node-agnostic core of the encrypt fan-out: pairwise-encrypt
819/// `plaintext_to_encrypt` for each device using sessions prepared by
820/// [`ensure_sessions_for_devices`] over the same `devices` slice, returning the
821/// per-device ciphertexts. No resolver, no network, no node-building — safe to
822/// run under locks that must not span I/O. A device whose session is still
823/// missing (e.g. its bundle was absent) fails its encrypt and is skipped.
824/// Same parallel fan-out + skip-on-fail contract as the message path.
825pub async fn encrypt_for_devices_with_sessions_raw(
826    runtime: &dyn Runtime,
827    stores: &mut SignalStores<'_>,
828    devices: &[Jid],
829    plaintext_to_encrypt: &[u8],
830    plan: SessionPlan,
831) -> Result<EncryptForDevicesRaw> {
832    Ok(encrypt_for_devices_with_sessions_raw_detailed(
833        runtime,
834        stores,
835        devices,
836        plaintext_to_encrypt,
837        plan,
838    )
839    .await?
840    .result)
841}
842
843#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.encrypt_fanout_raw", level = "debug", skip_all, fields(count = devices.len()), err(Debug)))]
844async fn encrypt_for_devices_with_sessions_raw_detailed(
845    runtime: &dyn Runtime,
846    stores: &mut SignalStores<'_>,
847    devices: &[Jid],
848    plaintext_to_encrypt: &[u8],
849    plan: SessionPlan,
850) -> Result<RawEncryptAttempt> {
851    debug_assert_eq!(
852        plan.device_count,
853        devices.len(),
854        "SessionPlan built for a different device list"
855    );
856    let SessionPlan {
857        device_count: _,
858        encryption_overrides,
859        had_unregistered_device,
860        rejected_devices,
861        mut first_error,
862    } = plan;
863
864    let mut encrypted = Vec::with_capacity(devices.len());
865    let mut includes_prekey_message = false;
866
867    // The wire-order of `<to>` participants does not need to match the input
868    // device order: WA Web's `phash` (computed both client and server side)
869    // sorts before hashing, as does our `participant_list_hash`.
870    if devices.len() == 1 {
871        // Single recipient device: the parallel fan-out is pure overhead here
872        // (an Arc<[u8]> copy of the plaintext, a spawned task + oneshot channel,
873        // a FuturesUnordered, and two store clones), with no parallelism to gain.
874        // Encrypt inline.
875        let device_jid = devices[0].clone();
876        let addr = encryption_override_at(&encryption_overrides, 0)
877            .unwrap_or(&devices[0])
878            .to_protocol_address();
879        let res = encrypt_one_device(
880            plaintext_to_encrypt,
881            &addr,
882            &mut *stores.session_store,
883            &mut *stores.identity_store,
884            device_jid,
885        )
886        .await;
887        push_raw_result(
888            res,
889            &mut encrypted,
890            &mut includes_prekey_message,
891            &mut first_error,
892        );
893    } else {
894        // One task per chunk, not per device: the per-device fan-out allocated a
895        // task + oneshot + two store clones for every recipient. Same parallelism,
896        // spawns bounded by ENCRYPT_FANOUT_CONCURRENCY. Wire order is irrelevant
897        // (phash sorts before hashing on both ends).
898        let plaintext_arc: std::sync::Arc<[u8]> = std::sync::Arc::from(plaintext_to_encrypt);
899
900        let total = devices.len();
901        let num_chunks = ENCRYPT_FANOUT_CONCURRENCY.min(total);
902
903        let mut in_flight: FuturesUnordered<_> = FuturesUnordered::new();
904        // Index partitioning gives exactly num_chunks slices (keeps the configured
905        // parallelism) and no-ops on an empty device set instead of dividing by zero.
906        for chunk_idx in 0..num_chunks {
907            let chunk_start = chunk_idx * total / num_chunks;
908            let chunk_end = (chunk_idx + 1) * total / num_chunks;
909            // The 'static task can't borrow devices/encryption_overrides.
910            let jobs: Vec<(ProtocolAddress, Jid)> = (chunk_start..chunk_end)
911                .map(|idx| {
912                    let addr = encryption_override_at(&encryption_overrides, idx)
913                        .unwrap_or(&devices[idx])
914                        .to_protocol_address();
915                    (addr, devices[idx].clone())
916                })
917                .collect();
918            let plaintext = plaintext_arc.clone();
919            // clone_box shares the Arc-backed backend, so the sequential ratchet
920            // advances persist despite one clone serving the whole chunk.
921            let mut session_store = stores.session_store.clone_box();
922            let mut identity_store = stores.identity_store.clone_box();
923
924            in_flight.push(spawn_oneshot(runtime, async move {
925                let mut out = Vec::with_capacity(jobs.len());
926                for (addr, device_jid) in jobs {
927                    out.push(
928                        encrypt_one_device(
929                            &plaintext,
930                            &addr,
931                            &mut *session_store,
932                            &mut *identity_store,
933                            device_jid,
934                        )
935                        .await,
936                    );
937                }
938                out
939            }));
940        }
941        while let Some(spawn_result) = in_flight.next().await {
942            match spawn_result {
943                Ok(results) => {
944                    for res in results {
945                        push_raw_result(
946                            res,
947                            &mut encrypted,
948                            &mut includes_prekey_message,
949                            &mut first_error,
950                        );
951                    }
952                }
953                Err(error) => {
954                    // A whole chunk drops (not one device); its members stay
955                    // un-warm and are re-targeted next send.
956                    log::warn!(
957                        "Encrypt chunk did not deliver a result; up to ~{} device(s) skipped this send.",
958                        total.div_ceil(num_chunks)
959                    );
960                    if first_error.is_none() {
961                        first_error = Some(anyhow::Error::new(error));
962                    }
963                }
964            }
965        }
966    }
967
968    Ok(RawEncryptAttempt {
969        result: EncryptForDevicesRaw {
970            devices: encrypted,
971            includes_prekey_message,
972            had_unregistered_device,
973            rejected_devices,
974        },
975        first_error,
976    })
977}
978
979#[cfg(test)]
980mod encryption_override_tests {
981    use super::{SessionPlan, encryption_override_at, record_encryption_override};
982    use wacore_binary::Jid;
983
984    fn lid(user: &str, device: u16) -> Jid {
985        Jid::lid_device(user.to_owned(), device)
986    }
987
988    /// The steady state: nothing is overridden, so the per-device map is never
989    /// allocated and every lookup still answers "use the device's own JID".
990    #[test]
991    fn an_empty_map_answers_every_index_without_allocating() {
992        let overrides: Vec<Option<Jid>> = Vec::new();
993        assert_eq!(overrides.capacity(), 0, "no override must mean no buffer");
994        for index in [0, 1, 7, usize::MAX] {
995            assert!(encryption_override_at(&overrides, index).is_none());
996        }
997
998        let plan = SessionPlan::assume_ready(4);
999        assert!(
1000            plan.encryption_overrides.is_empty(),
1001            "a plan that overrides nothing must carry no override buffer"
1002        );
1003        assert_eq!(plan.device_count, 4, "the slice length is still recorded");
1004    }
1005
1006    /// The first recorded override materializes the full map, so later indices
1007    /// stay addressable and earlier ones stay `None`.
1008    #[test]
1009    fn recording_materializes_the_whole_map_once() {
1010        let mut overrides: Vec<Option<Jid>> = Vec::new();
1011        record_encryption_override(&mut overrides, 3, 2, lid("100000000000001", 5));
1012        assert_eq!(overrides.len(), 3);
1013        assert!(encryption_override_at(&overrides, 0).is_none());
1014        assert!(encryption_override_at(&overrides, 1).is_none());
1015        assert_eq!(
1016            encryption_override_at(&overrides, 2),
1017            Some(&lid("100000000000001", 5))
1018        );
1019
1020        // A second record must not resize again, nor clear the first.
1021        record_encryption_override(&mut overrides, 3, 0, lid("100000000000002", 0));
1022        assert_eq!(overrides.len(), 3);
1023        assert_eq!(
1024            encryption_override_at(&overrides, 0),
1025            Some(&lid("100000000000002", 0))
1026        );
1027        assert_eq!(
1028            encryption_override_at(&overrides, 2),
1029            Some(&lid("100000000000001", 5))
1030        );
1031
1032        // Overwriting an index replaces it rather than appending.
1033        record_encryption_override(&mut overrides, 3, 2, lid("100000000000003", 1));
1034        assert_eq!(overrides.len(), 3);
1035        assert_eq!(
1036            encryption_override_at(&overrides, 2),
1037            Some(&lid("100000000000003", 1))
1038        );
1039    }
1040
1041    /// A single-device fan-out is the DM hot path, and it reads index 0 off a
1042    /// map that may not exist.
1043    #[test]
1044    fn a_single_device_plan_records_and_reads_index_zero() {
1045        let mut overrides: Vec<Option<Jid>> = Vec::new();
1046        assert!(encryption_override_at(&overrides, 0).is_none());
1047        record_encryption_override(&mut overrides, 1, 0, lid("100000000000009", 33));
1048        assert_eq!(
1049            encryption_override_at(&overrides, 0),
1050            Some(&lid("100000000000009", 33))
1051        );
1052        assert!(encryption_override_at(&overrides, 1).is_none());
1053    }
1054}