From dd8915fe61a9aeb71d32a7434c56443db49ef136 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Fri, 6 Jun 2025 03:11:57 +0200 Subject: [PATCH 1/7] update webauthn to 0.5 --- Cargo.lock | 189 +++++++++++++++++++++++++--- Cargo.toml | 5 +- src/api/core/two_factor/webauthn.rs | 134 ++++++++++---------- src/api/identity.rs | 17 ++- src/db/models/two_factor.rs | 130 +++++++++++++++++-- src/error.rs | 2 +- src/main.rs | 3 + 7 files changed, 380 insertions(+), 100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1d1e2f6..c6994f59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,45 @@ dependencies = [ "password-hash", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -657,12 +696,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -691,6 +724,17 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "base64urlsafedata" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f0ad38ce7fbed55985ad5b2197f05cff8324ee6eb6638304e78f0108fae56c" +dependencies = [ + "base64 0.21.7", + "paste", + "serde", +] + [[package]] name = "bigdecimal" version = "0.4.8" @@ -941,6 +985,23 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" +[[package]] +name = "compact_jwt" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bbab6445446e8d0b07468a01d0bfdae15879de5c440c5e47ae4ae0e18a1fba" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "hex", + "openssl", + "serde", + "serde_json", + "tracing", + "url", + "uuid", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1187,6 +1248,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.4.0" @@ -2865,6 +2940,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3938,6 +4022,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.0.7" @@ -4150,10 +4243,10 @@ dependencies = [ ] [[package]] -name = "serde_cbor" -version = "0.11.2" +name = "serde_cbor_2" +version = "0.12.0-dev" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +checksum = "b46d75f449e01f1eddbe9b00f432d616fbbd899b809c837d0fbc380496a0dd55" dependencies = [ "half", "serde", @@ -5057,6 +5150,7 @@ dependencies = [ "url", "uuid", "webauthn-rs", + "webauthn-rs-proto", "which", "yubico_ng", ] @@ -5218,22 +5312,70 @@ dependencies = [ ] [[package]] -name = "webauthn-rs" -version = "0.3.2" +name = "webauthn-attestation-ca" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90b266eccb4b32595876f5c73ea443b0516da0b1df72ca07bc08ed9ba7f96ec1" +checksum = "29e77e8859ecb93b00e4a8e56ae45f8a8dd69b1539e3d32cf4cce1db9a3a0b99" dependencies = [ - "base64 0.13.1", + "base64urlsafedata", + "openssl", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b44347ee0d66f222043663a6aaf5ec78022b9b11c3a9ed488c21f2bd5680856" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef48f07ed8f3dfe304d6c48e85317feba0439675f31a13063b2936c9b4eaf0d" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "compact_jwt", + "der-parser", + "hex", "nom 7.1.3", "openssl", "rand 0.8.5", + "rand_chacha 0.3.1", "serde", - "serde_cbor", - "serde_derive", + "serde_cbor_2", "serde_json", "thiserror 1.0.69", "tracing", "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e1367f70e7dc7b83afc971ce8a54d578f4fdf488ea093021180e073744a69f" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", ] [[package]] @@ -5694,6 +5836,23 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xml-rs" version = "0.8.26" diff --git a/Cargo.toml b/Cargo.toml index 0ddd167b..af1e6a09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,7 +120,10 @@ totp-lite = "2.0.1" yubico = { package = "yubico_ng", version = "0.13.0", features = ["online-tokio"], default-features = false } # WebAuthn libraries -webauthn-rs = "0.3.2" +# danger-allow-state-serialisation is needed to save the state in the db +# danger-credential-internals is needed to support U2F to Webauthn migration +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } +webauthn-rs-proto = "0.5" # Handling of URL's for WebAuthn and favicons url = "2.5.4" diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 614c5df3..97355fad 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,9 +1,14 @@ +use std::str::FromStr; +use std::sync::Arc; +use once_cell::sync::Lazy; use rocket::serde::json::Json; use rocket::Route; use serde_json::Value; use url::Url; -use webauthn_rs::{base64_data::Base64UrlSafeData, proto::*, AuthenticationState, RegistrationState, Webauthn}; - +use uuid::Uuid; +use webauthn_rs::{Webauthn, WebauthnBuilder}; +use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication, PasskeyRegistration}; +use webauthn_rs_proto::{AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, UserVerificationPolicy}; use crate::{ api::{ core::{log_user_event, two_factor::_generate_recover_code}, @@ -19,6 +24,26 @@ use crate::{ CONFIG, }; +pub static WEBAUTHN_2FA_CONFIG: Lazy> = Lazy::new(|| { + let domain = CONFIG.domain(); + let domain_origin = CONFIG.domain_origin(); + let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); + let rp_origin = Url::parse(&domain_origin).unwrap(); + + let webauthn = WebauthnBuilder::new( + &rp_id, + &rp_origin, + ).expect("Creating WebauthnBuilder failed") + .rp_name(&domain); + + // TODO check what happened to get_require_uv_consistency() + + // TODO check if there is a better way to handle these errors (would they instantly through or only when used?) + Arc::new(webauthn.build().expect("Building Webauthn failed")) +}); + +pub type Webauthn2FaConfig<'a> = &'a rocket::State>; + pub fn routes() -> Vec { routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] } @@ -45,52 +70,14 @@ pub struct U2FRegistration { pub migrated: Option, } -struct WebauthnConfig { - url: String, - origin: Url, - rpid: String, -} - -impl WebauthnConfig { - fn load() -> Webauthn { - let domain = CONFIG.domain(); - let domain_origin = CONFIG.domain_origin(); - Webauthn::new(Self { - rpid: Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(), - url: domain, - origin: Url::parse(&domain_origin).unwrap(), - }) - } -} - -impl webauthn_rs::WebauthnConfig for WebauthnConfig { - fn get_relying_party_name(&self) -> &str { - &self.url - } - - fn get_origin(&self) -> &Url { - &self.origin - } - - fn get_relying_party_id(&self) -> &str { - &self.rpid - } - - /// We have WebAuthn configured to discourage user verification - /// if we leave this enabled, it will cause verification issues when a keys send UV=1. - /// Upstream (the library they use) ignores this when set to discouraged, so we should too. - fn get_require_uv_consistency(&self) -> bool { - false - } -} - #[derive(Debug, Serialize, Deserialize)] pub struct WebauthnRegistration { pub id: i32, pub name: String, pub migrated: bool, - pub credential: Credential, + // TODO should this be renamed or just stay this way + pub credential: Passkey, } impl WebauthnRegistration { @@ -125,7 +112,7 @@ async fn get_webauthn(data: Json, headers: Headers, mut conn: } #[post("/two-factor/get-webauthn-challenge", data = "")] -async fn generate_webauthn_challenge(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { +async fn generate_webauthn_challenge(data: Json, headers: Headers, webauthn: Webauthn2FaConfig<'_>, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; @@ -135,18 +122,24 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea .await? .1 .into_iter() - .map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering + .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); - let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options( - user.uuid.as_bytes().to_vec(), - user.email, - user.name, + // TODO handle errors + let (mut challenge, state) = webauthn.start_passkey_registration( + Uuid::from_str(&*user.uuid).unwrap(), + &user.email, + &user.name, Some(registrations), - None, - None, )?; + // TODO is there a nicer way to do this? + // this is done since `start_passkey_registration()` always sets this to `Required` which shouldn't be needed for 2FA + challenge.public_key.authenticator_selection = challenge.public_key.authenticator_selection.map(|mut a| { + a.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; + a + }); + let type_ = TwoFactorType::WebauthnRegisterChallenge; TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&mut conn).await?; @@ -193,8 +186,10 @@ impl From for RegisterPublicKeyCredential { response: AuthenticatorAttestationResponseRaw { attestation_object: r.response.attestation_object, client_data_json: r.response.client_data_json, + transports: None, }, type_: r.r#type, + extensions: RegistrationExtensionsClientOutputs::default(), } } } @@ -205,7 +200,7 @@ pub struct PublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAssertionResponseRawCopy, - pub extensions: Option, + pub extensions: AuthenticationExtensionsClientOutputs, pub r#type: String, } @@ -238,7 +233,7 @@ impl From for PublicKeyCredential { } #[post("/two-factor/webauthn", data = "")] -async fn activate_webauthn(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { +async fn activate_webauthn(data: Json, headers: Headers, webauthn: Webauthn2FaConfig<'_>, mut conn: DbConn) -> JsonResult { let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; @@ -253,7 +248,7 @@ async fn activate_webauthn(data: Json, headers: Headers, mut let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await { Some(tf) => { - let state: RegistrationState = serde_json::from_str(&tf.data)?; + let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&mut conn).await?; state } @@ -261,8 +256,8 @@ async fn activate_webauthn(data: Json, headers: Headers, mut }; // Verify the credentials with the saved state - let (credential, _data) = - WebauthnConfig::load().register_credential(&data.device_response.into(), &state, |_| Ok(false))?; + let credential = webauthn + .finish_passkey_registration(&data.device_response.into(), &state)?; let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1; // TODO: Check for repeated ID's @@ -291,8 +286,8 @@ async fn activate_webauthn(data: Json, headers: Headers, mut } #[put("/two-factor/webauthn", data = "")] -async fn activate_webauthn_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - activate_webauthn(data, headers, conn).await +async fn activate_webauthn_put(data: Json, headers: Headers, webauthn: Webauthn2FaConfig<'_>, conn: DbConn) -> JsonResult { + activate_webauthn(data, headers, webauthn, conn).await } #[derive(Debug, Deserialize)] @@ -335,7 +330,7 @@ async fn delete_webauthn(data: Json, headers: Headers, mut conn: Err(_) => err!("Error parsing U2F data"), }; - data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id); + data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); let new_data_str = serde_json::to_string(&data)?; u2f.data = new_data_str; @@ -362,9 +357,9 @@ pub async fn get_webauthn_registrations( } } -pub async fn generate_webauthn_login(user_id: &UserId, conn: &mut DbConn) -> JsonResult { +pub async fn generate_webauthn_login(user_id: &UserId, webauthn: Webauthn2FaConfig<'_>, conn: &mut DbConn) -> JsonResult { // Load saved credentials - let creds: Vec = + let creds: Vec<_> = get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect(); if creds.is_empty() { @@ -372,8 +367,11 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &mut DbConn) -> Jso } // Generate a challenge based on the credentials - let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build(); - let (response, state) = WebauthnConfig::load().generate_challenge_authenticate_options(creds, Some(ext))?; + let (mut response, state) = webauthn.start_passkey_authentication(&creds)?; + response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; + + // TODO does the appid extension matter? As far as I understand, this was only put into the authentication state anyway + // let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build(); // Save the challenge state for later validation TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) @@ -384,11 +382,11 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &mut DbConn) -> Jso Ok(Json(serde_json::to_value(response.public_key)?)) } -pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &mut DbConn) -> EmptyResult { +pub async fn validate_webauthn_login(user_id: &UserId, response: &str, webauthn: Webauthn2FaConfig<'_>, conn: &mut DbConn) -> EmptyResult { let type_ = TwoFactorType::WebauthnLoginChallenge as i32; let state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { Some(tf) => { - let state: AuthenticationState = serde_json::from_str(&tf.data)?; + let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; state } @@ -405,13 +403,11 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &mu let mut registrations = get_webauthn_registrations(user_id, conn).await?.1; - // If the credential we received is migrated from U2F, enable the U2F compatibility - //let use_u2f = registrations.iter().any(|r| r.migrated && r.credential.cred_id == rsp.raw_id.0); - let (cred_id, auth_data) = WebauthnConfig::load().authenticate_credential(&rsp, &state)?; + let authentication_result = webauthn.finish_passkey_authentication(&rsp, &state)?; for reg in &mut registrations { - if ®.credential.cred_id == cred_id { - reg.credential.counter = auth_data.counter; + if reg.credential.cred_id() == authentication_result.cred_id() && authentication_result.needs_update() { + reg.credential.update_credential(&authentication_result); TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) .save(conn) diff --git a/src/api/identity.rs b/src/api/identity.rs index 6a6e52fc..761d4e9c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -23,6 +23,7 @@ use crate::{ error::MapResult, mail, util, CONFIG, }; +use crate::api::core::two_factor::webauthn::Webauthn2FaConfig; pub fn routes() -> Vec { routes![login, prelogin, identity_register, register_verification_email, register_finish] @@ -33,6 +34,7 @@ async fn login( data: Form, client_header: ClientHeaders, client_version: Option, + webauthn: Webauthn2FaConfig<'_>, mut conn: DbConn, ) -> JsonResult { let data: ConnectData = data.into_inner(); @@ -54,7 +56,7 @@ async fn login( _check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_type, "device_type cannot be blank")?; - _password_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version).await + _password_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version, webauthn).await } "client_credentials" => { _check_is_some(&data.client_id, "client_id cannot be blank")?; @@ -139,6 +141,7 @@ async fn _password_login( conn: &mut DbConn, ip: &ClientIp, client_version: &Option, + webauthn: Webauthn2FaConfig<'_>, ) -> JsonResult { // Validate scope let scope = data.scope.as_ref().unwrap(); @@ -257,7 +260,7 @@ async fn _password_login( let (mut device, new_device) = get_device(&data, conn, &user).await; - let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, webauthn, conn).await?; if CONFIG.mail_enabled() && new_device { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { @@ -488,6 +491,7 @@ async fn twofactor_auth( device: &mut Device, ip: &ClientIp, client_version: &Option, + webauthn: Webauthn2FaConfig<'_>, conn: &mut DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; @@ -507,7 +511,7 @@ async fn twofactor_auth( Some(ref code) => code, None => { err_json!( - _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, + _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, webauthn, conn).await?, "2FA token not provided" ) } @@ -524,7 +528,7 @@ async fn twofactor_auth( Some(TwoFactorType::Authenticator) => { authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await? } - Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?, + Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, webauthn, conn).await?, Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?, Some(TwoFactorType::Duo) => { match CONFIG.duo_use_iframe() { @@ -556,7 +560,7 @@ async fn twofactor_auth( } _ => { err_json!( - _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, + _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, webauthn, conn).await?, "2FA Remember token not provided" ) } @@ -589,6 +593,7 @@ async fn _json_err_twofactor( user_id: &UserId, data: &ConnectData, client_version: &Option, + webauthn: Webauthn2FaConfig<'_>, conn: &mut DbConn, ) -> ApiResult { let mut result = json!({ @@ -608,7 +613,7 @@ async fn _json_err_twofactor( Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ } Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => { - let request = webauthn::generate_webauthn_login(user_id, conn).await?; + let request = webauthn::generate_webauthn_login(user_id, webauthn, conn).await?; result["TwoFactorProviders2"][provider.to_string()] = request.0; } diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 0f5a5de5..f945bd29 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -1,5 +1,6 @@ use serde_json::Value; - +use webauthn_rs::prelude::{Credential, ParsedAttestation}; +use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; use super::UserId; use crate::{api::EmptyResult, db::DbConn, error::MapResult}; @@ -40,6 +41,107 @@ pub enum TwoFactorType { ProtectedActions = 2000, } +mod webauthn_0_3 { + use webauthn_rs::prelude::ParsedAttestation; + use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; + + // Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#316-339 + pub struct Credential { + pub cred_id: Vec, + pub cred: COSEKey, + pub counter: u32, + pub verified: bool, + pub registration_policy: webauthn_rs_proto::UserVerificationPolicy, + } + + impl From for webauthn_rs::prelude::Credential { + fn from(value: Credential) -> Self { + Self { + cred_id: value.cred_id.into(), + cred: value.cred.into(), + counter: value.counter, + transports: None, + user_verified: value.verified, + backup_eligible: false, + backup_state: false, + registration_policy: value.registration_policy, + extensions: RegisteredExtensions::none(), + attestation: ParsedAttestation::default(), + attestation_format: AttestationFormat::None, + } + } + } + + // Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#300-305 + #[derive(Deserialize)] + pub struct COSEKey { + pub type_: webauthn_rs::prelude::COSEAlgorithm, + pub key: COSEKeyType, + } + + impl From for webauthn_rs::prelude::COSEKey { + fn from(value: COSEKey) -> Self { + Self { + type_: value.type_, + key: value.key.into(), + } + } + } + + // Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#260-278 + #[allow(non_camel_case_types)] + #[derive(Deserialize)] + pub enum COSEKeyType { + EC_OKP, + EC_EC2(COSEEC2Key), + RSA(COSERSAKey), + } + + impl From for webauthn_rs::prelude::COSEKeyType { + fn from(value: COSEKeyType) -> Self { + match value { + COSEKeyType::EC_OKP => panic!(), // TODO what to do here + COSEKeyType::EC_EC2(a) => Self::EC_EC2(a.into()), + COSEKeyType::RSA(a) => Self::RSA(a.into()), + } + } + } + + // Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#249-254 + #[derive(Deserialize)] + pub struct COSERSAKey { + pub n: Vec, + pub e: [u8; 3], + } + + impl From for webauthn_rs::prelude::COSERSAKey { + fn from(value: COSERSAKey) -> Self { + Self { + n: value.n.into(), + e: value.e, + } + } + } + + // Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#235-242 + #[derive(Deserialize)] + pub struct COSEEC2Key { + pub curve: webauthn_rs::prelude::ECDSACurve, + pub x: [u8; 32], + pub y: [u8; 32], + } + + impl From for webauthn_rs::prelude::COSEEC2Key { + fn from(value: COSEEC2Key) -> Self { + Self { + curve: value.curve, + x: value.x.into(), + y: value.y.into(), + } + } + } +} + /// Local methods impl TwoFactor { pub fn new(user_uuid: UserId, atype: TwoFactorType, data: String) -> Self { @@ -160,7 +262,8 @@ impl TwoFactor { use crate::api::core::two_factor::webauthn::U2FRegistration; use crate::api::core::two_factor::webauthn::{get_webauthn_registrations, WebauthnRegistration}; - use webauthn_rs::proto::*; + use webauthn_rs::prelude::{COSEEC2Key, COSEKey, COSEKeyType, ECDSACurve}; + use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy}; for mut u2f in u2f_factors { let mut regs: Vec = serde_json::from_str(&u2f.data)?; @@ -184,8 +287,8 @@ impl TwoFactor { type_: COSEAlgorithm::ES256, key: COSEKeyType::EC_EC2(COSEEC2Key { curve: ECDSACurve::SECP256R1, - x, - y, + x: x.into(), + y: y.into(), }), }; @@ -195,11 +298,18 @@ impl TwoFactor { name: reg.name.clone(), credential: Credential { counter: reg.counter, - verified: false, + user_verified: false, cred: key, - cred_id: reg.reg.key_handle.clone(), - registration_policy: UserVerificationPolicy::Discouraged, - }, + cred_id: reg.reg.key_handle.clone().into(), + registration_policy: UserVerificationPolicy::Discouraged_DO_NOT_USE, + + transports: None, + backup_eligible: false, + backup_state: false, + extensions: RegisteredExtensions::none(), + attestation: ParsedAttestation::default(), + attestation_format: AttestationFormat::None, + }.into(), }; webauthn_regs.push(new_reg); @@ -217,6 +327,10 @@ impl TwoFactor { Ok(()) } + + pub async fn migrate_credential_to_passkey(conn: &mut DbConn) -> EmptyResult { + todo!() + } } #[derive(Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/src/error.rs b/src/error.rs index 04de98a4..2a8670e1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -54,7 +54,7 @@ use rocket::error::Error as RocketErr; use serde_json::{Error as SerdeErr, Value}; use std::io::Error as IoErr; use std::time::SystemTimeError as TimeErr; -use webauthn_rs::error::WebauthnError as WebauthnErr; +use webauthn_rs::prelude::WebauthnError as WebauthnErr; use yubico::yubicoerror::YubicoError as YubiErr; #[derive(Serialize)] diff --git a/src/main.rs b/src/main.rs index fc104997..c9a2b4c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,7 @@ pub use error::{Error, MapResult}; use rocket::data::{Limits, ToByteUnit}; use std::sync::{atomic::Ordering, Arc}; pub use util::is_running_in_container; +use crate::api::core::two_factor::webauthn::WEBAUTHN_2FA_CONFIG; #[rocket::main] async fn main() -> Result<(), Error> { @@ -86,6 +87,7 @@ async fn main() -> Result<(), Error> { let pool = create_db_pool().await; schedule_jobs(pool.clone()); db::models::TwoFactor::migrate_u2f_to_webauthn(&mut pool.get().await.unwrap()).await.unwrap(); + db::models::TwoFactor::migrate_credential_to_passkey(&mut pool.get().await.unwrap()).await.unwrap(); let extra_debug = matches!(level, log::LevelFilter::Trace | log::LevelFilter::Debug); launch_rocket(pool, extra_debug).await // Blocks until program termination. @@ -597,6 +599,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> .manage(pool) .manage(Arc::clone(&WS_USERS)) .manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS)) + .manage(Arc::clone(&WEBAUTHN_2FA_CONFIG)) .attach(util::AppHeaders()) .attach(util::Cors()) .attach(util::BetterLogging(extra_debug)) From fffb4442753073297a89e40831df8060739366b1 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Fri, 6 Jun 2025 05:30:16 +0200 Subject: [PATCH 2/7] add basic migration impl --- src/api/core/two_factor/webauthn.rs | 21 ++++++++----- src/db/models/two_factor.rs | 47 ++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 97355fad..19a79c7c 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -133,12 +133,15 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea Some(registrations), )?; - // TODO is there a nicer way to do this? // this is done since `start_passkey_registration()` always sets this to `Required` which shouldn't be needed for 2FA - challenge.public_key.authenticator_selection = challenge.public_key.authenticator_selection.map(|mut a| { - a.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; - a - }); + challenge.public_key.extensions = None; + if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { + asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; + } + + let mut state = serde_json::to_value(&state)?; + state["rs"]["policy"] = Value::String("discouraged".to_string()); + state["rs"]["extensions"].as_object_mut().unwrap().clear(); let type_ = TwoFactorType::WebauthnRegisterChallenge; TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&mut conn).await?; @@ -368,10 +371,12 @@ pub async fn generate_webauthn_login(user_id: &UserId, webauthn: Webauthn2FaConf // Generate a challenge based on the credentials let (mut response, state) = webauthn.start_passkey_authentication(&creds)?; + + // Modify to discourage user verification + let mut state = serde_json::to_value(&state)?; + state["ast"]["policy"] = Value::String("discouraged".to_string()); + state["ast"]["appid"] = Value::String(format!("{}/app-id.json", &CONFIG.domain())); response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; - - // TODO does the appid extension matter? As far as I understand, this was only put into the authentication state anyway - // let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build(); // Save the challenge state for later validation TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index f945bd29..d43dda33 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -3,6 +3,7 @@ use webauthn_rs::prelude::{Credential, ParsedAttestation}; use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; use super::UserId; use crate::{api::EmptyResult, db::DbConn, error::MapResult}; +use crate::api::core::two_factor::webauthn::WebauthnRegistration; db_object! { #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -45,7 +46,27 @@ mod webauthn_0_3 { use webauthn_rs::prelude::ParsedAttestation; use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; + #[derive(Deserialize)] + pub struct WebauthnRegistration { + pub id: i32, + pub name: String, + pub migrated: bool, + pub credential: Credential, + } + + impl From for crate::api::core::two_factor::webauthn::WebauthnRegistration { + fn from(value: WebauthnRegistration) -> Self { + Self { + id: value.id, + name: value.name, + migrated: value.migrated, + credential: webauthn_rs::prelude::Credential::from(value.credential).into(), + } + } + } + // Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#316-339 + #[derive(Deserialize)] pub struct Credential { pub cred_id: Vec, pub cred: COSEKey, @@ -329,7 +350,31 @@ impl TwoFactor { } pub async fn migrate_credential_to_passkey(conn: &mut DbConn) -> EmptyResult { - todo!() + let webauthn_factors = db_run! { conn: { + twofactor::table + .filter(twofactor::atype.eq(TwoFactorType::Webauthn as i32)) + .load::(conn) + .expect("Error loading twofactor") + .from_db() + }}; + + for webauthn_factor in webauthn_factors { + // assume that a failure to parse into the old struct, means that it was already converted + // alternatively this could also be checked via an extra field in the db + let Ok(regs) = serde_json::from_str::>(&webauthn_factor.data) else { + continue; + }; + + let regs = regs.into_iter() + .map(|r| r.into()) + .collect::>(); + + TwoFactor::new(webauthn_factor.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®s)?) + .save(conn) + .await?; + } + + Ok(()) } } From a0afcd9dfbb0e13d782730cc448a10152a80639c Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Sat, 7 Jun 2025 16:34:09 +0200 Subject: [PATCH 3/7] fix clippy warnings --- src/api/core/two_factor/webauthn.rs | 2 +- src/db/models/two_factor.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 19a79c7c..8bf58e33 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -127,7 +127,7 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea // TODO handle errors let (mut challenge, state) = webauthn.start_passkey_registration( - Uuid::from_str(&*user.uuid).unwrap(), + Uuid::from_str(&user.uuid).unwrap(), &user.email, &user.name, Some(registrations), diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index d43dda33..13153e8c 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -111,6 +111,7 @@ mod webauthn_0_3 { // Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#260-278 #[allow(non_camel_case_types)] + #[allow(clippy::upper_case_acronyms)] #[derive(Deserialize)] pub enum COSEKeyType { EC_OKP, From c611004bd41f8f2de845d4bb0c9d5e9c86f36320 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Sat, 7 Jun 2025 16:57:41 +0200 Subject: [PATCH 4/7] clear up `COSEKeyType::EC_OKP` case --- src/db/models/two_factor.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 13153e8c..f6d646c7 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -122,9 +122,11 @@ mod webauthn_0_3 { impl From for webauthn_rs::prelude::COSEKeyType { fn from(value: COSEKeyType) -> Self { match value { - COSEKeyType::EC_OKP => panic!(), // TODO what to do here COSEKeyType::EC_EC2(a) => Self::EC_EC2(a.into()), COSEKeyType::RSA(a) => Self::RSA(a.into()), + // This should've never been able to be constructed when webauthn 0.3 was used + // Refer: https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/crypto.rs.html#414 + COSEKeyType::EC_OKP => unreachable!(), } } } From 46183d3cca692f8b8e38f66a517c6aa018badda6 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Sat, 7 Jun 2025 17:27:20 +0200 Subject: [PATCH 5/7] fix TODOs --- src/api/core/two_factor/webauthn.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 8bf58e33..4f90a497 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -8,7 +8,7 @@ use url::Url; use uuid::Uuid; use webauthn_rs::{Webauthn, WebauthnBuilder}; use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication, PasskeyRegistration}; -use webauthn_rs_proto::{AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, UserVerificationPolicy}; +use webauthn_rs_proto::{AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, RequestAuthenticationExtensions, UserVerificationPolicy}; use crate::{ api::{ core::{log_user_event, two_factor::_generate_recover_code}, @@ -36,9 +36,6 @@ pub static WEBAUTHN_2FA_CONFIG: Lazy> = Lazy::new(|| { ).expect("Creating WebauthnBuilder failed") .rp_name(&domain); - // TODO check what happened to get_require_uv_consistency() - - // TODO check if there is a better way to handle these errors (would they instantly through or only when used?) Arc::new(webauthn.build().expect("Building Webauthn failed")) }); @@ -76,7 +73,6 @@ pub struct WebauthnRegistration { pub name: String, pub migrated: bool, - // TODO should this be renamed or just stay this way pub credential: Passkey, } @@ -125,9 +121,8 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); - // TODO handle errors let (mut challenge, state) = webauthn.start_passkey_registration( - Uuid::from_str(&user.uuid).unwrap(), + Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail &user.email, &user.name, Some(registrations), @@ -375,9 +370,17 @@ pub async fn generate_webauthn_login(user_id: &UserId, webauthn: Webauthn2FaConf // Modify to discourage user verification let mut state = serde_json::to_value(&state)?; state["ast"]["policy"] = Value::String("discouraged".to_string()); - state["ast"]["appid"] = Value::String(format!("{}/app-id.json", &CONFIG.domain())); response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; + // Add appid + let app_id = format!("{}/app-id.json", &CONFIG.domain()); + state["ast"]["appid"] = Value::String(app_id.clone()); + response.public_key.extensions.get_or_insert_with(|| RequestAuthenticationExtensions { + appid: None, + uvm: None, + hmac_get_secret: None, + }).appid = Some(app_id); + // Save the challenge state for later validation TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) .save(conn) From 3d4b29ff6bab587fde6a280a48f1d35ef0027be1 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Sat, 7 Jun 2025 18:04:45 +0200 Subject: [PATCH 6/7] use same timeout as in webauthn 0.3 impl --- src/api/core/two_factor/webauthn.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 4f90a497..8b33b685 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,5 +1,6 @@ use std::str::FromStr; use std::sync::Arc; +use std::time::Duration; use once_cell::sync::Lazy; use rocket::serde::json::Json; use rocket::Route; @@ -34,7 +35,8 @@ pub static WEBAUTHN_2FA_CONFIG: Lazy> = Lazy::new(|| { &rp_id, &rp_origin, ).expect("Creating WebauthnBuilder failed") - .rp_name(&domain); + .rp_name(&domain) + .timeout(Duration::from_millis(60000)); Arc::new(webauthn.build().expect("Building Webauthn failed")) }); From 257e20b97194ab33deb86d0d47b44866f9274b1b Mon Sep 17 00:00:00 2001 From: "Helmut K. C. Tessarek" Date: Mon, 30 Jun 2025 19:18:57 -0400 Subject: [PATCH 7/7] fix: clippy warnings and formatting --- src/api/core/two_factor/webauthn.rs | 88 +++++++++++++++++++---------- src/api/identity.rs | 6 +- src/db/models/two_factor.rs | 18 +++--- src/main.rs | 2 +- 4 files changed, 72 insertions(+), 42 deletions(-) diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 8b33b685..26d11705 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,15 +1,3 @@ -use std::str::FromStr; -use std::sync::Arc; -use std::time::Duration; -use once_cell::sync::Lazy; -use rocket::serde::json::Json; -use rocket::Route; -use serde_json::Value; -use url::Url; -use uuid::Uuid; -use webauthn_rs::{Webauthn, WebauthnBuilder}; -use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication, PasskeyRegistration}; -use webauthn_rs_proto::{AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, RequestAuthenticationExtensions, UserVerificationPolicy}; use crate::{ api::{ core::{log_user_event, two_factor::_generate_recover_code}, @@ -24,6 +12,22 @@ use crate::{ util::NumberOrString, CONFIG, }; +use once_cell::sync::Lazy; +use rocket::serde::json::Json; +use rocket::Route; +use serde_json::Value; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use url::Url; +use uuid::Uuid; +use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication, PasskeyRegistration}; +use webauthn_rs::{Webauthn, WebauthnBuilder}; +use webauthn_rs_proto::{ + AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, + PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, + RequestAuthenticationExtensions, UserVerificationPolicy, +}; pub static WEBAUTHN_2FA_CONFIG: Lazy> = Lazy::new(|| { let domain = CONFIG.domain(); @@ -31,10 +35,8 @@ pub static WEBAUTHN_2FA_CONFIG: Lazy> = Lazy::new(|| { let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); let rp_origin = Url::parse(&domain_origin).unwrap(); - let webauthn = WebauthnBuilder::new( - &rp_id, - &rp_origin, - ).expect("Creating WebauthnBuilder failed") + let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) + .expect("Creating WebauthnBuilder failed") .rp_name(&domain) .timeout(Duration::from_millis(60000)); @@ -110,7 +112,12 @@ async fn get_webauthn(data: Json, headers: Headers, mut conn: } #[post("/two-factor/get-webauthn-challenge", data = "")] -async fn generate_webauthn_challenge(data: Json, headers: Headers, webauthn: Webauthn2FaConfig<'_>, mut conn: DbConn) -> JsonResult { +async fn generate_webauthn_challenge( + data: Json, + headers: Headers, + webauthn: Webauthn2FaConfig<'_>, + mut conn: DbConn, +) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; @@ -233,7 +240,12 @@ impl From for PublicKeyCredential { } #[post("/two-factor/webauthn", data = "")] -async fn activate_webauthn(data: Json, headers: Headers, webauthn: Webauthn2FaConfig<'_>, mut conn: DbConn) -> JsonResult { +async fn activate_webauthn( + data: Json, + headers: Headers, + webauthn: Webauthn2FaConfig<'_>, + mut conn: DbConn, +) -> JsonResult { let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; @@ -256,8 +268,7 @@ async fn activate_webauthn(data: Json, headers: Headers, web }; // Verify the credentials with the saved state - let credential = webauthn - .finish_passkey_registration(&data.device_response.into(), &state)?; + let credential = webauthn.finish_passkey_registration(&data.device_response.into(), &state)?; let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1; // TODO: Check for repeated ID's @@ -286,7 +297,12 @@ async fn activate_webauthn(data: Json, headers: Headers, web } #[put("/two-factor/webauthn", data = "")] -async fn activate_webauthn_put(data: Json, headers: Headers, webauthn: Webauthn2FaConfig<'_>, conn: DbConn) -> JsonResult { +async fn activate_webauthn_put( + data: Json, + headers: Headers, + webauthn: Webauthn2FaConfig<'_>, + conn: DbConn, +) -> JsonResult { activate_webauthn(data, headers, webauthn, conn).await } @@ -357,10 +373,13 @@ pub async fn get_webauthn_registrations( } } -pub async fn generate_webauthn_login(user_id: &UserId, webauthn: Webauthn2FaConfig<'_>, conn: &mut DbConn) -> JsonResult { +pub async fn generate_webauthn_login( + user_id: &UserId, + webauthn: Webauthn2FaConfig<'_>, + conn: &mut DbConn, +) -> JsonResult { // Load saved credentials - let creds: Vec<_> = - get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect(); + let creds: Vec<_> = get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect(); if creds.is_empty() { err!("No Webauthn devices registered") @@ -377,11 +396,15 @@ pub async fn generate_webauthn_login(user_id: &UserId, webauthn: Webauthn2FaConf // Add appid let app_id = format!("{}/app-id.json", &CONFIG.domain()); state["ast"]["appid"] = Value::String(app_id.clone()); - response.public_key.extensions.get_or_insert_with(|| RequestAuthenticationExtensions { - appid: None, - uvm: None, - hmac_get_secret: None, - }).appid = Some(app_id); + response + .public_key + .extensions + .get_or_insert(RequestAuthenticationExtensions { + appid: None, + uvm: None, + hmac_get_secret: None, + }) + .appid = Some(app_id); // Save the challenge state for later validation TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) @@ -392,7 +415,12 @@ pub async fn generate_webauthn_login(user_id: &UserId, webauthn: Webauthn2FaConf Ok(Json(serde_json::to_value(response.public_key)?)) } -pub async fn validate_webauthn_login(user_id: &UserId, response: &str, webauthn: Webauthn2FaConfig<'_>, conn: &mut DbConn) -> EmptyResult { +pub async fn validate_webauthn_login( + user_id: &UserId, + response: &str, + webauthn: Webauthn2FaConfig<'_>, + conn: &mut DbConn, +) -> EmptyResult { let type_ = TwoFactorType::WebauthnLoginChallenge as i32; let state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { Some(tf) => { diff --git a/src/api/identity.rs b/src/api/identity.rs index 761d4e9c..573210e7 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -7,6 +7,7 @@ use rocket::{ }; use serde_json::Value; +use crate::api::core::two_factor::webauthn::Webauthn2FaConfig; use crate::{ api::{ core::{ @@ -23,7 +24,6 @@ use crate::{ error::MapResult, mail, util, CONFIG, }; -use crate::api::core::two_factor::webauthn::Webauthn2FaConfig; pub fn routes() -> Vec { routes![login, prelogin, identity_register, register_verification_email, register_finish] @@ -528,7 +528,9 @@ async fn twofactor_auth( Some(TwoFactorType::Authenticator) => { authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await? } - Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, webauthn, conn).await?, + Some(TwoFactorType::Webauthn) => { + webauthn::validate_webauthn_login(&user.uuid, twofactor_code, webauthn, conn).await? + } Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?, Some(TwoFactorType::Duo) => { match CONFIG.duo_use_iframe() { diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index f6d646c7..b71bb1af 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -1,9 +1,9 @@ +use super::UserId; +use crate::api::core::two_factor::webauthn::WebauthnRegistration; +use crate::{api::EmptyResult, db::DbConn, error::MapResult}; use serde_json::Value; use webauthn_rs::prelude::{Credential, ParsedAttestation}; use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; -use super::UserId; -use crate::{api::EmptyResult, db::DbConn, error::MapResult}; -use crate::api::core::two_factor::webauthn::WebauthnRegistration; db_object! { #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -333,7 +333,8 @@ impl TwoFactor { extensions: RegisteredExtensions::none(), attestation: ParsedAttestation::default(), attestation_format: AttestationFormat::None, - }.into(), + } + .into(), }; webauthn_regs.push(new_reg); @@ -364,19 +365,18 @@ impl TwoFactor { for webauthn_factor in webauthn_factors { // assume that a failure to parse into the old struct, means that it was already converted // alternatively this could also be checked via an extra field in the db - let Ok(regs) = serde_json::from_str::>(&webauthn_factor.data) else { + let Ok(regs) = serde_json::from_str::>(&webauthn_factor.data) + else { continue; }; - let regs = regs.into_iter() - .map(|r| r.into()) - .collect::>(); + let regs = regs.into_iter().map(|r| r.into()).collect::>(); TwoFactor::new(webauthn_factor.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®s)?) .save(conn) .await?; } - + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index c9a2b4c5..b4093bc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,7 @@ mod ratelimit; mod util; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; +use crate::api::core::two_factor::webauthn::WEBAUTHN_2FA_CONFIG; use crate::api::purge_auth_requests; use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}; pub use config::{PathType, CONFIG}; @@ -66,7 +67,6 @@ pub use error::{Error, MapResult}; use rocket::data::{Limits, ToByteUnit}; use std::sync::{atomic::Ordering, Arc}; pub use util::is_running_in_container; -use crate::api::core::two_factor::webauthn::WEBAUTHN_2FA_CONFIG; #[rocket::main] async fn main() -> Result<(), Error> {