Skip to main content

whatsapp_rust/features/
rotate_key.rs

1//! Signed pre-key rotation, mirroring WhatsApp Web's `RotateKeyJob`.
2//!
3//! The signed pre-key minted at pairing is otherwise permanent. WA Web
4//! periodically generates a fresh one, uploads it via an `encrypt` IQ, and
5//! retains the old ones so prekey messages already in flight against a
6//! previous signed pre-key still decrypt.
7
8use crate::client::{Client, SignalMaintenanceError};
9use crate::request::IqError;
10use wacore::iq::prekeys::RotateSignedPreKeySpec;
11use wacore::libsignal::protocol::{KeyPair, PrivateKey, PublicKey, SignalProtocolError};
12use wacore::libsignal::store::record_helpers::new_signed_pre_key_record;
13use wacore::store::commands::DeviceCommand;
14
15/// Rotation cadence. This is the one value NOT grounded in the WA Web bundle
16/// (there it is a persisted background job with a server-tuned schedule), so
17/// treat it as a policy default that is safe to tune.
18pub(crate) const SIGNED_PRE_KEY_ROTATION_INTERVAL_MS: i64 = 7 * 24 * 60 * 60 * 1000; // weekly
19
20/// Total signed pre-keys kept addressable: the current key (device field) plus
21/// the RETENTION-1 most recent rotated-out keys in the backend table. Bounds
22/// the decrypt window for delayed prekey messages built against a rotated key.
23pub(crate) const SIGNED_PRE_KEY_RETENTION: usize = 3;
24
25/// 24-bit ceiling, matching the one-time prekey id border. Ids advance by one
26/// per rotation and wrap back to 1 here.
27const MAX_SIGNED_PRE_KEY_ID: u32 = 16_777_215;
28
29/// Wraps a backend failure as [`SignalMaintenanceError::Storage`], keeping the
30/// typed cause in the `source()` chain under `context`.
31fn storage_err<E>(
32    context: impl std::fmt::Display + Send + Sync + 'static,
33) -> impl FnOnce(E) -> SignalMaintenanceError
34where
35    E: std::error::Error + Send + Sync + 'static,
36{
37    move |e| SignalMaintenanceError::Storage(anyhow::Error::new(e).context(context))
38}
39
40/// Whether the cadence has elapsed. `last == 0` means the field predates this
41/// feature; the baseline path handles that, so we never rotate on `0`.
42pub(crate) fn should_rotate_signed_pre_key(last_rotation_ms: i64, now_ms: i64) -> bool {
43    last_rotation_ms != 0
44        && now_ms.saturating_sub(last_rotation_ms) >= SIGNED_PRE_KEY_ROTATION_INTERVAL_MS
45}
46
47/// Next id = current + 1, wrapping at the 24-bit border back to 1.
48pub(crate) fn next_signed_pre_key_id(current: u32) -> u32 {
49    if current >= MAX_SIGNED_PRE_KEY_ID {
50        1
51    } else {
52        current + 1
53    }
54}
55
56impl Client {
57    /// Rotate the signed pre-key if the cadence has elapsed. Seeds the cadence
58    /// baseline (without rotating) for devices upgraded in with the field at 0.
59    pub(crate) async fn maybe_rotate_signed_pre_key(&self) -> Result<(), SignalMaintenanceError> {
60        // Single-flight: a concurrent rotation (e.g. an older post-login task
61        // racing a newer one across reconnect churn) already covers this cadence,
62        // so skip rather than run the rotate/upload/prune flow twice.
63        let Some(_guard) = self.signed_pre_key_rotation_lock.try_lock() else {
64            return Ok(());
65        };
66
67        let last = self
68            .persistence_manager
69            .get_device_snapshot()
70            .last_signed_pre_key_rotation_ms;
71        let now = wacore::time::now_millis();
72
73        if last == 0 {
74            self.persistence_manager
75                .process_command(DeviceCommand::SetSignedPreKeyRotationBaseline(now))
76                .await;
77            self.persistence_manager
78                .flush()
79                .await
80                .map_err(storage_err("failed to flush rotation baseline"))?;
81            return Ok(());
82        }
83
84        if should_rotate_signed_pre_key(last, now) {
85            self.rotate_signed_pre_key_inner().await?;
86        }
87        Ok(())
88    }
89
90    /// Stage a fresh signed pre-key durably, upload it, and only on server
91    /// acceptance promote it locally: retain the outgoing key, advance the
92    /// current key + cadence, and prune to `SIGNED_PRE_KEY_RETENTION`.
93    ///
94    /// Both the new candidate and the outgoing key are written to the backend
95    /// table *before* upload (the candidate reused verbatim on retry), so every
96    /// partial failure is safe: whatever the server ends up advertising, we hold
97    /// its private key, and the old id's decrypt window survives regardless. An
98    /// ambiguous transport error (the server may have accepted `new_id`) leaves
99    /// the staged key decryptable via the load fallback; a definitive rejection
100    /// just leaves the current key in place to retry — never advancing the
101    /// cadence or pruning the key the server still hands out. Calls are
102    /// serialized with the automatic rotation path.
103    pub async fn rotate_signed_pre_key(&self) -> Result<(), SignalMaintenanceError> {
104        let _guard = self.signed_pre_key_rotation_lock.lock().await;
105        self.rotate_signed_pre_key_inner().await
106    }
107
108    async fn rotate_signed_pre_key_inner(&self) -> Result<(), SignalMaintenanceError> {
109        let snapshot = self.persistence_manager.get_device_snapshot();
110        let now = wacore::time::now_millis();
111        let backend = self.persistence_manager.backend();
112
113        let old_id = snapshot.signed_pre_key_id;
114        let new_id = next_signed_pre_key_id(old_id);
115
116        // Stage the candidate before upload, reusing an already-staged one for
117        // this id verbatim. A retry after an ambiguous failure then re-uploads
118        // THIS exact key instead of minting a fresh one under the same id, so the
119        // key the server may already have accepted is never overwritten/lost.
120        let (new_kp, signature) = match backend
121            .load_signed_prekey(new_id)
122            .await
123            .map_err(storage_err("failed to load staged signed pre-key"))?
124        {
125            Some(bytes) => {
126                let s = waproto::codec::signed_pre_key_record_decode(&bytes).map_err(|e| {
127                    SignalMaintenanceError::CorruptKey(format!("staged record decode: {e}"))
128                })?;
129                let public = PublicKey::from_djb_public_key_bytes(s.public_key.as_deref().ok_or(
130                    SignalMaintenanceError::CorruptKey("staged record missing public".to_string()),
131                )?)
132                .map_err(|e| {
133                    SignalMaintenanceError::CorruptKey(format!("staged record public key: {e}"))
134                })?;
135                let private = PrivateKey::deserialize(s.private_key.as_deref().ok_or(
136                    SignalMaintenanceError::CorruptKey("staged record missing private".to_string()),
137                )?)
138                .map_err(|e| {
139                    SignalMaintenanceError::CorruptKey(format!("staged record private key: {e}"))
140                })?;
141                let signature: [u8; 64] = s
142                    .signature
143                    .ok_or(SignalMaintenanceError::CorruptKey(
144                        "staged record missing signature".to_string(),
145                    ))?
146                    .try_into()
147                    .map_err(|_| {
148                        SignalMaintenanceError::CorruptKey(
149                            "staged signature must be 64 bytes".to_string(),
150                        )
151                    })?;
152                (KeyPair::new(public, private), signature)
153            }
154            None => {
155                let mut rng = rand::make_rng::<rand::rngs::StdRng>();
156                let kp = KeyPair::generate(&mut rng);
157                // Sign the new public with the identity private key over the
158                // serialized (not raw) public bytes, matching Device::new().
159                let signature: [u8; 64] = snapshot
160                    .identity_key
161                    .private_key
162                    .calculate_signature(&kp.public_key.serialize(), &mut rng)
163                    .map_err(|e| SignalMaintenanceError::Signal(e.into()))?
164                    .as_ref()
165                    .try_into()
166                    // Not CorruptKey: nothing was read back from storage here, so
167                    // a wrong width means the signing backend broke its contract.
168                    .map_err(|_| {
169                        SignalMaintenanceError::Signal(SignalProtocolError::InvalidState(
170                            "rotate_signed_pre_key",
171                            "Ed25519 signature must be 64 bytes".to_string(),
172                        ))
173                    })?;
174                let record =
175                    new_signed_pre_key_record(new_id, &kp, signature, wacore::time::now_utc());
176                backend
177                    .store_signed_prekey(
178                        new_id,
179                        &waproto::codec::signed_pre_key_record_to_vec(&record),
180                    )
181                    .await
182                    .map_err(storage_err("failed to stage new signed pre-key"))?;
183                (kp, signature)
184            }
185        };
186
187        // Retain the outgoing key BEFORE upload, so once the server accepts the
188        // new key the old id's decrypt window is already durable — no
189        // post-acceptance write can strand it. Required: on failure we abort
190        // before sending anything, leaving the current key fully intact to retry.
191        let old_record = new_signed_pre_key_record(
192            old_id,
193            &snapshot.signed_pre_key,
194            snapshot.signed_pre_key_signature,
195            wacore::time::now_utc(),
196        );
197        backend
198            .store_signed_prekey(
199                old_id,
200                &waproto::codec::signed_pre_key_record_to_vec(&old_record),
201            )
202            .await
203            .map_err(storage_err("failed to retain old signed pre-key"))?;
204
205        // WA Web reads 406 = bad key, 409 = server validation fail, >=500 =
206        // transient; none advance local state or fail the automatic login path.
207        // Deterministic rejections discard the candidate; retryable and
208        // ambiguous failures retain it verbatim for the next attempt.
209        let upload_result = self
210            .execute(RotateSignedPreKeySpec::new(
211                new_id,
212                new_kp.public_key,
213                signature.to_vec(),
214            ))
215            .await;
216        match upload_result {
217            Ok(()) => {}
218            Err(error) => {
219                if let IqError::ServerError { code, text, .. } = &error {
220                    // WA Web treats 406 (bad key) and 409 (validation fail) as
221                    // deterministic rejections of THIS key; reusing the staged
222                    // candidate on retry would then wedge rotation forever (old_id
223                    // never advances, so new_id is recomputed the same). Drop it to
224                    // force a fresh mint — and REQUIRE the cleanup: if the remove
225                    // fails, propagate so we never silently leave the rejected key
226                    // staged. Every other code (rate limits, transient 5xx, …) is
227                    // retryable, so keep the staged key for a plain retry.
228                    if *code == 406 || *code == 409 {
229                        backend
230                            .remove_signed_prekey(new_id)
231                            .await
232                            .map_err(storage_err(format!(
233                                "failed to drop rejected staged signed pre-key {new_id}"
234                            )))?;
235                        log::warn!(
236                            "signed pre-key rotation rejected (code={code}, text='{text}'); \
237                             discarded the rejected key, will remint on a later connect"
238                        );
239                    } else {
240                        log::warn!(
241                            "signed pre-key rotation upload rejected (code={code}, text='{text}'); \
242                             keeping the staged key, will retry on a later connect"
243                        );
244                    }
245                } else {
246                    // Ambiguous transport failure: the server may have accepted the
247                    // key, so keep the staged candidate and reuse it on retry.
248                    log::warn!(
249                        "signed pre-key rotation upload failed: {error:?}; \
250                         keeping the staged key, will retry on a later connect"
251                    );
252                }
253                return Err(error.into());
254            }
255        }
256
257        // Server accepted new_id, and both the old (retained) and new (staged)
258        // keys are already durable, so promotion cannot strand either.
259        self.persistence_manager
260            .process_command(DeviceCommand::SetSignedPreKey {
261                key_pair: new_kp,
262                id: new_id,
263                signature,
264                rotation_ms: now,
265            })
266            .await;
267        self.persistence_manager
268            .flush()
269            .await
270            .map_err(storage_err("failed to flush rotated signed pre-key"))?;
271
272        // new_id now lives in the device field, so drop its redundant staged copy
273        // before pruning to RETENTION total addressable keys (field + RETENTION-1
274        // rotated-out). Numeric ordering is safe: ids advance one per rotation, so
275        // the wrap at MAX is ~300k years out.
276        if let Err(e) = backend.remove_signed_prekey(new_id).await {
277            log::warn!("failed to drop staged signed pre-key {new_id}: {e}");
278        }
279        let mut retained = backend
280            .load_all_signed_prekeys()
281            .await
282            .map_err(storage_err("failed to load retained signed pre-keys"))?;
283        retained.sort_unstable_by_key(|(id, _)| std::cmp::Reverse(*id));
284        for (id, _) in retained
285            .into_iter()
286            .skip(SIGNED_PRE_KEY_RETENTION.saturating_sub(1))
287        {
288            if let Err(e) = backend.remove_signed_prekey(id).await {
289                log::warn!("failed to prune retained signed pre-key {id}: {e}");
290            }
291        }
292
293        Ok(())
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn should_rotate_truth_table() {
303        // last == 0 never rotates (baseline path owns it).
304        assert!(!should_rotate_signed_pre_key(0, i64::MAX));
305
306        let last = 1_000_000_000_000;
307        // Just before the interval: no rotation.
308        assert!(!should_rotate_signed_pre_key(
309            last,
310            last + SIGNED_PRE_KEY_ROTATION_INTERVAL_MS - 1
311        ));
312        // Exactly at the boundary: rotate.
313        assert!(should_rotate_signed_pre_key(
314            last,
315            last + SIGNED_PRE_KEY_ROTATION_INTERVAL_MS
316        ));
317        // Well past: rotate.
318        assert!(should_rotate_signed_pre_key(
319            last,
320            last + SIGNED_PRE_KEY_ROTATION_INTERVAL_MS * 3
321        ));
322        // Clock skew backwards: saturating_sub yields 0, no rotation.
323        assert!(!should_rotate_signed_pre_key(last, last - 1));
324    }
325
326    #[test]
327    fn next_id_increments_and_wraps() {
328        assert_eq!(next_signed_pre_key_id(1), 2);
329        assert_eq!(next_signed_pre_key_id(41), 42);
330        assert_eq!(
331            next_signed_pre_key_id(MAX_SIGNED_PRE_KEY_ID - 1),
332            MAX_SIGNED_PRE_KEY_ID
333        );
334        // At and beyond the 24-bit border, wrap back to 1.
335        assert_eq!(next_signed_pre_key_id(MAX_SIGNED_PRE_KEY_ID), 1);
336    }
337
338    #[tokio::test]
339    async fn public_rotation_uses_the_single_flight_lock() {
340        let client = crate::test_utils::create_test_client().await;
341        let guard = client.signed_pre_key_rotation_lock.lock().await;
342
343        assert!(
344            tokio::time::timeout(
345                std::time::Duration::from_millis(100),
346                client.rotate_signed_pre_key(),
347            )
348            .await
349            .is_err(),
350            "manual rotation must serialize with an active rotation"
351        );
352        drop(guard);
353    }
354
355    #[tokio::test]
356    async fn due_automatic_rotation_does_not_relock_its_single_flight_guard() {
357        let client = crate::test_utils::create_test_client().await;
358        let snapshot = client.persistence_manager.get_device_snapshot();
359        let staged_id = next_signed_pre_key_id(snapshot.signed_pre_key_id);
360        let due_baseline =
361            wacore::time::now_millis().saturating_sub(SIGNED_PRE_KEY_ROTATION_INTERVAL_MS);
362        client
363            .persistence_manager
364            .process_command(DeviceCommand::SetSignedPreKeyRotationBaseline(due_baseline))
365            .await;
366        client
367            .persistence_manager
368            .flush()
369            .await
370            .expect("persist due rotation baseline");
371
372        let error = tokio::time::timeout(
373            std::time::Duration::from_secs(5),
374            client.maybe_rotate_signed_pre_key(),
375        )
376        .await
377        .expect("automatic rotation must not recursively acquire its held lock")
378        .expect_err("the disconnected test client must fail at upload");
379        assert!(matches!(
380            error,
381            SignalMaintenanceError::Iq(IqError::NotConnected)
382        ));
383        assert!(
384            client
385                .persistence_manager
386                .backend()
387                .load_signed_prekey(staged_id)
388                .await
389                .expect("load staged signed pre-key")
390                .is_some(),
391            "the due path must reach the inner rotation flow before upload"
392        );
393    }
394
395    #[tokio::test]
396    async fn public_rotation_reports_upload_failure() {
397        let client = crate::test_utils::create_test_client().await;
398
399        let error = client
400            .rotate_signed_pre_key()
401            .await
402            .expect_err("manual rotation must report a failed upload");
403        assert!(matches!(
404            error,
405            SignalMaintenanceError::Iq(IqError::NotConnected)
406        ));
407    }
408
409    #[tokio::test]
410    async fn rotation_reports_an_unusable_staged_key_as_corrupt() {
411        let client = crate::test_utils::create_test_client().await;
412        let snapshot = client.persistence_manager.get_device_snapshot();
413        let staged_id = next_signed_pre_key_id(snapshot.signed_pre_key_id);
414        // A record that decodes but carries no key material: rereading it would
415        // yield the same bytes, so it is a corruption, not a storage failure.
416        client
417            .persistence_manager
418            .backend()
419            .store_signed_prekey(staged_id, &[])
420            .await
421            .expect("stage an empty signed pre-key record");
422
423        let error = client
424            .rotate_signed_pre_key()
425            .await
426            .expect_err("an unusable staged key must abort the rotation");
427        assert!(matches!(error, SignalMaintenanceError::CorruptKey(_)));
428    }
429}