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
14const CHALLENGE_LEN: usize = 32;
16
17#[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
168pub 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, }],
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 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
241pub 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 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 verify_client_data(
268 &response.response.client_data_json,
269 &state.challenge,
270 config,
271 "webauthn.create",
272 )?;
273
274 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 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 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 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 CoseKey::from_slice(pub_key_cbor)
321 .map_err(|e| PasskeyError::InternalError(format!("Invalid Public Key CBOR: {e}")))?;
322
323 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
349pub 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
378pub async fn finish_login<S: PasskeyStore + ?Sized>(
383 store: &S,
384 config: &PasskeyConfig,
385 response: LoginResponse,
386 now_ms: i64,
387) -> Result<String> {
388 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 verify_client_data(
401 &response.response.client_data_json,
402 &state.challenge,
403 config,
404 "webauthn.get",
405 )?;
406
407 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 let passkey = store
416 .get_passkey(&response.id)
417 .await?
418 .ok_or(PasskeyError::PasskeyNotFound)?;
419
420 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 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 if (auth_data.sign_count as i64) <= passkey.counter
444 && auth_data.sign_count != 0
445 && passkey.counter > 0
446 {
447 return Err(PasskeyError::SignatureCounterRegression);
449 }
450
451 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 Ok(passkey.user_id)
460}