From a133d4e90c6f864c87ad54a877ea501f4d4f92ec Mon Sep 17 00:00:00 2001 From: zUnixorn <77864446+zUnixorn@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:44:28 +0200 Subject: [PATCH] Update webauthn-rs to 0.5.x (#5934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update webauthn to 0.5 * add basic migration impl * fix clippy warnings * clear up `COSEKeyType::EC_OKP` case * fix TODOs * use same timeout as in webauthn 0.3 impl * fix: clippy warnings and formatting * Update Cargo.toml Co-authored-by: Daniel * Update src/api/core/two_factor/webauthn.rs Co-authored-by: Daniel * Update src/api/core/two_factor/webauthn.rs Co-authored-by: Daniel * Update src/api/core/two_factor/webauthn.rs Co-authored-by: Daniel * regenerate Cargo.lock * Use securitykey methods * use CredentialsV3 from webauthn-rs instead of own webauthn_0_3 module * fix cargo fmt issue --------- Co-authored-by: Helmut K. C. Tessarek Co-authored-by: Daniel Co-authored-by: Daniel GarcĂ­a --- Cargo.lock | 172 +++++++++++++++++++++++++--- Cargo.toml | 7 +- src/api/core/two_factor/webauthn.rs | 168 +++++++++++++++------------ src/api/identity.rs | 24 ++-- src/db/models/two_factor.rs | 75 ++++++++++-- src/error.rs | 2 +- src/main.rs | 3 + 7 files changed, 346 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a84d365..d005e6c6 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" @@ -661,12 +700,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[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" @@ -695,6 +728,17 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "base64urlsafedata" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5913e643e4dfb43d5908e9e6f1386f8e0dfde086ecef124a6450c6195d89160" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bigdecimal" version = "0.4.8" @@ -1269,6 +1313,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" @@ -3164,6 +3222,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" @@ -4330,6 +4397,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.8" @@ -4606,10 +4682,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", @@ -5657,6 +5733,8 @@ dependencies = [ "url", "uuid", "webauthn-rs", + "webauthn-rs-core", + "webauthn-rs-proto", "which", "yubico_ng", ] @@ -5817,23 +5895,72 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384e43534efe4e8f56c4eb1615a27e24d2ff29281385c843cf9f16ac1077dbdc" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + [[package]] name = "webauthn-rs" -version = "0.3.2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90b266eccb4b32595876f5c73ea443b0516da0b1df72ca07bc08ed9ba7f96ec1" +checksum = "ed1f861a94557baeb0cf711e3e55d623c46b68f4aab7aa932562f785b8b5f1ab" dependencies = [ - "base64 0.13.1", + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269c210cd5f183aaca860bb5733187d1dd110ebed54640f8fc1aca31a04aa4dc" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", "nom 7.1.3", "openssl", + "openssl-sys", "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144dbee9abb4bfad78fd283a2613f0312a0ed5955051b7864cfc98679112ae60" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", ] [[package]] @@ -6295,6 +6422,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.27" diff --git a/Cargo.toml b/Cargo.toml index e9cc91d0..0c4a511d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,7 +124,12 @@ 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 +# danger-user-presence-only-security-keys is needed to disable UV +webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals", "danger-user-presence-only-security-keys"] } +webauthn-rs-proto = "0.5.2" +webauthn-rs-core = "0.5.2" # 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..51e66b97 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,9 +1,3 @@ -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 crate::{ api::{ core::{log_user_event, two_factor::_generate_recover_code}, @@ -18,6 +12,38 @@ use crate::{ util::NumberOrString, CONFIG, }; +use rocket::serde::json::Json; +use rocket::Route; +use serde_json::Value; +use std::str::FromStr; +use std::sync::{Arc, LazyLock}; +use std::time::Duration; +use url::Url; +use uuid::Uuid; +use webauthn_rs::prelude::{Base64UrlSafeData, SecurityKey, SecurityKeyAuthentication, SecurityKeyRegistration}; +use webauthn_rs::{Webauthn, WebauthnBuilder}; +use webauthn_rs_proto::{ + AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, + PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, + RequestAuthenticationExtensions, +}; + +pub static WEBAUTHN_2FA_CONFIG: LazyLock> = LazyLock::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) + .timeout(Duration::from_millis(60000)) + .danger_set_user_presence_only_security_keys(true); + + 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 +71,13 @@ 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, + pub credential: SecurityKey, } impl WebauthnRegistration { @@ -125,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, 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,13 +127,13 @@ 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, + let (challenge, state) = webauthn.start_securitykey_registration( + Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail + &user.email, + &user.name, Some(registrations), None, None, @@ -193,8 +185,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 +199,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 +232,12 @@ 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 +252,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: SecurityKeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&mut conn).await?; state } @@ -261,8 +260,7 @@ 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_securitykey_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 +289,13 @@ 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 +338,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,18 +365,36 @@ 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 = - 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") } // 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_securitykey_authentication(&creds)?; + + // Modify to discourage user verification + let mut state = serde_json::to_value(&state)?; + + // Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well + let app_id = format!("{}/app-id.json", &CONFIG.domain()); + state["ast"]["appid"] = Value::String(app_id.clone()); + 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)?) @@ -384,11 +405,16 @@ 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: SecurityKeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; state } @@ -405,13 +431,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_securitykey_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 253b3fed..d39c7319 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -9,6 +9,7 @@ use rocket::{ }; use serde_json::Value; +use crate::api::core::two_factor::webauthn::Webauthn2FaConfig; use crate::{ api::{ core::{ @@ -48,6 +49,7 @@ async fn login( data: Form, client_header: ClientHeaders, client_version: Option, + webauthn: Webauthn2FaConfig<'_>, mut conn: DbConn, ) -> JsonResult { let data: ConnectData = data.into_inner(); @@ -70,7 +72,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")?; @@ -91,7 +93,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")?; - _sso_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version).await + _sso_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version, webauthn).await } "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), @@ -169,6 +171,7 @@ async fn _sso_login( conn: &mut DbConn, ip: &ClientIp, client_version: &Option, + webauthn: Webauthn2FaConfig<'_>, ) -> JsonResult { AuthMethod::Sso.check_scope(data.scope.as_ref())?; @@ -267,7 +270,7 @@ async fn _sso_login( } Some((mut user, sso_user)) => { let mut 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 user.private_key.is_none() { // User was invited a stub was created @@ -322,6 +325,7 @@ async fn _password_login( conn: &mut DbConn, ip: &ClientIp, client_version: &Option, + webauthn: Webauthn2FaConfig<'_>, ) -> JsonResult { // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; @@ -431,7 +435,7 @@ async fn _password_login( let mut 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?; let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); @@ -664,6 +668,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; @@ -683,7 +688,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" ) } @@ -700,7 +705,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, 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() { @@ -732,7 +739,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" ) } @@ -766,6 +773,7 @@ async fn _json_err_twofactor( user_id: &UserId, data: &ConnectData, client_version: &Option, + webauthn: Webauthn2FaConfig<'_>, conn: &mut DbConn, ) -> ApiResult { let mut result = json!({ @@ -785,7 +793,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..589967df 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -1,7 +1,10 @@ -use serde_json::Value; - 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_core::proto::CredentialV3; +use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; db_object! { #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -160,7 +163,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 +188,8 @@ impl TwoFactor { type_: COSEAlgorithm::ES256, key: COSEKeyType::EC_EC2(COSEEC2Key { curve: ECDSACurve::SECP256R1, - x, - y, + x: x.into(), + y: y.into(), }), }; @@ -195,11 +199,19 @@ 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,7 +229,52 @@ impl TwoFactor { Ok(()) } + + pub async fn migrate_credential_to_passkey(conn: &mut DbConn) -> EmptyResult { + 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(()) + } } #[derive(Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct TwoFactorId(String); + +#[derive(Deserialize)] +pub struct WebauthnRegistrationV3 { + pub id: i32, + pub name: String, + pub migrated: bool, + pub credential: CredentialV3, +} + +impl From for WebauthnRegistration { + fn from(value: WebauthnRegistrationV3) -> Self { + Self { + id: value.id, + name: value.name, + migrated: value.migrated, + credential: Credential::from(value.credential).into(), + } + } +} diff --git a/src/error.rs b/src/error.rs index beaf4780..06ebf3aa 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 8fbd3453..e91dcbc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,7 @@ mod sso_client; 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}; @@ -88,6 +89,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. @@ -599,6 +601,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))