mod client_data;
pub use client_data::*;
use std::{borrow::Cow, fmt::Display, ops::ControlFlow};
use ciborium::{cbor, value::Value};
use coset::{iana::EnumI64, Algorithm};
use passkey_authenticator::{Authenticator, CredentialStore, UserValidationMethod};
use passkey_types::{
crypto::sha256,
ctap2, encoding,
webauthn::{
self, AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement,
},
Passkey,
};
use serde::Serialize;
use typeshare::typeshare;
use url::Url;
mod extensions;
#[cfg(feature = "android-asset-validation")]
mod android;
#[cfg(feature = "android-asset-validation")]
pub use self::android::{valid_fingerprint, UnverifiedAssetLink, ValidationError};
#[cfg(test)]
mod tests;
#[typeshare]
#[derive(Debug, serde::Serialize, PartialEq, Eq)]
#[serde(tag = "type", content = "content")]
pub enum WebauthnError {
CredentialIdTooLong,
OriginMissingDomain,
OriginRpMissmatch,
UnprotectedOrigin,
InsecureLocalhostNotAllowed,
CredentialNotFound,
InvalidRpId,
AuthenticatorError(u8),
NotSupportedError,
SyntaxError,
ValidationError,
}
impl WebauthnError {
pub fn is_vendor_error(&self) -> bool {
matches!(self, WebauthnError::AuthenticatorError(ctap_error) if ctap2::VendorError::try_from(*ctap_error).is_ok())
}
}
impl From<ctap2::StatusCode> for WebauthnError {
fn from(value: ctap2::StatusCode) -> Self {
match value {
ctap2::StatusCode::Ctap1(u2f) => WebauthnError::AuthenticatorError(u2f.into()),
ctap2::StatusCode::Ctap2(ctap2::Ctap2Code::Known(ctap2::Ctap2Error::NoCredentials)) => {
WebauthnError::CredentialNotFound
}
ctap2::StatusCode::Ctap2(ctap2code) => {
WebauthnError::AuthenticatorError(ctap2code.into())
}
}
}
}
fn decode_host(host: &str) -> Option<Cow<str>> {
if host.split('.').any(|s| s.starts_with("xn--")) {
let (decoded, result) = idna::domain_to_unicode(host);
result.ok().map(|_| Cow::from(decoded))
} else {
Some(Cow::from(host))
}
}
pub enum Origin<'a> {
Web(Cow<'a, Url>),
#[cfg(feature = "android-asset-validation")]
Android(UnverifiedAssetLink<'a>),
}
impl From<Url> for Origin<'_> {
fn from(value: Url) -> Self {
Origin::Web(Cow::Owned(value))
}
}
impl<'a> From<&'a Url> for Origin<'a> {
fn from(value: &'a Url) -> Self {
Origin::Web(Cow::Borrowed(value))
}
}
impl Display for Origin<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Origin::Web(url) => write!(f, "{}", url.as_str().trim_end_matches('/')),
#[cfg(feature = "android-asset-validation")]
Origin::Android(target_link) => {
write!(
f,
"android:apk-key-hash:{}",
encoding::base64url(target_link.sha256_cert_fingerprint())
)
}
}
}
}
pub struct Client<S, U, P>
where
S: CredentialStore + Sync,
U: UserValidationMethod + Sync,
P: public_suffix::EffectiveTLDProvider + Sync + 'static,
{
authenticator: Authenticator<S, U>,
rp_id_verifier: RpIdVerifier<P>,
}
impl<S, U> Client<S, U, public_suffix::PublicSuffixList>
where
S: CredentialStore + Sync,
U: UserValidationMethod + Sync,
Passkey: TryFrom<<S as CredentialStore>::PasskeyItem>,
{
pub fn new(authenticator: Authenticator<S, U>) -> Self {
Self {
authenticator,
rp_id_verifier: RpIdVerifier::new(public_suffix::DEFAULT_PROVIDER),
}
}
}
impl<S, U, P> Client<S, U, P>
where
S: CredentialStore + Sync,
U: UserValidationMethod<PasskeyItem = <S as CredentialStore>::PasskeyItem> + Sync,
P: public_suffix::EffectiveTLDProvider + Sync + 'static,
{
pub fn new_with_custom_tld_provider(
authenticator: Authenticator<S, U>,
custom_provider: P,
) -> Self {
Self {
authenticator,
rp_id_verifier: RpIdVerifier::new(custom_provider),
}
}
pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
self.rp_id_verifier = self.rp_id_verifier.allows_insecure_localhost(is_allowed);
self
}
pub fn authenticator(&self) -> &Authenticator<S, U> {
&self.authenticator
}
pub fn authenticator_mut(&mut self) -> &mut Authenticator<S, U> {
&mut self.authenticator
}
pub async fn register<D: ClientData<E>, E: Serialize + Clone>(
&mut self,
origin: impl Into<Origin<'_>>,
request: webauthn::CredentialCreationOptions,
client_data: D,
) -> Result<webauthn::CreatedPublicKeyCredential, WebauthnError> {
let origin = origin.into();
let request = request.public_key;
let auth_info = self.authenticator.get_info().await;
let pub_key_cred_params = if request.pub_key_cred_params.is_empty() {
webauthn::PublicKeyCredentialParameters::default_algorithms()
} else {
request.pub_key_cred_params
};
let rp_id = self
.rp_id_verifier
.assert_domain(&origin, request.rp.id.as_deref())?;
let collected_client_data = webauthn::CollectedClientData::<E> {
ty: webauthn::ClientDataType::Create,
challenge: encoding::base64url(&request.challenge),
origin: origin.to_string(),
cross_origin: None,
extra_data: client_data.extra_client_data(),
unknown_keys: Default::default(),
};
let client_data_json = serde_json::to_string(&collected_client_data).unwrap();
let client_data_json_hash = client_data
.client_data_hash()
.unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec());
let extension_request = request.extensions.and_then(|e| e.zip_contents());
let ctap_extensions = self.registration_extension_ctap2_input(
extension_request.as_ref(),
auth_info.extensions.as_deref().unwrap_or_default(),
)?;
let rk = self.map_rk(&request.authenticator_selection, &auth_info);
let uv = request.authenticator_selection.map(|s| s.user_verification)
!= Some(UserVerificationRequirement::Discouraged);
let ctap2_response = self
.authenticator
.make_credential(ctap2::make_credential::Request {
client_data_hash: client_data_json_hash.into(),
rp: ctap2::make_credential::PublicKeyCredentialRpEntity {
id: rp_id.to_owned(),
name: Some(request.rp.name),
},
user: request.user,
pub_key_cred_params,
exclude_list: request.exclude_credentials,
extensions: ctap_extensions,
options: ctap2::make_credential::Options { rk, up: true, uv },
pin_auth: None,
pin_protocol: None,
})
.await
.map_err(|sc| WebauthnError::AuthenticatorError(sc.into()))?;
let mut attestation_object = Vec::with_capacity(128);
let attestation_object_value = cbor!({
"fmt" => "none",
"attStmt" => {},
"authData" => Value::Bytes(ctap2_response.auth_data.to_vec()),
})
.unwrap();
ciborium::ser::into_writer(&attestation_object_value, &mut attestation_object).unwrap();
let credential_id = ctap2_response
.auth_data
.attested_credential_data
.as_ref()
.unwrap();
let alg = match credential_id.key.alg.as_ref().unwrap() {
Algorithm::PrivateUse(val) => *val,
Algorithm::Assigned(alg) => alg.to_i64(),
Algorithm::Text(_) => {
unreachable!()
}
};
let public_key = Some(
passkey_authenticator::public_key_der_from_cose_key(&credential_id.key)
.map_err(|e| WebauthnError::AuthenticatorError(e.into()))?,
);
let store_info = self.authenticator.store().get_info().await;
let client_extension_results = self.registration_extension_outputs(
extension_request.as_ref(),
store_info,
rk,
ctap2_response.unsigned_extension_outputs,
);
let response = webauthn::CreatedPublicKeyCredential {
id: encoding::base64url(credential_id.credential_id()),
raw_id: credential_id.credential_id().to_vec().into(),
ty: webauthn::PublicKeyCredentialType::PublicKey,
response: webauthn::AuthenticatorAttestationResponse {
client_data_json: Vec::from(client_data_json).into(),
authenticator_data: ctap2_response.auth_data.to_vec().into(),
public_key,
public_key_algorithm: alg,
attestation_object: attestation_object.into(),
transports: auth_info.transports,
},
authenticator_attachment: Some(self.authenticator().attachment_type()),
client_extension_results,
};
Ok(response)
}
pub async fn authenticate<D: ClientData<E>, E: Serialize + Clone>(
&mut self,
origin: impl Into<Origin<'_>>,
request: webauthn::CredentialRequestOptions,
client_data: D,
) -> Result<webauthn::AuthenticatedPublicKeyCredential, WebauthnError> {
let origin = origin.into();
let request = request.public_key;
let auth_info = self.authenticator().get_info().await;
let rp_id = self
.rp_id_verifier
.assert_domain(&origin, request.rp_id.as_deref())?;
let collected_client_data = webauthn::CollectedClientData::<E> {
ty: webauthn::ClientDataType::Get,
challenge: encoding::base64url(&request.challenge),
origin: origin.to_string(),
cross_origin: None, extra_data: client_data.extra_client_data(),
unknown_keys: Default::default(),
};
let client_data_json = serde_json::to_string(&collected_client_data).unwrap();
let client_data_json_hash = client_data
.client_data_hash()
.unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec());
let ctap_extensions = self.auth_extension_ctap2_input(
&request,
auth_info.extensions.unwrap_or_default().as_slice(),
)?;
let rk = false;
let uv = request.user_verification != UserVerificationRequirement::Discouraged;
let ctap2_response = self
.authenticator
.get_assertion(ctap2::get_assertion::Request {
rp_id: rp_id.to_owned(),
client_data_hash: client_data_json_hash.into(),
allow_list: request.allow_credentials,
extensions: ctap_extensions,
options: ctap2::get_assertion::Options { rk, up: true, uv },
pin_auth: None,
pin_protocol: None,
})
.await
.map_err(Into::<WebauthnError>::into)?;
let client_extension_results =
self.auth_extension_outputs(ctap2_response.unsigned_extension_outputs);
let credential_id_bytes = ctap2_response.credential.unwrap().id;
Ok(webauthn::AuthenticatedPublicKeyCredential {
id: encoding::base64url(&credential_id_bytes),
raw_id: credential_id_bytes.to_vec().into(),
ty: webauthn::PublicKeyCredentialType::PublicKey,
response: webauthn::AuthenticatorAssertionResponse {
client_data_json: Vec::from(client_data_json).into(),
authenticator_data: ctap2_response.auth_data.to_vec().into(),
signature: ctap2_response.signature,
user_handle: ctap2_response.user.map(|user| user.id),
attestation_object: None,
},
authenticator_attachment: Some(self.authenticator().attachment_type()),
client_extension_results,
})
}
fn map_rk(
&self,
criteria: &Option<AuthenticatorSelectionCriteria>,
auth_info: &ctap2::get_info::Response,
) -> bool {
let supports_rk = auth_info.options.as_ref().is_some_and(|o| o.rk);
match criteria.as_ref().unwrap_or(&Default::default()) {
AuthenticatorSelectionCriteria {
resident_key: Some(ResidentKeyRequirement::Required),
..
} => true,
AuthenticatorSelectionCriteria {
resident_key: Some(ResidentKeyRequirement::Preferred),
..
} => supports_rk,
AuthenticatorSelectionCriteria {
resident_key: Some(ResidentKeyRequirement::Discouraged),
..
} => false,
AuthenticatorSelectionCriteria {
resident_key: None,
require_resident_key,
..
} => *require_resident_key,
}
}
}
pub struct RpIdVerifier<P> {
tld_provider: Box<P>,
allows_insecure_localhost: bool,
}
impl<P> RpIdVerifier<P>
where
P: public_suffix::EffectiveTLDProvider + Sync + 'static,
{
pub fn new(tld_provider: P) -> Self {
Self {
tld_provider: Box::new(tld_provider),
allows_insecure_localhost: false,
}
}
pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
self.allows_insecure_localhost = is_allowed;
self
}
pub fn assert_domain<'a>(
&self,
origin: &'a Origin,
rp_id: Option<&'a str>,
) -> Result<&'a str, WebauthnError> {
match origin {
Origin::Web(url) => self.assert_web_rp_id(url, rp_id),
#[cfg(feature = "android-asset-validation")]
Origin::Android(unverified) => self.assert_android_rp_id(unverified, rp_id),
}
}
fn assert_web_rp_id<'a>(
&self,
origin: &'a Url,
rp_id: Option<&'a str>,
) -> Result<&'a str, WebauthnError> {
let mut effective_domain = origin.domain().ok_or(WebauthnError::OriginMissingDomain)?;
if let Some(rp_id) = rp_id {
if !effective_domain.ends_with(rp_id) {
return Err(WebauthnError::OriginRpMissmatch);
}
effective_domain = rp_id;
}
if let ControlFlow::Break(res) = self.assert_valid_rp_id(effective_domain) {
return res;
}
if !(origin.scheme().eq_ignore_ascii_case("https")) {
return Err(WebauthnError::UnprotectedOrigin);
}
Ok(effective_domain)
}
fn assert_valid_rp_id<'a>(
&self,
rp_id: &'a str,
) -> ControlFlow<Result<&'a str, WebauthnError>, ()> {
if rp_id == "localhost" {
return if self.allows_insecure_localhost {
ControlFlow::Break(Ok(rp_id))
} else {
ControlFlow::Break(Err(WebauthnError::InsecureLocalhostNotAllowed))
};
}
if decode_host(rp_id)
.as_ref()
.and_then(|s| self.tld_provider.effective_tld_plus_one(s).ok())
.is_none()
{
return ControlFlow::Break(Err(WebauthnError::InvalidRpId));
}
ControlFlow::Continue(())
}
pub fn is_valid_rp_id(&self, rp_id: &str) -> bool {
match self.assert_valid_rp_id(rp_id) {
ControlFlow::Continue(_) | ControlFlow::Break(Ok(_)) => true,
ControlFlow::Break(Err(_)) => false,
}
}
#[cfg(feature = "android-asset-validation")]
fn assert_android_rp_id<'a>(
&self,
target_link: &'a UnverifiedAssetLink,
rp_id: Option<&'a str>,
) -> Result<&'a str, WebauthnError> {
let mut effective_rp_id = target_link.host();
if let Some(rp_id) = rp_id {
if !effective_rp_id.ends_with(rp_id) {
return Err(WebauthnError::OriginRpMissmatch);
}
effective_rp_id = rp_id;
}
if decode_host(effective_rp_id)
.as_ref()
.and_then(|s| self.tld_provider.effective_tld_plus_one(s).ok())
.is_none()
{
return Err(WebauthnError::InvalidRpId);
}
Ok(effective_rp_id)
}
}
#[cfg(test)]
mod test {
use passkey_authenticator::{Authenticator, MemoryStore, MockUserValidationMethod};
use passkey_types::{
ctap2,
webauthn::{
AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement,
},
};
use crate::Client;
#[test]
fn map_rk_maps_criteria_to_rk_bool() {
#[derive(Debug)]
struct TestCase {
resident_key: Option<ResidentKeyRequirement>,
require_resident_key: bool,
expected_rk: bool,
}
let test_cases = vec![
TestCase {
resident_key: None,
require_resident_key: false,
expected_rk: false,
},
TestCase {
resident_key: None,
require_resident_key: true,
expected_rk: true,
},
TestCase {
resident_key: Some(ResidentKeyRequirement::Discouraged),
require_resident_key: false,
expected_rk: false,
},
TestCase {
resident_key: Some(ResidentKeyRequirement::Preferred),
require_resident_key: false,
expected_rk: true,
},
TestCase {
resident_key: Some(ResidentKeyRequirement::Required),
require_resident_key: false,
expected_rk: true,
},
TestCase {
resident_key: Some(ResidentKeyRequirement::Discouraged),
require_resident_key: true,
expected_rk: false,
},
];
for test_case in test_cases {
let criteria = AuthenticatorSelectionCriteria {
resident_key: test_case.resident_key,
require_resident_key: test_case.require_resident_key,
user_verification: UserVerificationRequirement::Discouraged,
authenticator_attachment: None,
};
let auth_info = ctap2::get_info::Response {
versions: vec![],
extensions: None,
aaguid: ctap2::Aaguid::new_empty(),
options: Some(ctap2::get_info::Options {
rk: true,
uv: Some(true),
up: true,
plat: true,
client_pin: None,
}),
max_msg_size: None,
pin_protocols: None,
transports: None,
};
let client = Client::new(Authenticator::new(
ctap2::Aaguid::new_empty(),
MemoryStore::new(),
MockUserValidationMethod::verified_user(0),
));
let result = client.map_rk(&Some(criteria), &auth_info);
assert_eq!(result, test_case.expected_rk, "{:?}", test_case);
}
}
}