diff --git a/src/client.rs b/src/client.rs index a2d966f04..d3d85c850 100644 --- a/src/client.rs +++ b/src/client.rs @@ -560,6 +560,15 @@ pub struct Client { /// Tracks the pending pair code request and ephemeral keys. pub(crate) pair_code_state: Arc>, + /// SHORTCAKE_PASSKEY linking flow state: the pending handoff key, the + /// per-attempt ephemeral linking cache, and the optional host authenticator. + pub(crate) passkey_state: Arc>, + + /// Wait-free "an open is in flight" reservation for the passkey flow. Kept + /// outside `passkey_state` so it can be released synchronously on drop (a + /// cancelled open can't leave it stuck), unlike a flag behind the async lock. + pub(crate) passkey_opening: AtomicBool, + /// Custom handlers for encrypted message types. Set once at `Bot::build` and /// immutable afterward, so the receive hot path reads it with a plain /// `OnceLock::get` (no lock) and no per-node guard acquisition. diff --git a/src/client/lifecycle.rs b/src/client/lifecycle.rs index 0e161fd73..ed30d06f6 100644 --- a/src/client/lifecycle.rs +++ b/src/client/lifecycle.rs @@ -225,6 +225,8 @@ impl Client { major_sync_task_sender: tx, pairing_cancellation_tx: Arc::new(Mutex::new(None)), pair_code_state: Arc::new(Mutex::new(wacore::pair_code::PairCodeState::default())), + passkey_state: Arc::new(Mutex::new(crate::passkey::flow::PasskeyFlowState::default())), + passkey_opening: AtomicBool::new(false), custom_enc_handlers: std::sync::OnceLock::new(), inbound_durability_hook: std::sync::OnceLock::new(), chatstate_handlers: Arc::new(RwLock::new(Vec::new())), diff --git a/src/handlers/notification/mod.rs b/src/handlers/notification/mod.rs index e56cb0946..74a93938b 100644 --- a/src/handlers/notification/mod.rs +++ b/src/handlers/notification/mod.rs @@ -61,6 +61,12 @@ async fn handle_notification_impl(client: &Arc, node: Arc) "disappearing_mode" => handle_disappearing_mode_notification(client, nr), "newsletter" => handle_newsletter_notification(client, Arc::clone(&node)), "mex" => handle_mex_notification(client, nr), + crate::passkey::flow::NOTIF_PASSKEY_REQUEST => { + crate::passkey::flow::handle_passkey_notification(client, Arc::clone(&node)).await; + } + crate::passkey::flow::NOTIF_PASSKEY_CONTINUATION => { + crate::passkey::flow::handle_passkey_continuation(client, Arc::clone(&node)).await; + } "mediaretry" => { debug!( "Received mediaretry notification for msg {}", diff --git a/src/lib.rs b/src/lib.rs index eaab6d66a..2efb3f7df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,6 +63,7 @@ pub mod message; pub(crate) mod msg_secret_buffer; pub mod pair; pub mod pair_code; +pub mod passkey; pub mod request; pub use request::IqError; #[cfg(feature = "tokio-runtime")] diff --git a/src/pair.rs b/src/pair.rs index 996146312..8579bfa39 100644 --- a/src/pair.rs +++ b/src/pair.rs @@ -180,8 +180,7 @@ async fn handle_pair_success<'a>( } }; - let device_identity_node = success_node.get_optional_child_by_tag(&["device-identity"]); - let device_identity_bytes = match device_identity_node.and_then(|n| n.content_bytes()) { + let device_identity_bytes = match PairUtils::extract_device_identity_bytes(success_node) { Some(b) => b, None => { let error_node = PairUtils::build_pair_error_node(&req_id, 500, "internal-error"); diff --git a/src/passkey/flow.rs b/src/passkey/flow.rs new file mode 100644 index 000000000..e1788977b --- /dev/null +++ b/src/passkey/flow.rs @@ -0,0 +1,1017 @@ +//! Client-side SHORTCAKE_PASSKEY linking flow: the runtime glue that drives the +//! deterministic primitives in [`wacore::shortcake`] over real IQ exchanges. +//! +//! The handshake sits on top of the normal companion-linking connection: the +//! server requests a WebAuthn assertion, the companion answers with an ephemeral +//! identity prologue, both sides exchange nonces to derive a shared key, and the +//! companion finally sends its rotated ADV secret encrypted under that key. Linking +//! then completes through the ordinary `pair-success` path. +//! +//! The handshake state machine ([`ShortcakeSession`]) drives against a +//! [`ShortcakeIo`] seam rather than the concrete [`Client`], so the full IQ +//! sequence is unit-testable with a scripted stand-in. + +use crate::client::Client; +use crate::passkey::{Assertion, PasskeyAuthenticator, PasskeyError, parse_request_options}; +use crate::request::InfoQuery; +use crate::store::commands::DeviceCommand; +use crate::types::events::{Event, PairPasskeyConfirmation, PairPasskeyError, PairPasskeyRequest}; +use async_trait::async_trait; +use log::warn; +use rand::RngExt; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use wacore::libsignal::protocol::KeyPair; +use wacore::shortcake::ShortcakeUtils; +use wacore::sync_marker::MaybeSendSync; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::{Jid, Node, NodeContent, NodeRef, OwnedNodeRef, SERVER_JID, Server}; + +/// `` routing keys, consumed by the notification dispatcher. +pub(crate) const NOTIF_PASSKEY_REQUEST: &str = "passkey_prologue_request"; +pub(crate) const NOTIF_PASSKEY_CONTINUATION: &str = "crsc_continuation"; + +const MD_NAMESPACE: &str = "md"; +const TAG_REF: &str = "ref"; +const TAG_PASSKEY_REQUEST_OPTIONS: &str = "passkey_request_options"; +const TAG_PASSKEY_PROLOGUE: &str = "passkey_prologue"; +const TAG_CREDENTIAL_ID: &str = "credential_id"; +const TAG_WEBAUTHN_ASSERTION: &str = "webauthn_assertion"; +const TAG_PROLOGUE_PAYLOAD: &str = "prologue_payload"; +const TAG_PAIRING_HANDOFF_PROOF: &str = "pairing_handoff_proof"; +const TAG_PRIMARY_EPHEMERAL_IDENTITY: &str = "primary_ephemeral_identity"; +const TAG_COMPANION_NONCE: &str = "companion_nonce"; +const TAG_ENCRYPTED_PAIRING_REQUEST: &str = "encrypted_pairing_request"; + +/// Length of each half of the "XXXX-XXXX" verification code grouping. +const CODE_GROUP_LEN: usize = 4; + +/// The device material the handshake reads: the companion's static public keys +/// and the reported platform. +#[derive(Clone)] +struct DeviceMaterial { + noise_public: [u8; 32], + identity_public: [u8; 32], + device_type: i32, +} + +/// The effects the handshake needs from its environment. Abstracted so the full +/// IQ sequence can be driven by a scripted stand-in in tests. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +trait ShortcakeIo: MaybeSendSync { + async fn query(&self, query: InfoQuery<'static>) -> Result, PasskeyError>; + fn device_material(&self) -> Result; + async fn commit_adv_secret(&self, secret: [u8; 32]); +} + +#[derive(PartialEq, Eq)] +enum Stage { + AwaitingPrimaryIdentity, + AwaitingConfirmation, + Done, +} + +/// The in-flight handshake, created by [`ShortcakeSession::open`] and advanced by +/// the continuation + confirmation steps. Its rotated ADV secret is held here (not +/// in the device store) until [`confirm`](Self::confirm) commits it, so an +/// abandoned attempt never rotates to a secret the primary never received. +struct ShortcakeSession { + keypair: KeyPair, + companion_nonce: [u8; 32], + pairing_ref: String, + device_type: i32, + new_adv_secret: [u8; 32], + skip_handoff_ux: bool, + stage: Stage, + encryption_key: Option<[u8; 32]>, +} + +impl ShortcakeSession { + /// Fetch a fresh ref, build the ephemeral identity + commitment, attach the + /// handoff proof on a re-link, and send the `passkey_prologue` IQ. + async fn open( + io: &dyn ShortcakeIo, + assertion: Assertion, + handoff_key: Option<[u8; 32]>, + ) -> Result { + let device_type = io.device_material()?.device_type; + let pairing_ref = fetch_ref(io).await?; + + let keypair = ShortcakeUtils::generate_companion_ephemeral_keypair(); + let companion_nonce = ShortcakeUtils::generate_companion_nonce(); + let mut new_adv_secret = [0u8; 32]; + rand::make_rng::().fill(&mut new_adv_secret); + + let companion_pub: [u8; 32] = keypair + .public_key + .public_key_bytes() + .try_into() + .map_err(|_| PasskeyError::Flow("ephemeral public key is not 32 bytes".into()))?; + let identity = ShortcakeUtils::build_companion_ephemeral_identity( + &companion_pub, + device_type, + &pairing_ref, + ); + let commitment = ShortcakeUtils::commitment_hash(&identity, &companion_nonce); + let prologue_payload = ShortcakeUtils::build_prologue_payload(&identity, &commitment); + + let handoff_proof = handoff_key + .map(|key| ShortcakeUtils::compute_pairing_handoff_proof(&key, &prologue_payload)); + let skip_handoff_ux = handoff_proof.is_some(); + + let prologue = build_prologue_node( + assertion.credential_id, + assertion.assertion_json, + prologue_payload, + handoff_proof, + ); + io.query(InfoQuery::set( + MD_NAMESPACE, + server_jid(), + Some(NodeContent::Nodes(vec![prologue])), + )) + .await + .map_err(|e| PasskeyError::Flow(format!("passkey_prologue iq failed: {e}")))?; + + Ok(Self { + keypair, + companion_nonce, + pairing_ref, + device_type, + new_adv_secret, + skip_handoff_ux, + stage: Stage::AwaitingPrimaryIdentity, + encryption_key: None, + }) + } + + /// Agree on the shared secret, reveal the companion nonce, and derive the code + /// and encryption key. Returns the confirmation payload for the caller to + /// publish, so the caller can restore the session before a synchronous listener + /// that confirms observes it. + async fn on_primary_identity( + &mut self, + io: &dyn ShortcakeIo, + primary_bytes: &[u8], + ) -> Result { + if self.stage != Stage::AwaitingPrimaryIdentity { + return Err(PasskeyError::Flow( + "unexpected continuation for this stage".into(), + )); + } + let primary = ShortcakeUtils::parse_primary_ephemeral_identity(primary_bytes) + .map_err(|e| PasskeyError::Flow(format!("primary ephemeral identity: {e}")))?; + + let nonce_node = NodeBuilder::new(TAG_COMPANION_NONCE) + .bytes(self.companion_nonce.to_vec()) + .build(); + io.query(InfoQuery::set( + MD_NAMESPACE, + server_jid(), + Some(NodeContent::Nodes(vec![nonce_node])), + )) + .await + .map_err(|e| PasskeyError::Flow(format!("companion_nonce iq failed: {e}")))?; + + let encryption_key = ShortcakeUtils::derive_encryption_key( + &self.keypair, + &primary.public_key, + self.device_type, + &self.pairing_ref, + ) + .map_err(|e| PasskeyError::Flow(format!("encryption key: {e}")))?; + let bare = ShortcakeUtils::derive_verification_code( + &self.companion_nonce, + &primary.public_key, + &primary.nonce, + ); + // Grouped "XXXX-XXXX" for display (the code is ASCII). + let code = format!("{}-{}", &bare[..CODE_GROUP_LEN], &bare[CODE_GROUP_LEN..]); + + self.encryption_key = Some(encryption_key); + self.stage = Stage::AwaitingConfirmation; + Ok(PairPasskeyConfirmation { + code, + skip_handoff_ux: self.skip_handoff_ux, + }) + } + + /// Encrypt the `PairingRequest` (companion static keys + rotated ADV secret) + /// and send ``, then commit the rotation. + async fn confirm(&mut self, io: &dyn ShortcakeIo) -> Result<(), PasskeyError> { + if self.stage != Stage::AwaitingConfirmation { + return Err(PasskeyError::Flow( + "confirmation before the verification stage".into(), + )); + } + let encryption_key = self.encryption_key.ok_or_else(|| { + PasskeyError::Flow("confirmation before encryption key derived".into()) + })?; + let material = io.device_material()?; + + let plaintext = ShortcakeUtils::build_pairing_request( + &material.noise_public, + &material.identity_public, + &self.new_adv_secret, + ); + let encrypted = ShortcakeUtils::encrypt_pairing_request(&plaintext, &encryption_key) + .map_err(|e| PasskeyError::Flow(format!("encrypt pairing request: {e}")))?; + let wrapped = ShortcakeUtils::build_encrypted_pairing_request(&encrypted); + + let node = NodeBuilder::new(TAG_ENCRYPTED_PAIRING_REQUEST) + .bytes(wrapped) + .build(); + io.query(InfoQuery::set( + MD_NAMESPACE, + server_jid(), + Some(NodeContent::Nodes(vec![node])), + )) + .await + .map_err(|e| PasskeyError::Flow(format!("encrypted_pairing_request iq failed: {e}")))?; + + // Primary has the secret now: commit the rotation (before pair-success + // validates against it). + io.commit_adv_secret(self.new_adv_secret).await; + self.stage = Stage::Done; + Ok(()) + } +} + +/// SHORTCAKE_PASSKEY flow state held on the [`Client`]. +#[derive(Default)] +pub(crate) struct PasskeyFlowState { + /// HMAC key from the pre-rotation ADV secret; presence marks the re-link path + /// that lets the server skip the verification-code UX. Consumed once. + handoff_key: Option<[u8; 32]>, + session: Option, + authenticator: Option>, +} + +/// Holds the wait-free open reservation and releases it on drop. Because it clears +/// a plain [`AtomicBool`] (not a flag behind the async lock), the release is a sync, +/// always-succeeding store, so a `send_passkey_response` cancelled at any await +/// can't leave the reservation stuck. +struct OpeningGuard<'a> { + flag: &'a AtomicBool, +} + +impl Drop for OpeningGuard<'_> { + fn drop(&mut self) { + self.flag.store(false, Ordering::Release); + } +} + +fn server_jid() -> Jid { + Jid::new("", Server::Pn) +} + +/// Pull a child node's payload as bytes, accepting either binary or string content +/// (the server sends the options JSON as a text node). +fn child_payload(nr: &NodeRef<'_>, tag: &str) -> Option> { + let child = nr.get_optional_child(tag)?; + if let Some(b) = child.content_bytes() { + Some(b.to_vec()) + } else { + child.content_str().map(|s| s.as_bytes().to_vec()) + } +} + +/// Pure so the wire shape (child tags + conditional proof) is unit-testable. +fn build_prologue_node( + credential_id: Vec, + webauthn_assertion: Vec, + prologue_payload: Vec, + handoff_proof: Option<[u8; 32]>, +) -> Node { + let mut children = vec![ + NodeBuilder::new(TAG_CREDENTIAL_ID) + .bytes(credential_id) + .build(), + NodeBuilder::new(TAG_WEBAUTHN_ASSERTION) + .bytes(webauthn_assertion) + .build(), + NodeBuilder::new(TAG_PROLOGUE_PAYLOAD) + .bytes(prologue_payload) + .build(), + ]; + if let Some(proof) = handoff_proof { + children.push( + NodeBuilder::new(TAG_PAIRING_HANDOFF_PROOF) + .bytes(proof.to_vec()) + .build(), + ); + } + NodeBuilder::new(TAG_PASSKEY_PROLOGUE) + .children(children) + .build() +} + +async fn fetch_ref(io: &dyn ShortcakeIo) -> Result { + let resp = io + .query(InfoQuery::get( + MD_NAMESPACE, + server_jid(), + Some(NodeContent::Nodes(vec![NodeBuilder::new(TAG_REF).build()])), + )) + .await + .map_err(|e| PasskeyError::Flow(format!("ref iq failed: {e}")))?; + child_payload(resp.get(), TAG_REF) + .and_then(|b| String::from_utf8(b).ok()) + .ok_or_else(|| PasskeyError::Flow("missing ref in server response".into())) +} + +async fn fetch_request_options(io: &dyn ShortcakeIo) -> Result { + let resp = io + .query(InfoQuery::get( + MD_NAMESPACE, + server_jid(), + Some(NodeContent::Nodes(vec![ + NodeBuilder::new(TAG_PASSKEY_REQUEST_OPTIONS).build(), + ])), + )) + .await + .map_err(|e| PasskeyError::Flow(format!("passkey_request_options iq failed: {e}")))?; + child_payload(resp.get(), TAG_PASSKEY_REQUEST_OPTIONS) + .and_then(|b| String::from_utf8(b).ok()) + .ok_or_else(|| PasskeyError::Flow("missing passkey_request_options in response".into())) +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl ShortcakeIo for Client { + async fn query(&self, query: InfoQuery<'static>) -> Result, PasskeyError> { + self.send_iq(query) + .await + .map_err(|e| PasskeyError::Flow(e.to_string())) + } + + fn device_material(&self) -> Result { + let snapshot = self.persistence_manager.get_device_snapshot(); + let noise_public: [u8; 32] = snapshot + .noise_key + .public_key + .public_key_bytes() + .try_into() + .map_err(|_| PasskeyError::Flow("noise public key is not 32 bytes".into()))?; + let identity_public: [u8; 32] = snapshot + .identity_key + .public_key + .public_key_bytes() + .try_into() + .map_err(|_| PasskeyError::Flow("identity public key is not 32 bytes".into()))?; + Ok(DeviceMaterial { + noise_public, + identity_public, + device_type: snapshot.device_props.platform_type.unwrap_or(0), + }) + } + + async fn commit_adv_secret(&self, secret: [u8; 32]) { + self.persistence_manager + .process_command(DeviceCommand::SetAdvSecretKey(secret)) + .await; + } +} + +impl Client { + /// Register a passkey authenticator. When set, the client auto-drives the + /// assertion step and auto-confirms a re-link (where the handoff proof skips the + /// verification-code UX). Leave it unset to drive the steps manually via the + /// `Event::PairPasskey*` events. + pub async fn set_passkey_authenticator(&self, authenticator: Arc) { + self.passkey_state.lock().await.authenticator = Some(authenticator); + } + + async fn passkey_authenticator(&self) -> Option> { + self.passkey_state.lock().await.authenticator.clone() + } + + /// Send the WebAuthn assertion as `` and open the handshake. + /// Call after an [`Event::PairPasskeyRequest`]. + pub async fn send_passkey_response(&self, assertion: Assertion) -> Result<(), PasskeyError> { + // Reserve the single open slot BEFORE the awaits, so a concurrent response + // can't open a second overlapping handshake and clobber this one's nonce/ref + // (which would then commit the wrong ADV rotation). The guard releases the + // reservation on every exit, including cancellation mid-open. + if self + .passkey_opening + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(PasskeyError::Flow( + "a passkey open is already in progress".into(), + )); + } + let _guard = OpeningGuard { + flag: &self.passkey_opening, + }; + + let handoff_key = { + let mut state = self.passkey_state.lock().await; + if state.session.is_some() { + return Err(PasskeyError::Flow( + "a passkey link is already in progress".into(), + )); + } + state.handoff_key.take() + }; + + let session = ShortcakeSession::open(self, assertion, handoff_key).await?; + self.passkey_state.lock().await.session = Some(session); + Ok(()) + } + + /// Finish the link. For a fresh link, call this only after the user confirms the + /// [`Event::PairPasskeyConfirmation`] code. + pub async fn send_passkey_confirmation(&self) -> Result<(), PasskeyError> { + // Only consume the session once it's actually at the confirmation stage — a + // premature call must NOT drop the in-flight attempt. + let mut session = { + let mut state = self.passkey_state.lock().await; + match state.session.take() { + Some(s) if s.stage == Stage::AwaitingConfirmation => s, + Some(s) => { + state.session = Some(s); + return Err(PasskeyError::Flow( + "confirmation before the verification stage".into(), + )); + } + None => { + return Err(PasskeyError::Flow( + "confirmation without an active session".into(), + )); + } + } + }; + session.confirm(self).await + } + + async fn drive_continuation(&self, primary_bytes: Vec) -> Result<(), PasskeyError> { + let mut session = self + .passkey_state + .lock() + .await + .session + .take() + .ok_or_else(|| PasskeyError::Flow("continuation without an active session".into()))?; + let confirmation = session.on_primary_identity(self, &primary_bytes).await?; + let skip = confirmation.skip_handoff_ux; + + // Restore the session BEFORE publishing the event, so a synchronous listener + // that confirms from it sees an active session. + self.passkey_state.lock().await.session = Some(session); + self.core + .event_bus + .dispatch(Event::PairPasskeyConfirmation(confirmation)); + + // Re-link: continuity is already proven, so finish without a user code when + // an authenticator is driving. + if skip && self.passkey_authenticator().await.is_some() { + self.send_passkey_confirmation().await?; + } + Ok(()) + } +} + +/// Handle a `passkey_prologue_request` notification: emit the request (and +/// auto-drive it if an authenticator is registered). +pub(crate) async fn handle_passkey_notification(client: &Arc, node: Arc) { + // The staged rotation is security-sensitive: only honor a server request. + if node + .get() + .get_attr("from") + .is_none_or(|v| v.as_str() != SERVER_JID) + { + warn!("ignoring passkey notification from a non-server JID"); + return; + } + + match child_payload(node.get(), TAG_PASSKEY_REQUEST_OPTIONS) + .and_then(|b| String::from_utf8(b).ok()) + { + Some(json) => drive_passkey_request(client, json).await, + // Options omitted: fetch them via IQ. Spawned because it awaits a round-trip. + None => { + let client = client.clone(); + client + .clone() + .runtime + .spawn(Box::pin(async move { + match fetch_request_options(client.as_ref()).await { + Ok(json) => drive_passkey_request(&client, json).await, + Err(e) => { + warn!("failed to fetch passkey request options: {e}"); + client.core.event_bus.dispatch(Event::PairPasskeyError( + PairPasskeyError { + error: e.to_string(), + continuation: false, + }, + )); + } + } + })) + .detach(); + } + } +} + +async fn drive_passkey_request(client: &Arc, options_json: String) { + // Handoff key from the current secret proves continuity to the server; presence + // of a key marks this as a re-link. The rotated secret itself is generated + // per-attempt in the session. + let adv_secret = client + .persistence_manager + .get_device_snapshot() + .adv_secret_key; + let handoff_key = ShortcakeUtils::derive_pairing_handoff_hmac_key(&adv_secret) + .inspect_err(|e| warn!("failed to derive pairing-handoff key: {e}")) + .ok(); + client.passkey_state.lock().await.handoff_key = handoff_key; + + client + .core + .event_bus + .dispatch(Event::PairPasskeyRequest(PairPasskeyRequest { + request_options_json: options_json.clone(), + })); + + if let Some(authenticator) = client.passkey_authenticator().await { + let client = client.clone(); + client + .clone() + .runtime + .spawn(Box::pin(async move { + if let Err(e) = auto_drive_response(&client, authenticator, &options_json).await { + warn!("passkey auto-drive failed: {e}"); + client + .core + .event_bus + .dispatch(Event::PairPasskeyError(PairPasskeyError { + error: e.to_string(), + continuation: false, + })); + } + })) + .detach(); + } +} + +async fn auto_drive_response( + client: &Arc, + authenticator: Arc, + options_json: &str, +) -> Result<(), PasskeyError> { + let request = parse_request_options(options_json)?; + let assertion = authenticator.get_assertion(&request).await?; + client.send_passkey_response(assertion).await +} + +/// Handle a `crsc_continuation` notification. Spawned: it awaits an IQ round-trip +/// and must not block the receive loop. +pub(crate) async fn handle_passkey_continuation(client: &Arc, node: Arc) { + if node + .get() + .get_attr("from") + .is_none_or(|v| v.as_str() != SERVER_JID) + { + warn!("ignoring passkey continuation from a non-server JID"); + return; + } + + let primary_bytes = match child_payload(node.get(), TAG_PRIMARY_EPHEMERAL_IDENTITY) { + Some(bytes) => bytes, + None => { + warn!("passkey continuation missing primary_ephemeral_identity"); + client + .core + .event_bus + .dispatch(Event::PairPasskeyError(PairPasskeyError { + error: "missing primary_ephemeral_identity".into(), + continuation: true, + })); + return; + } + }; + + let client = client.clone(); + client + .clone() + .runtime + .spawn(Box::pin(async move { + if let Err(e) = client.drive_continuation(primary_bytes).await { + warn!("passkey continuation failed: {e}"); + client + .core + .event_bus + .dispatch(Event::PairPasskeyError(PairPasskeyError { + error: e.to_string(), + continuation: true, + })); + } + })) + .detach(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{TestEventCollector, create_test_client, node_to_owned_ref}; + use crate::types::events::EventHandler; + use prost::Message as _; + use std::sync::Mutex; + use std::time::Duration; + use wacore::libsignal::protocol::PublicKey; + use waproto::whatsapp as wa; + + fn server_notification(notif_type: &'static str, child: Option) -> Arc { + let mut builder = NodeBuilder::new("notification") + .attr("type", notif_type) + .attr("from", SERVER_JID); + if let Some(child) = child { + builder = builder.children([child]); + } + node_to_owned_ref(&builder.build()) + } + + async fn wait_for(collector: &Arc, pred: impl Fn(&Event) -> bool) { + for _ in 0..200 { + if collector.events().iter().any(|e| pred(e.as_ref())) { + return; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("expected event was not observed within the timeout"); + } + + // Scripted IQ stand-in: answers each `md` IQ by its child tag and records the + // child that was sent, plus the committed secret. + struct MockIo { + device: DeviceMaterial, + pairing_ref: String, + options_json: String, + sent: Mutex>, + committed: Mutex>, + } + + impl MockIo { + fn sent_tags(&self) -> Vec { + self.sent + .lock() + .unwrap() + .iter() + .map(|n| n.tag.to_string()) + .collect() + } + + fn sent_node(&self, tag: &str) -> Node { + self.sent + .lock() + .unwrap() + .iter() + .find(|n| n.tag == tag) + .cloned() + .unwrap_or_else(|| panic!("expected a {tag} IQ to have been sent")) + } + } + + #[async_trait] + impl ShortcakeIo for MockIo { + async fn query( + &self, + query: InfoQuery<'static>, + ) -> Result, PasskeyError> { + let child = match &query.content { + Some(NodeContent::Nodes(nodes)) => nodes.first().cloned(), + _ => None, + }; + let child = child.expect("md IQ must carry a child node"); + let tag = child.tag.to_string(); + self.sent.lock().unwrap().push(child); + + let response = if tag == TAG_REF { + NodeBuilder::new("iq") + .children([NodeBuilder::new(TAG_REF) + .bytes(self.pairing_ref.as_bytes().to_vec()) + .build()]) + .build() + } else if tag == TAG_PASSKEY_REQUEST_OPTIONS { + NodeBuilder::new("iq") + .children([NodeBuilder::new(TAG_PASSKEY_REQUEST_OPTIONS) + .bytes(self.options_json.as_bytes().to_vec()) + .build()]) + .build() + } else { + NodeBuilder::new("iq").build() + }; + Ok(node_to_owned_ref(&response)) + } + + fn device_material(&self) -> Result { + Ok(self.device.clone()) + } + + async fn commit_adv_secret(&self, secret: [u8; 32]) { + *self.committed.lock().unwrap() = Some(secret); + } + } + + fn child_bytes(node: &Node, tag: &str) -> Vec { + child_payload(&node.as_node_ref(), tag).unwrap_or_else(|| panic!("missing {tag} child")) + } + + #[tokio::test] + async fn full_handshake_drives_the_iq_sequence_and_delivers_the_committed_secret() { + // Companion static keys (arbitrary but distinct) + a primary playing the peer. + let device = DeviceMaterial { + noise_public: [0x11; 32], + identity_public: [0x12; 32], + device_type: 1, + }; + let io = MockIo { + device: device.clone(), + pairing_ref: "REF-XYZ".to_string(), + options_json: "{}".to_string(), + sent: Mutex::new(Vec::new()), + committed: Mutex::new(None), + }; + + // A re-link: pass a handoff key so the prologue carries the proof. + let handoff_key = [0x55u8; 32]; + let assertion = Assertion { + assertion_json: br#"{"type":"public-key"}"#.to_vec(), + credential_id: b"cred-id".to_vec(), + }; + + let mut session = ShortcakeSession::open(&io, assertion, Some(handoff_key)) + .await + .unwrap(); + + // step 1-2: ref fetched, then a prologue with the handoff proof. + assert_eq!(io.sent_tags(), vec![TAG_REF, TAG_PASSKEY_PROLOGUE]); + let prologue = io.sent_node(TAG_PASSKEY_PROLOGUE); + assert_eq!(child_bytes(&prologue, TAG_CREDENTIAL_ID), b"cred-id"); + assert!( + prologue + .as_node_ref() + .get_optional_child(TAG_PAIRING_HANDOFF_PROOF) + .is_some() + ); + + // primary's ephemeral identity + let primary_kp = KeyPair::generate(&mut rand::make_rng::()); + let primary_pub: [u8; 32] = primary_kp.public_key.public_key_bytes().try_into().unwrap(); + let primary_nonce = [0x77u8; 32]; + let primary_bytes = wa::PrimaryEphemeralIdentity { + public_key: Some(primary_pub.to_vec()), + nonce: Some(primary_nonce.to_vec()), + } + .encode_to_vec(); + + // step 3: continuation sends the companion nonce and yields the code. + let confirmation = session + .on_primary_identity(&io, &primary_bytes) + .await + .unwrap(); + assert!( + confirmation.skip_handoff_ux, + "re-link with a handoff proof skips the code UX" + ); + assert_eq!(confirmation.code.len(), 9, "code is grouped XXXX-XXXX"); + assert_eq!( + io.sent_tags().last().map(String::as_str), + Some(TAG_COMPANION_NONCE) + ); + + // step 4: confirm seals + sends the pairing request and commits the secret. + session.confirm(&io).await.unwrap(); + assert_eq!( + io.sent_tags().last().map(String::as_str), + Some(TAG_ENCRYPTED_PAIRING_REQUEST) + ); + let committed = io + .committed + .lock() + .unwrap() + .expect("secret must be committed"); + + // The primary decrypts the pairing request and reads the SAME secret that + // was committed — proving the deferred rotation delivers what it persists. + let prologue_payload = child_bytes(&prologue, TAG_PROLOGUE_PAYLOAD); + let companion_eph_pub = wa::CompanionEphemeralIdentity::decode( + wa::ProloguePayload::decode(prologue_payload.as_slice()) + .unwrap() + .companion_ephemeral_identity + .unwrap() + .as_slice(), + ) + .unwrap() + .public_key + .unwrap(); + let shared = primary_kp + .private_key + .calculate_agreement(&PublicKey::from_djb_public_key_bytes(&companion_eph_pub).unwrap()) + .unwrap(); + let key = ShortcakeUtils::derive_encryption_key_from_shared_secret(&shared, 1, "REF-XYZ") + .unwrap(); + let wrapped = match io.sent_node(TAG_ENCRYPTED_PAIRING_REQUEST).content { + Some(NodeContent::Bytes(bytes)) => bytes, + _ => panic!("encrypted_pairing_request must carry bytes"), + }; + let epr = wa::EncryptedPairingRequest::decode(wrapped.as_slice()).unwrap(); + let iv: [u8; 12] = epr.iv.unwrap().as_slice().try_into().unwrap(); + let mut plaintext = Vec::new(); + wacore::libsignal::crypto::aes_256_gcm_decrypt( + &key, + &iv, + b"", + &epr.encrypted_payload.unwrap(), + &mut plaintext, + ) + .unwrap(); + let pr = wa::PairingRequest::decode(plaintext.as_slice()).unwrap(); + assert_eq!(pr.adv_secret.as_deref(), Some(&committed[..])); + assert_eq!( + pr.companion_public_key.as_deref(), + Some(&device.noise_public[..]) + ); + } + + #[tokio::test] + async fn premature_confirmation_keeps_the_session() { + let client = create_test_client().await; + // A session that hasn't reached the confirmation stage yet. + client.passkey_state.lock().await.session = Some(ShortcakeSession { + keypair: KeyPair::generate(&mut rand::make_rng::()), + companion_nonce: [0; 32], + pairing_ref: "r".into(), + device_type: 1, + new_adv_secret: [1; 32], + skip_handoff_ux: false, + stage: Stage::AwaitingPrimaryIdentity, + encryption_key: None, + }); + + assert!( + client.send_passkey_confirmation().await.is_err(), + "confirming before the verification stage errors" + ); + assert!( + client.passkey_state.lock().await.session.is_some(), + "a premature confirmation must not drop the in-flight attempt" + ); + } + + #[tokio::test] + async fn cancelled_open_releases_the_reservation() { + let client = create_test_client().await; + client.passkey_opening.store(true, Ordering::Release); + // A dropped guard stands in for a send_passkey_response cancelled mid-open; + // the release is a sync store, so it holds even under lock contention. + drop(OpeningGuard { + flag: &client.passkey_opening, + }); + assert!( + !client.passkey_opening.load(Ordering::Acquire), + "a cancelled open must release the reservation" + ); + } + + #[tokio::test] + async fn passkey_prologue_request_emits_event_without_committing_rotation() { + let client = create_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.register_handler(collector.clone() as Arc); + + let before = client + .persistence_manager + .get_device_snapshot() + .adv_secret_key; + + let options = r#"{"challenge":"YWJjZGVm","rpId":"web.whatsapp.com"}"#; + let child = NodeBuilder::new(TAG_PASSKEY_REQUEST_OPTIONS) + .bytes(options.as_bytes().to_vec()) + .build(); + client + .process_node(server_notification(NOTIF_PASSKEY_REQUEST, Some(child))) + .await; + + // The rotation is deferred to confirmation, so the stored secret is unchanged. + let after = client + .persistence_manager + .get_device_snapshot() + .adv_secret_key; + assert_eq!(before, after, "ADV secret must not commit at request time"); + + let request = collector + .events() + .into_iter() + .find_map(|e| match e.as_ref() { + Event::PairPasskeyRequest(r) => Some(r.clone()), + _ => None, + }) + .expect("a PairPasskeyRequest event must be dispatched"); + assert_eq!(request.request_options_json, options); + } + + #[tokio::test] + async fn passkey_prologue_request_from_non_server_is_ignored() { + let client = create_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.register_handler(collector.clone() as Arc); + + let child = NodeBuilder::new(TAG_PASSKEY_REQUEST_OPTIONS) + .bytes(b"{}".to_vec()) + .build(); + let node = NodeBuilder::new("notification") + .attr("type", NOTIF_PASSKEY_REQUEST) + .attr("from", "12345@s.whatsapp.net") + .children([child]) + .build(); + client.process_node(node_to_owned_ref(&node)).await; + + assert!( + !collector + .events() + .iter() + .any(|e| matches!(e.as_ref(), Event::PairPasskeyRequest(_))), + "no event for a non-server request" + ); + } + + #[tokio::test] + async fn passkey_prologue_request_without_inline_options_falls_back_to_fetch() { + let client = create_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.register_handler(collector.clone() as Arc); + + // No inline options: the handler falls back to an IQ fetch. The test client + // isn't connected, so the fetch fails and surfaces a non-continuation error. + client + .process_node(server_notification(NOTIF_PASSKEY_REQUEST, None)) + .await; + + wait_for(&collector, |e| { + matches!( + e, + Event::PairPasskeyError(err) + if !err.continuation && err.error.contains("passkey_request_options iq failed") + ) + }) + .await; + } + + #[tokio::test] + async fn passkey_continuation_without_session_emits_error() { + let client = create_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.register_handler(collector.clone() as Arc); + + let primary = wa::PrimaryEphemeralIdentity { + public_key: Some(vec![0xAB; 32]), + nonce: Some(vec![0xCD; 32]), + }; + let child = NodeBuilder::new(TAG_PRIMARY_EPHEMERAL_IDENTITY) + .bytes(prost::Message::encode_to_vec(&primary)) + .build(); + client + .process_node(server_notification(NOTIF_PASSKEY_CONTINUATION, Some(child))) + .await; + + wait_for( + &collector, + |e| matches!(e, Event::PairPasskeyError(err) if err.continuation), + ) + .await; + } + + #[test] + fn prologue_node_wire_shape() { + let node = build_prologue_node( + b"cred-id".to_vec(), + b"{\"type\":\"public-key\"}".to_vec(), + b"prologue-proto".to_vec(), + Some([0x42; 32]), + ); + let nr = node.as_node_ref(); + assert_eq!(nr.tag.as_ref(), TAG_PASSKEY_PROLOGUE); + assert_eq!( + nr.get_optional_child(TAG_CREDENTIAL_ID) + .and_then(|n| n.content_bytes()), + Some(&b"cred-id"[..]) + ); + assert_eq!( + nr.get_optional_child(TAG_PAIRING_HANDOFF_PROOF) + .and_then(|n| n.content_bytes()), + Some(&[0x42u8; 32][..]) + ); + + let fresh = build_prologue_node(b"c".to_vec(), b"a".to_vec(), b"p".to_vec(), None); + assert!( + fresh + .as_node_ref() + .get_optional_child(TAG_PAIRING_HANDOFF_PROOF) + .is_none() + ); + } +} diff --git a/src/passkey/mod.rs b/src/passkey/mod.rs new file mode 100644 index 000000000..f76c481d8 --- /dev/null +++ b/src/passkey/mod.rs @@ -0,0 +1,406 @@ +//! `PasskeyAuthenticator` — the single pluggable point of the SHORTCAKE_PASSKEY +//! login flow (see `wacore::shortcake` for the deterministic protocol core). +//! +//! WhatsApp's passkey linking gate requires ONE thing an unofficial client cannot +//! reproduce on its own: a WebAuthn assertion (`navigator.credentials.get`) signed +//! by a passkey ALREADY REGISTERED to the account. Everything else in the protocol +//! is deterministic and lives in `wacore::shortcake`. This module abstracts that +//! single step so the rest of the linking flow is platform-agnostic. +//! +//! ## Why a real authenticator (not a software forgery) +//! The assertion's private key lives in a platform authenticator (Google Password +//! Manager / iCloud Keychain), non-extractable. So we do what WhatsApp Web does: +//! delegate to a real authenticator. The assertion signs only the SERVER +//! challenge and origin/rpId (NOT the Shortcake payload), so producing it is a +//! standard WebAuthn `get`. Using the real authenticator = a legitimate, +//! user-verified assertion = low ban risk (forging without the key is +//! impossible anyway). +//! +//! ## Strategies +//! - **Android Credential Manager (recommended for GPM passkeys):** the Android +//! host app calls `CredentialManager.getCredential(...)` with the server's +//! `raw_options_json` (a `GetCredentialRequest` containing a +//! `GetPublicKeyCredentialOption(requestJson = raw_options_json)`); GPM signs +//! with biometric; the app maps the returned +//! `PublicKeyCredential.authenticationResponseJson` into an [`Assertion`] and +//! returns it via a [`CallbackAuthenticator`]. No private key ever touches Rust. +//! - **hybrid/caBLE:** a desktop client tunnels CTAP2 to the phone's authenticator. +//! - **software (`passkey-rs`):** only when the passkey lives in an exportable vault. +//! +//! All three implement [`PasskeyAuthenticator`]; the default build ships only the +//! generic [`CallbackAuthenticator`] (host provides the assertion) so no platform +//! dependency leaks into headless/library builds. + +pub mod flow; + +use async_trait::async_trait; +use base64::prelude::*; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +/// WebAuthn user-verification requirement from the server's request options. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserVerification { + Required, + Preferred, + Discouraged, +} + +impl UserVerification { + /// Fail closed: a present-but-unrecognized value is rejected rather than + /// silently downgraded to `Preferred` (absence is handled by the caller). + fn parse(s: &str) -> Result { + match s { + "required" => Ok(Self::Required), + "preferred" => Ok(Self::Preferred), + "discouraged" => Ok(Self::Discouraged), + other => Err(PasskeyError::InvalidOptions(format!( + "unsupported userVerification: {other}" + ))), + } + } +} + +/// A WebAuthn assertion request, parsed from the server's +/// `` (a standard `PublicKeyCredentialRequestOptions` +/// JSON). `challenge` and `allow_credentials` are already base64url-decoded. +#[derive(Debug, Clone)] +pub struct AssertionRequest { + /// Server challenge (raw bytes). + pub challenge: Vec, + /// Relying-party id (e.g. "web.whatsapp.com") the authenticator must sign for. + pub rp_id: Option, + /// Allowed credential ids (raw bytes); empty = discoverable. + pub allow_credentials: Vec>, + pub user_verification: UserVerification, + pub timeout_ms: Option, + /// The verbatim server JSON — pass straight to Android Credential Manager's + /// `GetPublicKeyCredentialOption(requestJson = ...)` (it wants the original). + pub raw_options_json: String, +} + +/// The result of a WebAuthn assertion, packaged for the `` IQ. +#[derive(Debug, Clone)] +pub struct Assertion { + /// UTF-8 JSON for ``: + /// `{id, rawId(b64url), type:"public-key", response:{clientDataJSON, authenticatorData, signature, userHandle}}`. + pub assertion_json: Vec, + /// Raw credential rawId bytes for ``. + pub credential_id: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum PasskeyError { + #[error("no passkey registered for this account on the authenticator")] + NoCredential, + #[error("user cancelled or the ceremony timed out")] + Cancelled, + #[error("invalid request options: {0}")] + InvalidOptions(String), + #[error("authenticator backend error: {0}")] + Backend(String), + #[error("passkey linking flow error: {0}")] + Flow(String), +} + +/// Produces a WebAuthn assertion for a SHORTCAKE_PASSKEY link. Implemented by a +/// real authenticator (Android Credential Manager / hybrid / software vault). +/// +/// The `MaybeSendSync` supertrait keeps this `Send + Sync` on native (the client +/// stores it as `Arc` and drives it across threads) but +/// drops the bound on wasm32, where a browser authenticator may hold `!Send` JS +/// handles, matching the sibling extension points (`Transport`, `EventHandler`). +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait PasskeyAuthenticator: wacore::sync_marker::MaybeSendSync { + async fn get_assertion(&self, request: &AssertionRequest) -> Result; +} + +// Mirror the trait's `async_trait(?Send)` on wasm: a browser authenticator's +// future (e.g. awaiting `navigator.credentials.get`) is `!Send`. `cb`/`new` +// reference this alias, so they pick up the right bound per-target automatically. +#[cfg(not(target_arch = "wasm32"))] +type AssertionFuture = Pin> + Send>>; +#[cfg(target_arch = "wasm32")] +type AssertionFuture = Pin>>>; + +// The stored closure: `Send + Sync` on native, relaxed on wasm to mirror +// `AssertionFuture` (a browser closure may capture `!Send` JS handles). +#[cfg(not(target_arch = "wasm32"))] +type AssertionCallback = dyn Fn(AssertionRequest) -> AssertionFuture + Send + Sync; +#[cfg(target_arch = "wasm32")] +type AssertionCallback = dyn Fn(AssertionRequest) -> AssertionFuture; + +/// Generic [`PasskeyAuthenticator`] that defers to a host-provided async closure. +/// +/// This is the integration seam for the Android Credential Manager strategy: the +/// Kotlin/JNI layer performs `CredentialManager.getCredential(...)` and resolves +/// the future with the mapped [`Assertion`]. Keeps all platform code out of the lib. +#[derive(Clone)] +pub struct CallbackAuthenticator { + cb: Arc, +} + +impl CallbackAuthenticator { + pub fn new(f: F) -> Self + where + F: Fn(AssertionRequest) -> AssertionFuture + wacore::sync_marker::MaybeSendSync + 'static, + { + Self { cb: Arc::new(f) } + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl PasskeyAuthenticator for CallbackAuthenticator { + async fn get_assertion(&self, request: &AssertionRequest) -> Result { + (self.cb)(request.clone()).await + } +} + +/// Parse the server's `PublicKeyCredentialRequestOptions` JSON into an +/// [`AssertionRequest`], base64url-decoding `challenge` and `allowCredentials[].id`. +pub fn parse_request_options(json: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(json).map_err(|e| PasskeyError::InvalidOptions(e.to_string()))?; + + let challenge_b64 = v + .get("challenge") + .and_then(|c| c.as_str()) + .ok_or_else(|| PasskeyError::InvalidOptions("missing challenge".into()))?; + let challenge = BASE64_URL_SAFE_NO_PAD + .decode(challenge_b64.trim_end_matches('=')) + .map_err(|e| PasskeyError::InvalidOptions(format!("challenge b64url: {e}")))?; + if challenge.is_empty() { + return Err(PasskeyError::InvalidOptions("empty challenge".into())); + } + + // Absent rpId is fine (the authenticator defaults it); a present-but-non-string + // value is malformed and must fail closed, not silently drop the RP binding. + let rp_id = match v.get("rpId") { + None => None, + Some(r) => Some( + r.as_str() + .ok_or_else(|| PasskeyError::InvalidOptions("rpId must be a string".into()))? + .to_string(), + ), + }; + + // Reject malformed descriptors instead of dropping them: silently skipping + // entries can collapse a populated allowCredentials into an empty list, which + // this API treats as "discoverable" — a confusing, weaker outcome than failing. + let mut allow_credentials = Vec::new(); + if let Some(allow_credentials_value) = v.get("allowCredentials") { + let arr = allow_credentials_value.as_array().ok_or_else(|| { + PasskeyError::InvalidOptions("allowCredentials must be an array".into()) + })?; + for cred in arr { + let id = cred.get("id").and_then(|i| i.as_str()).ok_or_else(|| { + PasskeyError::InvalidOptions("allowCredentials[].id must be a string".into()) + })?; + let bytes = BASE64_URL_SAFE_NO_PAD + .decode(id.trim_end_matches('=')) + .map_err(|e| PasskeyError::InvalidOptions(format!("credential id b64url: {e}")))?; + if bytes.is_empty() { + return Err(PasskeyError::InvalidOptions( + "allowCredentials[].id is empty".into(), + )); + } + allow_credentials.push(bytes); + } + } + + let user_verification = match v.get("userVerification") { + None => UserVerification::Preferred, + Some(u) => UserVerification::parse(u.as_str().ok_or_else(|| { + PasskeyError::InvalidOptions("userVerification must be a string".into()) + })?)?, + }; + + let timeout_ms = v.get("timeout").and_then(|t| t.as_u64()); + + Ok(AssertionRequest { + challenge, + rp_id, + allow_credentials, + user_verification, + timeout_ms, + raw_options_json: json.to_string(), + }) +} + +/// Assemble the `` JSON (WhatsApp Web's exact shape) from raw +/// WebAuthn assertion components. For authenticator backends that return raw bytes +/// rather than WA-shaped JSON (e.g. a software/hybrid authenticator). `user_handle` +/// is optional. All binary fields are base64url-encoded (no padding). +pub fn build_webauthn_assertion_json( + credential_id: &[u8], + client_data_json: &[u8], + authenticator_data: &[u8], + signature: &[u8], + user_handle: Option<&[u8]>, +) -> Vec { + let id = BASE64_URL_SAFE_NO_PAD.encode(credential_id); + let assertion = serde_json::json!({ + "id": id, + "rawId": id, + "type": "public-key", + "response": { + "clientDataJSON": BASE64_URL_SAFE_NO_PAD.encode(client_data_json), + "authenticatorData": BASE64_URL_SAFE_NO_PAD.encode(authenticator_data), + "signature": BASE64_URL_SAFE_NO_PAD.encode(signature), + "userHandle": user_handle.map(|u| BASE64_URL_SAFE_NO_PAD.encode(u)), + } + }); + assertion.to_string().into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_request_options() { + let challenge = b"the-challenge-bytes!"; + let cred = b"credential-id-1"; + let json = serde_json::json!({ + "challenge": BASE64_URL_SAFE_NO_PAD.encode(challenge), + "rpId": "web.whatsapp.com", + "userVerification": "required", + "timeout": 60000u64, + "allowCredentials": [ + {"type": "public-key", "id": BASE64_URL_SAFE_NO_PAD.encode(cred)} + ] + }) + .to_string(); + + let req = parse_request_options(&json).unwrap(); + assert_eq!(req.challenge, challenge); + assert_eq!(req.rp_id.as_deref(), Some("web.whatsapp.com")); + assert_eq!(req.user_verification, UserVerification::Required); + assert_eq!(req.timeout_ms, Some(60000)); + assert_eq!(req.allow_credentials, vec![cred.to_vec()]); + assert_eq!(req.raw_options_json, json); // verbatim for Credential Manager + } + + #[test] + fn missing_challenge_is_error() { + assert!(parse_request_options("{\"rpId\":\"x\"}").is_err()); + } + + #[test] + fn unknown_user_verification_fails_closed() { + let json = serde_json::json!({ + "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"), + "userVerification": "sometimes", + }) + .to_string(); + assert!(matches!( + parse_request_options(&json), + Err(PasskeyError::InvalidOptions(_)) + )); + } + + #[test] + fn absent_user_verification_defaults_to_preferred() { + let json = + serde_json::json!({ "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c") }).to_string(); + let req = parse_request_options(&json).unwrap(); + assert_eq!(req.user_verification, UserVerification::Preferred); + } + + #[test] + fn malformed_allow_credentials_is_rejected() { + // non-array + let json = serde_json::json!({ + "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"), + "allowCredentials": "nope", + }) + .to_string(); + assert!(parse_request_options(&json).is_err()); + + // entry without a string id must error, not be silently dropped + let json = serde_json::json!({ + "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"), + "allowCredentials": [{"type": "public-key"}], + }) + .to_string(); + assert!(parse_request_options(&json).is_err()); + + // present-but-empty id (all padding / empty string) decodes to zero bytes, + // which is never a real credential id, so it must error, not push an empty entry. + let json = serde_json::json!({ + "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"), + "allowCredentials": [{"type": "public-key", "id": ""}], + }) + .to_string(); + assert!(parse_request_options(&json).is_err()); + } + + #[test] + fn empty_challenge_is_rejected() { + // a present-but-empty challenge provides zero replay protection; reject it + // rather than handing a degenerate request to the authenticator. + let json = serde_json::json!({ "challenge": "" }).to_string(); + assert!(matches!( + parse_request_options(&json), + Err(PasskeyError::InvalidOptions(_)) + )); + } + + #[test] + fn non_string_rp_id_is_rejected() { + // present-but-malformed rpId must fail closed, not silently drop the RP. + let json = serde_json::json!({ + "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"), + "rpId": 123, + }) + .to_string(); + assert!(matches!( + parse_request_options(&json), + Err(PasskeyError::InvalidOptions(_)) + )); + } + + #[test] + fn builds_wa_assertion_json_shape() { + let bytes = build_webauthn_assertion_json(b"cid", b"cdj", b"authdata", b"sig", None); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["type"], "public-key"); + assert_eq!(v["id"], BASE64_URL_SAFE_NO_PAD.encode(b"cid")); + assert_eq!(v["rawId"], BASE64_URL_SAFE_NO_PAD.encode(b"cid")); + assert_eq!( + v["response"]["clientDataJSON"], + BASE64_URL_SAFE_NO_PAD.encode(b"cdj") + ); + assert_eq!( + v["response"]["signature"], + BASE64_URL_SAFE_NO_PAD.encode(b"sig") + ); + assert!(v["response"]["userHandle"].is_null()); + } + + #[tokio::test] + async fn callback_authenticator_invokes_closure() { + let auth = CallbackAuthenticator::new(|req: AssertionRequest| { + Box::pin(async move { + Ok(Assertion { + assertion_json: req.raw_options_json.into_bytes(), + credential_id: req.challenge, + }) + }) + }); + let req = AssertionRequest { + challenge: vec![1, 2, 3], + rp_id: Some("web.whatsapp.com".into()), + allow_credentials: vec![], + user_verification: UserVerification::Preferred, + timeout_ms: None, + raw_options_json: "{}".into(), + }; + let a = auth.get_assertion(&req).await.unwrap(); + assert_eq!(a.credential_id, vec![1, 2, 3]); + assert_eq!(a.assertion_json, b"{}".to_vec()); + } +} diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index be55d3a59..da2849067 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -40,6 +40,7 @@ pub mod runtime; pub mod secret_enc_addon; pub mod send; pub mod session; +pub mod shortcake; pub mod stanza; pub mod sticker_pack; diff --git a/wacore/src/pair.rs b/wacore/src/pair.rs index 978534d36..2529efb87 100644 --- a/wacore/src/pair.rs +++ b/wacore/src/pair.rs @@ -118,6 +118,23 @@ impl PairUtils { .build() } + /// Extract the `` bytes from a `pair-success` node, ignoring + /// any extra children. SHORTCAKE_PASSKEY (QR-less) pair-success adds + /// ``, ``, `` siblings; the + /// companion completes linking purely via this `` (HMAC vs + /// the ADV secret), never decrypting the metadata. Returns `None` when the + /// child is absent or carries no byte content (caller maps that to a 500). + /// + /// This is the single production parse point shared by the live handler and + /// the passkey regression test, so a child-parsing regression breaks both. + pub fn extract_device_identity_bytes<'n, 'a>( + success_node: &'n NodeRef<'a>, + ) -> Option<&'n [u8]> { + success_node + .get_optional_child_by_tag(&["device-identity"]) + .and_then(|n| n.content_bytes()) + } + /// Performs the cryptographic operations for pairing pub fn do_pair_crypto( device_state: &DeviceState, @@ -688,6 +705,74 @@ mod tests { assert_eq!(err.text, "hmac-mismatch"); } + // A SHORTCAKE_PASSKEY (QR-less) login converges to the SAME pair-success as the + // classic QR flow, but the node carries extra children: , a new + // block, , . + // RE of WAWebHandlePairSuccess (waVersion 2.3000.1042386815) confirmed the + // companion PARSES but never DECRYPTS — linking completes + // purely via the classic HMAC vs the ADV secret. This guards + // that our handler still extracts device-identity (ignoring the new children) and + // verifies, i.e. NO encryption-metadata decryption is needed. + #[test] + fn pair_success_with_passkey_encryption_metadata_completes_via_device_identity() { + let state = dummy_device_state(); + let payload = build_pair_success_payload(&state, &state.adv_secret_key, false); + + let pair_success = NodeBuilder::new("pair-success") + .children([ + NodeBuilder::new("jurisdiction") + .attr("iso", "BR") + .attr("cc", "55") + .build(), + NodeBuilder::new("encryption-metadata") + .attr("version", "1") + .attr("algorithm", "aes-256-gcm") + .children([ + NodeBuilder::new("encrypted_key") + .bytes(vec![0xAAu8; 48]) + .build(), + NodeBuilder::new("nonce").bytes(vec![0xBBu8; 12]).build(), + NodeBuilder::new("encrypted_data") + .bytes(vec![0xCCu8; 280]) + .build(), + NodeBuilder::new("auth_tag").bytes(vec![0xDDu8; 16]).build(), + ]) + .build(), + NodeBuilder::new("client-props") + .bytes(vec![0x08u8, 0x01]) + .build(), + NodeBuilder::new("platform").attr("name", "android").build(), + NodeBuilder::new("device-identity") + .bytes(payload.clone()) + .build(), + NodeBuilder::new("device") + .attr("jid", "5511999999999:57@s.whatsapp.net") + .build(), + ]) + .build(); + + let success_ref = pair_success.as_node_ref(); + + // The new passkey block is present in the stanza... + assert!( + success_ref + .get_optional_child_by_tag(&["encryption-metadata"]) + .is_some(), + "test fixture should include the new encryption-metadata block" + ); + + // ...but the PRODUCTION extraction (the same helper the live pair-success + // handler calls) pulls by tag, ignoring all extras. + let di_bytes = PairUtils::extract_device_identity_bytes(&success_ref) + .expect("production extraction must find device-identity among passkey children"); + assert_eq!(di_bytes, payload.as_slice()); + + // Classic crypto completes the passkey link (HMAC vs ADV secret) — no + // decryption of required. + PairUtils::do_pair_crypto(&state, di_bytes) + .expect("device-identity HMAC must verify for the passkey pair-success"); + } + #[test] fn do_pair_crypto_accepts_matching_hmac_for_hosted_account() { let state = dummy_device_state(); diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index 54038eeaa..ef40217a0 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -208,7 +208,9 @@ impl PairCodeUtils { /// Encodes 5 bytes to an 8-character Crockford Base32 string. /// /// 5 bytes = 40 bits = 8 × 5-bit groups, each mapped to the alphabet. - fn encode_crockford(bytes: &[u8; 5]) -> String { + /// `pub(crate)` so the Shortcake passkey flow reuses the exact same encoder + /// for its verification code (see `crate::shortcake`). + pub(crate) fn encode_crockford(bytes: &[u8; 5]) -> String { // Combine 5 bytes into a 40-bit value let mut accumulator: u64 = 0; for &byte in bytes { diff --git a/wacore/src/shortcake.rs b/wacore/src/shortcake.rs new file mode 100644 index 000000000..443e4ad21 --- /dev/null +++ b/wacore/src/shortcake.rs @@ -0,0 +1,596 @@ +//! SHORTCAKE_PASSKEY companion-linking — platform-independent crypto + protobuf. +//! +//! WhatsApp's 2026 passkey/WebAuthn linking gate adds a third `PairingType` +//! (`QR_CODE`, `ALT_DEVICE_LINKING`, `SHORTCAKE_PASSKEY`). When the account has +//! the server flag `shortcake_companion_prologue__passkeys__enabled`, linking a +//! companion requires a WebAuthn assertion (the SOLE unforgeable step) followed +//! by an ephemeral-identity handshake that ends in an AES-256-GCM-encrypted +//! `PairingRequest` carrying the newly-rotated ADV secret. +//! +//! This module is the deterministic, offline-testable foundation: every function +//! here is pure crypto / protobuf encoding. The single non-reproducible step — +//! the WebAuthn assertion — is abstracted behind the host's `PasskeyAuthenticator` +//! (see the main crate's `passkey` module); it is NOT in this file. +//! +//! All constants/labels are reverse-engineered verbatim from WhatsApp Web +//! (waVersion 2.3000.1042386815, modules `WAWebShortcakeLinking*`). Key facts the +//! RE pinned down (and which are easy to get wrong): +//! - companion ephemeral pubkey is RAW 32 bytes (no 0x05 Signal prefix). +//! - commitment = SHA256(companionEphemeralIdentityBytes ‖ companionNonce). +//! - verification code = SHA256(companionNonce ‖ primaryPublicKey); then +//! `out[i] = primaryNonce[i] ^ digest[i]` for i in 0..5; Crockford-base32 → 8 chars. +//! - encryption key = HKDF-SHA256(IKM = X25519(companionPriv, primaryPub), +//! **salt = "Companion Pairing {deviceTypeNumeric} with ref {ref}"**, +//! **info = "Pairing Information Encryption Key"**, len 32). The human-readable +//! string is the SALT, not the info; deviceType is the numeric enum (CHROME=1). +//! - handoff key = HKDF-SHA256(IKM = priorAdvSecret, salt = none, info = +//! "shortcake-passkey-handoff-v1", len 32); proof = HMAC-SHA256(key, prologuePayload). +//! The handoff only proves ADV-secret continuity for a re-link (suppresses the +//! verification-code UX); it does NOT replace the WebAuthn assertion. + +use crate::libsignal::crypto::aes_256_gcm_encrypt; +use crate::libsignal::protocol::{CurveError, KeyPair, PublicKey}; +use crate::pair_code::PairCodeUtils; +use hkdf::Hkdf; +use hmac::{Hmac, KeyInit as _, Mac}; +use prost::Message; +use rand::RngExt; +use sha2::{Digest, Sha256}; +use waproto::whatsapp as wa; + +/// HKDF `info` for the pairing-handoff HMAC key (RE: "shortcake-passkey-handoff-v1"). +const HANDOFF_INFO: &[u8] = b"shortcake-passkey-handoff-v1"; +/// HKDF `info` for the pairing-request encryption key (RE: "Pairing Information Encryption Key"). +const ENC_KEY_INFO: &[u8] = b"Pairing Information Encryption Key"; +/// First N bytes of the verification-code reveal (RE: const s=5 → 8 Crockford chars). +const VERIFICATION_CODE_BYTES: usize = 5; + +#[derive(Debug, thiserror::Error)] +pub enum ShortcakeError { + #[error("invalid primary public key: {0}")] + InvalidPrimaryKey(CurveError), + #[error("X25519 agreement failed: {0}")] + KeyAgreement(CurveError), + #[error("HKDF expand failed for {0}")] + Hkdf(&'static str), + #[error("AES-256-GCM encryption failed")] + Aead, + #[error("failed to decode {0} protobuf")] + Decode(&'static str), + #[error("unexpected length: {what} expected {expected} got {got}")] + Length { + what: &'static str, + expected: usize, + got: usize, + }, +} + +/// Parsed `` from the server's continuation +/// notification: the primary's ephemeral X25519 pubkey + nonce, both fixed 32 B. +pub struct PrimaryEphemeralIdentity { + pub public_key: [u8; 32], + pub nonce: [u8; 32], +} + +/// Output of [`ShortcakeUtils::encrypt_pairing_request`]. +pub struct EncryptedPairing { + /// AES-256-GCM ciphertext ‖ 16-byte tag (matches WebCrypto's single buffer). + pub encrypted_payload: Vec, + /// 12-byte random GCM IV. + pub iv: [u8; 12], +} + +/// Platform-independent SHORTCAKE_PASSKEY crypto + protobuf builders. +pub struct ShortcakeUtils; + +impl ShortcakeUtils { + /// Encode the companion ephemeral identity protobuf. + /// `public_key` is the RAW 32-byte X25519 pubkey (no 0x05 prefix). + /// `device_type` is the numeric `DeviceProps.PlatformType` (CHROME = 1). + pub fn build_companion_ephemeral_identity( + public_key: &[u8; 32], + device_type: i32, + ref_str: &str, + ) -> Vec { + wa::CompanionEphemeralIdentity { + public_key: Some(public_key.to_vec()), + device_type: Some(device_type), + r#ref: Some(ref_str.to_string()), + } + .encode_to_vec() + } + + /// commitment = SHA256(companionEphemeralIdentityBytes ‖ companionNonce). + /// The identity is a variable-length protobuf blob; the nonce is fixed 32 B. + pub fn commitment_hash( + companion_ephemeral_identity: &[u8], + companion_nonce: &[u8; 32], + ) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(companion_ephemeral_identity); + h.update(companion_nonce); + h.finalize().into() + } + + /// Encode the prologue payload protobuf (companion identity + commitment{hash}). + pub fn build_prologue_payload( + companion_ephemeral_identity: &[u8], + commitment_hash: &[u8; 32], + ) -> Vec { + wa::ProloguePayload { + companion_ephemeral_identity: Some(companion_ephemeral_identity.to_vec()), + commitment: Some(wa::CompanionCommitment { + hash: Some(commitment_hash.to_vec()), + }), + } + .encode_to_vec() + } + + /// Decode + length-validate the `PrimaryEphemeralIdentity` protobuf from the + /// `` continuation node. Both fields must be + /// exactly 32 bytes; a wrong length is rejected here rather than producing a + /// bogus shared secret / verification code downstream. + pub fn parse_primary_ephemeral_identity( + bytes: &[u8], + ) -> Result { + let parsed = wa::PrimaryEphemeralIdentity::decode(bytes) + .map_err(|_| ShortcakeError::Decode("primary_ephemeral_identity"))?; + let pk = parsed.public_key.unwrap_or_default(); + let nc = parsed.nonce.unwrap_or_default(); + let public_key: [u8; 32] = + pk.as_slice() + .try_into() + .map_err(|_| ShortcakeError::Length { + what: "primary_public_key", + expected: 32, + got: pk.len(), + })?; + let nonce: [u8; 32] = nc + .as_slice() + .try_into() + .map_err(|_| ShortcakeError::Length { + what: "primary_nonce", + expected: 32, + got: nc.len(), + })?; + Ok(PrimaryEphemeralIdentity { public_key, nonce }) + } + + /// Derive the 8-char verification code shown to the user (and the phone). + /// `h = SHA256(companionNonce ‖ primaryPublicKey)`; `out[i] = primaryNonce[i] ^ h[i]` + /// for the first 5 bytes; Crockford base32 → 8 chars. + pub fn derive_verification_code( + companion_nonce: &[u8; 32], + primary_public_key: &[u8; 32], + primary_nonce: &[u8; 32], + ) -> String { + let mut h = Sha256::new(); + h.update(companion_nonce); + h.update(primary_public_key); + let digest = h.finalize(); + let mut out = [0u8; VERIFICATION_CODE_BYTES]; + for (i, slot) in out.iter_mut().enumerate() { + *slot = primary_nonce[i] ^ digest[i]; + } + PairCodeUtils::encode_crockford(&out) + } + + /// Derive the AES-256 pairing-request encryption key from the shared secret + /// (deterministic core, unit-testable). `device_type` is the numeric enum value. + pub fn derive_encryption_key_from_shared_secret( + shared_secret: &[u8; 32], + device_type: i32, + ref_str: &str, + ) -> Result<[u8; 32], ShortcakeError> { + let salt = format!("Companion Pairing {device_type} with ref {ref_str}"); + let hk = Hkdf::::new(Some(salt.as_bytes()), shared_secret); + let mut key = [0u8; 32]; + hk.expand(ENC_KEY_INFO, &mut key) + .map_err(|_| ShortcakeError::Hkdf("encryption_key"))?; + Ok(key) + } + + /// Full encryption-key derivation: X25519(companionPriv, primaryPub) → HKDF. + pub fn derive_encryption_key( + companion_keypair: &KeyPair, + primary_public_key: &[u8; 32], + device_type: i32, + ref_str: &str, + ) -> Result<[u8; 32], ShortcakeError> { + let primary = PublicKey::from_djb_public_key_bytes(primary_public_key) + .map_err(ShortcakeError::InvalidPrimaryKey)?; + let shared = companion_keypair + .private_key + .calculate_agreement(&primary) + .map_err(ShortcakeError::KeyAgreement)?; + Self::derive_encryption_key_from_shared_secret(&shared, device_type, ref_str) + } + + /// Encode the inner `PairingRequest` plaintext (companion static + identity + /// pubkeys + the NEWLY-ROTATED ADV secret) — this is what gets encrypted. + pub fn build_pairing_request( + companion_public_key: &[u8; 32], + companion_identity_key: &[u8; 32], + adv_secret: &[u8; 32], + ) -> Vec { + wa::PairingRequest { + companion_public_key: Some(companion_public_key.to_vec()), + companion_identity_key: Some(companion_identity_key.to_vec()), + adv_secret: Some(adv_secret.to_vec()), + } + .encode_to_vec() + } + + /// AES-256-GCM encrypt the pairing-request plaintext with a fresh 12-byte IV, + /// no AAD. Output is ciphertext‖tag in one buffer (matches WebCrypto). + pub fn encrypt_pairing_request( + plaintext: &[u8], + key: &[u8; 32], + ) -> Result { + let mut iv = [0u8; 12]; + rand::make_rng::().fill(&mut iv); + let mut encrypted_payload = Vec::with_capacity(plaintext.len() + 16); + aes_256_gcm_encrypt(key, &iv, b"", plaintext, &mut encrypted_payload) + .map_err(|_| ShortcakeError::Aead)?; + Ok(EncryptedPairing { + encrypted_payload, + iv, + }) + } + + /// Encode the `EncryptedPairingRequest` protobuf sent in the final IQ. + pub fn build_encrypted_pairing_request(enc: &EncryptedPairing) -> Vec { + wa::EncryptedPairingRequest { + encrypted_payload: Some(enc.encrypted_payload.clone()), + iv: Some(enc.iv.to_vec()), + } + .encode_to_vec() + } + + /// Derive the pairing-handoff HMAC key from a PRIOR session's 32-byte ADV + /// secret: HKDF-SHA256(IKM = priorAdvSecret, salt = none, info = handoff label). + pub fn derive_pairing_handoff_hmac_key( + prior_adv_secret: &[u8; 32], + ) -> Result<[u8; 32], ShortcakeError> { + let hk = Hkdf::::new(None, prior_adv_secret); + let mut key = [0u8; 32]; + hk.expand(HANDOFF_INFO, &mut key) + .map_err(|_| ShortcakeError::Hkdf("handoff_key"))?; + Ok(key) + } + + /// Compute the pairing-handoff proof = HMAC-SHA256(handoffKey, prologuePayload). + /// Proves continuity from a prior linked session (re-link UX skip); OPTIONAL. + pub fn compute_pairing_handoff_proof( + handoff_key: &[u8; 32], + prologue_payload: &[u8], + ) -> [u8; 32] { + let mut mac = + Hmac::::new_from_slice(handoff_key).expect("HMAC accepts any key length"); + mac.update(prologue_payload); + mac.finalize().into_bytes().into() + } + + /// Generate the companion ephemeral X25519 keypair for a new SHORTCAKE attempt. + pub fn generate_companion_ephemeral_keypair() -> KeyPair { + let mut rng = rand::make_rng::(); + KeyPair::generate(&mut rng) + } + + /// Generate a fresh 32-byte companion nonce. + pub fn generate_companion_nonce() -> [u8; 32] { + let mut nonce = [0u8; 32]; + rand::make_rng::().fill(&mut nonce); + nonce + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Deterministic vectors guard the bug-prone concat/XOR/label details the RE flagged. + + #[test] + fn commitment_is_sha256_of_identity_then_nonce() { + let identity = b"identity-bytes"; + let nonce = [7u8; 32]; + let got = ShortcakeUtils::commitment_hash(identity, &nonce); + // independent re-derivation, asserting the exact concat ORDER + let mut h = Sha256::new(); + h.update(identity); + h.update(nonce); + let want: [u8; 32] = h.finalize().into(); + assert_eq!(got, want); + // order matters: nonce-then-identity must differ + let mut h2 = Sha256::new(); + h2.update(nonce); + h2.update(identity); + let wrong: [u8; 32] = h2.finalize().into(); + assert_ne!(got, wrong); + } + + #[test] + fn verification_code_format_and_xor_order() { + let companion_nonce = [1u8; 32]; + let primary_pub = [2u8; 32]; + let primary_nonce = [3u8; 32]; + let code = ShortcakeUtils::derive_verification_code( + &companion_nonce, + &primary_pub, + &primary_nonce, + ); + // exactly 8 Crockford chars + assert_eq!(code.len(), 8); + const CROCKFORD: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTVWXYZ"; + assert!(code.bytes().all(|b| CROCKFORD.contains(&b))); + // re-derive independently: SHA256(companionNonce ‖ primaryPub), XOR first 5 of primaryNonce + let mut h = Sha256::new(); + h.update(companion_nonce); + h.update(primary_pub); + let d = h.finalize(); + let mut out = [0u8; 5]; + for i in 0..5 { + out[i] = primary_nonce[i] ^ d[i]; + } + assert_eq!(code, PairCodeUtils::encode_crockford(&out)); + // deterministic + assert_eq!( + code, + ShortcakeUtils::derive_verification_code( + &companion_nonce, + &primary_pub, + &primary_nonce + ) + ); + } + + // The fixed-size crypto inputs are `&[u8; 32]` parameters, so a wrong length + // is a compile error rather than a runtime check. Length validation only + // survives at the wire boundary: `parse_primary_ephemeral_identity` below. + + #[test] + fn parse_primary_ephemeral_identity_roundtrip_and_length_validation() { + let proto = wa::PrimaryEphemeralIdentity { + public_key: Some(vec![0xAB; 32]), + nonce: Some(vec![0xCD; 32]), + } + .encode_to_vec(); + let parsed = ShortcakeUtils::parse_primary_ephemeral_identity(&proto).unwrap(); + assert_eq!(parsed.public_key, [0xAB; 32]); + assert_eq!(parsed.nonce, [0xCD; 32]); + + // wrong-length pubkey is rejected with a Length error + let bad_pk = wa::PrimaryEphemeralIdentity { + public_key: Some(vec![0xAB; 31]), + nonce: Some(vec![0xCD; 32]), + } + .encode_to_vec(); + assert!(matches!( + ShortcakeUtils::parse_primary_ephemeral_identity(&bad_pk), + Err(ShortcakeError::Length { + what: "primary_public_key", + .. + }) + )); + + // wrong-length nonce is rejected + let bad_nonce = wa::PrimaryEphemeralIdentity { + public_key: Some(vec![0xAB; 32]), + nonce: Some(vec![0xCD; 1]), + } + .encode_to_vec(); + assert!(matches!( + ShortcakeUtils::parse_primary_ephemeral_identity(&bad_nonce), + Err(ShortcakeError::Length { + what: "primary_nonce", + .. + }) + )); + + // garbage that isn't a valid protobuf for this message: an absent field + // decodes to empty (length 0), which is also a Length error, not a panic. + assert!(matches!( + ShortcakeUtils::parse_primary_ephemeral_identity(&[]), + Err(ShortcakeError::Length { got: 0, .. }) + )); + } + + #[test] + fn encryption_key_uses_string_as_salt_not_info() { + let ikm = [9u8; 32]; + let key = + ShortcakeUtils::derive_encryption_key_from_shared_secret(&ikm, 1, "REF123").unwrap(); + // independent re-derivation with the documented salt/info placement + let salt = "Companion Pairing 1 with ref REF123"; + let hk = Hkdf::::new(Some(salt.as_bytes()), &ikm); + let mut want = [0u8; 32]; + hk.expand(b"Pairing Information Encryption Key", &mut want) + .unwrap(); + assert_eq!(key, want); + // swapping salt<->info (a plausible bug) must produce a different key + let hk2 = Hkdf::::new(Some(b"Pairing Information Encryption Key"), &ikm); + let mut wrong = [0u8; 32]; + hk2.expand(salt.as_bytes(), &mut wrong).unwrap(); + assert_ne!(key, wrong); + // device_type and ref are bound into the key + assert_ne!( + key, + ShortcakeUtils::derive_encryption_key_from_shared_secret(&ikm, 2, "REF123").unwrap() + ); + assert_ne!( + key, + ShortcakeUtils::derive_encryption_key_from_shared_secret(&ikm, 1, "OTHER").unwrap() + ); + } + + #[test] + fn handoff_key_and_proof() { + let prior = [5u8; 32]; + let k = ShortcakeUtils::derive_pairing_handoff_hmac_key(&prior).unwrap(); + // independent HKDF (salt none, info label) + let hk = Hkdf::::new(None, &prior); + let mut want = [0u8; 32]; + hk.expand(b"shortcake-passkey-handoff-v1", &mut want) + .unwrap(); + assert_eq!(k, want); + let proof = ShortcakeUtils::compute_pairing_handoff_proof(&k, b"prologue"); + let mut mac = Hmac::::new_from_slice(&k).unwrap(); + mac.update(b"prologue"); + let want_proof: [u8; 32] = mac.finalize().into_bytes().into(); + assert_eq!(proof, want_proof); + } + + #[test] + fn protobufs_roundtrip_with_expected_fields() { + let id = ShortcakeUtils::build_companion_ephemeral_identity(&[0xAA; 32], 1, "theref"); + let decoded = wa::CompanionEphemeralIdentity::decode(id.as_slice()).unwrap(); + assert_eq!(decoded.public_key.as_deref(), Some(&[0xAA; 32][..])); + assert_eq!(decoded.device_type, Some(1)); + assert_eq!(decoded.r#ref.as_deref(), Some("theref")); + + let prologue = ShortcakeUtils::build_prologue_payload(&id, &[0xBB; 32]); + let dp = wa::ProloguePayload::decode(prologue.as_slice()).unwrap(); + assert_eq!( + dp.companion_ephemeral_identity.as_deref(), + Some(id.as_slice()) + ); + assert_eq!( + dp.commitment.and_then(|c| c.hash).as_deref(), + Some(&[0xBB; 32][..]) + ); + + let pr = ShortcakeUtils::build_pairing_request(&[1; 32], &[2; 32], &[3; 32]); + let dpr = wa::PairingRequest::decode(pr.as_slice()).unwrap(); + assert_eq!(dpr.companion_public_key.as_deref(), Some(&[1u8; 32][..])); + assert_eq!(dpr.companion_identity_key.as_deref(), Some(&[2u8; 32][..])); + assert_eq!(dpr.adv_secret.as_deref(), Some(&[3u8; 32][..])); + } + + #[test] + fn encrypt_pairing_request_shape() { + let key = [4u8; 32]; + let enc = ShortcakeUtils::encrypt_pairing_request(b"hello pairing", &key).unwrap(); + assert_eq!(enc.iv.len(), 12); + // ciphertext + 16-byte GCM tag + assert_eq!(enc.encrypted_payload.len(), b"hello pairing".len() + 16); + let wire = ShortcakeUtils::build_encrypted_pairing_request(&enc); + let d = wa::EncryptedPairingRequest::decode(wire.as_slice()).unwrap(); + assert_eq!(d.iv.as_deref(), Some(&enc.iv[..])); + assert_eq!(d.encrypted_payload, Some(enc.encrypted_payload)); + } + + // Runs both the companion and a simulated primary through the primitives: the + // verification codes match, the X25519+HKDF keys agree, the primary decrypts the + // companion's PairingRequest, and the handoff proof verifies. + #[test] + fn full_handshake_interops_with_a_simulated_primary() { + use crate::libsignal::crypto::aes_256_gcm_decrypt; + + let device_type = 1; // CHROME + let pairing_ref = "REF-XYZ"; + let prior_adv_secret = [0x11u8; 32]; // a prior linked session's secret + let new_adv_secret = [0x22u8; 32]; // rotated for this link + + // companion: ephemeral identity + commitment + prologue + handoff proof + let companion_kp = ShortcakeUtils::generate_companion_ephemeral_keypair(); + let companion_nonce = ShortcakeUtils::generate_companion_nonce(); + let companion_pub: [u8; 32] = companion_kp + .public_key + .public_key_bytes() + .try_into() + .unwrap(); + let identity = ShortcakeUtils::build_companion_ephemeral_identity( + &companion_pub, + device_type, + pairing_ref, + ); + let commitment = ShortcakeUtils::commitment_hash(&identity, &companion_nonce); + let prologue = ShortcakeUtils::build_prologue_payload(&identity, &commitment); + let companion_handoff = + ShortcakeUtils::derive_pairing_handoff_hmac_key(&prior_adv_secret).unwrap(); + let proof = ShortcakeUtils::compute_pairing_handoff_proof(&companion_handoff, &prologue); + + // primary: its own ephemeral identity, sent back as + let primary_kp = KeyPair::generate(&mut rand::make_rng::()); + let primary_pub: [u8; 32] = primary_kp.public_key.public_key_bytes().try_into().unwrap(); + let primary_nonce = [0x33u8; 32]; + let primary_wire = wa::PrimaryEphemeralIdentity { + public_key: Some(primary_pub.to_vec()), + nonce: Some(primary_nonce.to_vec()), + } + .encode_to_vec(); + let parsed = ShortcakeUtils::parse_primary_ephemeral_identity(&primary_wire).unwrap(); + + // the primary verifies the handoff proof against the shared prior secret + let primary_handoff = + ShortcakeUtils::derive_pairing_handoff_hmac_key(&prior_adv_secret).unwrap(); + assert_eq!( + proof, + ShortcakeUtils::compute_pairing_handoff_proof(&primary_handoff, &prologue), + "handoff proof must verify on the primary" + ); + + // both sides derive the SAME verification code from the same inputs + let companion_code = ShortcakeUtils::derive_verification_code( + &companion_nonce, + &parsed.public_key, + &parsed.nonce, + ); + let primary_code = ShortcakeUtils::derive_verification_code( + &companion_nonce, + &primary_pub, + &primary_nonce, + ); + assert_eq!(companion_code, primary_code); + + // X25519 is symmetric, so both sides derive the same AES key + let companion_key = ShortcakeUtils::derive_encryption_key( + &companion_kp, + &parsed.public_key, + device_type, + pairing_ref, + ) + .unwrap(); + let primary_shared = primary_kp + .private_key + .calculate_agreement(&PublicKey::from_djb_public_key_bytes(&companion_pub).unwrap()) + .unwrap(); + let primary_key = ShortcakeUtils::derive_encryption_key_from_shared_secret( + &primary_shared, + device_type, + pairing_ref, + ) + .unwrap(); + assert_eq!(companion_key, primary_key, "X25519+HKDF keys must agree"); + + // companion encrypts the PairingRequest; the primary decrypts it and reads + // the newly-rotated ADV secret out + let request = + ShortcakeUtils::build_pairing_request(&[0xAA; 32], &[0xBB; 32], &new_adv_secret); + let enc = ShortcakeUtils::encrypt_pairing_request(&request, &companion_key).unwrap(); + let wire = ShortcakeUtils::build_encrypted_pairing_request(&enc); + let decoded = wa::EncryptedPairingRequest::decode(wire.as_slice()).unwrap(); + let iv: [u8; 12] = decoded.iv.unwrap().as_slice().try_into().unwrap(); + + let mut plaintext = Vec::new(); + aes_256_gcm_decrypt( + &primary_key, + &iv, + b"", + &decoded.encrypted_payload.unwrap(), + &mut plaintext, + ) + .unwrap(); + let recovered = wa::PairingRequest::decode(plaintext.as_slice()).unwrap(); + assert_eq!(recovered.adv_secret.as_deref(), Some(&new_adv_secret[..])); + assert_eq!( + recovered.companion_public_key.as_deref(), + Some(&[0xAA; 32][..]) + ); + assert_eq!( + recovered.companion_identity_key.as_deref(), + Some(&[0xBB; 32][..]) + ); + } +} diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 8bb1ae222..633612c47 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -261,6 +261,9 @@ pub enum EventKind { NewsletterLiveUpdate, RawNode, MexNotification, + PairPasskeyRequest, + PairPasskeyConfirmation, + PairPasskeyError, // When adding a variant, mind the 64-kind ceiling below (EventInterest packs // each discriminant as a bit in a u64) and keep the guard pointing at the // last variant. @@ -274,7 +277,7 @@ impl EventKind { // Build-time tripwire: a new variant that would overflow EventInterest's bitmask // fails compilation instead of silently corrupting the mask at runtime. -const _: () = assert!((EventKind::MexNotification as u8) < EventKind::CAPACITY); +const _: () = assert!((EventKind::PairPasskeyError as u8) < EventKind::CAPACITY); /// A set of [`EventKind`]s a handler wants delivered. The event bus skips /// materializing and dispatching events whose kind no handler wants, so a @@ -700,6 +703,46 @@ pub enum Event { /// Server-pushed MEX (GraphQL) update. Routed by the textual `op_name`, /// which is stable across WA Web bundle releases. MexNotification(MexNotification), + + /// SHORTCAKE_PASSKEY: the server asked for a WebAuthn assertion to gate this + /// companion link. Carries the verbatim `PublicKeyCredentialRequestOptions` + /// JSON; the host obtains an assertion (via [`crate::sync_marker`]-agnostic + /// authenticator) and the client sends it back. If a passkey authenticator is + /// registered the client drives this automatically; this event is for hosts + /// that drive the assertion manually. + PairPasskeyRequest(PairPasskeyRequest), + + /// SHORTCAKE_PASSKEY: the link reached the verification stage. `code` is the + /// 8-char (dashed) pairing code; when `skip_handoff_ux` is set, continuity was + /// proven via the handoff proof and the code need not be shown to the user. + PairPasskeyConfirmation(PairPasskeyConfirmation), + + /// SHORTCAKE_PASSKEY: the passkey link failed. `continuation` distinguishes a + /// failure during the continuation/verification stage from the initial request. + PairPasskeyError(PairPasskeyError), +} + +/// Payload for [`Event::PairPasskeyRequest`]. +#[derive(Debug, Clone, Serialize)] +pub struct PairPasskeyRequest { + /// Verbatim `PublicKeyCredentialRequestOptions` JSON from the server. Pass it + /// straight to a WebAuthn `get` (e.g. Android Credential Manager), or parse it + /// with `whatsapp_rust::passkey::parse_request_options`. + pub request_options_json: String, +} + +/// Payload for [`Event::PairPasskeyConfirmation`]. +#[derive(Debug, Clone, Serialize)] +pub struct PairPasskeyConfirmation { + pub code: String, + pub skip_handoff_ux: bool, +} + +/// Payload for [`Event::PairPasskeyError`]. +#[derive(Debug, Clone, Serialize)] +pub struct PairPasskeyError { + pub error: String, + pub continuation: bool, } /// `payload` shape depends on `op_name`. `offline` mirrors the raw string @@ -771,6 +814,9 @@ impl Event { Event::NewsletterLiveUpdate(_) => EventKind::NewsletterLiveUpdate, Event::RawNode(_) => EventKind::RawNode, Event::MexNotification(_) => EventKind::MexNotification, + Event::PairPasskeyRequest(_) => EventKind::PairPasskeyRequest, + Event::PairPasskeyConfirmation(_) => EventKind::PairPasskeyConfirmation, + Event::PairPasskeyError(_) => EventKind::PairPasskeyError, } }