Skip to main content

passkey_server/
protocol.rs

1use crate::error::{PasskeyError, Result};
2use crate::store::PasskeyStore;
3use crate::types::*;
4use base64::prelude::*;
5use coset::cbor::value::Value;
6use coset::{CborSerializable, CoseKey, Label};
7use p256::ecdsa::signature::Verifier;
8use p256::ecdsa::{Signature, VerifyingKey};
9use p256::EncodedPoint;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use uuid::Uuid;
13
14// Constants
15const CHALLENGE_LEN: usize = 32;
16
17// Internal helpers
18
19#[derive(Serialize, Deserialize)]
20struct RegState {
21    challenge: String,
22    user_id: String,
23}
24
25#[derive(Serialize, Deserialize)]
26struct LoginState {
27    challenge: String,
28}
29
30#[derive(Deserialize)]
31struct ClientData {
32    challenge: String,
33    origin: String,
34    #[serde(rename = "type")]
35    type_: String,
36}
37
38struct AuthData {
39    rp_id_hash: Vec<u8>,
40    flags: u8,
41    sign_count: u32,
42    credential_data: Option<Vec<u8>>,
43}
44
45fn generate_challenge() -> Result<String> {
46    let mut buf = [0u8; CHALLENGE_LEN];
47    getrandom::fill(&mut buf).map_err(|e| {
48        PasskeyError::InternalError(format!("Failed to generate random challenge: {e}"))
49    })?;
50    Ok(BASE64_URL_SAFE_NO_PAD.encode(buf))
51}
52
53fn verify_client_data(
54    client_data_b64: &str,
55    expected_challenge: &str,
56    config: &PasskeyConfig,
57    expected_type: &str,
58) -> Result<(ClientData, Vec<u8>)> {
59    let bytes = BASE64_URL_SAFE_NO_PAD.decode(client_data_b64)?;
60    let data: ClientData = serde_json::from_slice(&bytes)?;
61
62    if data.challenge != expected_challenge {
63        return Err(PasskeyError::InvalidChallenge);
64    }
65    if data.origin != config.origin {
66        return Err(PasskeyError::OriginMismatch {
67            expected: config.origin.clone(),
68            got: data.origin,
69        });
70    }
71    if data.type_ != expected_type {
72        return Err(PasskeyError::InvalidOperationType);
73    }
74    Ok((data, bytes))
75}
76
77fn parse_auth_data(raw: &[u8]) -> Result<AuthData> {
78    if raw.len() < 37 {
79        return Err(PasskeyError::InternalError("authData too short".into()));
80    }
81    let rp_id_hash = raw[0..32].to_vec();
82    let flags = raw[32];
83    let sign_count = u32::from_be_bytes(raw[33..37].try_into().unwrap());
84    let credential_data = if (flags & 0x40) != 0 {
85        Some(raw[37..].to_vec())
86    } else {
87        None
88    };
89    Ok(AuthData {
90        rp_id_hash,
91        flags,
92        sign_count,
93        credential_data,
94    })
95}
96
97fn verify_rp_id_hash(hash: &[u8], config: &PasskeyConfig) -> Result<()> {
98    let expected = Sha256::digest(config.rp_id.as_bytes());
99    if hash != expected.as_slice() {
100        return Err(PasskeyError::RpIdHashMismatch);
101    }
102    Ok(())
103}
104
105fn verify_user_present(flags: u8) -> Result<()> {
106    if (flags & 0x01) == 0 {
107        return Err(PasskeyError::UserPresentFlagNotSet);
108    }
109    Ok(())
110}
111
112fn extract_credential(data: &[u8]) -> Result<(&[u8], &[u8])> {
113    if data.len() < 18 {
114        return Err(PasskeyError::InternalError(
115            "Credential Data too short".into(),
116        ));
117    }
118    let cred_id_len = u16::from_be_bytes(data[16..18].try_into().unwrap()) as usize;
119    if data.len() < 18 + cred_id_len {
120        return Err(PasskeyError::InternalError(
121            "Credential ID incomplete".into(),
122        ));
123    }
124    let cred_id = &data[18..18 + cred_id_len];
125    let pub_key_cbor = &data[18 + cred_id_len..];
126    Ok((cred_id, pub_key_cbor))
127}
128
129fn verify_p256_signature(
130    pub_key_cbor: &[u8],
131    signed_data: &[u8],
132    signature_der: &[u8],
133) -> Result<()> {
134    let cose_key = CoseKey::from_slice(pub_key_cbor)
135        .map_err(|e| PasskeyError::InternalError(format!("Invalid COSE key: {e}")))?;
136
137    let x = match cose_key.params.iter().find(|(k, _)| k == &Label::Int(-2)) {
138        Some((_, Value::Bytes(b))) => b,
139        _ => return Err(PasskeyError::InternalError("Missing x coordinate".into())),
140    };
141    let y = match cose_key.params.iter().find(|(k, _)| k == &Label::Int(-3)) {
142        Some((_, Value::Bytes(b))) => b,
143        _ => return Err(PasskeyError::InternalError("Missing y coordinate".into())),
144    };
145
146    if x.len() != 32 || y.len() != 32 {
147        return Err(PasskeyError::InternalError(
148            "Invalid coordinate length".into(),
149        ));
150    }
151
152    let encoded_point = EncodedPoint::from_affine_coordinates(
153        p256::FieldBytes::from_slice(x),
154        p256::FieldBytes::from_slice(y),
155        false,
156    );
157    let verifying_key = VerifyingKey::from_encoded_point(&encoded_point)
158        .map_err(|e| PasskeyError::InternalError(format!("Invalid P-256 key: {e}")))?;
159
160    let signature = Signature::from_der(signature_der)
161        .map_err(|e| PasskeyError::InvalidSignature(e.to_string()))?;
162
163    verifying_key
164        .verify(signed_data, &signature)
165        .map_err(|e| PasskeyError::InvalidSignature(e.to_string()))
166}
167
168// Core WebAuthn Flows
169
170/// Initiates a new passkey registration.
171///
172/// Returns the options that must be sent to the WebAuthn client (`navigator.credentials.create`).
173/// It also saves the registration session state to the provided `store`.
174pub async fn start_registration<S: PasskeyStore + ?Sized>(
175    store: &S,
176    user_id: &str,
177    username: &str,
178    display_name: &str,
179    config: &PasskeyConfig,
180    now_ms: i64,
181) -> Result<PublicKeyCredentialCreationOptions> {
182    let challenge = generate_challenge()?;
183    let user_handle = BASE64_URL_SAFE_NO_PAD.encode(user_id.as_bytes());
184
185    let existing = store.list_passkeys(user_id.to_string()).await?;
186    let exclude_credentials = if existing.is_empty() {
187        None
188    } else {
189        Some(
190            existing
191                .into_iter()
192                .map(|pk| CredentialDescriptor {
193                    type_: "public-key".into(),
194                    id: pk.cred_id,
195                    transports: None,
196                })
197                .collect(),
198        )
199    };
200
201    let options = PublicKeyCredentialCreationOptions {
202        rp: RpEntity {
203            name: config.rp_name.clone(),
204            id: config.rp_id.clone(),
205        },
206        user: UserEntity {
207            id: user_handle,
208            name: username.to_string(),
209            display_name: display_name.to_string(),
210        },
211        challenge: challenge.clone(),
212        pub_key_cred_params: vec![PubKeyCredParam {
213            type_: "public-key".into(),
214            alg: -7, // ES256
215        }],
216        timeout: Some(60000),
217        exclude_credentials,
218        authenticator_selection: Some(AuthenticatorSelection {
219            authenticator_attachment: None,
220            require_resident_key: Some(false),
221            resident_key: Some("preferred".into()),
222            user_verification: Some("preferred".into()),
223        }),
224        attestation: Some("none".into()),
225    };
226
227    // Persist challenge state
228    let state = RegState {
229        challenge,
230        user_id: user_id.to_string(),
231    };
232    let state_id = format!("reg:{}", user_id);
233    let expires_at = now_ms + (config.state_ttl * 1000);
234    store
235        .save_state(&state_id, &serde_json::to_string(&state)?, expires_at)
236        .await?;
237
238    Ok(options)
239}
240
241/// Completes a passkey registration.
242///
243/// Validates the client response against the stored challenge and RP configuration.
244/// On success, a new [`StoredPasskey`](crate::types::StoredPasskey) is created via the `store`.
245pub async fn finish_registration<S: PasskeyStore + ?Sized>(
246    store: &S,
247    user_id: &str,
248    config: &PasskeyConfig,
249    response: RegistrationResponse,
250    now_ms: i64,
251) -> Result<()> {
252    // 1. Retrieve & validate state
253    let state_id = format!("reg:{}", user_id);
254    let record = store
255        .get_state(&state_id)
256        .await?
257        .ok_or(PasskeyError::RegistrationSessionExpired)?;
258    let state: RegState = serde_json::from_str(&record.state_json)?;
259
260    if state.user_id != user_id {
261        return Err(PasskeyError::InternalError(
262            "User ID mismatch in session".into(),
263        ));
264    }
265
266    // 2. Verify clientDataJSON
267    verify_client_data(
268        &response.response.client_data_json,
269        &state.challenge,
270        config,
271        "webauthn.create",
272    )?;
273
274    // 3. Parse attestation object (CBOR)
275    let att_bytes = BASE64_URL_SAFE_NO_PAD.decode(&response.response.attestation_object)?;
276
277    let att_obj: Value = ciborium::from_reader(att_bytes.as_slice())
278        .map_err(|e| PasskeyError::InternalError(format!("Invalid attestationObject CBOR: {e}")))?;
279
280    let Value::Map(m) = &att_obj else {
281        return Err(PasskeyError::InternalError(
282            "Invalid attestation object structure".into(),
283        ));
284    };
285
286    let (_, auth_data_value) = m
287        .iter()
288        .find(|(k, _)| k.as_text().is_some_and(|s| s == "authData"))
289        .ok_or_else(|| PasskeyError::InternalError("authData missing".into()))?;
290
291    let auth_data_bytes = auth_data_value
292        .as_bytes()
293        .ok_or_else(|| PasskeyError::InternalError("authData not bytes".into()))?;
294
295    // 4. Verify authData
296    let auth_data = parse_auth_data(auth_data_bytes)?;
297    verify_rp_id_hash(&auth_data.rp_id_hash, config)?;
298    verify_user_present(auth_data.flags)?;
299
300    // 5. Extract credential
301    let cred_bytes = auth_data
302        .credential_data
303        .ok_or_else(|| PasskeyError::InternalError("Attested Credential Data missing".into()))?;
304    let (cred_id, pub_key_cbor) = extract_credential(&cred_bytes)?;
305
306    // Extract AAGUID
307    let aaguid = if cred_bytes.len() >= 16 {
308        let aaguid_bytes: [u8; 16] = cred_bytes[0..16].try_into().unwrap();
309        let uuid = Uuid::from_bytes(aaguid_bytes);
310        if uuid.is_nil() {
311            None
312        } else {
313            Some(uuid.to_string())
314        }
315    } else {
316        None
317    };
318
319    // Validate the public key parses
320    CoseKey::from_slice(pub_key_cbor)
321        .map_err(|e| PasskeyError::InternalError(format!("Invalid Public Key CBOR: {e}")))?;
322
323    // 6. Store credential
324    let cred_id_b64 = BASE64_URL_SAFE_NO_PAD.encode(cred_id);
325    let pub_key_b64 = BASE64_URL_SAFE_NO_PAD.encode(pub_key_cbor);
326
327    let passkey_name = match (response.name.as_deref(), aaguid) {
328        (Some(name), Some(id)) => format!("{}-{}", name, id),
329        (Some(name), None) => name.to_string(),
330        (None, Some(id)) => format!("Passkey-{}", id),
331        (None, None) => "Passkey".to_string(),
332    };
333
334    store
335        .create_passkey(
336            user_id.to_string(),
337            &cred_id_b64,
338            &pub_key_b64,
339            &passkey_name,
340            auth_data.sign_count as i64,
341            now_ms,
342        )
343        .await?;
344
345    store.delete_state(&state_id).await?;
346    Ok(())
347}
348
349/// Initiates a passkey login flow.
350///
351/// Returns the options that must be sent to the WebAuthn client (`navigator.credentials.get`).
352/// It saves a login session state keyed by the challenge.
353pub async fn start_login<S: PasskeyStore + ?Sized>(
354    store: &S,
355    config: &PasskeyConfig,
356    now_ms: i64,
357) -> Result<PublicKeyCredentialRequestOptions> {
358    let challenge = generate_challenge()?;
359
360    let options = PublicKeyCredentialRequestOptions {
361        challenge: challenge.clone(),
362        timeout: Some(60000),
363        rp_id: config.rp_id.clone(),
364        allow_credentials: None,
365        user_verification: Some("preferred".into()),
366    };
367
368    let state = LoginState { challenge };
369    let state_id = format!("login:{}", options.challenge);
370    let expires_at = now_ms + (config.state_ttl * 1000);
371    store
372        .save_state(&state_id, &serde_json::to_string(&state)?, expires_at)
373        .await?;
374
375    Ok(options)
376}
377
378/// Completes a passkey login flow.
379///
380/// Validates the client response, signature, and counter.
381/// On success, returns the `user_id` of the authenticated user and updates the counter in the `store`.
382pub async fn finish_login<S: PasskeyStore + ?Sized>(
383    store: &S,
384    config: &PasskeyConfig,
385    response: LoginResponse,
386    now_ms: i64,
387) -> Result<String> {
388    // 1. Parse clientDataJSON to retrieve the challenge for state lookup
389    let client_data_bytes = BASE64_URL_SAFE_NO_PAD.decode(&response.response.client_data_json)?;
390    let client_data_peek: ClientData = serde_json::from_slice(&client_data_bytes)?;
391
392    let state_id = format!("login:{}", client_data_peek.challenge);
393    let record = store
394        .get_state(&state_id)
395        .await?
396        .ok_or(PasskeyError::LoginSessionExpired)?;
397    let state: LoginState = serde_json::from_str(&record.state_json)?;
398
399    // 2. Full clientDataJSON verification
400    verify_client_data(
401        &response.response.client_data_json,
402        &state.challenge,
403        config,
404        "webauthn.get",
405    )?;
406
407    // 3. Parse & verify authenticator data
408    let auth_data_bytes = BASE64_URL_SAFE_NO_PAD.decode(&response.response.authenticator_data)?;
409
410    let auth_data = parse_auth_data(&auth_data_bytes)?;
411    verify_rp_id_hash(&auth_data.rp_id_hash, config)?;
412    verify_user_present(auth_data.flags)?;
413
414    // 4. Look up stored credential
415    let passkey = store
416        .get_passkey(&response.id)
417        .await?
418        .ok_or(PasskeyError::PasskeyNotFound)?;
419
420    // 5. Verify user handle if present
421    if let Some(ref uh_b64) = response.response.user_handle {
422        let uh_bytes = BASE64_URL_SAFE_NO_PAD.decode(uh_b64)?;
423        let uid_str = String::from_utf8(uh_bytes)
424            .map_err(|_| PasskeyError::InternalError("Invalid userHandle utf8".into()))?;
425        if uid_str != passkey.user_id {
426            return Err(PasskeyError::UserHandleMismatch);
427        }
428    }
429
430    // 6. Verify signature
431    let pub_key_bytes = BASE64_URL_SAFE_NO_PAD.decode(&passkey.public_key)?;
432
433    let client_data_hash = Sha256::digest(&client_data_bytes);
434    let mut signed_data = Vec::with_capacity(auth_data_bytes.len() + 32);
435    signed_data.extend_from_slice(&auth_data_bytes);
436    signed_data.extend_from_slice(&client_data_hash);
437
438    let sig_bytes = BASE64_URL_SAFE_NO_PAD.decode(&response.response.signature)?;
439
440    verify_p256_signature(&pub_key_bytes, &signed_data, &sig_bytes)?;
441
442    // 7. Counter check (clone detection)
443    if (auth_data.sign_count as i64) <= passkey.counter
444        && auth_data.sign_count != 0
445        && passkey.counter > 0
446    {
447        // Log removed (was console_error!)
448        return Err(PasskeyError::SignatureCounterRegression);
449    }
450
451    // 8. Update counter
452    store
453        .update_passkey_counter(&passkey.cred_id, auth_data.sign_count as i64, now_ms)
454        .await?;
455
456    store.delete_state(&state_id).await?;
457
458    // 9. Return the user ID
459    Ok(passkey.user_id)
460}