1mod client_data;
18pub use client_data::*;
19
20use std::{borrow::Cow, fmt::Display, ops::ControlFlow};
21
22use ciborium::{cbor, value::Value};
23use coset::{iana::EnumI64, Algorithm};
24use passkey_authenticator::{Authenticator, CredentialStore, UserValidationMethod};
25use passkey_types::{
26 crypto::sha256,
27 ctap2, encoding,
28 webauthn::{
29 self, AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement,
30 },
31 Passkey,
32};
33use serde::Serialize;
34use typeshare::typeshare;
35use url::Url;
36
37mod extensions;
38
39#[cfg(feature = "android-asset-validation")]
40mod android;
41
42#[cfg(feature = "android-asset-validation")]
43pub use self::android::{valid_fingerprint, UnverifiedAssetLink, ValidationError};
44
45#[cfg(test)]
46mod tests;
47
48#[typeshare]
49#[derive(Debug, serde::Serialize, PartialEq, Eq)]
50#[serde(tag = "type", content = "content")]
51pub enum WebauthnError {
53 CredentialIdTooLong,
55 OriginMissingDomain,
57 OriginRpMissmatch,
59 UnprotectedOrigin,
61 InsecureLocalhostNotAllowed,
63 CredentialNotFound,
65 InvalidRpId,
67 AuthenticatorError(u8),
69 NotSupportedError,
71 SyntaxError,
73 ValidationError,
75}
76
77impl WebauthnError {
78 pub fn is_vendor_error(&self) -> bool {
80 matches!(self, WebauthnError::AuthenticatorError(ctap_error) if ctap2::VendorError::try_from(*ctap_error).is_ok())
81 }
82}
83
84impl From<ctap2::StatusCode> for WebauthnError {
85 fn from(value: ctap2::StatusCode) -> Self {
86 match value {
87 ctap2::StatusCode::Ctap1(u2f) => WebauthnError::AuthenticatorError(u2f.into()),
88 ctap2::StatusCode::Ctap2(ctap2::Ctap2Code::Known(ctap2::Ctap2Error::NoCredentials)) => {
89 WebauthnError::CredentialNotFound
90 }
91 ctap2::StatusCode::Ctap2(ctap2code) => {
92 WebauthnError::AuthenticatorError(ctap2code.into())
93 }
94 }
95 }
96}
97
98fn decode_host(host: &str) -> Option<Cow<str>> {
101 if host.split('.').any(|s| s.starts_with("xn--")) {
102 let (decoded, result) = idna::domain_to_unicode(host);
103 result.ok().map(|_| Cow::from(decoded))
104 } else {
105 Some(Cow::from(host))
106 }
107}
108
109pub enum Origin<'a> {
111 Web(Cow<'a, Url>),
113 #[cfg(feature = "android-asset-validation")]
116 Android(UnverifiedAssetLink<'a>),
117}
118
119impl From<Url> for Origin<'_> {
120 fn from(value: Url) -> Self {
121 Origin::Web(Cow::Owned(value))
122 }
123}
124
125impl<'a> From<&'a Url> for Origin<'a> {
126 fn from(value: &'a Url) -> Self {
127 Origin::Web(Cow::Borrowed(value))
128 }
129}
130
131impl Display for Origin<'_> {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 match self {
134 Origin::Web(url) => write!(f, "{}", url.as_str().trim_end_matches('/')),
135 #[cfg(feature = "android-asset-validation")]
136 Origin::Android(target_link) => {
137 write!(
138 f,
139 "android:apk-key-hash:{}",
140 encoding::base64url(target_link.sha256_cert_fingerprint())
141 )
142 }
143 }
144 }
145}
146
147pub struct Client<S, U, P>
157where
158 S: CredentialStore + Sync,
159 U: UserValidationMethod + Sync,
160 P: public_suffix::EffectiveTLDProvider + Sync + 'static,
161{
162 authenticator: Authenticator<S, U>,
163 rp_id_verifier: RpIdVerifier<P>,
164}
165
166impl<S, U> Client<S, U, public_suffix::PublicSuffixList>
167where
168 S: CredentialStore + Sync,
169 U: UserValidationMethod + Sync,
170 Passkey: TryFrom<<S as CredentialStore>::PasskeyItem>,
171{
172 pub fn new(authenticator: Authenticator<S, U>) -> Self {
175 Self {
176 authenticator,
177 rp_id_verifier: RpIdVerifier::new(public_suffix::DEFAULT_PROVIDER),
178 }
179 }
180}
181
182impl<S, U, P> Client<S, U, P>
183where
184 S: CredentialStore + Sync,
185 U: UserValidationMethod<PasskeyItem = <S as CredentialStore>::PasskeyItem> + Sync,
186 P: public_suffix::EffectiveTLDProvider + Sync + 'static,
187{
188 pub fn new_with_custom_tld_provider(
191 authenticator: Authenticator<S, U>,
192 custom_provider: P,
193 ) -> Self {
194 Self {
195 authenticator,
196 rp_id_verifier: RpIdVerifier::new(custom_provider),
197 }
198 }
199
200 pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
202 self.rp_id_verifier = self.rp_id_verifier.allows_insecure_localhost(is_allowed);
203 self
204 }
205
206 pub fn authenticator(&self) -> &Authenticator<S, U> {
208 &self.authenticator
209 }
210
211 pub fn authenticator_mut(&mut self) -> &mut Authenticator<S, U> {
213 &mut self.authenticator
214 }
215
216 pub async fn register<D: ClientData<E>, E: Serialize + Clone>(
220 &mut self,
221 origin: impl Into<Origin<'_>>,
222 request: webauthn::CredentialCreationOptions,
223 client_data: D,
224 ) -> Result<webauthn::CreatedPublicKeyCredential, WebauthnError> {
225 let origin = origin.into();
226
227 let request = request.public_key;
229 let auth_info = self.authenticator.get_info().await;
230
231 let pub_key_cred_params = if request.pub_key_cred_params.is_empty() {
232 webauthn::PublicKeyCredentialParameters::default_algorithms()
233 } else {
234 request.pub_key_cred_params
235 };
236 let rp_id = self
244 .rp_id_verifier
245 .assert_domain(&origin, request.rp.id.as_deref())?;
246
247 let collected_client_data = webauthn::CollectedClientData::<E> {
248 ty: webauthn::ClientDataType::Create,
249 challenge: encoding::base64url(&request.challenge),
250 origin: origin.to_string(),
251 cross_origin: None,
252 extra_data: client_data.extra_client_data(),
253 unknown_keys: Default::default(),
254 };
255
256 let client_data_json = serde_json::to_string(&collected_client_data).unwrap();
258 let client_data_json_hash = client_data
259 .client_data_hash()
260 .unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec());
261
262 let extension_request = request.extensions.and_then(|e| e.zip_contents());
263
264 let ctap_extensions = self.registration_extension_ctap2_input(
265 extension_request.as_ref(),
266 auth_info.extensions.as_deref().unwrap_or_default(),
267 )?;
268
269 let rk = self.map_rk(&request.authenticator_selection, &auth_info);
270 let uv = request.authenticator_selection.map(|s| s.user_verification)
271 != Some(UserVerificationRequirement::Discouraged);
272
273 let ctap2_response = self
274 .authenticator
275 .make_credential(ctap2::make_credential::Request {
276 client_data_hash: client_data_json_hash.into(),
277 rp: ctap2::make_credential::PublicKeyCredentialRpEntity {
278 id: rp_id.to_owned(),
279 name: Some(request.rp.name),
280 },
281 user: request.user,
282 pub_key_cred_params,
283 exclude_list: request.exclude_credentials,
284 extensions: ctap_extensions,
285 options: ctap2::make_credential::Options { rk, up: true, uv },
286 pin_auth: None,
287 pin_protocol: None,
288 })
289 .await
290 .map_err(|sc| WebauthnError::AuthenticatorError(sc.into()))?;
291
292 let mut attestation_object = Vec::with_capacity(128);
293 let attestation_object_value = cbor!({
298 "fmt" => "none",
300 "attStmt" => {},
301 "authData" => Value::Bytes(ctap2_response.auth_data.to_vec()),
303 })
304 .unwrap();
305 ciborium::ser::into_writer(&attestation_object_value, &mut attestation_object).unwrap();
306
307 let credential_id = ctap2_response
312 .auth_data
313 .attested_credential_data
314 .as_ref()
315 .unwrap();
316 let alg = match credential_id.key.alg.as_ref().unwrap() {
317 Algorithm::PrivateUse(val) => *val,
318 Algorithm::Assigned(alg) => alg.to_i64(),
319 Algorithm::Text(_) => {
320 unreachable!()
321 }
322 };
323 let public_key = Some(
324 passkey_authenticator::public_key_der_from_cose_key(&credential_id.key)
325 .map_err(|e| WebauthnError::AuthenticatorError(e.into()))?,
326 );
327
328 let store_info = self.authenticator.store().get_info().await;
329 let client_extension_results = self.registration_extension_outputs(
330 extension_request.as_ref(),
331 store_info,
332 rk,
333 ctap2_response.unsigned_extension_outputs,
334 );
335
336 let response = webauthn::CreatedPublicKeyCredential {
337 id: encoding::base64url(credential_id.credential_id()),
338 raw_id: credential_id.credential_id().to_vec().into(),
339 ty: webauthn::PublicKeyCredentialType::PublicKey,
340 response: webauthn::AuthenticatorAttestationResponse {
341 client_data_json: Vec::from(client_data_json).into(),
342 authenticator_data: ctap2_response.auth_data.to_vec().into(),
343 public_key,
344 public_key_algorithm: alg,
345 attestation_object: attestation_object.into(),
346 transports: auth_info.transports,
347 },
348 authenticator_attachment: Some(self.authenticator().attachment_type()),
349 client_extension_results,
350 };
351
352 Ok(response)
353 }
354
355 pub async fn authenticate<D: ClientData<E>, E: Serialize + Clone>(
359 &mut self,
360 origin: impl Into<Origin<'_>>,
361 request: webauthn::CredentialRequestOptions,
362 client_data: D,
363 ) -> Result<webauthn::AuthenticatedPublicKeyCredential, WebauthnError> {
364 let origin = origin.into();
365
366 let request = request.public_key;
368 let auth_info = self.authenticator().get_info().await;
369
370 let rp_id = self
378 .rp_id_verifier
379 .assert_domain(&origin, request.rp_id.as_deref())?;
380
381 let collected_client_data = webauthn::CollectedClientData::<E> {
382 ty: webauthn::ClientDataType::Get,
383 challenge: encoding::base64url(&request.challenge),
384 origin: origin.to_string(),
385 cross_origin: None, extra_data: client_data.extra_client_data(),
387 unknown_keys: Default::default(),
388 };
389
390 let client_data_json = serde_json::to_string(&collected_client_data).unwrap();
392 let client_data_json_hash = client_data
393 .client_data_hash()
394 .unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec());
395
396 let ctap_extensions = self.auth_extension_ctap2_input(
397 &request,
398 auth_info.extensions.unwrap_or_default().as_slice(),
399 )?;
400 let rk = false;
401 let uv = request.user_verification != UserVerificationRequirement::Discouraged;
402
403 let ctap2_response = self
404 .authenticator
405 .get_assertion(ctap2::get_assertion::Request {
406 rp_id: rp_id.to_owned(),
407 client_data_hash: client_data_json_hash.into(),
408 allow_list: request.allow_credentials,
409 extensions: ctap_extensions,
410 options: ctap2::get_assertion::Options { rk, up: true, uv },
411 pin_auth: None,
412 pin_protocol: None,
413 })
414 .await
415 .map_err(Into::<WebauthnError>::into)?;
416
417 let client_extension_results =
418 self.auth_extension_outputs(ctap2_response.unsigned_extension_outputs);
419
420 let credential_id_bytes = ctap2_response.credential.unwrap().id;
425 Ok(webauthn::AuthenticatedPublicKeyCredential {
426 id: encoding::base64url(&credential_id_bytes),
427 raw_id: credential_id_bytes.to_vec().into(),
428 ty: webauthn::PublicKeyCredentialType::PublicKey,
429 response: webauthn::AuthenticatorAssertionResponse {
430 client_data_json: Vec::from(client_data_json).into(),
431 authenticator_data: ctap2_response.auth_data.to_vec().into(),
432 signature: ctap2_response.signature,
433 user_handle: ctap2_response.user.map(|user| user.id),
434 attestation_object: None,
435 },
436 authenticator_attachment: Some(self.authenticator().attachment_type()),
437 client_extension_results,
438 })
439 }
440
441 fn map_rk(
442 &self,
443 criteria: &Option<AuthenticatorSelectionCriteria>,
444 auth_info: &ctap2::get_info::Response,
445 ) -> bool {
446 let supports_rk = auth_info.options.as_ref().is_some_and(|o| o.rk);
447
448 match criteria.as_ref().unwrap_or(&Default::default()) {
449 AuthenticatorSelectionCriteria {
452 resident_key: Some(ResidentKeyRequirement::Required),
453 ..
454 } => true,
456
457 AuthenticatorSelectionCriteria {
459 resident_key: Some(ResidentKeyRequirement::Preferred),
460 ..
461 } => supports_rk,
466
467 AuthenticatorSelectionCriteria {
469 resident_key: Some(ResidentKeyRequirement::Discouraged),
470 ..
471 } => false,
473
474 AuthenticatorSelectionCriteria {
476 resident_key: None,
477 require_resident_key,
478 ..
479 } => *require_resident_key,
481 }
482 }
483}
484
485pub struct RpIdVerifier<P> {
491 tld_provider: Box<P>,
492 allows_insecure_localhost: bool,
493}
494
495impl<P> RpIdVerifier<P>
496where
497 P: public_suffix::EffectiveTLDProvider + Sync + 'static,
498{
499 pub fn new(tld_provider: P) -> Self {
502 Self {
503 tld_provider: Box::new(tld_provider),
504 allows_insecure_localhost: false,
505 }
506 }
507
508 pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
510 self.allows_insecure_localhost = is_allowed;
511 self
512 }
513
514 pub fn assert_domain<'a>(
520 &self,
521 origin: &'a Origin,
522 rp_id: Option<&'a str>,
523 ) -> Result<&'a str, WebauthnError> {
524 match origin {
525 Origin::Web(url) => self.assert_web_rp_id(url, rp_id),
526 #[cfg(feature = "android-asset-validation")]
527 Origin::Android(unverified) => self.assert_android_rp_id(unverified, rp_id),
528 }
529 }
530
531 fn assert_web_rp_id<'a>(
532 &self,
533 origin: &'a Url,
534 rp_id: Option<&'a str>,
535 ) -> Result<&'a str, WebauthnError> {
536 let mut effective_domain = origin.domain().ok_or(WebauthnError::OriginMissingDomain)?;
537
538 if let Some(rp_id) = rp_id {
539 if !effective_domain.ends_with(rp_id) {
540 return Err(WebauthnError::OriginRpMissmatch);
541 }
542
543 effective_domain = rp_id;
544 }
545
546 if let ControlFlow::Break(res) = self.assert_valid_rp_id(effective_domain) {
548 return res;
549 }
550
551 if !(origin.scheme().eq_ignore_ascii_case("https")) {
553 return Err(WebauthnError::UnprotectedOrigin);
554 }
555
556 Ok(effective_domain)
557 }
558
559 fn assert_valid_rp_id<'a>(
560 &self,
561 rp_id: &'a str,
562 ) -> ControlFlow<Result<&'a str, WebauthnError>, ()> {
563 if rp_id == "localhost" {
565 return if self.allows_insecure_localhost {
566 ControlFlow::Break(Ok(rp_id))
567 } else {
568 ControlFlow::Break(Err(WebauthnError::InsecureLocalhostNotAllowed))
569 };
570 }
571
572 if decode_host(rp_id)
574 .as_ref()
575 .and_then(|s| self.tld_provider.effective_tld_plus_one(s).ok())
576 .is_none()
577 {
578 return ControlFlow::Break(Err(WebauthnError::InvalidRpId));
579 }
580
581 ControlFlow::Continue(())
582 }
583
584 pub fn is_valid_rp_id(&self, rp_id: &str) -> bool {
595 match self.assert_valid_rp_id(rp_id) {
596 ControlFlow::Continue(_) | ControlFlow::Break(Ok(_)) => true,
597 ControlFlow::Break(Err(_)) => false,
598 }
599 }
600
601 #[cfg(feature = "android-asset-validation")]
602 fn assert_android_rp_id<'a>(
603 &self,
604 target_link: &'a UnverifiedAssetLink,
605 rp_id: Option<&'a str>,
606 ) -> Result<&'a str, WebauthnError> {
607 let mut effective_rp_id = target_link.host();
608
609 if let Some(rp_id) = rp_id {
610 if !effective_rp_id.ends_with(rp_id) {
612 return Err(WebauthnError::OriginRpMissmatch);
613 }
614 effective_rp_id = rp_id;
615 }
616
617 if decode_host(effective_rp_id)
618 .as_ref()
619 .and_then(|s| self.tld_provider.effective_tld_plus_one(s).ok())
620 .is_none()
621 {
622 return Err(WebauthnError::InvalidRpId);
623 }
624
625 Ok(effective_rp_id)
630 }
631}
632
633#[cfg(test)]
634mod test {
635 use passkey_authenticator::{Authenticator, MemoryStore, MockUserValidationMethod};
636 use passkey_types::{
637 ctap2,
638 webauthn::{
639 AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement,
640 },
641 };
642
643 use crate::Client;
644
645 #[test]
646 fn map_rk_maps_criteria_to_rk_bool() {
647 #[derive(Debug)]
648 struct TestCase {
649 resident_key: Option<ResidentKeyRequirement>,
650 require_resident_key: bool,
651 expected_rk: bool,
652 }
653
654 let test_cases = vec![
655 TestCase {
657 resident_key: None,
658 require_resident_key: false,
659 expected_rk: false,
660 },
661 TestCase {
662 resident_key: None,
663 require_resident_key: true,
664 expected_rk: true,
665 },
666 TestCase {
668 resident_key: Some(ResidentKeyRequirement::Discouraged),
669 require_resident_key: false,
670 expected_rk: false,
671 },
672 TestCase {
673 resident_key: Some(ResidentKeyRequirement::Preferred),
674 require_resident_key: false,
675 expected_rk: true,
676 },
677 TestCase {
678 resident_key: Some(ResidentKeyRequirement::Required),
679 require_resident_key: false,
680 expected_rk: true,
681 },
682 TestCase {
684 resident_key: Some(ResidentKeyRequirement::Discouraged),
685 require_resident_key: true,
686 expected_rk: false,
687 },
688 ];
689
690 for test_case in test_cases {
691 let criteria = AuthenticatorSelectionCriteria {
692 resident_key: test_case.resident_key,
693 require_resident_key: test_case.require_resident_key,
694 user_verification: UserVerificationRequirement::Discouraged,
695 authenticator_attachment: None,
696 };
697 let auth_info = ctap2::get_info::Response {
698 versions: vec![],
699 extensions: None,
700 aaguid: ctap2::Aaguid::new_empty(),
701 options: Some(ctap2::get_info::Options {
702 rk: true,
703 uv: Some(true),
704 up: true,
705 plat: true,
706 client_pin: None,
707 }),
708 max_msg_size: None,
709 pin_protocols: None,
710 transports: None,
711 };
712 let client = Client::new(Authenticator::new(
713 ctap2::Aaguid::new_empty(),
714 MemoryStore::new(),
715 MockUserValidationMethod::verified_user(0),
716 ));
717
718 let result = client.map_rk(&Some(criteria), &auth_info);
719
720 assert_eq!(result, test_case.expected_rk, "{:?}", test_case);
721 }
722 }
723}