Skip to main content

whatsapp_rust/passkey/
mod.rs

1//! `PasskeyAuthenticator` — the single pluggable point of the SHORTCAKE_PASSKEY
2//! login flow (see `wacore::shortcake` for the deterministic protocol core).
3//!
4//! WhatsApp's passkey linking gate requires ONE thing an unofficial client cannot
5//! reproduce on its own: a WebAuthn assertion (`navigator.credentials.get`) signed
6//! by a passkey ALREADY REGISTERED to the account. Everything else in the protocol
7//! is deterministic and lives in `wacore::shortcake`. This module abstracts that
8//! single step so the rest of the linking flow is platform-agnostic.
9//!
10//! ## Why a real authenticator (not a software forgery)
11//! The assertion's private key lives in a platform authenticator (Google Password
12//! Manager / iCloud Keychain), non-extractable. So we do what WhatsApp Web does:
13//! delegate to a real authenticator. The assertion signs only the SERVER
14//! challenge and origin/rpId (NOT the Shortcake payload), so producing it is a
15//! standard WebAuthn `get`. Using the real authenticator = a legitimate,
16//! user-verified assertion = low ban risk (forging without the key is
17//! impossible anyway).
18//!
19//! ## Strategies
20//! - **Android Credential Manager (recommended for GPM passkeys):** the Android
21//!   host app calls `CredentialManager.getCredential(...)` with the server's
22//!   `raw_options_json` (a `GetCredentialRequest` containing a
23//!   `GetPublicKeyCredentialOption(requestJson = raw_options_json)`); GPM signs
24//!   with biometric; the app maps the returned
25//!   `PublicKeyCredential.authenticationResponseJson` into an [`Assertion`] and
26//!   returns it via a [`CallbackAuthenticator`]. No private key ever touches Rust.
27//! - **hybrid/caBLE:** a desktop client tunnels CTAP2 to the phone's authenticator.
28//! - **software (`passkey-rs`):** only when the passkey lives in an exportable vault.
29//!
30//! All three implement [`PasskeyAuthenticator`]; the default build ships only the
31//! generic [`CallbackAuthenticator`] (host provides the assertion) so no platform
32//! dependency leaks into headless/library builds.
33
34pub mod flow;
35
36use async_trait::async_trait;
37use base64::prelude::*;
38use std::future::Future;
39use std::pin::Pin;
40use std::sync::Arc;
41
42/// WebAuthn user-verification requirement from the server's request options.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum UserVerification {
45    Required,
46    Preferred,
47    Discouraged,
48}
49
50impl UserVerification {
51    /// Fail closed: a present-but-unrecognized value is rejected rather than
52    /// silently downgraded to `Preferred` (absence is handled by the caller).
53    fn parse(s: &str) -> Result<Self, PasskeyError> {
54        match s {
55            "required" => Ok(Self::Required),
56            "preferred" => Ok(Self::Preferred),
57            "discouraged" => Ok(Self::Discouraged),
58            other => Err(PasskeyError::InvalidOptions(format!(
59                "unsupported userVerification: {other}"
60            ))),
61        }
62    }
63}
64
65/// A WebAuthn assertion request, parsed from the server's
66/// `<passkey_request_options>` (a standard `PublicKeyCredentialRequestOptions`
67/// JSON). `challenge` and `allow_credentials` are already base64url-decoded.
68#[derive(Debug, Clone)]
69pub struct AssertionRequest {
70    /// Server challenge (raw bytes).
71    pub challenge: Vec<u8>,
72    /// Relying-party id (e.g. "web.whatsapp.com") the authenticator must sign for.
73    pub rp_id: Option<String>,
74    /// Allowed credential ids (raw bytes); empty = discoverable.
75    pub allow_credentials: Vec<Vec<u8>>,
76    pub user_verification: UserVerification,
77    pub timeout_ms: Option<u64>,
78    /// The verbatim server JSON — pass straight to Android Credential Manager's
79    /// `GetPublicKeyCredentialOption(requestJson = ...)` (it wants the original).
80    pub raw_options_json: String,
81}
82
83/// The result of a WebAuthn assertion, packaged for the `<passkey_prologue>` IQ.
84#[derive(Debug, Clone)]
85pub struct Assertion {
86    /// UTF-8 JSON for `<webauthn_assertion>`:
87    /// `{id, rawId(b64url), type:"public-key", response:{clientDataJSON, authenticatorData, signature, userHandle}}`.
88    pub assertion_json: Vec<u8>,
89    /// Raw credential rawId bytes for `<credential_id>`.
90    pub credential_id: Vec<u8>,
91}
92
93#[derive(Debug, thiserror::Error)]
94#[non_exhaustive]
95pub enum PasskeyError {
96    #[error("no passkey registered for this account on the authenticator")]
97    NoCredential,
98    #[error("user cancelled or the ceremony timed out")]
99    Cancelled,
100    #[error("invalid request options: {0}")]
101    InvalidOptions(String),
102    #[error("authenticator backend error: {0}")]
103    Backend(String),
104    #[error("passkey linking flow error: {0}")]
105    Flow(String),
106}
107
108/// Produces a WebAuthn assertion for a SHORTCAKE_PASSKEY link. Implemented by a
109/// real authenticator (Android Credential Manager / hybrid / software vault).
110///
111/// The `MaybeSendSync` supertrait keeps this `Send + Sync` on native (the client
112/// stores it as `Arc<dyn PasskeyAuthenticator>` and drives it across threads) but
113/// drops the bound on wasm32, where a browser authenticator may hold `!Send` JS
114/// handles, matching the sibling extension points (`Transport`, `EventHandler`).
115#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
116#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
117pub trait PasskeyAuthenticator: wacore::sync_marker::MaybeSendSync {
118    async fn get_assertion(&self, request: &AssertionRequest) -> Result<Assertion, PasskeyError>;
119}
120
121// Mirror the trait's `async_trait(?Send)` on wasm: a browser authenticator's
122// future (e.g. awaiting `navigator.credentials.get`) is `!Send`. `cb`/`new`
123// reference this alias, so they pick up the right bound per-target automatically.
124#[cfg(not(target_arch = "wasm32"))]
125type AssertionFuture = Pin<Box<dyn Future<Output = Result<Assertion, PasskeyError>> + Send>>;
126#[cfg(target_arch = "wasm32")]
127type AssertionFuture = Pin<Box<dyn Future<Output = Result<Assertion, PasskeyError>>>>;
128
129// The stored closure: `Send + Sync` on native, relaxed on wasm to mirror
130// `AssertionFuture` (a browser closure may capture `!Send` JS handles).
131#[cfg(not(target_arch = "wasm32"))]
132type AssertionCallback = dyn Fn(AssertionRequest) -> AssertionFuture + Send + Sync;
133#[cfg(target_arch = "wasm32")]
134type AssertionCallback = dyn Fn(AssertionRequest) -> AssertionFuture;
135
136/// Generic [`PasskeyAuthenticator`] that defers to a host-provided async closure.
137///
138/// This is the integration seam for the Android Credential Manager strategy: the
139/// Kotlin/JNI layer performs `CredentialManager.getCredential(...)` and resolves
140/// the future with the mapped [`Assertion`]. Keeps all platform code out of the lib.
141#[derive(Clone)]
142pub struct CallbackAuthenticator {
143    cb: Arc<AssertionCallback>,
144}
145
146impl CallbackAuthenticator {
147    pub fn new<F>(f: F) -> Self
148    where
149        F: Fn(AssertionRequest) -> AssertionFuture + wacore::sync_marker::MaybeSendSync + 'static,
150    {
151        Self { cb: Arc::new(f) }
152    }
153}
154
155#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
156#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
157impl PasskeyAuthenticator for CallbackAuthenticator {
158    async fn get_assertion(&self, request: &AssertionRequest) -> Result<Assertion, PasskeyError> {
159        (self.cb)(request.clone()).await
160    }
161}
162
163/// Parse the server's `PublicKeyCredentialRequestOptions` JSON into an
164/// [`AssertionRequest`], base64url-decoding `challenge` and `allowCredentials[].id`.
165pub fn parse_request_options(json: &str) -> Result<AssertionRequest, PasskeyError> {
166    let v: serde_json::Value =
167        serde_json::from_str(json).map_err(|e| PasskeyError::InvalidOptions(e.to_string()))?;
168
169    let challenge_b64 = v
170        .get("challenge")
171        .and_then(|c| c.as_str())
172        .ok_or_else(|| PasskeyError::InvalidOptions("missing challenge".into()))?;
173    let challenge = BASE64_URL_SAFE_NO_PAD
174        .decode(challenge_b64.trim_end_matches('='))
175        .map_err(|e| PasskeyError::InvalidOptions(format!("challenge b64url: {e}")))?;
176    if challenge.is_empty() {
177        return Err(PasskeyError::InvalidOptions("empty challenge".into()));
178    }
179
180    // Absent rpId is fine (the authenticator defaults it); a present-but-non-string
181    // value is malformed and must fail closed, not silently drop the RP binding.
182    let rp_id = match v.get("rpId") {
183        None => None,
184        Some(r) => Some(
185            r.as_str()
186                .ok_or_else(|| PasskeyError::InvalidOptions("rpId must be a string".into()))?
187                .to_string(),
188        ),
189    };
190
191    // Reject malformed descriptors instead of dropping them: silently skipping
192    // entries can collapse a populated allowCredentials into an empty list, which
193    // this API treats as "discoverable" — a confusing, weaker outcome than failing.
194    let mut allow_credentials = Vec::new();
195    if let Some(allow_credentials_value) = v.get("allowCredentials") {
196        let arr = allow_credentials_value.as_array().ok_or_else(|| {
197            PasskeyError::InvalidOptions("allowCredentials must be an array".into())
198        })?;
199        for cred in arr {
200            let id = cred.get("id").and_then(|i| i.as_str()).ok_or_else(|| {
201                PasskeyError::InvalidOptions("allowCredentials[].id must be a string".into())
202            })?;
203            let bytes = BASE64_URL_SAFE_NO_PAD
204                .decode(id.trim_end_matches('='))
205                .map_err(|e| PasskeyError::InvalidOptions(format!("credential id b64url: {e}")))?;
206            if bytes.is_empty() {
207                return Err(PasskeyError::InvalidOptions(
208                    "allowCredentials[].id is empty".into(),
209                ));
210            }
211            allow_credentials.push(bytes);
212        }
213    }
214
215    let user_verification = match v.get("userVerification") {
216        None => UserVerification::Preferred,
217        Some(u) => UserVerification::parse(u.as_str().ok_or_else(|| {
218            PasskeyError::InvalidOptions("userVerification must be a string".into())
219        })?)?,
220    };
221
222    let timeout_ms = v.get("timeout").and_then(|t| t.as_u64());
223
224    Ok(AssertionRequest {
225        challenge,
226        rp_id,
227        allow_credentials,
228        user_verification,
229        timeout_ms,
230        raw_options_json: json.to_string(),
231    })
232}
233
234/// Assemble the `<webauthn_assertion>` JSON (WhatsApp Web's exact shape) from raw
235/// WebAuthn assertion components. For authenticator backends that return raw bytes
236/// rather than WA-shaped JSON (e.g. a software/hybrid authenticator). `user_handle`
237/// is optional. All binary fields are base64url-encoded (no padding).
238pub fn build_webauthn_assertion_json(
239    credential_id: &[u8],
240    client_data_json: &[u8],
241    authenticator_data: &[u8],
242    signature: &[u8],
243    user_handle: Option<&[u8]>,
244) -> Vec<u8> {
245    let id = BASE64_URL_SAFE_NO_PAD.encode(credential_id);
246    let assertion = serde_json::json!({
247        "id": id,
248        "rawId": id,
249        "type": "public-key",
250        "response": {
251            "clientDataJSON": BASE64_URL_SAFE_NO_PAD.encode(client_data_json),
252            "authenticatorData": BASE64_URL_SAFE_NO_PAD.encode(authenticator_data),
253            "signature": BASE64_URL_SAFE_NO_PAD.encode(signature),
254            "userHandle": user_handle.map(|u| BASE64_URL_SAFE_NO_PAD.encode(u)),
255        }
256    });
257    assertion.to_string().into_bytes()
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn parses_request_options() {
266        let challenge = b"the-challenge-bytes!";
267        let cred = b"credential-id-1";
268        let json = serde_json::json!({
269            "challenge": BASE64_URL_SAFE_NO_PAD.encode(challenge),
270            "rpId": "web.whatsapp.com",
271            "userVerification": "required",
272            "timeout": 60000u64,
273            "allowCredentials": [
274                {"type": "public-key", "id": BASE64_URL_SAFE_NO_PAD.encode(cred)}
275            ]
276        })
277        .to_string();
278
279        let req = parse_request_options(&json).unwrap();
280        assert_eq!(req.challenge, challenge);
281        assert_eq!(req.rp_id.as_deref(), Some("web.whatsapp.com"));
282        assert_eq!(req.user_verification, UserVerification::Required);
283        assert_eq!(req.timeout_ms, Some(60000));
284        assert_eq!(req.allow_credentials, vec![cred.to_vec()]);
285        assert_eq!(req.raw_options_json, json); // verbatim for Credential Manager
286    }
287
288    #[test]
289    fn missing_challenge_is_error() {
290        assert!(parse_request_options("{\"rpId\":\"x\"}").is_err());
291    }
292
293    #[test]
294    fn unknown_user_verification_fails_closed() {
295        let json = serde_json::json!({
296            "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
297            "userVerification": "sometimes",
298        })
299        .to_string();
300        assert!(matches!(
301            parse_request_options(&json),
302            Err(PasskeyError::InvalidOptions(_))
303        ));
304    }
305
306    #[test]
307    fn absent_user_verification_defaults_to_preferred() {
308        let json =
309            serde_json::json!({ "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c") }).to_string();
310        let req = parse_request_options(&json).unwrap();
311        assert_eq!(req.user_verification, UserVerification::Preferred);
312    }
313
314    #[test]
315    fn malformed_allow_credentials_is_rejected() {
316        // non-array
317        let json = serde_json::json!({
318            "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
319            "allowCredentials": "nope",
320        })
321        .to_string();
322        assert!(parse_request_options(&json).is_err());
323
324        // entry without a string id must error, not be silently dropped
325        let json = serde_json::json!({
326            "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
327            "allowCredentials": [{"type": "public-key"}],
328        })
329        .to_string();
330        assert!(parse_request_options(&json).is_err());
331
332        // present-but-empty id (all padding / empty string) decodes to zero bytes,
333        // which is never a real credential id, so it must error, not push an empty entry.
334        let json = serde_json::json!({
335            "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
336            "allowCredentials": [{"type": "public-key", "id": ""}],
337        })
338        .to_string();
339        assert!(parse_request_options(&json).is_err());
340    }
341
342    #[test]
343    fn empty_challenge_is_rejected() {
344        // a present-but-empty challenge provides zero replay protection; reject it
345        // rather than handing a degenerate request to the authenticator.
346        let json = serde_json::json!({ "challenge": "" }).to_string();
347        assert!(matches!(
348            parse_request_options(&json),
349            Err(PasskeyError::InvalidOptions(_))
350        ));
351    }
352
353    #[test]
354    fn non_string_rp_id_is_rejected() {
355        // present-but-malformed rpId must fail closed, not silently drop the RP.
356        let json = serde_json::json!({
357            "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
358            "rpId": 123,
359        })
360        .to_string();
361        assert!(matches!(
362            parse_request_options(&json),
363            Err(PasskeyError::InvalidOptions(_))
364        ));
365    }
366
367    #[test]
368    fn builds_wa_assertion_json_shape() {
369        let bytes = build_webauthn_assertion_json(b"cid", b"cdj", b"authdata", b"sig", None);
370        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
371        assert_eq!(v["type"], "public-key");
372        assert_eq!(v["id"], BASE64_URL_SAFE_NO_PAD.encode(b"cid"));
373        assert_eq!(v["rawId"], BASE64_URL_SAFE_NO_PAD.encode(b"cid"));
374        assert_eq!(
375            v["response"]["clientDataJSON"],
376            BASE64_URL_SAFE_NO_PAD.encode(b"cdj")
377        );
378        assert_eq!(
379            v["response"]["signature"],
380            BASE64_URL_SAFE_NO_PAD.encode(b"sig")
381        );
382        assert!(v["response"]["userHandle"].is_null());
383    }
384
385    #[tokio::test]
386    async fn callback_authenticator_invokes_closure() {
387        let auth = CallbackAuthenticator::new(|req: AssertionRequest| {
388            Box::pin(async move {
389                Ok(Assertion {
390                    assertion_json: req.raw_options_json.into_bytes(),
391                    credential_id: req.challenge,
392                })
393            })
394        });
395        let req = AssertionRequest {
396            challenge: vec![1, 2, 3],
397            rp_id: Some("web.whatsapp.com".into()),
398            allow_credentials: vec![],
399            user_verification: UserVerification::Preferred,
400            timeout_ms: None,
401            raw_options_json: "{}".into(),
402        };
403        let a = auth.get_assertion(&req).await.unwrap();
404        assert_eq!(a.credential_id, vec![1, 2, 3]);
405        assert_eq!(a.assertion_json, b"{}".to_vec());
406    }
407}