Skip to main content

whatsapp_rust/passkey/
flow.rs

1//! Client-side SHORTCAKE_PASSKEY linking flow: the runtime glue that drives the
2//! deterministic primitives in [`wacore::shortcake`] over real IQ exchanges.
3//!
4//! The handshake sits on top of the normal companion-linking connection: the
5//! server requests a WebAuthn assertion, the companion answers with an ephemeral
6//! identity prologue, both sides exchange nonces to derive a shared key, and the
7//! companion finally sends its rotated ADV secret encrypted under that key. Linking
8//! then completes through the ordinary `pair-success` path.
9//!
10//! The handshake state machine (`ShortcakeSession`) drives against a
11//! `ShortcakeIo` seam rather than the concrete [`Client`], so the full IQ
12//! sequence is unit-testable with a scripted stand-in.
13
14use crate::client::Client;
15use crate::passkey::{Assertion, PasskeyAuthenticator, PasskeyError, parse_request_options};
16use crate::request::InfoQuery;
17use crate::store::commands::DeviceCommand;
18use crate::types::events::{Event, PairPasskeyConfirmation, PairPasskeyError, PairPasskeyRequest};
19use async_trait::async_trait;
20use log::warn;
21use rand::RngExt;
22use std::sync::Arc;
23use std::sync::atomic::{AtomicBool, Ordering};
24use wacore::libsignal::protocol::KeyPair;
25use wacore::shortcake::ShortcakeUtils;
26use wacore::sync_marker::MaybeSendSync;
27use wacore_binary::builder::NodeBuilder;
28use wacore_binary::{Jid, Node, NodeContent, NodeRef, OwnedNodeRef, SERVER_JID, Server};
29use waproto::whatsapp as wa;
30
31/// `<notification type=...>` routing keys, consumed by the notification dispatcher.
32pub(crate) const NOTIF_PASSKEY_REQUEST: &str = "passkey_prologue_request";
33pub(crate) const NOTIF_PASSKEY_CONTINUATION: &str = "crsc_continuation";
34
35const MD_NAMESPACE: &str = "md";
36const TAG_REF: &str = "ref";
37const TAG_PASSKEY_REQUEST_OPTIONS: &str = "passkey_request_options";
38const TAG_PASSKEY_PROLOGUE: &str = "passkey_prologue";
39const TAG_CREDENTIAL_ID: &str = "credential_id";
40const TAG_WEBAUTHN_ASSERTION: &str = "webauthn_assertion";
41const TAG_PROLOGUE_PAYLOAD: &str = "prologue_payload";
42const TAG_PAIRING_HANDOFF_PROOF: &str = "pairing_handoff_proof";
43const TAG_PRIMARY_EPHEMERAL_IDENTITY: &str = "primary_ephemeral_identity";
44const TAG_COMPANION_NONCE: &str = "companion_nonce";
45const TAG_ENCRYPTED_PAIRING_REQUEST: &str = "encrypted_pairing_request";
46
47/// Length of each half of the "XXXX-XXXX" verification code grouping.
48const CODE_GROUP_LEN: usize = 4;
49
50/// The device material the handshake reads: the companion's static public keys
51/// and the reported platform.
52#[derive(Clone)]
53struct DeviceMaterial {
54    noise_public: [u8; 32],
55    identity_public: [u8; 32],
56    device_type: wa::device_props::PlatformType,
57}
58
59/// The effects the handshake needs from its environment. Abstracted so the full
60/// IQ sequence can be driven by a scripted stand-in in tests.
61#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
62#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
63trait ShortcakeIo: MaybeSendSync {
64    async fn query(&self, query: InfoQuery<'static>) -> Result<Arc<OwnedNodeRef>, PasskeyError>;
65    fn device_material(&self) -> Result<DeviceMaterial, PasskeyError>;
66    async fn commit_adv_secret(&self, secret: [u8; 32]);
67}
68
69#[derive(PartialEq, Eq)]
70enum Stage {
71    AwaitingPrimaryIdentity,
72    AwaitingConfirmation,
73    Done,
74}
75
76/// The in-flight handshake, created by [`ShortcakeSession::open`] and advanced by
77/// the continuation + confirmation steps. Its rotated ADV secret is held here (not
78/// in the device store) until [`confirm`](Self::confirm) commits it, so an
79/// abandoned attempt never rotates to a secret the primary never received.
80struct ShortcakeSession {
81    keypair: KeyPair,
82    companion_nonce: [u8; 32],
83    pairing_ref: String,
84    device_type: wa::device_props::PlatformType,
85    new_adv_secret: [u8; 32],
86    skip_handoff_ux: bool,
87    stage: Stage,
88    encryption_key: Option<[u8; 32]>,
89}
90
91impl ShortcakeSession {
92    /// Fetch a fresh ref, build the ephemeral identity + commitment, attach the
93    /// handoff proof on a re-link, and send the `passkey_prologue` IQ.
94    async fn open(
95        io: &dyn ShortcakeIo,
96        assertion: Assertion,
97        handoff_key: Option<[u8; 32]>,
98    ) -> Result<Self, PasskeyError> {
99        let device_type = io.device_material()?.device_type;
100        let pairing_ref = fetch_ref(io).await?;
101
102        let keypair = ShortcakeUtils::generate_companion_ephemeral_keypair();
103        let companion_nonce = ShortcakeUtils::generate_companion_nonce();
104        let mut new_adv_secret = [0u8; 32];
105        rand::make_rng::<rand::rngs::StdRng>().fill(&mut new_adv_secret);
106
107        let companion_pub: [u8; 32] = keypair
108            .public_key
109            .public_key_bytes()
110            .try_into()
111            .map_err(|_| PasskeyError::Flow("ephemeral public key is not 32 bytes".into()))?;
112        let identity = ShortcakeUtils::build_companion_ephemeral_identity(
113            &companion_pub,
114            device_type,
115            &pairing_ref,
116        );
117        let commitment = ShortcakeUtils::commitment_hash(&identity, &companion_nonce);
118        let prologue_payload = ShortcakeUtils::build_prologue_payload(&identity, &commitment);
119
120        let handoff_proof = handoff_key
121            .map(|key| ShortcakeUtils::compute_pairing_handoff_proof(&key, &prologue_payload));
122        let skip_handoff_ux = handoff_proof.is_some();
123
124        let prologue = build_prologue_node(
125            assertion.credential_id,
126            assertion.assertion_json,
127            prologue_payload,
128            handoff_proof,
129        );
130        io.query(InfoQuery::set(
131            MD_NAMESPACE,
132            server_jid(),
133            Some(NodeContent::Nodes(vec![prologue])),
134        ))
135        .await
136        .map_err(|e| PasskeyError::Flow(format!("passkey_prologue iq failed: {e}")))?;
137
138        Ok(Self {
139            keypair,
140            companion_nonce,
141            pairing_ref,
142            device_type,
143            new_adv_secret,
144            skip_handoff_ux,
145            stage: Stage::AwaitingPrimaryIdentity,
146            encryption_key: None,
147        })
148    }
149
150    /// Agree on the shared secret, reveal the companion nonce, and derive the code
151    /// and encryption key. Returns the confirmation payload for the caller to
152    /// publish, so the caller can restore the session before a synchronous listener
153    /// that confirms observes it.
154    async fn on_primary_identity(
155        &mut self,
156        io: &dyn ShortcakeIo,
157        primary_bytes: &[u8],
158    ) -> Result<PairPasskeyConfirmation, PasskeyError> {
159        if self.stage != Stage::AwaitingPrimaryIdentity {
160            return Err(PasskeyError::Flow(
161                "unexpected continuation for this stage".into(),
162            ));
163        }
164        let primary = ShortcakeUtils::parse_primary_ephemeral_identity(primary_bytes)
165            .map_err(|e| PasskeyError::Flow(format!("primary ephemeral identity: {e}")))?;
166
167        let nonce_node = NodeBuilder::new(TAG_COMPANION_NONCE)
168            .bytes(self.companion_nonce.to_vec())
169            .build();
170        io.query(InfoQuery::set(
171            MD_NAMESPACE,
172            server_jid(),
173            Some(NodeContent::Nodes(vec![nonce_node])),
174        ))
175        .await
176        .map_err(|e| PasskeyError::Flow(format!("companion_nonce iq failed: {e}")))?;
177
178        let encryption_key = ShortcakeUtils::derive_encryption_key(
179            &self.keypair,
180            &primary.public_key,
181            self.device_type,
182            &self.pairing_ref,
183        )
184        .map_err(|e| PasskeyError::Flow(format!("encryption key: {e}")))?;
185        let bare = ShortcakeUtils::derive_verification_code(
186            &self.companion_nonce,
187            &primary.public_key,
188            &primary.nonce,
189        );
190        // Grouped "XXXX-XXXX" for display (the code is ASCII).
191        let code = format!("{}-{}", &bare[..CODE_GROUP_LEN], &bare[CODE_GROUP_LEN..]);
192
193        self.encryption_key = Some(encryption_key);
194        self.stage = Stage::AwaitingConfirmation;
195        Ok(PairPasskeyConfirmation::builder()
196            .code(code)
197            .skip_handoff_ux(self.skip_handoff_ux)
198            .build())
199    }
200
201    /// Encrypt the `PairingRequest` (companion static keys + rotated ADV secret)
202    /// and send `<encrypted_pairing_request>`, then commit the rotation.
203    async fn confirm(&mut self, io: &dyn ShortcakeIo) -> Result<(), PasskeyError> {
204        if self.stage != Stage::AwaitingConfirmation {
205            return Err(PasskeyError::Flow(
206                "confirmation before the verification stage".into(),
207            ));
208        }
209        let encryption_key = self.encryption_key.ok_or_else(|| {
210            PasskeyError::Flow("confirmation before encryption key derived".into())
211        })?;
212        let material = io.device_material()?;
213
214        let plaintext = ShortcakeUtils::build_pairing_request(
215            &material.noise_public,
216            &material.identity_public,
217            &self.new_adv_secret,
218        );
219        let encrypted = ShortcakeUtils::encrypt_pairing_request(&plaintext, &encryption_key)
220            .map_err(|e| PasskeyError::Flow(format!("encrypt pairing request: {e}")))?;
221        let wrapped = ShortcakeUtils::build_encrypted_pairing_request(&encrypted);
222
223        let node = NodeBuilder::new(TAG_ENCRYPTED_PAIRING_REQUEST)
224            .bytes(wrapped)
225            .build();
226        io.query(InfoQuery::set(
227            MD_NAMESPACE,
228            server_jid(),
229            Some(NodeContent::Nodes(vec![node])),
230        ))
231        .await
232        .map_err(|e| PasskeyError::Flow(format!("encrypted_pairing_request iq failed: {e}")))?;
233
234        // Primary has the secret now: commit the rotation (before pair-success
235        // validates against it).
236        io.commit_adv_secret(self.new_adv_secret).await;
237        self.stage = Stage::Done;
238        Ok(())
239    }
240}
241
242/// SHORTCAKE_PASSKEY flow state held on the [`Client`].
243#[derive(Default)]
244pub(crate) struct PasskeyFlowState {
245    /// HMAC key from the pre-rotation ADV secret; presence marks the re-link path
246    /// that lets the server skip the verification-code UX. Consumed once.
247    handoff_key: Option<[u8; 32]>,
248    session: Option<ShortcakeSession>,
249    authenticator: Option<Arc<dyn PasskeyAuthenticator>>,
250}
251
252/// Holds the wait-free open reservation and releases it on drop. Because it clears
253/// a plain [`AtomicBool`] (not a flag behind the async lock), the release is a sync,
254/// always-succeeding store, so a `send_passkey_response` cancelled at any await
255/// can't leave the reservation stuck.
256struct OpeningGuard<'a> {
257    flag: &'a AtomicBool,
258}
259
260impl Drop for OpeningGuard<'_> {
261    fn drop(&mut self) {
262        self.flag.store(false, Ordering::Release);
263    }
264}
265
266fn server_jid() -> Jid {
267    Jid::new("", Server::Pn)
268}
269
270/// Pull a child node's payload as bytes, accepting either binary or string content
271/// (the server sends the options JSON as a text node).
272fn child_payload(nr: &NodeRef<'_>, tag: &str) -> Option<Vec<u8>> {
273    let child = nr.get_optional_child(tag)?;
274    if let Some(b) = child.content_bytes() {
275        Some(b.to_vec())
276    } else {
277        child.content_str().map(|s| s.as_bytes().to_vec())
278    }
279}
280
281/// Pure so the wire shape (child tags + conditional proof) is unit-testable.
282fn build_prologue_node(
283    credential_id: Vec<u8>,
284    webauthn_assertion: Vec<u8>,
285    prologue_payload: Vec<u8>,
286    handoff_proof: Option<[u8; 32]>,
287) -> Node {
288    let mut children = vec![
289        NodeBuilder::new(TAG_CREDENTIAL_ID)
290            .bytes(credential_id)
291            .build(),
292        NodeBuilder::new(TAG_WEBAUTHN_ASSERTION)
293            .bytes(webauthn_assertion)
294            .build(),
295        NodeBuilder::new(TAG_PROLOGUE_PAYLOAD)
296            .bytes(prologue_payload)
297            .build(),
298    ];
299    if let Some(proof) = handoff_proof {
300        children.push(
301            NodeBuilder::new(TAG_PAIRING_HANDOFF_PROOF)
302                .bytes(proof.to_vec())
303                .build(),
304        );
305    }
306    NodeBuilder::new(TAG_PASSKEY_PROLOGUE)
307        .children(children)
308        .build()
309}
310
311async fn fetch_ref(io: &dyn ShortcakeIo) -> Result<String, PasskeyError> {
312    let resp = io
313        .query(InfoQuery::get(
314            MD_NAMESPACE,
315            server_jid(),
316            Some(NodeContent::Nodes(vec![NodeBuilder::new(TAG_REF).build()])),
317        ))
318        .await
319        .map_err(|e| PasskeyError::Flow(format!("ref iq failed: {e}")))?;
320    child_payload(resp.get(), TAG_REF)
321        .and_then(|b| String::from_utf8(b).ok())
322        .ok_or_else(|| PasskeyError::Flow("missing ref in server response".into()))
323}
324
325async fn fetch_request_options(io: &dyn ShortcakeIo) -> Result<String, PasskeyError> {
326    let resp = io
327        .query(InfoQuery::get(
328            MD_NAMESPACE,
329            server_jid(),
330            Some(NodeContent::Nodes(vec![
331                NodeBuilder::new(TAG_PASSKEY_REQUEST_OPTIONS).build(),
332            ])),
333        ))
334        .await
335        .map_err(|e| PasskeyError::Flow(format!("passkey_request_options iq failed: {e}")))?;
336    child_payload(resp.get(), TAG_PASSKEY_REQUEST_OPTIONS)
337        .and_then(|b| String::from_utf8(b).ok())
338        .ok_or_else(|| PasskeyError::Flow("missing passkey_request_options in response".into()))
339}
340
341#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
342#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
343impl ShortcakeIo for Client {
344    async fn query(&self, query: InfoQuery<'static>) -> Result<Arc<OwnedNodeRef>, PasskeyError> {
345        self.send_iq(query)
346            .await
347            .map_err(|e| PasskeyError::Flow(e.to_string()))
348    }
349
350    fn device_material(&self) -> Result<DeviceMaterial, PasskeyError> {
351        let snapshot = self.persistence_manager.get_device_snapshot();
352        let noise_public: [u8; 32] = snapshot
353            .noise_key
354            .public_key
355            .public_key_bytes()
356            .try_into()
357            .map_err(|_| PasskeyError::Flow("noise public key is not 32 bytes".into()))?;
358        let identity_public: [u8; 32] = snapshot
359            .identity_key
360            .public_key
361            .public_key_bytes()
362            .try_into()
363            .map_err(|_| PasskeyError::Flow("identity public key is not 32 bytes".into()))?;
364        Ok(DeviceMaterial {
365            noise_public,
366            identity_public,
367            device_type: snapshot
368                .device_props
369                .platform_type
370                .unwrap_or(wa::device_props::PlatformType::UNKNOWN),
371        })
372    }
373
374    async fn commit_adv_secret(&self, secret: [u8; 32]) {
375        self.persistence_manager
376            .process_command(DeviceCommand::SetAdvSecretKey(secret))
377            .await;
378    }
379}
380
381impl Client {
382    /// Register a passkey authenticator. When set, the client auto-drives the
383    /// assertion step and auto-confirms a re-link (where the handoff proof skips the
384    /// verification-code UX). Leave it unset to drive the steps manually via the
385    /// `Event::PairPasskey*` events.
386    pub async fn set_passkey_authenticator(&self, authenticator: Arc<dyn PasskeyAuthenticator>) {
387        self.passkey_state.lock().await.authenticator = Some(authenticator);
388    }
389
390    async fn passkey_authenticator(&self) -> Option<Arc<dyn PasskeyAuthenticator>> {
391        self.passkey_state.lock().await.authenticator.clone()
392    }
393
394    /// Send the WebAuthn assertion as `<passkey_prologue>` and open the handshake.
395    /// Call after an [`Event::PairPasskeyRequest`].
396    pub async fn send_passkey_response(&self, assertion: Assertion) -> Result<(), PasskeyError> {
397        // Reserve the single open slot BEFORE the awaits, so a concurrent response
398        // can't open a second overlapping handshake and clobber this one's nonce/ref
399        // (which would then commit the wrong ADV rotation). The guard releases the
400        // reservation on every exit, including cancellation mid-open.
401        if self
402            .passkey_opening
403            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
404            .is_err()
405        {
406            return Err(PasskeyError::Flow(
407                "a passkey open is already in progress".into(),
408            ));
409        }
410        let _guard = OpeningGuard {
411            flag: &self.passkey_opening,
412        };
413
414        let handoff_key = {
415            let mut state = self.passkey_state.lock().await;
416            if state.session.is_some() {
417                return Err(PasskeyError::Flow(
418                    "a passkey link is already in progress".into(),
419                ));
420            }
421            state.handoff_key.take()
422        };
423
424        let session = ShortcakeSession::open(self, assertion, handoff_key).await?;
425        self.passkey_state.lock().await.session = Some(session);
426        Ok(())
427    }
428
429    /// Finish the link. For a fresh link, call this only after the user confirms the
430    /// [`Event::PairPasskeyConfirmation`] code.
431    pub async fn send_passkey_confirmation(&self) -> Result<(), PasskeyError> {
432        // Only consume the session once it's actually at the confirmation stage — a
433        // premature call must NOT drop the in-flight attempt.
434        let mut session = {
435            let mut state = self.passkey_state.lock().await;
436            match state.session.take() {
437                Some(s) if s.stage == Stage::AwaitingConfirmation => s,
438                Some(s) => {
439                    state.session = Some(s);
440                    return Err(PasskeyError::Flow(
441                        "confirmation before the verification stage".into(),
442                    ));
443                }
444                None => {
445                    return Err(PasskeyError::Flow(
446                        "confirmation without an active session".into(),
447                    ));
448                }
449            }
450        };
451        session.confirm(self).await
452    }
453
454    async fn drive_continuation(&self, primary_bytes: Vec<u8>) -> Result<(), PasskeyError> {
455        let mut session = self
456            .passkey_state
457            .lock()
458            .await
459            .session
460            .take()
461            .ok_or_else(|| PasskeyError::Flow("continuation without an active session".into()))?;
462        let confirmation = session.on_primary_identity(self, &primary_bytes).await?;
463        let skip = confirmation.skip_handoff_ux;
464
465        // Restore the session BEFORE publishing the event, so a synchronous listener
466        // that confirms from it sees an active session.
467        self.passkey_state.lock().await.session = Some(session);
468        self.core
469            .event_bus
470            .dispatch(Event::PairPasskeyConfirmation(confirmation));
471
472        // Re-link: continuity is already proven, so finish without a user code when
473        // an authenticator is driving.
474        if skip && self.passkey_authenticator().await.is_some() {
475            self.send_passkey_confirmation().await?;
476        }
477        Ok(())
478    }
479}
480
481/// Handle a `passkey_prologue_request` notification: emit the request (and
482/// auto-drive it if an authenticator is registered).
483pub(crate) async fn handle_passkey_notification(client: &Arc<Client>, node: Arc<OwnedNodeRef>) {
484    // The staged rotation is security-sensitive: only honor a server request.
485    if node.get().get_attr("from").is_none_or(|v| v != SERVER_JID) {
486        warn!("ignoring passkey notification from a non-server JID");
487        return;
488    }
489
490    match child_payload(node.get(), TAG_PASSKEY_REQUEST_OPTIONS)
491        .and_then(|b| String::from_utf8(b).ok())
492    {
493        Some(json) => drive_passkey_request(client, json).await,
494        // Options omitted: fetch them via IQ. Spawned because it awaits a round-trip.
495        None => {
496            let client = client.clone();
497            client
498                .clone()
499                .runtime
500                .spawn(Box::pin(async move {
501                    match fetch_request_options(client.as_ref()).await {
502                        Ok(json) => drive_passkey_request(&client, json).await,
503                        Err(e) => {
504                            warn!("failed to fetch passkey request options: {e}");
505                            client.core.event_bus.dispatch(Event::PairPasskeyError(
506                                PairPasskeyError::builder()
507                                    .error(e.to_string())
508                                    .continuation(false)
509                                    .build(),
510                            ));
511                        }
512                    }
513                }))
514                .detach();
515        }
516    }
517}
518
519async fn drive_passkey_request(client: &Arc<Client>, options_json: String) {
520    // The handoff key suppresses the verification-code UX, so it must be a RE-LINK
521    // signal only: adv_secret_key alone is present on a fresh device too, which would
522    // disable the code check on a first link. Gate on a prior linked identity.
523    let snapshot = client.persistence_manager.get_device_snapshot();
524    let previously_linked =
525        snapshot.account.is_some() || snapshot.pn.is_some() || snapshot.lid.is_some();
526    let handoff_key = if previously_linked {
527        ShortcakeUtils::derive_pairing_handoff_hmac_key(&snapshot.adv_secret_key)
528            .inspect_err(|e| warn!("failed to derive pairing-handoff key: {e}"))
529            .ok()
530    } else {
531        None
532    };
533    client.passkey_state.lock().await.handoff_key = handoff_key;
534
535    client.core.event_bus.dispatch(Event::PairPasskeyRequest(
536        PairPasskeyRequest::builder()
537            .request_options_json(options_json.clone())
538            .build(),
539    ));
540
541    if let Some(authenticator) = client.passkey_authenticator().await {
542        let client = client.clone();
543        client
544            .clone()
545            .runtime
546            .spawn(Box::pin(async move {
547                if let Err(e) = auto_drive_response(&client, authenticator, &options_json).await {
548                    warn!("passkey auto-drive failed: {e}");
549                    client.core.event_bus.dispatch(Event::PairPasskeyError(
550                        PairPasskeyError::builder()
551                            .error(e.to_string())
552                            .continuation(false)
553                            .build(),
554                    ));
555                }
556            }))
557            .detach();
558    }
559}
560
561async fn auto_drive_response(
562    client: &Arc<Client>,
563    authenticator: Arc<dyn PasskeyAuthenticator>,
564    options_json: &str,
565) -> Result<(), PasskeyError> {
566    let request = parse_request_options(options_json)?;
567    let assertion = authenticator.get_assertion(&request).await?;
568    client.send_passkey_response(assertion).await
569}
570
571/// Handle a `crsc_continuation` notification. Spawned: it awaits an IQ round-trip
572/// and must not block the receive loop.
573pub(crate) async fn handle_passkey_continuation(client: &Arc<Client>, node: Arc<OwnedNodeRef>) {
574    if node.get().get_attr("from").is_none_or(|v| v != SERVER_JID) {
575        warn!("ignoring passkey continuation from a non-server JID");
576        return;
577    }
578
579    let primary_bytes = match child_payload(node.get(), TAG_PRIMARY_EPHEMERAL_IDENTITY) {
580        Some(bytes) => bytes,
581        None => {
582            warn!("passkey continuation missing primary_ephemeral_identity");
583            client.core.event_bus.dispatch(Event::PairPasskeyError(
584                PairPasskeyError::builder()
585                    .error("missing primary_ephemeral_identity".into())
586                    .continuation(true)
587                    .build(),
588            ));
589            return;
590        }
591    };
592
593    let client = client.clone();
594    client
595        .clone()
596        .runtime
597        .spawn(Box::pin(async move {
598            if let Err(e) = client.drive_continuation(primary_bytes).await {
599                warn!("passkey continuation failed: {e}");
600                client.core.event_bus.dispatch(Event::PairPasskeyError(
601                    PairPasskeyError::builder()
602                        .error(e.to_string())
603                        .continuation(true)
604                        .build(),
605                ));
606            }
607        }))
608        .detach();
609}
610
611#[cfg(test)]
612#[allow(clippy::disallowed_methods)]
613mod tests {
614    use super::*;
615    use crate::test_utils::{TestEventCollector, create_test_client, node_to_owned_ref};
616    use crate::types::events::EventHandler;
617    use buffa::Message as _;
618    use std::sync::Mutex;
619    use std::time::Duration;
620    use wacore::libsignal::protocol::PublicKey;
621    use waproto::whatsapp as wa;
622
623    fn server_notification(notif_type: &'static str, child: Option<Node>) -> Arc<OwnedNodeRef> {
624        let mut builder = NodeBuilder::new("notification")
625            .attr("type", notif_type)
626            .attr("from", SERVER_JID);
627        if let Some(child) = child {
628            builder = builder.children([child]);
629        }
630        node_to_owned_ref(&builder.build())
631    }
632
633    async fn wait_for(collector: &Arc<TestEventCollector>, pred: impl Fn(&Event) -> bool) {
634        for _ in 0..200 {
635            if collector.events().iter().any(|e| pred(e.as_ref())) {
636                return;
637            }
638            tokio::time::sleep(Duration::from_millis(5)).await;
639        }
640        panic!("expected event was not observed within the timeout");
641    }
642
643    // Scripted IQ stand-in: answers each `md` IQ by its child tag and records the
644    // child that was sent, plus the committed secret.
645    struct MockIo {
646        device: DeviceMaterial,
647        pairing_ref: String,
648        options_json: String,
649        sent: Mutex<Vec<Node>>,
650        committed: Mutex<Option<[u8; 32]>>,
651    }
652
653    impl MockIo {
654        fn sent_tags(&self) -> Vec<String> {
655            self.sent
656                .lock()
657                .unwrap()
658                .iter()
659                .map(|n| n.tag.to_string())
660                .collect()
661        }
662
663        fn sent_node(&self, tag: &str) -> Node {
664            self.sent
665                .lock()
666                .unwrap()
667                .iter()
668                .find(|n| n.tag == tag)
669                .cloned()
670                .unwrap_or_else(|| panic!("expected a {tag} IQ to have been sent"))
671        }
672    }
673
674    #[async_trait]
675    impl ShortcakeIo for MockIo {
676        async fn query(
677            &self,
678            query: InfoQuery<'static>,
679        ) -> Result<Arc<OwnedNodeRef>, PasskeyError> {
680            let child = match &query.content {
681                Some(NodeContent::Nodes(nodes)) => nodes.first().cloned(),
682                _ => None,
683            };
684            let child = child.expect("md IQ must carry a child node");
685            let tag = child.tag.to_string();
686            self.sent.lock().unwrap().push(child);
687
688            let response = if tag == TAG_REF {
689                NodeBuilder::new("iq")
690                    .children([NodeBuilder::new(TAG_REF)
691                        .bytes(self.pairing_ref.as_bytes().to_vec())
692                        .build()])
693                    .build()
694            } else if tag == TAG_PASSKEY_REQUEST_OPTIONS {
695                NodeBuilder::new("iq")
696                    .children([NodeBuilder::new(TAG_PASSKEY_REQUEST_OPTIONS)
697                        .bytes(self.options_json.as_bytes().to_vec())
698                        .build()])
699                    .build()
700            } else {
701                NodeBuilder::new("iq").build()
702            };
703            Ok(node_to_owned_ref(&response))
704        }
705
706        fn device_material(&self) -> Result<DeviceMaterial, PasskeyError> {
707            Ok(self.device.clone())
708        }
709
710        async fn commit_adv_secret(&self, secret: [u8; 32]) {
711            *self.committed.lock().unwrap() = Some(secret);
712        }
713    }
714
715    fn child_bytes(node: &Node, tag: &str) -> Vec<u8> {
716        child_payload(&node.as_node_ref(), tag).unwrap_or_else(|| panic!("missing {tag} child"))
717    }
718
719    #[tokio::test]
720    async fn full_handshake_drives_the_iq_sequence_and_delivers_the_committed_secret() {
721        // Companion static keys (arbitrary but distinct) + a primary playing the peer.
722        let device = DeviceMaterial {
723            noise_public: [0x11; 32],
724            identity_public: [0x12; 32],
725            device_type: wa::device_props::PlatformType::CHROME,
726        };
727        let io = MockIo {
728            device: device.clone(),
729            pairing_ref: "REF-XYZ".to_string(),
730            options_json: "{}".to_string(),
731            sent: Mutex::new(Vec::new()),
732            committed: Mutex::new(None),
733        };
734
735        // A re-link: pass a handoff key so the prologue carries the proof.
736        let handoff_key = [0x55u8; 32];
737        let assertion = Assertion {
738            assertion_json: br#"{"type":"public-key"}"#.to_vec(),
739            credential_id: b"cred-id".to_vec(),
740        };
741
742        let mut session = ShortcakeSession::open(&io, assertion, Some(handoff_key))
743            .await
744            .unwrap();
745
746        // step 1-2: ref fetched, then a prologue with the handoff proof.
747        assert_eq!(io.sent_tags(), vec![TAG_REF, TAG_PASSKEY_PROLOGUE]);
748        let prologue = io.sent_node(TAG_PASSKEY_PROLOGUE);
749        assert_eq!(child_bytes(&prologue, TAG_CREDENTIAL_ID), b"cred-id");
750        assert!(
751            prologue
752                .as_node_ref()
753                .get_optional_child(TAG_PAIRING_HANDOFF_PROOF)
754                .is_some()
755        );
756
757        // primary's ephemeral identity
758        let primary_kp = KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>());
759        let primary_pub: [u8; 32] = primary_kp.public_key.public_key_bytes().try_into().unwrap();
760        let primary_nonce = [0x77u8; 32];
761        let primary_bytes = wa::PrimaryEphemeralIdentity {
762            public_key: Some(primary_pub.to_vec()),
763            nonce: Some(primary_nonce.to_vec()),
764        }
765        .encode_to_vec();
766
767        // step 3: continuation sends the companion nonce and yields the code.
768        let confirmation = session
769            .on_primary_identity(&io, &primary_bytes)
770            .await
771            .unwrap();
772        assert!(
773            confirmation.skip_handoff_ux,
774            "re-link with a handoff proof skips the code UX"
775        );
776        assert_eq!(confirmation.code.len(), 9, "code is grouped XXXX-XXXX");
777        assert_eq!(
778            io.sent_tags().last().map(String::as_str),
779            Some(TAG_COMPANION_NONCE)
780        );
781
782        // step 4: confirm seals + sends the pairing request and commits the secret.
783        session.confirm(&io).await.unwrap();
784        assert_eq!(
785            io.sent_tags().last().map(String::as_str),
786            Some(TAG_ENCRYPTED_PAIRING_REQUEST)
787        );
788        let committed = io
789            .committed
790            .lock()
791            .unwrap()
792            .expect("secret must be committed");
793
794        // The primary decrypts the pairing request and reads the SAME secret that
795        // was committed — proving the deferred rotation delivers what it persists.
796        let prologue_payload = child_bytes(&prologue, TAG_PROLOGUE_PAYLOAD);
797        let companion_eph_pub = wa::CompanionEphemeralIdentity::decode_from_slice(
798            wa::ProloguePayload::decode_from_slice(prologue_payload.as_slice())
799                .unwrap()
800                .companion_ephemeral_identity
801                .unwrap()
802                .as_slice(),
803        )
804        .unwrap()
805        .public_key
806        .unwrap();
807        let shared = primary_kp
808            .private_key
809            .calculate_agreement(&PublicKey::from_djb_public_key_bytes(&companion_eph_pub).unwrap())
810            .unwrap();
811        let key = ShortcakeUtils::derive_encryption_key_from_shared_secret(
812            &shared,
813            wa::device_props::PlatformType::CHROME,
814            "REF-XYZ",
815        )
816        .unwrap();
817        let wrapped = match io.sent_node(TAG_ENCRYPTED_PAIRING_REQUEST).content {
818            Some(NodeContent::Bytes(bytes)) => bytes,
819            _ => panic!("encrypted_pairing_request must carry bytes"),
820        };
821        let epr = wa::EncryptedPairingRequest::decode_from_slice(wrapped.as_slice()).unwrap();
822        let iv: [u8; 12] = epr.iv.unwrap().as_slice().try_into().unwrap();
823        let mut plaintext = Vec::new();
824        wacore::libsignal::crypto::aes_256_gcm_decrypt(
825            &key,
826            &iv,
827            b"",
828            &epr.encrypted_payload.unwrap(),
829            &mut plaintext,
830        )
831        .unwrap();
832        let pr = wa::PairingRequest::decode_from_slice(plaintext.as_slice()).unwrap();
833        assert_eq!(pr.adv_secret.as_deref(), Some(&committed[..]));
834        assert_eq!(
835            pr.companion_public_key.as_deref(),
836            Some(&device.noise_public[..])
837        );
838    }
839
840    #[tokio::test]
841    async fn premature_confirmation_keeps_the_session() {
842        let client = create_test_client().await;
843        // A session that hasn't reached the confirmation stage yet.
844        client.passkey_state.lock().await.session = Some(ShortcakeSession {
845            keypair: KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>()),
846            companion_nonce: [0; 32],
847            pairing_ref: "r".into(),
848            device_type: wa::device_props::PlatformType::CHROME,
849            new_adv_secret: [1; 32],
850            skip_handoff_ux: false,
851            stage: Stage::AwaitingPrimaryIdentity,
852            encryption_key: None,
853        });
854
855        assert!(
856            client.send_passkey_confirmation().await.is_err(),
857            "confirming before the verification stage errors"
858        );
859        assert!(
860            client.passkey_state.lock().await.session.is_some(),
861            "a premature confirmation must not drop the in-flight attempt"
862        );
863    }
864
865    #[tokio::test]
866    async fn cancelled_open_releases_the_reservation() {
867        let client = create_test_client().await;
868        client.passkey_opening.store(true, Ordering::Release);
869        // A dropped guard stands in for a send_passkey_response cancelled mid-open;
870        // the release is a sync store, so it holds even under lock contention.
871        drop(OpeningGuard {
872            flag: &client.passkey_opening,
873        });
874        assert!(
875            !client.passkey_opening.load(Ordering::Acquire),
876            "a cancelled open must release the reservation"
877        );
878    }
879
880    #[tokio::test]
881    async fn passkey_prologue_request_emits_event_without_committing_rotation() {
882        let client = create_test_client().await;
883        let collector = Arc::new(TestEventCollector::default());
884        client
885            .subscribe_handler(collector.clone() as Arc<dyn EventHandler>)
886            .detach();
887
888        let before = client
889            .persistence_manager
890            .get_device_snapshot()
891            .adv_secret_key;
892
893        let options = r#"{"challenge":"YWJjZGVm","rpId":"web.whatsapp.com"}"#;
894        let child = NodeBuilder::new(TAG_PASSKEY_REQUEST_OPTIONS)
895            .bytes(options.as_bytes().to_vec())
896            .build();
897        client
898            .process_node(server_notification(NOTIF_PASSKEY_REQUEST, Some(child)))
899            .await;
900
901        // The rotation is deferred to confirmation, so the stored secret is unchanged.
902        let after = client
903            .persistence_manager
904            .get_device_snapshot()
905            .adv_secret_key;
906        assert_eq!(before, after, "ADV secret must not commit at request time");
907
908        let request = collector
909            .events()
910            .into_iter()
911            .find_map(|e| match e.as_ref() {
912                Event::PairPasskeyRequest(r) => Some(r.clone()),
913                _ => None,
914            })
915            .expect("a PairPasskeyRequest event must be dispatched");
916        assert_eq!(request.request_options_json, options);
917    }
918
919    #[tokio::test]
920    async fn passkey_prologue_request_from_non_server_is_ignored() {
921        let client = create_test_client().await;
922        let collector = Arc::new(TestEventCollector::default());
923        client
924            .subscribe_handler(collector.clone() as Arc<dyn EventHandler>)
925            .detach();
926
927        let child = NodeBuilder::new(TAG_PASSKEY_REQUEST_OPTIONS)
928            .bytes(b"{}".to_vec())
929            .build();
930        let node = NodeBuilder::new("notification")
931            .attr("type", NOTIF_PASSKEY_REQUEST)
932            .attr("from", "12345@s.whatsapp.net")
933            .children([child])
934            .build();
935        client.process_node(node_to_owned_ref(&node)).await;
936
937        assert!(
938            !collector
939                .events()
940                .iter()
941                .any(|e| matches!(e.as_ref(), Event::PairPasskeyRequest(_))),
942            "no event for a non-server request"
943        );
944    }
945
946    #[tokio::test]
947    async fn passkey_prologue_request_without_inline_options_falls_back_to_fetch() {
948        let client = create_test_client().await;
949        let collector = Arc::new(TestEventCollector::default());
950        client
951            .subscribe_handler(collector.clone() as Arc<dyn EventHandler>)
952            .detach();
953
954        // No inline options: the handler falls back to an IQ fetch. The test client
955        // isn't connected, so the fetch fails and surfaces a non-continuation error.
956        client
957            .process_node(server_notification(NOTIF_PASSKEY_REQUEST, None))
958            .await;
959
960        wait_for(&collector, |e| {
961            matches!(
962                e,
963                Event::PairPasskeyError(err)
964                    if !err.continuation && err.error.contains("passkey_request_options iq failed")
965            )
966        })
967        .await;
968    }
969
970    #[tokio::test]
971    async fn passkey_continuation_without_session_emits_error() {
972        let client = create_test_client().await;
973        let collector = Arc::new(TestEventCollector::default());
974        client
975            .subscribe_handler(collector.clone() as Arc<dyn EventHandler>)
976            .detach();
977
978        let primary = wa::PrimaryEphemeralIdentity {
979            public_key: Some(vec![0xAB; 32]),
980            nonce: Some(vec![0xCD; 32]),
981        };
982        let child = NodeBuilder::new(TAG_PRIMARY_EPHEMERAL_IDENTITY)
983            .bytes(buffa::Message::encode_to_vec(&primary))
984            .build();
985        client
986            .process_node(server_notification(NOTIF_PASSKEY_CONTINUATION, Some(child)))
987            .await;
988
989        wait_for(
990            &collector,
991            |e| matches!(e, Event::PairPasskeyError(err) if err.continuation),
992        )
993        .await;
994    }
995
996    #[test]
997    fn prologue_node_wire_shape() {
998        let node = build_prologue_node(
999            b"cred-id".to_vec(),
1000            b"{\"type\":\"public-key\"}".to_vec(),
1001            b"prologue-proto".to_vec(),
1002            Some([0x42; 32]),
1003        );
1004        let nr = node.as_node_ref();
1005        assert_eq!(nr.tag.as_ref(), TAG_PASSKEY_PROLOGUE);
1006        assert_eq!(
1007            nr.get_optional_child(TAG_CREDENTIAL_ID)
1008                .and_then(|n| n.content_bytes()),
1009            Some(&b"cred-id"[..])
1010        );
1011        assert_eq!(
1012            nr.get_optional_child(TAG_PAIRING_HANDOFF_PROOF)
1013                .and_then(|n| n.content_bytes()),
1014            Some(&[0x42u8; 32][..])
1015        );
1016
1017        let fresh = build_prologue_node(b"c".to_vec(), b"a".to_vec(), b"p".to_vec(), None);
1018        assert!(
1019            fresh
1020                .as_node_ref()
1021                .get_optional_child(TAG_PAIRING_HANDOFF_PROOF)
1022                .is_none()
1023        );
1024    }
1025
1026    /// A fresh device (never linked) must NOT derive a handoff key, so
1027    /// skip_handoff_ux stays false and the verification-code check runs.
1028    #[tokio::test]
1029    async fn fresh_link_does_not_derive_a_handoff_key() {
1030        let client = create_test_client().await;
1031        drive_passkey_request(&client, "{}".to_string()).await;
1032        assert!(
1033            client.passkey_state.lock().await.handoff_key.is_none(),
1034            "a fresh link must leave handoff_key None (verification-code UX stays on)"
1035        );
1036    }
1037
1038    /// A re-link (a prior identity is present) derives the handoff key, which is
1039    /// what legitimately lets the server skip the verification-code UX.
1040    #[tokio::test]
1041    async fn relink_derives_a_handoff_key() {
1042        let client = create_test_client().await;
1043        let pn: Jid = "15551230000:1@s.whatsapp.net".parse().unwrap();
1044        client
1045            .persistence_manager
1046            .process_command(DeviceCommand::SetId(Some(pn)))
1047            .await;
1048        drive_passkey_request(&client, "{}".to_string()).await;
1049        assert!(
1050            client.passkey_state.lock().await.handoff_key.is_some(),
1051            "a re-link with a prior identity must derive the handoff key"
1052        );
1053    }
1054}