Skip to main content

whatsapp_rust/
prekeys.rs

1//! Pre-key management for Signal Protocol.
2//!
3//! Pre-key IDs use a persistent monotonic counter (Device::next_pre_key_id)
4//! matching WhatsApp Web's NEXT_PK_ID pattern. IDs only increase to prevent
5//! collisions when prekeys are consumed non-sequentially from the store.
6
7use crate::client::Client;
8use anyhow;
9use anyhow::Context as _;
10use log;
11
12use std::sync::atomic::Ordering;
13use wacore::iq::prekeys::{
14    DigestKeyBundleSpec, PreKeyCountSpec, PreKeyFetchReason, PreKeyFetchSpec, PreKeyUploadSpec,
15};
16use wacore::libsignal::protocol::{KeyPair, PublicKey};
17use wacore::libsignal::store::record_helpers::encode_pre_key_record_to;
18use wacore::store::commands::DeviceCommand;
19use wacore_binary::Jid;
20
21pub use wacore::prekeys::PreKeyUtils;
22
23/// Default number of one-time pre-keys generated and uploaded per batch.
24/// Mirrors WA Web's UPLOAD_KEYS_COUNT (`WAWebUploadPreKeysJob`).
25pub(crate) const DEFAULT_WANTED_PRE_KEY_COUNT: usize = 812;
26
27const MIN_PRE_KEY_COUNT: usize = 5;
28
29/// Whether `upload_pre_keys` should upload, given the `force` flag and the server's
30/// reported pre-key count. The prekey-low path forces, matching WA Web's
31/// `handlePreKeyLow` which uploads unconditionally, so `force` bypasses the count guard.
32fn should_upload_pre_keys(force: bool, server_count: usize) -> bool {
33    force || server_count < MIN_PRE_KEY_COUNT
34}
35
36/// WA Web uses 24-bit PreKey IDs (max 2^24 - 1); IDs wrap modulo this.
37const MAX_PREKEY_ID: u32 = 16_777_215;
38
39/// Next one-time prekey id to mint from the persistent monotonic counter, falling back to
40/// `max_store_id + 1` on migration (when the counter is unset) and wrapping into the 24-bit
41/// range. Shared by the batch upload path and the retry-receipt single-key allocation so both
42/// draw from the same `NEXT_PK_ID` namespace and never collide (matching WA Web).
43fn start_prekey_id(next_pre_key_id: u32, max_store_id: u32) -> u32 {
44    let raw = if next_pre_key_id > 0 {
45        std::cmp::max(next_pre_key_id as u64, max_store_id as u64 + 1)
46    } else {
47        max_store_id as u64 + 1
48    };
49    ((raw - 1) % MAX_PREKEY_ID as u64) as u32 + 1
50}
51
52/// One upload pass's accounting, mirroring WA Web `getOrGenPreKeys` semantics
53/// (`WAWebSignalStoreApi`): the upload window starts at the FIRST_UNUPLOAD
54/// watermark and re-offers leftover generated-but-unuploaded keys, generating
55/// only enough new ones to reach the target.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57struct PreKeyUploadPlan {
58    /// First id of the upload window (the FIRST_UNUPLOAD watermark after
59    /// migration init / self-heal).
60    window_start: u32,
61    /// Leftover generated-but-unuploaded keys already in the store.
62    available: u32,
63    /// New keys to generate (`wanted - available`, floored at 0).
64    gen_count: u32,
65    /// First id of the newly generated range (`window_start + available`).
66    gen_start: u32,
67    /// NEXT_PK_ID after generation (`gen_start + gen_count`).
68    new_next: u32,
69}
70
71/// Compute the upload window and generation range from the two watermarks.
72///
73/// `first_unupload == 0` is the unset/legacy state: the window starts fresh at
74/// the legacy-safe `start_prekey_id` (which skips stored-but-unconfirmed rows
75/// from the pre-watermark model) with no leftovers. The same reset handles a
76/// corrupt `first > next` pair and a window that would cross the 24-bit id
77/// boundary; the wrap collapse keeps the window contiguous and is the same
78/// accepted tradeoff as the old per-id modulo (the server consumes keys well
79/// before a 16M cycle).
80fn plan_prekey_upload(
81    first_unupload: u32,
82    next: u32,
83    max_store_id: u32,
84    wanted: usize,
85) -> PreKeyUploadPlan {
86    let wanted = wanted as u64;
87    let first = first_unupload as u64;
88    let next_eff = next as u64;
89
90    let fresh_window = |start: u64| {
91        let start = if start + wanted - 1 > MAX_PREKEY_ID as u64 {
92            1
93        } else {
94            start
95        };
96        PreKeyUploadPlan {
97            window_start: start as u32,
98            available: 0,
99            gen_count: wanted as u32,
100            gen_start: start as u32,
101            new_next: (start + wanted) as u32,
102        }
103    };
104
105    if first == 0 || first > next_eff {
106        return fresh_window(start_prekey_id(next, max_store_id) as u64);
107    }
108    // Cap leftovers at the target: surplus stays in the window for next time
109    // (WA Web p <= 0 path uploads only getPreKeysByRange(s, wanted)). New
110    // generation always starts at NEXT (savePreKeys semantics), so a capped
111    // window never regresses the counter.
112    let available = (next_eff - first).min(wanted);
113    let gen_count = wanted - available;
114    if first + wanted - 1 > MAX_PREKEY_ID as u64
115        || (gen_count > 0 && next_eff + gen_count - 1 > MAX_PREKEY_ID as u64)
116    {
117        // Window or generation would cross the id boundary: collapse to a
118        // fresh window at 1 (old high-id rows are overwritten progressively,
119        // same acceptance as the previous modulo wrap).
120        return fresh_window(1);
121    }
122    PreKeyUploadPlan {
123        window_start: first as u32,
124        available: available as u32,
125        gen_count: gen_count as u32,
126        gen_start: next_eff as u32,
127        new_next: (next_eff + gen_count) as u32,
128    }
129}
130
131/// The upload IQ encodes the pre-key `<list>` length as a u16
132/// (`Encoder::write_list_start`), so a larger batch fails to encode after the
133/// keys were already generated and stored. Well below MAX_PREKEY_ID, so a single
134/// batch never reuses an ID either.
135const MAX_PRE_KEY_UPLOAD_BATCH: usize = u16::MAX as usize;
136
137/// Below MIN_PRE_KEY_COUNT the pool ends up flagged-but-empty or loops on
138/// re-upload (the count guard never clears); above MAX_PRE_KEY_UPLOAD_BATCH the
139/// upload IQ fails to encode. Only an explicitly misconfigured count hits either.
140fn clamp_wanted_pre_key_count(n: usize) -> usize {
141    n.clamp(MIN_PRE_KEY_COUNT, MAX_PRE_KEY_UPLOAD_BATCH)
142}
143
144impl Client {
145    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.fetch_pre_keys", level = "debug", skip_all, fields(count = jids.len()), err(Debug)))]
146    pub(crate) async fn fetch_pre_keys(
147        &self,
148        jids: &[Jid],
149        reason: Option<PreKeyFetchReason>,
150    ) -> Result<wacore::prekeys::PreKeyFetchOutcome, anyhow::Error> {
151        let spec = match reason {
152            Some(r) => PreKeyFetchSpec::with_reason(jids.to_vec(), r),
153            None => PreKeyFetchSpec::new(jids.to_vec()),
154        };
155
156        // Pre-load each companion's account (device 0) identity as the ADV
157        // `account_signature_key` fallback: the server omits that field from a
158        // contact's companion `<device-identity>` because it's the contact's
159        // primary identity the client already stores. Without it we'd reject the
160        // bundle and the device would stop receiving (WA Web uses the same stored
161        // identity as the fallback in validateADVwithIdentityKey).
162        let spec = spec.with_account_identities(self.collect_account_identities(jids).await);
163
164        let outcome = self.execute(spec).await?;
165
166        for jid in outcome.bundles.keys() {
167            log::debug!("Successfully parsed pre-key bundle for {}", jid.observe());
168        }
169
170        Ok(outcome)
171    }
172
173    /// Load, for each companion JID, its account (device 0) identity key from the
174    /// store, keyed by the normalized companion JID so the prekey parser can use
175    /// it as the ADV `account_signature_key` fallback. Missing entries are simply
176    /// absent (no fallback for that JID).
177    async fn collect_account_identities(
178        &self,
179        jids: &[Jid],
180    ) -> std::collections::HashMap<Jid, [u8; 32]> {
181        use futures::StreamExt;
182        // Fan out the per-companion identity loads (independent cache/DB reads) —
183        // the keyless-companion set can be large on a cold group send. Owned Vec so
184        // the stream doesn't borrow `jids` through buffer_unordered (Send bound).
185        const COMPANION_IDENTITY_LOAD_CONCURRENCY: usize = 16;
186        let companions: Vec<Jid> = jids.iter().filter(|j| j.device != 0).cloned().collect();
187        futures::stream::iter(companions)
188            .map(|jid| async move { self.load_account_identity(&jid).await.map(|id| (jid, id)) })
189            .buffer_unordered(COMPANION_IDENTITY_LOAD_CONCURRENCY)
190            .filter_map(|entry| async move { entry })
191            .collect()
192            .await
193    }
194
195    /// Load a companion's account (device 0) identity from the store, for use as
196    /// the ADV `account_signature_key` fallback (WA Web `validateADVwithIdentityKey`
197    /// loads the same stored identity). Reads through the signal cache so an
198    /// identity established earlier this session (not yet flushed) is still found.
199    /// `None` when not stored.
200    pub(crate) async fn load_account_identity(&self, companion_jid: &Jid) -> Option<[u8; 32]> {
201        use wacore::types::jid::JidExt;
202
203        let account_jid = companion_jid.with_device(0);
204        let addr = account_jid.to_protocol_address();
205        let backend = self
206            .persistence_manager
207            .get_device_snapshot()
208            .backend
209            .clone();
210        match self.signal_cache.get_identity(&addr, &*backend).await {
211            Ok(Some(id)) if id.len() == 32 => {
212                let mut arr = [0u8; 32];
213                arr.copy_from_slice(&id);
214                Some(arr)
215            }
216            Ok(_) => None,
217            Err(e) => {
218                log::debug!(
219                    "ADV fallback: failed to load account identity for {}: {}",
220                    account_jid.observe(),
221                    e
222                );
223                None
224            }
225        }
226    }
227
228    /// Query the WhatsApp server for how many pre-keys it currently has for this device.
229    #[cfg_attr(
230        feature = "tracing",
231        tracing::instrument(
232            name = "wa.session.server_pre_key_count",
233            level = "debug",
234            skip_all,
235            err(Debug)
236        )
237    )]
238    pub(crate) async fn get_server_pre_key_count(&self) -> Result<usize, crate::request::IqError> {
239        let response = self.execute(PreKeyCountSpec::new()).await?;
240        Ok(response.count)
241    }
242
243    /// Upload prekeys at login if the persisted flag indicates they're needed.
244    /// Matches WA Web's PassiveTasks.js:30 which checks `getServerHasPreKeys()`.
245    #[cfg_attr(
246        feature = "tracing",
247        tracing::instrument(
248            name = "wa.session.upload_pre_keys_at_login",
249            level = "debug",
250            skip_all,
251            err(Debug)
252        )
253    )]
254    pub(crate) async fn upload_pre_keys_at_login(&self) -> Result<(), anyhow::Error> {
255        let has_prekeys = self
256            .persistence_manager
257            .get_device_snapshot()
258            .server_has_prekeys;
259
260        if has_prekeys {
261            log::debug!("Server has prekeys (persisted flag), skipping login upload.");
262            return Ok(());
263        }
264
265        // Serialize with prekey-low/digest paths to avoid duplicate uploads
266        let _guard = self.prekey_upload_lock.lock().await;
267
268        // Re-check after acquiring lock (another task may have uploaded)
269        if self
270            .persistence_manager
271            .get_device_snapshot()
272            .server_has_prekeys
273        {
274            return Ok(());
275        }
276
277        log::info!("Server missing prekeys (persisted flag), uploading.");
278        // Operation-level outcome (the login path skips the retry wrapper).
279        let r = self.upload_pre_keys_inner().await;
280        wacore::telemetry::prekey_upload(if r.is_ok() { "ok" } else { "fail" });
281        r
282    }
283
284    #[cfg_attr(
285        feature = "tracing",
286        tracing::instrument(
287            name = "wa.session.upload_pre_keys",
288            level = "debug",
289            skip_all,
290            fields(force = force, wanted = ?wanted),
291            err(Debug)
292        )
293    )]
294    async fn upload_pre_keys_with_count(
295        &self,
296        force: bool,
297        wanted: Option<usize>,
298    ) -> Result<(), anyhow::Error> {
299        // Decision is should_upload_pre_keys(force, count), but a forced upload short-circuits
300        // and skips the server-count IQ entirely: WA Web's handlePreKeyLow uploads
301        // unconditionally, so a stale or transiently-failing count must never block or delay
302        // the replenish. Only a non-forced caller queries the count and applies the guard.
303        if !force {
304            let server_count = self
305                .get_server_pre_key_count()
306                .await
307                .map_err(|e| anyhow::anyhow!(e))?;
308
309            if !should_upload_pre_keys(force, server_count) {
310                log::debug!("Server has {server_count} pre-keys, no upload needed.");
311                return Ok(());
312            }
313
314            log::debug!("Server has {server_count} pre-keys, uploading.");
315        }
316
317        match wanted {
318            Some(wanted) => self.upload_pre_keys_inner_with_count(wanted).await,
319            None => self.upload_pre_keys_inner().await,
320        }
321    }
322
323    /// Get-or-generate ONE one-time prekey, mirroring WA Web's
324    /// `getOrGenSinglePreKey` = `getOrGenPreKeys(1)`: reuse the first
325    /// generated-but-unuploaded window key when one exists (it stays in the
326    /// window and is uploaded by the next batch, like WA Web), else generate a
327    /// fresh key at `NEXT_PK_ID` and advance the counter at generation time.
328    /// The caller must hold `prekey_upload_lock` to serialize the watermark
329    /// math with the upload path.
330    #[cfg_attr(
331        feature = "tracing",
332        tracing::instrument(
333            name = "wa.session.get_or_gen_prekey",
334            level = "debug",
335            skip_all,
336            err(Debug)
337        )
338    )]
339    pub(crate) async fn get_or_gen_single_pre_key(
340        &self,
341    ) -> Result<(u32, PublicKey), anyhow::Error> {
342        let device_snapshot = self.persistence_manager.get_device_snapshot();
343        let backend = device_snapshot.backend.clone();
344        let max_id = backend.get_max_prekey_id().await?;
345        let plan = plan_prekey_upload(
346            device_snapshot.first_unupload_pre_key_id,
347            device_snapshot.next_pre_key_id,
348            max_id,
349            1,
350        );
351
352        if plan.gen_count == 0 {
353            // Load the whole remaining window: a consumed head (a previously
354            // reused retry key the peer already spent) must not abandon the
355            // still-live keys behind it, so the heal advances FIRST to the
356            // next stored id instead of past the window. WA Web throws on a
357            // missing head; healing is strictly better and stays in the same
358            // id namespace.
359            let window_ids: Vec<u32> =
360                (plan.window_start..device_snapshot.next_pre_key_id).collect();
361            let mut rows = backend.load_prekeys_batch(&window_ids).await?;
362            rows.sort_unstable_by_key(|(id, _)| *id);
363            if let Some((id, record)) = rows.into_iter().next() {
364                if id != plan.window_start {
365                    log::warn!(
366                        "prekey window head {} missing from store; advancing to {id}",
367                        plan.window_start
368                    );
369                    self.persistence_manager
370                        .process_command(DeviceCommand::SetPreKeyWatermarks {
371                            next_pre_key_id: device_snapshot.next_pre_key_id,
372                            first_unupload_pre_key_id: id,
373                        })
374                        .await;
375                }
376                let structure = waproto::codec::pre_key_record_decode(&record)?;
377                let record = wacore::libsignal::store::record_helpers::prekey_structure_to_record(
378                    structure,
379                )?;
380                return Ok((id, record.key_pair()?.public_key));
381            }
382            log::warn!(
383                "prekey window [{}, {}) fully consumed; generating fresh",
384                plan.window_start,
385                device_snapshot.next_pre_key_id
386            );
387        }
388
389        let id = if plan.gen_count > 0 {
390            plan.gen_start
391        } else {
392            // Empty window: generate at NEXT and collapse FIRST onto it. This
393            // path bypasses the planner's boundary handling, so wrap to 1 at
394            // the 24-bit edge like the planner's collapse does.
395            let raw = device_snapshot
396                .next_pre_key_id
397                .max(plan.window_start.saturating_add(1));
398            if raw > MAX_PREKEY_ID { 1 } else { raw }
399        };
400        let key_pair = KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>());
401        let mut encoded_record = Vec::new();
402        encode_pre_key_record_to(id, &key_pair, &mut encoded_record);
403        backend.store_prekey(id, &encoded_record, false).await?;
404        self.persistence_manager
405            .process_command(DeviceCommand::SetPreKeyWatermarks {
406                next_pre_key_id: id.saturating_add(1),
407                first_unupload_pre_key_id: if plan.gen_count > 0 {
408                    plan.window_start
409                } else {
410                    id
411                },
412            })
413            .await;
414        // Same durability pairing as the batch path: the stored key is
415        // durable, so its watermarks must not ride the lazy saver. A failed
416        // flush fails the allocation; the retry dance recovers later.
417        self.persistence_manager
418            .flush()
419            .await
420            .context("failed to flush prekey watermarks")?;
421        Ok((id, key_pair.public_key))
422    }
423
424    /// WA Web `markKeyAsUploaded`: exclude a retry-distributed one-time prekey
425    /// from the next batch upload's server offer, so the same id is never both
426    /// direct-distributed AND pooled. The `_guard` proof makes holding
427    /// `prekey_upload_lock` a compile-time requirement: the snapshot read and
428    /// the watermark write must be atomic against the batch upload path, or a
429    /// stale `next_pre_key_id` could roll the upper watermark back. Idempotent.
430    pub(crate) async fn mark_single_prekey_uploaded(
431        &self,
432        _guard: &async_lock::MutexGuard<'_, ()>,
433        id: u32,
434    ) -> Result<(), anyhow::Error> {
435        let device_snapshot = self.persistence_manager.get_device_snapshot();
436        if device_snapshot.first_unupload_pre_key_id != id {
437            return Ok(());
438        }
439        let next_first = if id >= MAX_PREKEY_ID { 1 } else { id + 1 };
440        // Only when marking the terminal id itself (id == MAX) does a preceding
441        // allocation leave NEXT at MAX+1 (out of range); collapse it onto the
442        // wrapped low watermark so the next window is empty ([next_first,
443        // next_first)). Keying on NEXT alone would wrongly discard a non-terminal
444        // high-end window whose head sits just below MAX.
445        let next_pre_key_id =
446            if id >= MAX_PREKEY_ID && device_snapshot.next_pre_key_id > MAX_PREKEY_ID {
447                next_first
448            } else {
449                device_snapshot.next_pre_key_id
450            };
451        self.persistence_manager
452            .process_command(DeviceCommand::SetPreKeyWatermarks {
453                next_pre_key_id,
454                first_unupload_pre_key_id: next_first,
455            })
456            .await;
457        self.persistence_manager
458            .flush()
459            .await
460            .context("failed to flush prekey watermark after mark")?;
461        Ok(())
462    }
463
464    /// Generate and upload the configured number of pre-keys (see
465    /// [`Client::set_wanted_pre_key_count`]). Shared by `upload_pre_keys` and
466    /// `upload_pre_keys_at_login` to avoid redundant server count queries.
467    #[cfg_attr(
468        feature = "tracing",
469        tracing::instrument(
470            name = "wa.session.upload_pre_keys_inner",
471            level = "debug",
472            skip_all,
473            err(Debug)
474        )
475    )]
476    async fn upload_pre_keys_inner(&self) -> Result<(), anyhow::Error> {
477        let wanted = self.wanted_pre_key_count.load(Ordering::Relaxed);
478        self.upload_pre_keys_inner_with_count(wanted).await
479    }
480
481    async fn upload_pre_keys_inner_with_count(&self, wanted: usize) -> Result<(), anyhow::Error> {
482        self.upload_pre_keys_pass(true, wanted).await
483    }
484
485    /// One upload pass. `allow_collapse_retry` permits a single inline rerun
486    /// after collapsing a fully consumed window, so a one-shot caller (the
487    /// login path logs and moves on) still ends the pass with fresh keys; the
488    /// rerun cannot hit the empty branch again because the collapsed plan
489    /// generates a full batch.
490    async fn upload_pre_keys_pass(
491        &self,
492        allow_collapse_retry: bool,
493        configured: usize,
494    ) -> Result<(), anyhow::Error> {
495        // INVARIANT: every caller holds `prekey_upload_lock` (login, prekey-low
496        // notification, refresh, digest repair), serializing the watermark math
497        // with the retry-receipt single-key path.
498        let device_snapshot = self.persistence_manager.get_device_snapshot();
499        let backend = device_snapshot.backend.clone();
500
501        let wanted = clamp_wanted_pre_key_count(configured);
502        if wanted != configured {
503            log::warn!("wanted_pre_key_count {configured} out of range, clamped to {wanted}");
504        }
505
506        // WA Web getOrGenPreKeys: re-offer the leftover generated-but-unuploaded
507        // window first and only generate enough new keys to reach the target.
508        let max_id = backend.get_max_prekey_id().await?;
509        if device_snapshot.first_unupload_pre_key_id == 0 {
510            log::info!(
511                "Initialising prekey upload window (legacy counter = {}, MAX(key_id) = {})",
512                device_snapshot.next_pre_key_id,
513                max_id
514            );
515        }
516        let plan = plan_prekey_upload(
517            device_snapshot.first_unupload_pre_key_id,
518            device_snapshot.next_pre_key_id,
519            max_id,
520            wanted,
521        );
522
523        // Public keys of the freshly generated batch, kept from generation so the
524        // upload never reads them back out of the store and never decodes protobuf
525        // to recover a key it just held in hand. Empty when the plan only re-offers
526        // leftover window keys (gen_count == 0).
527        let mut fresh_pre_keys: Vec<(u32, PublicKey)> = Vec::new();
528        if plan.gen_count > 0 {
529            let gen_start = plan.gen_start;
530            let gen_count = plan.gen_count as usize;
531            // Per-key X25519 generation and prost encoding are CPU-bound, and the
532            // batch size is caller-configurable, so offload the whole batch to keep
533            // the async executor responsive. Records are encoded into one contiguous
534            // buffer with zero-copy Bytes slices instead of an alloc per record.
535            let (encoded_batch, generated) = wacore::runtime::blocking(&*self.runtime, move || {
536                // Seed one CSPRNG and advance it per key, rather than reseeding from
537                // entropy on every iteration.
538                let mut rng = rand::make_rng::<rand::rngs::StdRng>();
539                // Encode each record into the shared buffer and drop it immediately so the
540                // whole batch of PreKeyRecordStructures (each owns two heap Vecs of key bytes)
541                // is never resident at once — that batch was the dominant controllable peak on
542                // the connect path. MAX_RECORD_LEN keeps the buffer to a single allocation;
543                // being just a capacity hint, it uses the type-level upper bound (1-byte id
544                // tag + <=5-byte u32 varint + two 34 B key fields = 74) rather than depending
545                // on the tighter 24-bit id cap.
546                const MAX_RECORD_LEN: usize = 74;
547                let mut pubkeys = Vec::with_capacity(gen_count);
548                let mut offsets = Vec::with_capacity(gen_count);
549                let mut buf = Vec::with_capacity(gen_count * MAX_RECORD_LEN);
550                for i in 0..gen_count {
551                    let pre_key_id = gen_start + i as u32;
552                    let key_pair = KeyPair::generate(&mut rng);
553                    let start = buf.len();
554                    encode_pre_key_record_to(pre_key_id, &key_pair, &mut buf);
555                    offsets.push((pre_key_id, start..buf.len()));
556                    pubkeys.push((pre_key_id, key_pair.public_key));
557                }
558                let shared = bytes::Bytes::from(buf);
559                let encoded_batch: Vec<(u32, bytes::Bytes)> = offsets
560                    .into_iter()
561                    .map(|(id, range)| (id, shared.slice(range)))
562                    .collect();
563                (encoded_batch, pubkeys)
564            })
565            .await;
566
567            // Persist the freshly generated prekeys before uploading them so they are
568            // already available for local decryption if the server starts sending
569            // pkmsg traffic immediately after accepting the upload.
570            // Propagate errors — uploading a key we can't store locally would cause
571            // decryption failures when the server hands it out.
572            backend.store_prekeys_batch(&encoded_batch, false).await?;
573            fresh_pre_keys = generated;
574        }
575
576        // Advance NEXT at GENERATION time (WA Web savePreKeys) and initialise
577        // FIRST for a legacy device, in one command. From here the window
578        // covers every stored-but-unuploaded key, so a failure below never
579        // leads to regenerating over live ids.
580        self.persistence_manager
581            .process_command(DeviceCommand::SetPreKeyWatermarks {
582                next_pre_key_id: plan.new_next,
583                first_unupload_pre_key_id: plan.window_start,
584            })
585            .await;
586        // The generated rows are already durable; the watermarks ride the lazy
587        // device saver. Flush them now so a crash before the IQ cannot reload
588        // pre-generation watermarks and orphan the stored window. A failed
589        // flush aborts the pass: proceeding would upload keys whose
590        // accounting is not durable, the exact state this barrier prevents.
591        self.persistence_manager
592            .flush()
593            .await
594            .context("failed to flush prekey watermarks")?;
595
596        // Only the leftover (already-stored) window keys are read back and decoded;
597        // the fresh ones are already in `fresh_pre_keys`. On the common connect path
598        // the window is all-fresh, so this reads and decodes nothing. Leftover gaps
599        // are tolerated (a window key consumed via a retry receipt leaves a hole).
600        let leftover_ids: Vec<u32> = (0..plan.available).map(|i| plan.window_start + i).collect();
601        let mut leftover_rows = if leftover_ids.is_empty() {
602            Vec::new()
603        } else {
604            backend.load_prekeys_batch(&leftover_ids).await?
605        };
606        leftover_rows.sort_unstable_by_key(|(id, _)| *id);
607
608        if plan.gen_count == 0 && leftover_rows.is_empty() {
609            // A fully consumed/missing leftover window with no generation would bail
610            // forever (available > 0 keeps gen_count at 0). Collapse the window and
611            // rerun the pass so a one-shot caller still uploads.
612            self.persistence_manager
613                .process_command(DeviceCommand::SetPreKeyWatermarks {
614                    next_pre_key_id: plan.new_next,
615                    first_unupload_pre_key_id: plan.new_next,
616                })
617                .await;
618            if allow_collapse_retry {
619                log::warn!(
620                    "prekey window [{}, {}) fully missing; collapsed, regenerating",
621                    plan.window_start,
622                    plan.new_next
623                );
624                return Box::pin(self.upload_pre_keys_pass(false, configured)).await;
625            }
626            anyhow::bail!("no prekey available to upload");
627        }
628
629        let pre_key_pairs = {
630            let mut pairs: Vec<(u32, PublicKey)> =
631                Vec::with_capacity(leftover_rows.len() + fresh_pre_keys.len());
632            // Leftover keys live only in the store, so decode them in full — the same
633            // `PreKeyRecordStructure::decode` the consume path runs, so a record
634            // accepted here is one this device can later decrypt with. Fresh keys skip
635            // decode entirely; their public keys never left memory.
636            for (id, record) in &leftover_rows {
637                let public_key = waproto::codec::pre_key_record_decode(&record[..])
638                    .map_err(anyhow::Error::from)
639                    .and_then(|s| {
640                        let raw = s
641                            .public_key
642                            .ok_or_else(|| anyhow::anyhow!("record missing public key"))?;
643                        PublicKey::from_djb_public_key_bytes(&raw).map_err(anyhow::Error::from)
644                    });
645                match public_key {
646                    Ok(public_key) => pairs.push((*id, public_key)),
647                    Err(e) => log::warn!("skipping undecodable prekey record {id}: {e:?}"),
648                }
649            }
650            // Fresh ids exceed every leftover id and were generated in ascending
651            // order, so appending keeps `pairs` sorted (last_id reads the tail).
652            pairs.extend(fresh_pre_keys);
653            if pairs.is_empty() {
654                anyhow::bail!("no decodable prekey available to upload");
655            }
656            pairs
657        };
658        let last_id = pre_key_pairs
659            .last()
660            .map(|(id, _)| *id)
661            .expect("non-empty checked above");
662        let uploaded_count = pre_key_pairs.len();
663        let pre_key_ids: Vec<u32> = pre_key_pairs.iter().map(|(id, _)| *id).collect();
664
665        let spec = PreKeyUploadSpec::new(
666            device_snapshot.registration_id,
667            device_snapshot.identity_key.public_key,
668            device_snapshot.signed_pre_key_id,
669            device_snapshot.signed_pre_key.public_key,
670            device_snapshot.signed_pre_key_signature.to_vec(),
671            pre_key_pairs,
672        );
673
674        // Mark the window uploaded BEFORE the send, like WA Web's
675        // markKeyAsUploaded (PreKeysJob.js runs it ahead of the IQ). On a
676        // mid-flight failure the server state is unknown, so the keys are
677        // abandoned rather than re-offered: re-uploading an id a peer may
678        // already have consumed would corrupt the server pool. The keys stay
679        // stored locally and remain decryptable if the upload did land.
680        self.persistence_manager
681            .process_command(DeviceCommand::SetPreKeyWatermarks {
682                next_pre_key_id: plan.new_next,
683                first_unupload_pre_key_id: plan.window_start.max(last_id.saturating_add(1)),
684            })
685            .await;
686        // The abandon watermark must be durable BEFORE the fallible send: a
687        // crash after a failed IQ would otherwise reload FIRST=window_start
688        // and re-offer ids that may already be in the server pool. A failed
689        // flush aborts instead of sending with non-durable abandonment.
690        self.persistence_manager
691            .flush()
692            .await
693            .context("failed to flush abandon watermark")?;
694
695        self.execute(spec).await?;
696
697        // Mark the uploaded prekeys as server-synced. UPDATE semantics: a
698        // window key consumed by an inbound pkmsg while the IQ was in flight
699        // (retry-receipt keys are reachable that way, and consumption is not
700        // serialized by prekey_upload_lock) must stay deleted, not be
701        // resurrected by an upsert of the stale record.
702        let uploaded_ids: Vec<u32> = pre_key_ids;
703        if let Err(e) = backend.mark_prekeys_uploaded(&uploaded_ids).await {
704            log::warn!("Failed to mark prekeys as uploaded: {:?}", e);
705        }
706
707        // Persist flag matching WA Web's setServerHasPreKeys(true) (PreKeysJob.js:79)
708        self.persistence_manager
709            .modify_device(|d| d.server_has_prekeys = true)
710            .await;
711
712        log::debug!(
713            "Successfully uploaded {} pre-keys ({} reused from the window) starting from {}.",
714            uploaded_count,
715            plan.available,
716            plan.window_start
717        );
718
719        Ok(())
720    }
721
722    /// Upload pre-keys with Fibonacci retry backoff matching WA Web's `PromiseRetryLoop`.
723    ///
724    /// Retry schedule: 1s, 2s, 3s, 5s, 8s, 13s, ... capped at 610s.
725    /// Verified against WA Web JS: `{ algo: { type: "fibonacci", first: 1e3, second: 2e3 }, max: 61e4 }`
726    ///
727    /// When `force` is true, bypasses the count guard (used by digest repair path).
728    pub(crate) async fn upload_pre_keys_with_retry(
729        &self,
730        force: bool,
731    ) -> Result<(), anyhow::Error> {
732        self.upload_pre_keys_with_retry_count(force, None).await
733    }
734
735    #[cfg_attr(
736        feature = "tracing",
737        tracing::instrument(
738            name = "wa.session.upload_pre_keys_retry",
739            level = "debug",
740            skip_all,
741            fields(force = force, wanted = ?wanted),
742            err(Debug)
743        )
744    )]
745    async fn upload_pre_keys_with_retry_count(
746        &self,
747        force: bool,
748        wanted: Option<usize>,
749    ) -> Result<(), anyhow::Error> {
750        let mut delay_a: u64 = 1;
751        let mut delay_b: u64 = 2;
752        const MAX_DELAY_SECS: u64 = 610;
753
754        loop {
755            let result = self.upload_pre_keys_with_count(force, wanted).await;
756            match result {
757                Ok(()) => {
758                    log::info!("Pre-key upload succeeded");
759                    // Operation-level outcome: one emit per logical upload, not per attempt.
760                    wacore::telemetry::prekey_upload("ok");
761                    return Ok(());
762                }
763                Err(e) => {
764                    let delay = delay_a.min(MAX_DELAY_SECS);
765                    log::warn!("Pre-key upload failed, retrying in {}s: {:?}", delay, e);
766
767                    self.runtime
768                        .sleep(std::time::Duration::from_secs(delay))
769                        .await;
770
771                    // Bail if disconnected during retry wait
772                    if !self.is_logged_in.load(Ordering::Relaxed) {
773                        wacore::telemetry::prekey_upload("fail");
774                        return Err(anyhow::anyhow!(
775                            "Connection lost during pre-key upload retry"
776                        ));
777                    }
778
779                    let next = delay_a + delay_b;
780                    delay_a = delay_b;
781                    delay_b = next;
782                }
783            }
784        }
785    }
786
787    /// Force-refresh the server's one-time pre-key pool with a fresh batch.
788    ///
789    /// Intended for callers that just restored a device from an external source
790    /// into an `InMemoryBackend`. The server
791    /// may still hold pre-key IDs whose private key material the caller cannot
792    /// reconstruct; any `pkmsg` referencing those IDs will fail forever with
793    /// `InvalidPreKeyId`. Uploading a fresh batch gives the server new IDs the
794    /// caller *does* have locally, and old unmatched IDs drain as peers consume
795    /// them.
796    ///
797    /// Acquires `prekey_upload_lock` for the duration so this force-upload
798    /// cannot race on `start_id` with the count-based and digest-repair paths.
799    #[cfg_attr(
800        feature = "tracing",
801        tracing::instrument(
802            name = "wa.session.refresh_pre_keys",
803            level = "debug",
804            skip_all,
805            err(Debug)
806        )
807    )]
808    pub async fn refresh_pre_keys(&self) -> Result<(), anyhow::Error> {
809        let _guard = self.prekey_upload_lock.lock().await;
810        self.upload_pre_keys_with_retry(true).await
811    }
812
813    /// Force-refresh the server pool using a caller-selected batch size without
814    /// changing the client's configured background replenishment size. The
815    /// count is clamped to the same protocol-safe bounds as regular uploads.
816    #[cfg_attr(
817        feature = "tracing",
818        tracing::instrument(
819            name = "wa.session.refresh_pre_keys_with_count",
820            level = "debug",
821            skip_all,
822            fields(count = count),
823            err(Debug)
824        )
825    )]
826    pub async fn refresh_pre_keys_with_count(&self, count: usize) -> Result<(), anyhow::Error> {
827        let _guard = self.prekey_upload_lock.lock().await;
828        self.upload_pre_keys_with_retry_count(true, Some(count))
829            .await
830    }
831
832    /// Ensure the server pool is above the low-water mark.
833    #[cfg_attr(
834        feature = "tracing",
835        tracing::instrument(
836            name = "wa.session.ensure_pre_keys",
837            level = "debug",
838            skip_all,
839            err(Debug)
840        )
841    )]
842    pub async fn ensure_pre_keys(&self) -> Result<(), anyhow::Error> {
843        let _guard = self.prekey_upload_lock.lock().await;
844        self.upload_pre_keys_with_retry(false).await
845    }
846
847    /// Validate server key bundle digest, re-uploading only when the server has no record.
848    ///
849    /// Matches WA Web's `WAWebDigestKeyJob.digestKey()`:
850    /// 1. Queries server for key bundle digest (identity + signed prekey + prekey IDs + SHA-1 hash)
851    /// 2. If server returns 404 (no record): triggers `upload_pre_keys_with_retry()`
852    /// 3. If server returns 406/503/other error: logs and does nothing
853    /// 4. On success: loads local keys and computes SHA-1 over the same material
854    /// 5. If validation fails (regId mismatch, missing prekey, hash mismatch): logs warning,
855    ///    does NOT re-upload — WA Web catches all `validateLocalKeyBundle` exceptions without
856    ///    re-uploading; the normal `RotateKeyJob` will eventually refresh keys
857    #[cfg_attr(
858        feature = "tracing",
859        tracing::instrument(
860            name = "wa.session.validate_digest_key",
861            level = "debug",
862            skip_all,
863            err(Debug)
864        )
865    )]
866    pub async fn validate_digest_key(&self) -> Result<(), anyhow::Error> {
867        // Hold the lock across the whole pass so the 404 re-upload can't race with
868        // `upload_pre_keys_at_login`, `handle_prekey_low`, or `refresh_pre_keys` on
869        // `next_pre_key_id` allocation.
870        let _guard = self.prekey_upload_lock.lock().await;
871
872        let response = match self.execute(DigestKeyBundleSpec::new()).await {
873            Ok(resp) => resp,
874            Err(crate::request::IqError::ServerError { code: 404, .. }) => {
875                log::warn!("digestKey: no record found for current user, re-uploading");
876                return self.upload_pre_keys_with_retry(true).await;
877            }
878            Err(crate::request::IqError::ServerError { code: 406, .. }) => {
879                log::warn!("digestKey: malformed request");
880                return Ok(());
881            }
882            Err(crate::request::IqError::ServerError { code: 503, .. }) => {
883                log::warn!("digestKey: service unavailable");
884                return Ok(());
885            }
886            Err(crate::request::IqError::ParseError(e)) => {
887                // WA Web catches parse failures without re-uploading
888                log::debug!("digestKey: unparseable digest response ({e}), skipping");
889                return Ok(());
890            }
891            Err(e) => {
892                if !self.is_shutting_down() {
893                    log::warn!("digestKey: server error: {:?}", e);
894                }
895                return Ok(());
896            }
897        };
898
899        // WA Web's validateLocalKeyBundle validates but catches ALL exceptions without
900        // re-uploading. The catch block in digestKey() sets a=false for any throw from y(),
901        // meaning only 404 triggers re-upload. We match that: log warnings, return Ok(()).
902        let device_snapshot = self.persistence_manager.get_device_snapshot();
903        if response.reg_id != device_snapshot.registration_id {
904            log::warn!(
905                "digestKey: registration ID mismatch (server={}, local={}), skipping",
906                response.reg_id,
907                device_snapshot.registration_id
908            );
909            return Ok(());
910        }
911
912        // Compute local SHA-1 digest over the same material as WA Web's validateLocalKeyBundle:
913        // identity_pub_key + signed_prekey_pub + signed_prekey_signature + (for each prekey ID: load 32-byte pubkey)
914        let identity_bytes = device_snapshot.identity_key.public_key.public_key_bytes();
915        let skey_pub_bytes = device_snapshot.signed_pre_key.public_key.public_key_bytes();
916        let skey_sig_bytes = &device_snapshot.signed_pre_key_signature;
917
918        let backend = self
919            .persistence_manager
920            .get_device_snapshot()
921            .backend
922            .clone();
923
924        // Batch-load all prekeys referenced by the server digest
925        let loaded = match backend.load_prekeys_batch(&response.prekey_ids).await {
926            Ok(v) => v,
927            Err(e) => {
928                log::warn!("digestKey: failed to batch-load prekeys: {:?}, skipping", e);
929                return Ok(());
930            }
931        };
932
933        // Build a lookup so we preserve the server-requested order.
934        // Dedupe the expected count since the server may send duplicate IDs.
935        let loaded_map: std::collections::HashMap<u32, bytes::Bytes> = loaded.into_iter().collect();
936        let unique_requested: std::collections::HashSet<&u32> =
937            response.prekey_ids.iter().collect();
938
939        if loaded_map.len() < unique_requested.len() {
940            log::warn!(
941                "digestKey: missing {} local prekeys, skipping",
942                unique_requested.len() - loaded_map.len()
943            );
944            return Ok(());
945        }
946
947        // Extract public keys directly from stored protobuf bytes without full decode
948        let mut prekey_pubkeys = Vec::with_capacity(response.prekey_ids.len());
949        for prekey_id in &response.prekey_ids {
950            let Some(record_bytes) = loaded_map.get(prekey_id) else {
951                log::warn!("digestKey: missing local prekey {}, skipping", prekey_id);
952                return Ok(());
953            };
954            match wacore::prekeys::extract_prekey_public_key(record_bytes) {
955                Some(pk) => prekey_pubkeys.push(pk),
956                None => {
957                    log::warn!(
958                        "digestKey: prekey {} has no public key, skipping",
959                        prekey_id
960                    );
961                    return Ok(());
962                }
963            }
964        }
965
966        let local_hash = wacore::prekeys::compute_key_bundle_digest(
967            identity_bytes,
968            skey_pub_bytes,
969            skey_sig_bytes,
970            &prekey_pubkeys,
971        );
972
973        if local_hash.as_slice() != response.hash.as_slice() {
974            log::warn!(
975                "digestKey: hash mismatch (server={}, local={}), skipping",
976                hex::encode(&response.hash),
977                hex::encode(local_hash)
978            );
979            return Ok(());
980        }
981
982        log::debug!("digestKey: key bundle validation successful");
983        Ok(())
984    }
985}
986
987#[cfg(test)]
988mod tests {
989    use super::{
990        DEFAULT_WANTED_PRE_KEY_COUNT, MAX_PRE_KEY_UPLOAD_BATCH, MAX_PREKEY_ID, MIN_PRE_KEY_COUNT,
991        clamp_wanted_pre_key_count, plan_prekey_upload, should_upload_pre_keys, start_prekey_id,
992    };
993
994    #[test]
995    fn plan_initialises_window_for_legacy_device() {
996        // first unset: fresh window at the legacy-safe start (max(counter, store+1)),
997        // full generation.
998        let p = plan_prekey_upload(0, 7, 20, 812);
999        assert_eq!(p.window_start, 21, "legacy start skips stored rows");
1000        assert_eq!(p.available, 0);
1001        assert_eq!(p.gen_count, 812);
1002        assert_eq!(p.gen_start, 21);
1003        assert_eq!(p.new_next, 833);
1004    }
1005
1006    #[test]
1007    fn plan_generates_full_batch_on_empty_window() {
1008        let p = plan_prekey_upload(100, 100, 99, 812);
1009        assert_eq!(p.window_start, 100);
1010        assert_eq!(p.available, 0);
1011        assert_eq!(p.gen_count, 812);
1012        assert_eq!(p.gen_start, 100);
1013        assert_eq!(p.new_next, 912);
1014    }
1015
1016    #[test]
1017    fn plan_reuses_leftovers_and_tops_up() {
1018        // 50 leftover unuploaded keys: only 762 new ones, window re-offers all 812.
1019        let p = plan_prekey_upload(100, 150, 149, 812);
1020        assert_eq!(p.window_start, 100);
1021        assert_eq!(p.available, 50);
1022        assert_eq!(p.gen_count, 762);
1023        assert_eq!(p.gen_start, 150, "generation starts at NEXT (savePreKeys)");
1024        assert_eq!(p.new_next, 912);
1025    }
1026
1027    #[test]
1028    fn plan_full_window_generates_nothing_and_keeps_next() {
1029        // More leftovers than the target (WA Web p <= 0): upload the first
1030        // `wanted`, generate nothing, and never regress NEXT.
1031        let p = plan_prekey_upload(100, 1500, 1499, 812);
1032        assert_eq!(p.window_start, 100);
1033        assert_eq!(p.available, 812);
1034        assert_eq!(p.gen_count, 0);
1035        assert_eq!(p.new_next, 1500, "a capped window must not regress NEXT");
1036    }
1037
1038    #[test]
1039    fn plan_heals_corrupt_first_past_next() {
1040        let p = plan_prekey_upload(500, 100, 600, 812);
1041        assert_eq!(p.window_start, 601, "heals via the legacy-safe start");
1042        assert_eq!(p.available, 0);
1043        assert_eq!(p.gen_count, 812);
1044    }
1045
1046    #[test]
1047    fn plan_collapses_window_at_id_boundary() {
1048        // Window would cross the 24-bit boundary: collapse to a fresh window at 1.
1049        let p = plan_prekey_upload(
1050            MAX_PREKEY_ID - 10,
1051            MAX_PREKEY_ID - 5,
1052            MAX_PREKEY_ID - 6,
1053            812,
1054        );
1055        assert_eq!(p.window_start, 1);
1056        assert_eq!(p.available, 0);
1057        assert_eq!(p.gen_count, 812);
1058        assert_eq!(p.new_next, 813);
1059    }
1060
1061    #[test]
1062    fn plan_single_key_reuses_window_head() {
1063        // getOrGenSinglePreKey = getOrGenPreKeys(1): a non-empty window means
1064        // no generation; the head key is the answer.
1065        let p = plan_prekey_upload(10, 12, 11, 1);
1066        assert_eq!(p.window_start, 10);
1067        assert_eq!(p.available, 1);
1068        assert_eq!(p.gen_count, 0);
1069        assert_eq!(p.new_next, 12);
1070    }
1071
1072    #[test]
1073    fn default_matches_wa_web_upload_keys_count() {
1074        // WAWebUploadPreKeysJob's UPLOAD_KEYS_COUNT; drift here diverges from WA Web.
1075        assert_eq!(DEFAULT_WANTED_PRE_KEY_COUNT, 812);
1076    }
1077
1078    #[test]
1079    fn clamp_wanted_pre_key_count_bounds() {
1080        assert_eq!(clamp_wanted_pre_key_count(0), MIN_PRE_KEY_COUNT);
1081        assert_eq!(clamp_wanted_pre_key_count(2), MIN_PRE_KEY_COUNT);
1082        assert_eq!(clamp_wanted_pre_key_count(4), MIN_PRE_KEY_COUNT);
1083        assert_eq!(
1084            clamp_wanted_pre_key_count(MIN_PRE_KEY_COUNT),
1085            MIN_PRE_KEY_COUNT
1086        );
1087        assert_eq!(clamp_wanted_pre_key_count(812), 812);
1088        assert_eq!(
1089            clamp_wanted_pre_key_count(MAX_PRE_KEY_UPLOAD_BATCH),
1090            MAX_PRE_KEY_UPLOAD_BATCH
1091        );
1092        assert_eq!(
1093            clamp_wanted_pre_key_count(MAX_PRE_KEY_UPLOAD_BATCH + 1),
1094            MAX_PRE_KEY_UPLOAD_BATCH
1095        );
1096        assert_eq!(
1097            clamp_wanted_pre_key_count(usize::MAX),
1098            MAX_PRE_KEY_UPLOAD_BATCH
1099        );
1100    }
1101
1102    #[test]
1103    fn start_prekey_id_uses_counter_and_wraps() {
1104        // Counter ahead of the store: use the counter (monotonic, never reuses an id).
1105        assert_eq!(start_prekey_id(10, 5), 10);
1106        // Store ahead of (or equal to) the counter: use max_store + 1.
1107        assert_eq!(start_prekey_id(3, 100), 101);
1108        // Migration (counter unset = 0): max_store + 1.
1109        assert_eq!(start_prekey_id(0, 5), 6);
1110        // Wraps into the 24-bit range instead of pinning above MAX_PREKEY_ID.
1111        assert_eq!(start_prekey_id(MAX_PREKEY_ID + 1, 0), 1);
1112        assert_eq!(start_prekey_id(MAX_PREKEY_ID, 0), MAX_PREKEY_ID);
1113    }
1114
1115    #[test]
1116    fn force_upload_bypasses_count_guard() {
1117        // WA Web's handlePreKeyLow uploads unconditionally, so the prekey-low path forces
1118        // and the count guard must not apply.
1119        assert!(should_upload_pre_keys(true, 1000), "force always uploads");
1120        assert!(
1121            !should_upload_pre_keys(false, MIN_PRE_KEY_COUNT),
1122            "count guard skips when not forced and at/above threshold"
1123        );
1124        assert!(
1125            should_upload_pre_keys(false, MIN_PRE_KEY_COUNT - 1),
1126            "below threshold uploads even without force"
1127        );
1128    }
1129}
1130
1131#[cfg(test)]
1132#[allow(clippy::disallowed_methods)]
1133mod window_tests {
1134    use wacore::libsignal::protocol::PublicKey;
1135
1136    fn snapshot(client: &crate::client::Client) -> (u32, u32) {
1137        let d = client.persistence_manager.get_device_snapshot();
1138        (d.next_pre_key_id, d.first_unupload_pre_key_id)
1139    }
1140
1141    fn backend(
1142        client: &crate::client::Client,
1143    ) -> std::sync::Arc<dyn crate::store::traits::Backend> {
1144        client.persistence_manager.backend()
1145    }
1146
1147    #[tokio::test]
1148    async fn explicit_upload_count_does_not_change_background_configuration() {
1149        let client = crate::test_utils::create_test_client_with_name("prekey_explicit_count").await;
1150        client.set_wanted_pre_key_count(5);
1151
1152        let _ = client.upload_pre_keys_inner_with_count(7).await;
1153
1154        assert_eq!(client.wanted_pre_key_count(), 5);
1155        assert_eq!(snapshot(&client), (8, 8));
1156        assert_eq!(
1157            backend(&client)
1158                .load_prekeys_batch(&[1, 2, 3, 4, 5, 6, 7])
1159                .await
1160                .unwrap()
1161                .len(),
1162            7
1163        );
1164    }
1165
1166    /// A failed upload IQ must leave the watermarks past the generated window
1167    /// (WA Web abandons on unknown server state) and the next attempt must
1168    /// mint FRESH ids, never regenerating over the stored ones: that
1169    /// regeneration was the prekey-collision class on partial success.
1170    #[tokio::test]
1171    async fn failed_upload_abandons_window_and_never_remints_ids() {
1172        let client = crate::test_utils::create_test_client_with_name("prekey_window_fail").await;
1173        client.set_wanted_pre_key_count(5);
1174
1175        let err = client.upload_pre_keys_inner().await;
1176        assert!(err.is_err(), "IQ must fail on a disconnected client");
1177
1178        let (next, first) = snapshot(&client);
1179        assert_eq!(next, 6, "NEXT advances at generation time");
1180        assert_eq!(first, 6, "FIRST is marked past the window before the send");
1181
1182        let rows = backend(&client)
1183            .load_prekeys_batch(&[1, 2, 3, 4, 5])
1184            .await
1185            .expect("load");
1186        assert_eq!(rows.len(), 5, "the generated window stays stored");
1187        let before: Vec<_> = rows.into_iter().collect();
1188
1189        let _ = client.upload_pre_keys_inner().await;
1190        let (next, first) = snapshot(&client);
1191        assert_eq!(next, 11, "second attempt mints fresh ids 6..=10");
1192        assert_eq!(first, 11);
1193
1194        let rows2 = backend(&client)
1195            .load_prekeys_batch(&[6, 7, 8, 9, 10])
1196            .await
1197            .expect("load");
1198        assert_eq!(rows2.len(), 5);
1199
1200        let after = backend(&client)
1201            .load_prekeys_batch(&[1, 2, 3, 4, 5])
1202            .await
1203            .expect("load");
1204        assert_eq!(
1205            before, after,
1206            "abandoned rows must never be regenerated (collision class)"
1207        );
1208    }
1209
1210    /// getOrGenSinglePreKey parity: the window head is reused until an upload
1211    /// (or consumption) moves past it, and a consumed head heals by skipping
1212    /// the dead slot instead of failing like WA Web does.
1213    #[tokio::test]
1214    async fn single_prekey_is_reused_until_consumed() {
1215        let client = crate::test_utils::create_test_client_with_name("prekey_single_reuse").await;
1216
1217        let (id1, pk1) = client.get_or_gen_single_pre_key().await.expect("gen");
1218        let (id2, pk2) = client.get_or_gen_single_pre_key().await.expect("reuse");
1219        assert_eq!(id1, id2, "window head must be reused");
1220        assert_eq!(
1221            pk1.serialize(),
1222            pk2.serialize(),
1223            "same stored key, not a regenerated one"
1224        );
1225        let (next, first) = snapshot(&client);
1226        assert_eq!(first, id1);
1227        assert_eq!(next, id1 + 1);
1228
1229        // The peer consumed it via pkmsg: the row is gone.
1230        backend(&client).remove_prekey(id1).await.expect("remove");
1231        let (id3, pk3) = client.get_or_gen_single_pre_key().await.expect("heal");
1232        assert_eq!(id3, id1 + 1, "dead slot skipped, fresh id minted");
1233        assert_ne!(pk3.serialize(), pk1.serialize());
1234        let (next, first) = snapshot(&client);
1235        assert_eq!(first, id3);
1236        assert_eq!(next, id3 + 1);
1237    }
1238
1239    /// WA Web `markKeyAsUploaded`: a retry-distributed prekey must leave the
1240    /// unuploaded window so the next batch upload does not re-offer it (which
1241    /// would let a third party consume the same one-time id).
1242    #[tokio::test]
1243    async fn marking_retry_prekey_uploaded_excludes_it_from_reuse() {
1244        let client = crate::test_utils::create_test_client_with_name("prekey_mark_uploaded").await;
1245
1246        let (id1, _) = client.get_or_gen_single_pre_key().await.expect("gen");
1247        let (next, first) = snapshot(&client);
1248        assert_eq!(first, id1);
1249        assert_eq!(next, id1 + 1);
1250
1251        let guard = client.prekey_upload_lock.lock().await;
1252        client
1253            .mark_single_prekey_uploaded(&guard, id1)
1254            .await
1255            .expect("mark");
1256        drop(guard);
1257        let (next2, first2) = snapshot(&client);
1258        assert_eq!(
1259            first2,
1260            id1 + 1,
1261            "low watermark advances past the marked key"
1262        );
1263        assert_eq!(next2, id1 + 1, "window is now empty");
1264
1265        // The next retry key is a fresh id, not a reuse of the marked one.
1266        let (id2, _) = client.get_or_gen_single_pre_key().await.expect("fresh");
1267        assert_ne!(id2, id1, "marked key must not be reused");
1268
1269        // Marking with a now-stale id is a no-op (idempotent, head already moved).
1270        let guard = client.prekey_upload_lock.lock().await;
1271        client
1272            .mark_single_prekey_uploaded(&guard, id1)
1273            .await
1274            .expect("noop");
1275        drop(guard);
1276        let (_, first3) = snapshot(&client);
1277        assert_eq!(first3, id2, "stale mark leaves the current head untouched");
1278    }
1279
1280    /// Marking a non-terminal head near the 24-bit edge must NOT collapse NEXT:
1281    /// only advancing past MAX itself wraps. Here the head sits at MAX-1 while
1282    /// the window still holds MAX; keying the collapse on NEXT alone would drop
1283    /// the surviving terminal key.
1284    #[tokio::test]
1285    async fn marking_near_boundary_head_preserves_terminal_window_key() {
1286        use wacore::store::commands::DeviceCommand;
1287
1288        let client =
1289            crate::test_utils::create_test_client_with_name("prekey_mark_near_boundary").await;
1290        // Window [MAX-1, MAX+1): holds ids MAX-1 and MAX. NEXT is legitimately
1291        // out of range (exclusive upper bound past the terminal id).
1292        client
1293            .persistence_manager
1294            .process_command(DeviceCommand::SetPreKeyWatermarks {
1295                next_pre_key_id: super::MAX_PREKEY_ID + 1,
1296                first_unupload_pre_key_id: super::MAX_PREKEY_ID - 1,
1297            })
1298            .await;
1299
1300        let guard = client.prekey_upload_lock.lock().await;
1301        client
1302            .mark_single_prekey_uploaded(&guard, super::MAX_PREKEY_ID - 1)
1303            .await
1304            .expect("mark");
1305        drop(guard);
1306
1307        let (next, first) = snapshot(&client);
1308        assert_eq!(
1309            first,
1310            super::MAX_PREKEY_ID,
1311            "head advances onto the surviving key"
1312        );
1313        assert_eq!(
1314            next,
1315            super::MAX_PREKEY_ID + 1,
1316            "NEXT untouched: terminal key MAX is still in the window"
1317        );
1318    }
1319
1320    /// Marking the terminal id itself DOES collapse: with NEXT already pinned at
1321    /// MAX+1, the window must wrap to an empty low range, not a ~16M span.
1322    #[tokio::test]
1323    async fn marking_terminal_head_collapses_wrapped_window() {
1324        use wacore::store::commands::DeviceCommand;
1325
1326        let client = crate::test_utils::create_test_client_with_name("prekey_mark_terminal").await;
1327        client
1328            .persistence_manager
1329            .process_command(DeviceCommand::SetPreKeyWatermarks {
1330                next_pre_key_id: super::MAX_PREKEY_ID + 1,
1331                first_unupload_pre_key_id: super::MAX_PREKEY_ID,
1332            })
1333            .await;
1334
1335        let guard = client.prekey_upload_lock.lock().await;
1336        client
1337            .mark_single_prekey_uploaded(&guard, super::MAX_PREKEY_ID)
1338            .await
1339            .expect("mark");
1340        drop(guard);
1341
1342        let (next, first) = snapshot(&client);
1343        assert_eq!(first, 1, "head wraps to the low watermark");
1344        assert_eq!(
1345            next, 1,
1346            "NEXT collapses onto the wrapped head: window empty"
1347        );
1348    }
1349
1350    /// A consumed window head must not abandon the live keys behind it: the
1351    /// heal advances FIRST to the next stored id and reuses it.
1352    #[tokio::test]
1353    async fn consumed_head_advances_to_next_live_window_key() {
1354        use buffa::Message;
1355        use wacore::libsignal::protocol::KeyPair;
1356        use wacore::libsignal::store::record_helpers::new_pre_key_record;
1357        use wacore::store::commands::DeviceCommand;
1358
1359        let client = crate::test_utils::create_test_client_with_name("prekey_window_heal").await;
1360        let mut rng = rand::make_rng::<rand::rngs::StdRng>();
1361        let mut publics = std::collections::HashMap::new();
1362        for id in 10u32..13 {
1363            let kp = KeyPair::generate(&mut rng);
1364            publics.insert(id, kp.public_key);
1365            backend(&client)
1366                .store_prekey(id, &new_pre_key_record(id, &kp).encode_to_vec(), false)
1367                .await
1368                .expect("store");
1369        }
1370        client
1371            .persistence_manager
1372            .process_command(DeviceCommand::SetPreKeyWatermarks {
1373                next_pre_key_id: 13,
1374                first_unupload_pre_key_id: 10,
1375            })
1376            .await;
1377
1378        backend(&client).remove_prekey(10).await.expect("consume");
1379        let (id, pk) = client.get_or_gen_single_pre_key().await.expect("heal");
1380        assert_eq!(id, 11, "heal must advance to the next LIVE window key");
1381        assert_eq!(pk.serialize(), publics[&11].serialize());
1382        let (next, first) = snapshot(&client);
1383        assert_eq!(first, 11, "FIRST lands on the surviving key");
1384        assert_eq!(next, 13, "NEXT untouched: 12 is still in the window");
1385
1386        // And the key behind it is still reachable afterwards.
1387        backend(&client).remove_prekey(11).await.expect("consume");
1388        let (id, _) = client.get_or_gen_single_pre_key().await.expect("heal 2");
1389        assert_eq!(id, 12);
1390    }
1391
1392    /// A fully missing window must collapse AND regenerate within the same
1393    /// pass: the login path is one-shot, so bailing without minting would
1394    /// leave the device without prekeys until an unrelated trigger.
1395    #[tokio::test]
1396    async fn fully_missing_window_collapses_and_regenerates_in_one_pass() {
1397        use wacore::store::commands::DeviceCommand;
1398
1399        let client =
1400            crate::test_utils::create_test_client_with_name("prekey_window_collapse").await;
1401        client.set_wanted_pre_key_count(5);
1402        // Watermarks claim a 5-key window, but nothing is stored (all consumed).
1403        client
1404            .persistence_manager
1405            .process_command(DeviceCommand::SetPreKeyWatermarks {
1406                next_pre_key_id: 15,
1407                first_unupload_pre_key_id: 10,
1408            })
1409            .await;
1410
1411        // The IQ still fails (disconnected), but the SAME pass must have
1412        // collapsed and generated a fresh batch.
1413        let _ = client.upload_pre_keys_inner().await;
1414        let (next, first) = snapshot(&client);
1415        assert_eq!(next, 20, "fresh batch minted at the collapsed NEXT");
1416        assert_eq!(first, 20, "marked past the window before the send");
1417        let rows = backend(&client)
1418            .load_prekeys_batch(&[15, 16, 17, 18, 19])
1419            .await
1420            .expect("load");
1421        assert_eq!(rows.len(), 5, "regeneration happened within the pass");
1422    }
1423
1424    /// A retry-receipt single key lives in the unuploaded window, so the next
1425    /// batch upload re-offers the SAME stored key and only tops up the rest
1426    /// (WA Web getOrGenPreKeys target-total semantics).
1427    #[tokio::test]
1428    async fn upload_window_includes_retry_single_key() {
1429        let client = crate::test_utils::create_test_client_with_name("prekey_window_topup").await;
1430        client.set_wanted_pre_key_count(5);
1431
1432        let (retry_id, retry_pk) = client.get_or_gen_single_pre_key().await.expect("gen");
1433        let before = backend(&client)
1434            .load_prekeys_batch(&[retry_id])
1435            .await
1436            .expect("load");
1437        assert_eq!(before.len(), 1);
1438
1439        let _ = client.upload_pre_keys_inner().await;
1440        let (next, _) = snapshot(&client);
1441        assert_eq!(
1442            next,
1443            retry_id + 5,
1444            "only wanted - available new keys are generated"
1445        );
1446
1447        let after = backend(&client)
1448            .load_prekeys_batch(&[retry_id])
1449            .await
1450            .expect("load");
1451        assert_eq!(
1452            before, after,
1453            "the retry key is re-offered, not regenerated"
1454        );
1455        let window = backend(&client)
1456            .load_prekeys_batch(&[
1457                retry_id,
1458                retry_id + 1,
1459                retry_id + 2,
1460                retry_id + 3,
1461                retry_id + 4,
1462            ])
1463            .await
1464            .expect("load");
1465        assert_eq!(window.len(), 5, "window = retry key + top-up");
1466
1467        use buffa::Message;
1468        let structure =
1469            waproto::whatsapp::PreKeyRecordStructure::decode_from_slice(&after[0].1[..])
1470                .expect("decode structure");
1471        let reloaded = PublicKey::from_djb_public_key_bytes(
1472            structure.public_key.as_deref().expect("public key"),
1473        )
1474        .expect("pub");
1475        assert_eq!(
1476            reloaded.serialize(),
1477            retry_pk.serialize(),
1478            "stored record matches the key shipped in the receipt"
1479        );
1480    }
1481}