1use 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
31pub(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
47const CODE_GROUP_LEN: usize = 4;
49
50#[derive(Clone)]
53struct DeviceMaterial {
54 noise_public: [u8; 32],
55 identity_public: [u8; 32],
56 device_type: wa::device_props::PlatformType,
57}
58
59#[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
76struct 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 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 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 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 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 io.commit_adv_secret(self.new_adv_secret).await;
237 self.stage = Stage::Done;
238 Ok(())
239 }
240}
241
242#[derive(Default)]
244pub(crate) struct PasskeyFlowState {
245 handoff_key: Option<[u8; 32]>,
248 session: Option<ShortcakeSession>,
249 authenticator: Option<Arc<dyn PasskeyAuthenticator>>,
250}
251
252struct 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
270fn 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
281fn 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 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 pub async fn send_passkey_response(&self, assertion: Assertion) -> Result<(), PasskeyError> {
397 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 pub async fn send_passkey_confirmation(&self) -> Result<(), PasskeyError> {
432 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 self.passkey_state.lock().await.session = Some(session);
468 self.core
469 .event_bus
470 .dispatch(Event::PairPasskeyConfirmation(confirmation));
471
472 if skip && self.passkey_authenticator().await.is_some() {
475 self.send_passkey_confirmation().await?;
476 }
477 Ok(())
478 }
479}
480
481pub(crate) async fn handle_passkey_notification(client: &Arc<Client>, node: Arc<OwnedNodeRef>) {
484 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 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 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
571pub(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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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}