passki 0.2.1

A simple and secure WebAuthn/Passkey authentication library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
// Copyright 2026 Grzegorz Blach
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Passkey registration functionality.

use aws_lc_rs::digest::{self, SHA256};
use serde::{Deserialize, Serialize};

use crate::Passki;
use crate::client_data::{ClientData, ClientDataType};
use crate::types::*;

/// Challenge sent to the client to begin passkey registration.
///
/// This structure contains all the parameters needed by the WebAuthn client
/// to create a new credential.
#[derive(Serialize, Debug)]
pub struct RegistrationChallenge {
    /// Information about the relying party.
    pub rp: RelyingParty,

    /// Information about the user.
    pub user: UserInfo,

    /// The challenge value (base64url-encoded).
    pub challenge: String,

    /// List of acceptable public key credential parameters.
    #[serde(rename = "pubKeyCredParams")]
    pub pub_key_cred_params: Vec<PubKeyCredParam>,

    /// Timeout for the operation in milliseconds.
    pub timeout: u64,

    /// Attestation conveyance preference.
    pub attestation: AttestationConveyancePreference,

    /// Authenticator selection criteria.
    #[serde(rename = "authenticatorSelection")]
    pub authenticator_selection: AuthenticatorSelection,

    /// List of credentials to exclude from registration.
    ///
    /// These credentials will not be allowed to be registered again,
    /// preventing duplicate registrations.
    #[serde(rename = "excludeCredentials")]
    pub exclude_credentials: Vec<ExcludeCredential>,

    /// WebAuthn extensions to request from the authenticator.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extensions: Option<RegistrationExtensions>,
}

/// Server-side state for a passkey registration in progress.
///
/// This state must be stored temporarily and provided when completing the registration.
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct RegistrationState {
    /// The challenge that was sent to the client.
    pub challenge: Vec<u8>,

    /// The user information.
    pub user: UserInfo,

    /// The user verification requirement requested when the ceremony was started.
    pub user_verification: UserVerificationRequirement,
}

/// Credential data returned by the client after registration.
///
/// This structure contains the new credential's ID, public key, and client data.
#[derive(Deserialize)]
pub struct RegistrationCredential {
    /// The credential ID (base64url-encoded).
    pub credential_id: String,

    /// The attestation object containing the public key (base64url-encoded).
    pub public_key: String,

    /// The client data JSON (base64url-encoded).
    pub client_data_json: String,

    /// Extension results from the client (e.g., PRF support flag).
    pub client_extension_results: Option<ClientExtensionResults>,
}

/// Authenticator data parsed from a CBOR attestation object.
#[derive(Debug)]
pub(crate) struct ParsedAttestation {
    /// The credential ID from the attested credential data.
    pub credential_id: Vec<u8>,

    /// The raw COSE public key bytes.
    pub public_key: Vec<u8>,

    /// The COSE algorithm identifier.
    pub algorithm: i32,

    /// The authenticator data flags byte.
    pub flags: u8,

    /// The signature counter at registration time.
    pub counter: u32,

    /// The AAGUID of the authenticator that created the credential.
    pub aaguid: [u8; 16],
}

impl Passki {
    /// Starts a passkey registration ceremony.
    ///
    /// Generates a challenge and returns both the challenge to send to the client
    /// and the state to store on the server.
    ///
    /// # Arguments
    ///
    /// * `user_id` - Unique identifier for the user (must be at least 16 bytes)
    /// * `username` - Username or account identifier
    /// * `display_name` - Human-readable display name for the user
    /// * `timeout` - Timeout for the operation in milliseconds
    /// * `attestation` - Attestation conveyance preference
    /// * `resident_key` - Resident key requirement
    /// * `user_verification` - User verification requirement
    /// * `existing_credentials` - Optional list of existing credentials to exclude from registration
    /// * `extensions` - Optional WebAuthn extensions. Use `Some(RegistrationExtensions { prf:
    ///   PrfInput { eval: None } })` to probe PRF support, or include an `eval` to probe and
    ///   evaluate in a single round trip.
    ///
    /// # Returns
    ///
    /// A tuple containing:
    /// * `RegistrationChallenge` - Challenge to send to the client
    /// * `RegistrationState` - State to store on the server
    ///
    /// # Errors
    ///
    /// Returns an error if `user_id` is less than 16 bytes.
    #[allow(clippy::too_many_arguments)]
    pub fn start_passkey_registration(
        &self,
        user_id: &[u8],
        username: &str,
        display_name: &str,
        timeout: u64,
        attestation: AttestationConveyancePreference,
        resident_key: ResidentKeyRequirement,
        user_verification: UserVerificationRequirement,
        existing_credentials: Option<&[StoredPasskey]>,
        extensions: Option<RegistrationExtensions>,
    ) -> Result<(RegistrationChallenge, RegistrationState)> {
        if user_id.len() < 16 {
            return Err(Box::new(PasskiError::new(
                "user_id must be at least 16 bytes",
            )));
        }

        let challenge = Self::generate_challenge();
        let user_id_bytes = user_id.to_vec();

        let exclude_credentials = existing_credentials
            .unwrap_or(&[])
            .iter()
            .map(|pk| ExcludeCredential {
                id: Self::base64_encode(&pk.credential_id),
                type_: "public-key".to_string(),
            })
            .collect();

        let user = UserInfo {
            id: Self::base64_encode(&user_id_bytes),
            name: username.to_string(),
            display_name: display_name.to_string(),
        };

        let challenge_response = RegistrationChallenge {
            rp: RelyingParty {
                name: self.rp_name.clone(),
                id: self.rp_id.clone(),
            },
            user: user.clone(),
            challenge: Self::base64_encode(&challenge),
            pub_key_cred_params: [ALG_EDDSA, ALG_ES256, ALG_ES384, ALG_RS256, ALG_RS384]
                .into_iter()
                .map(|alg| PubKeyCredParam {
                    alg,
                    type_: "public-key".to_string(),
                })
                .collect(),
            timeout,
            attestation,
            authenticator_selection: AuthenticatorSelection {
                resident_key,
                user_verification,
            },
            exclude_credentials,
            extensions,
        };

        let state = RegistrationState {
            challenge: challenge.clone(),
            user,
            user_verification,
        };

        Ok((challenge_response, state))
    }

    /// Completes a passkey registration ceremony.
    ///
    /// Verifies the credential data returned by the client and returns a
    /// stored passkey that can be saved in the database.
    ///
    /// # Arguments
    ///
    /// * `credential` - The credential data returned by the client
    /// * `state` - The registration state stored on the server
    ///
    /// # Returns
    ///
    /// A `StoredPasskey` containing the credential information to save.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// * The client data is invalid
    /// * The challenge doesn't match
    /// * The origin doesn't match
    /// * The attestation object is malformed
    pub fn finish_passkey_registration(
        &self,
        credential: &RegistrationCredential,
        state: &RegistrationState,
    ) -> Result<StoredPasskey> {
        let client_data_bytes = Self::base64_decode(&credential.client_data_json)?;
        let client_data = ClientData::from_bytes(&client_data_bytes)?;
        client_data.verify(ClientDataType::Create, &state.challenge, &self.rp_origin)?;
        let client_data_hash = digest::digest(&SHA256, &client_data_bytes);

        // Parse the attestation object, extract the public key, and verify the
        // attestation statement.
        let attestation_bytes = Self::base64_decode(&credential.public_key)?;
        let parsed = self.verify_attestation(&attestation_bytes, client_data_hash.as_ref())?;

        if (parsed.flags & FLAG_UP) == 0 {
            return Err(Box::new(PasskiError::new(
                "User not present (UP flag not set)",
            )));
        }
        if state.user_verification == UserVerificationRequirement::Required
            && (parsed.flags & FLAG_UV) == 0
        {
            return Err(Box::new(PasskiError::new(
                "User verification required but UV flag not set",
            )));
        }

        // The credential ID in the attested credential data is authoritative;
        // the client-supplied one must match it.
        let credential_id = Self::base64_decode(&credential.credential_id)?;
        if credential_id != parsed.credential_id {
            return Err(Box::new(PasskiError::new(
                "Credential ID mismatch between client and attested credential data",
            )));
        }

        let rk = credential
            .client_extension_results
            .as_ref()
            .and_then(|ext| ext.cred_props.as_ref())
            .and_then(|cp| cp.rk);

        Ok(StoredPasskey {
            credential_id: parsed.credential_id,
            public_key: parsed.public_key,
            counter: parsed.counter,
            algorithm: parsed.algorithm,
            rk,
        })
    }

    /// Splits a CBOR attestation object into its `fmt`, raw `authData`, and `attStmt`.
    pub(crate) fn split_attestation_object(
        attestation_bytes: &[u8],
    ) -> Result<(Option<String>, Vec<u8>, ciborium::Value)> {
        let attestation: ciborium::Value = ciborium::from_reader(attestation_bytes)
            .map_err(|e| PasskiError::new(format!("Failed to parse attestation object: {}", e)))?;

        let map = attestation
            .as_map()
            .ok_or_else(|| PasskiError::new("Attestation object is not a map"))?;

        let auth_data = map
            .iter()
            .find(|(k, _)| k.as_text() == Some("authData"))
            .and_then(|(_, v)| v.as_bytes())
            .ok_or_else(|| PasskiError::new("Missing authData in attestation"))?
            .to_vec();

        let fmt = map
            .iter()
            .find(|(k, _)| k.as_text() == Some("fmt"))
            .and_then(|(_, v)| v.as_text())
            .map(str::to_string);

        let att_stmt = map
            .iter()
            .find(|(k, _)| k.as_text() == Some("attStmt"))
            .map(|(_, v)| v.clone())
            .unwrap_or_else(|| ciborium::Value::Map(Vec::new()));

        Ok((fmt, auth_data, att_stmt))
    }

    /// Parses a CBOR attestation object into its credential ID, public key, algorithm,
    /// flags byte, and signature counter.
    #[cfg(test)]
    pub(crate) fn parse_attestation_object(
        &self,
        attestation_bytes: &[u8],
    ) -> Result<ParsedAttestation> {
        let (_, auth_data, _) = Self::split_attestation_object(attestation_bytes)?;
        self.parse_auth_data(&auth_data)
    }

    /// Parses authenticator data into its credential ID, public key, algorithm,
    /// flags byte, signature counter, and AAGUID.
    pub(crate) fn parse_auth_data(&self, auth_data_bytes: &[u8]) -> Result<ParsedAttestation> {
        // Parse authenticator data
        if auth_data_bytes.len() < 37 {
            return Err(Box::new(PasskiError::new(
                "Invalid authenticator data length",
            )));
        }

        // Verify rpId hash (bytes 0-31)
        let rp_id_hash = digest::digest(&SHA256, self.rp_id.as_bytes());
        if &auth_data_bytes[..32] != rp_id_hash.as_ref() {
            return Err(Box::new(PasskiError::new("rpId hash mismatch")));
        }

        let flags = auth_data_bytes[32];

        let counter = u32::from_be_bytes([
            auth_data_bytes[33],
            auth_data_bytes[34],
            auth_data_bytes[35],
            auth_data_bytes[36],
        ]);

        // Check if attested credential data is present
        if (flags & FLAG_AT) == 0 {
            return Err(Box::new(PasskiError::new(
                "No attested credential data present",
            )));
        }

        // Skip: rpIdHash (32) + flags (1) + signCount (4) + aaguid (16) + credIdLen (2) = 55 bytes
        if auth_data_bytes.len() < 55 {
            return Err(Box::new(PasskiError::new("Authenticator data too short")));
        }

        let mut aaguid = [0u8; 16];
        aaguid.copy_from_slice(&auth_data_bytes[37..53]);

        let cred_id_len = u16::from_be_bytes([auth_data_bytes[53], auth_data_bytes[54]]) as usize;
        let cose_key_offset = 55 + cred_id_len;

        if auth_data_bytes.len() < cose_key_offset {
            return Err(Box::new(PasskiError::new(
                "Authenticator data too short for credential",
            )));
        }

        let credential_id = auth_data_bytes[55..cose_key_offset].to_vec();

        let cose_key_bytes = &auth_data_bytes[cose_key_offset..];
        let cose_key_value: ciborium::Value = ciborium::from_reader(cose_key_bytes)
            .map_err(|e| PasskiError::new(format!("Failed to parse COSE key: {}", e)))?;

        let algorithm = cose_key_value
            .as_map()
            .and_then(|m| m.iter().find(|(k, _)| k.as_integer() == Some(3.into())))
            .and_then(|(_, v)| v.as_integer())
            .and_then(|i| i.try_into().ok())
            .ok_or_else(|| PasskiError::new("Missing or invalid algorithm in COSE key"))?;

        // Re-serialize the parsed COSE key so trailing authData bytes (extension
        // data when the ED flag is set) are not stored with the key
        let mut public_key = Vec::new();
        ciborium::into_writer(&cose_key_value, &mut public_key)
            .map_err(|e| PasskiError::new(format!("Failed to serialize COSE key: {}", e)))?;

        Ok(ParsedAttestation {
            credential_id,
            public_key,
            algorithm,
            flags,
            counter,
            aaguid,
        })
    }
}