vw_small

Hardened fork of Vaultwarden (https://github.com/dani-garcia/vaultwarden) with fewer features.
git clone https://git.philomathiclife.com/repos/vw_small
Log | Files | Refs | README

commit a44749e827958adec946e74389f008507f4567aa
parent 9ce8b4dfc519b132ae06050c3f62286627388292
Author: Zack Newman <zack@philomathiclife.com>
Date:   Sun,  8 Dec 2024 10:37:01 -0700

use webauthn_rp

Diffstat:
Msrc/api/core/two_factor/webauthn.rs | 719+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/api/web.rs | 2+-
Msrc/auth.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/config.rs | 32++++++++++----------------------
Msrc/db/models/mod.rs | 4+---
Msrc/db/models/two_factor.rs | 510+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/db/models/user.rs | 6+-----
Msrc/db/schema.rs | 30+++++++++++++-----------------
Msrc/error.rs | 8++++----
Msrc/util.rs | 2+-
10 files changed, 1045 insertions(+), 386 deletions(-)

diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs @@ -1,19 +1,34 @@ use crate::{ api::{EmptyResult, JsonResult, PasswordOrOtpData}, - auth::Headers, + auth::{self, Headers}, config, db::{ - models::{WebAuthn, WebAuthnAuth, WebAuthnChallenge, WebAuthnInfo, WebAuthnReg}, + models::{WebAuthn, WebAuthnInfo}, DbConn, }, error::Error, }; +use data_encoding::{BASE64, BASE64URL_NOPAD}; use rocket::serde::json::Json; use rocket::Route; -use url::Url; -use webauthn_rs::prelude::{ - PublicKeyCredential, RegisterPublicKeyCredential, Uuid, Webauthn, WebauthnBuilder, - WebauthnError, +use serde::de::{Deserialize, Deserializer, Error as SerdeErr, MapAccess, Unexpected, Visitor}; +use std::fmt::{self, Formatter}; +use uuid::Uuid; +use webauthn_rp::{ + request::{ + auth::PublicKeyCredentialRequestOptions, + register::{ + Nickname, PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, + UserHandle, Username, + }, + InsertResult, + }, + response::{ + auth::Authentication, + register::{AuthenticatorAttestation, Registration}, + AuthTransports, AuthenticatorAttachment, CollectedClientData, CredentialId, + }, + AggErr, }; pub fn routes() -> Vec<Route> { @@ -25,14 +40,6 @@ pub fn routes() -> Vec<Route> { get_webauthn, ] } -fn build_webauthn() -> Result<Webauthn, WebauthnError> { - WebauthnBuilder::new( - config::get_config().domain(), - &Url::parse(&config::get_config().domain_origin()).expect("a valid URL"), - )? - .build() -} - #[post("/two-factor/get-webauthn", data = "<data>")] async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); @@ -54,21 +61,365 @@ async fn generate_webauthn_challenge( let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user)?; - let (challenge, registration) = build_webauthn()?.start_securitykey_registration( - Uuid::try_parse(user.uuid.as_str()).expect("unable to create UUID"), - user.email.as_str(), - user.name.as_str(), - Some(WebAuthn::get_all_credentials_by_user(&user.uuid, &conn).await?), - None, - None, - )?; - WebAuthnChallenge::Reg(WebAuthnReg::new(user.uuid, &registration)?) - .replace(&conn) - .await?; - let mut challenge_value = serde_json::to_value(challenge.public_key)?; - challenge_value["status"] = "ok".into(); - challenge_value["errorMessage"] = "".into(); - Ok(Json(challenge_value)) + let name = Username::try_from(user.email.as_str()).map_err(AggErr::Username)?; + let uuid = Uuid::parse_str(user.uuid.as_str()) + .map_err(|e| Error::from(e.to_string()))? + .into_bytes(); + let id = UserHandle::try_from(uuid.as_slice()).map_err(AggErr::UserHandle)?; + let display_name = Some(Nickname::try_from(user.name.as_str()).map_err(AggErr::Nickname)?); + let (server, client) = PublicKeyCredentialCreationOptions::second_factor( + &config::get_config().rp_id, + PublicKeyCredentialUserEntity { + name, + id, + display_name, + }, + WebAuthn::get_registered_creds(user.uuid.as_str(), &conn).await?, + ) + .start_ceremony() + .map_err(AggErr::CreationOptions)?; + match auth::get_reg_ceremonies().insert_or_replace_all_expired(server) { + InsertResult::Success => { + let mut challenge_value = serde_json::to_value(client)?; + challenge_value["status"] = "ok".into(); + challenge_value["errorMessage"] = "".into(); + Ok(Json(challenge_value)) + } + InsertResult::CapacityFull => Err(Error::from(String::from( + "too many active ceremonies. try again later", + ))), + InsertResult::Duplicate => Err(Error::from(String::from("duplicate webauthn challenge"))), + } +} +struct Type; +impl<'e> Deserialize<'e> for Type { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + struct TypeVisitor; + impl<'f> Visitor<'f> for TypeVisitor { + type Value = Type; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("public-key") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: SerdeErr, + { + if v == "public-key" { + Ok(Type) + } else { + Err(E::invalid_value(Unexpected::Str(v), &"public-key")) + } + } + } + deserializer.deserialize_str(TypeVisitor) + } +} +struct RegistrationWrapper(Registration); +impl<'de> Deserialize<'de> for RegistrationWrapper { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + struct RegistrationVisitor; + impl<'d> Visitor<'d> for RegistrationVisitor { + type Value = RegistrationWrapper; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("RegistrationWrapper") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + enum Field { + Id, + RawId, + Response, + Extensions, + Type, + } + impl<'e> Deserialize<'e> for Field { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + struct FieldVisitor; + impl<'f> Visitor<'f> for FieldVisitor { + type Value = Field; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!( + formatter, + "'{ID}', '{RAW_ID}', '{RESPONSE}', '{EXTENSIONS}', or '{TYPE}'" + ) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: SerdeErr, + { + match v { + ID => Ok(Field::Id), + RAW_ID => Ok(Field::RawId), + RESPONSE => Ok(Field::Response), + EXTENSIONS => Ok(Field::Extensions), + TYPE => Ok(Field::Type), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + struct RawId(CredentialId<Vec<u8>>); + impl<'e> Deserialize<'e> for RawId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + struct RawIdVisitor; + impl<'f> Visitor<'f> for RawIdVisitor { + type Value = RawId; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("RawId") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: SerdeErr, + { + BASE64.decode(v.as_bytes()).map_err(E::custom).and_then( + |base64url| { + BASE64URL_NOPAD + .decode(base64url.as_slice()) + .map_err(E::custom) + .and_then(|cred_id| { + CredentialId::try_from(cred_id) + .map_err(E::custom) + .map(RawId) + }) + }, + ) + } + } + deserializer.deserialize_str(RawIdVisitor) + } + } + struct AuthAttestWrapper(AuthenticatorAttestation); + impl<'e> Deserialize<'e> for AuthAttestWrapper { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + struct AuthAttestVisitor; + impl<'f> Visitor<'f> for AuthAttestVisitor { + type Value = AuthAttestWrapper; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthAttestWrapper") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'f>, + { + enum AuthField { + AttestationObject, + ClientDataJson, + } + impl<'g> Deserialize<'g> for AuthField { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'g>, + { + struct AuthFieldVisitor; + impl<'h> Visitor<'h> for AuthFieldVisitor { + type Value = AuthField; + fn expecting( + &self, + formatter: &mut Formatter<'_>, + ) -> fmt::Result + { + write!(formatter, "'{ATTESTATION_OBJECT}' or '{CLIENT_DATA_JSON}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: SerdeErr, + { + match v { + ATTESTATION_OBJECT => { + Ok(AuthField::AttestationObject) + } + CLIENT_DATA_JSON => { + Ok(AuthField::ClientDataJson) + } + _ => Err(E::unknown_field(v, AUTH_FIELDS)), + } + } + } + deserializer.deserialize_identifier(AuthFieldVisitor) + } + } + struct Base64(Vec<u8>); + impl<'g> Deserialize<'g> for Base64 { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'g>, + { + struct Base64Visitor; + impl<'h> Visitor<'h> for Base64Visitor { + type Value = Base64; + fn expecting( + &self, + formatter: &mut Formatter<'_>, + ) -> fmt::Result + { + formatter.write_str("base64-encoded value") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: SerdeErr, + { + BASE64 + .decode(v.as_bytes()) + .map_err(E::custom) + .map(Base64) + } + } + deserializer.deserialize_str(Base64Visitor) + } + } + let mut att_obj = None; + let mut client_data = None; + while let Some(key) = map.next_key()? { + match key { + AuthField::AttestationObject => { + if att_obj.is_some() { + return Err(SerdeErr::duplicate_field( + ATTESTATION_OBJECT, + )); + } + att_obj = map + .next_value::<Base64>() + .map(|base64| Some(base64.0))?; + } + AuthField::ClientDataJson => { + if client_data.is_some() { + return Err(SerdeErr::duplicate_field( + CLIENT_DATA_JSON, + )); + } + client_data = map + .next_value::<Base64>() + .map(|base64| Some(base64.0))?; + } + } + } + att_obj + .ok_or_else(|| SerdeErr::missing_field(ATTESTATION_OBJECT)) + .and_then(|attestation_object| { + client_data + .ok_or_else(|| { + SerdeErr::missing_field(CLIENT_DATA_JSON) + }) + .map(|client_data_json| { + AuthAttestWrapper(AuthenticatorAttestation::new( + client_data_json, + attestation_object, + AuthTransports::NONE, + )) + }) + }) + } + } + const ATTESTATION_OBJECT: &str = "attestationObject"; + const CLIENT_DATA_JSON: &str = "clientDataJSON"; + const AUTH_FIELDS: &[&str; 2] = &[ATTESTATION_OBJECT, CLIENT_DATA_JSON]; + deserializer.deserialize_struct( + "AuthAttestWrapper", + AUTH_FIELDS, + AuthAttestVisitor, + ) + } + } + let mut id = None; + let mut raw_id = None; + let mut response = None; + let mut extensions = None; + let mut typ = false; + while let Some(key) = map.next_key()? { + match key { + Field::Id => { + if id.is_some() { + return Err(SerdeErr::duplicate_field(ID)); + } + id = map.next_value::<CredentialId<Vec<u8>>>().map(Some)?; + } + Field::RawId => { + if raw_id.is_some() { + return Err(SerdeErr::duplicate_field(RAW_ID)); + } + raw_id = map.next_value::<RawId>().map(Some)?; + } + Field::Response => { + if response.is_some() { + return Err(SerdeErr::duplicate_field(RESPONSE)); + } + response = map + .next_value::<AuthAttestWrapper>() + .map(|attest| Some(attest.0))?; + } + Field::Extensions => { + if extensions.is_some() { + return Err(SerdeErr::duplicate_field(EXTENSIONS)); + } + extensions = map.next_value().map(Some)?; + } + Field::Type => { + if typ { + return Err(SerdeErr::duplicate_field(TYPE)); + } + typ = map.next_value::<Type>().map(|_| true)?; + } + } + } + id.ok_or_else(|| SerdeErr::missing_field(ID)).and_then(|i| { + raw_id + .ok_or_else(|| SerdeErr::missing_field(RAW_ID)) + .and_then(|r| { + if i == r.0 { + response + .ok_or_else(|| SerdeErr::missing_field(RESPONSE)) + .and_then(|resp| { + extensions + .ok_or_else(|| SerdeErr::missing_field(EXTENSIONS)) + .and_then(|ext| { + if typ { + Ok(RegistrationWrapper(Registration::new( + resp, + AuthenticatorAttachment::None, + ext, + ))) + } else { + Err(SerdeErr::missing_field(TYPE)) + } + }) + }) + } else { + Err(SerdeErr::invalid_value( + Unexpected::Bytes(r.0.as_ref()), + &format!("id: {:?} to match rawId", i.as_ref()).as_str(), + )) + } + }) + }) + } + } + const ID: &str = "id"; + const RAW_ID: &str = "rawId"; + const RESPONSE: &str = "response"; + const EXTENSIONS: &str = "extensions"; + const TYPE: &str = "type"; + const FIELDS: &[&str; 5] = &[ID, RAW_ID, RESPONSE, EXTENSIONS, TYPE]; + deserializer.deserialize_struct("RegistrationWrapper", FIELDS, RegistrationVisitor) + } } #[derive(Deserialize)] @@ -76,7 +427,7 @@ async fn generate_webauthn_challenge( struct EnableWebauthnData { id: i64, name: String, - device_response: RegisterPublicKeyCredential, + device_response: RegistrationWrapper, master_password_hash: String, } @@ -93,16 +444,27 @@ async fn activate_webauthn( otp: None, } .validate(&user)?; - // Retrieve and delete the saved challenge state - let chall = WebAuthnReg::find_by_user(&user.uuid, &conn) - .await? - .ok_or_else(|| Error::from(String::from("no webauthn challenge")))?; - let registration = chall.security_key_reg()?; - WebAuthnChallenge::Reg(chall).delete(&conn).await?; - // Verify the credentials with the saved state - let security_key = - build_webauthn()?.finish_securitykey_registration(&data.device_response, &registration)?; - WebAuthn::new(user.uuid.clone(), data.id, data.name, &security_key)? + let uuid = Uuid::parse_str(user.uuid.as_str()) + .map_err(|e| Error::from(e.to_string()))? + .into_bytes(); + let user_handle = UserHandle::try_from(uuid.as_slice()).map_err(AggErr::UserHandle)?; + let cred = auth::get_reg_ceremonies() + .take( + &CollectedClientData::from_client_data_json_relaxed::<true>( + data.device_response.0.response().client_data_json(), + ) + .map_err(AggErr::SerdeJson)? + .challenge, + ) + .ok_or_else(|| Error::from(String::from("missing registration challenge")))? + .verify( + &config::get_config().rp_id, + user_handle, + &data.device_response.0, + auth::get_reg_options(), + ) + .map_err(AggErr::RegCeremony)?; + WebAuthn::new(&cred, user.uuid.clone(), data.id, data.name)? .insert(&conn) .await?; let keys = WebAuthnInfo::get_all_by_user(user.uuid.as_str(), &conn).await?; @@ -147,46 +509,259 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbCo } pub async fn generate_webauthn_login(user_uuid: &str, conn: &DbConn) -> JsonResult { - let keys = WebAuthn::get_all_security_keys(user_uuid, conn).await?; - if keys.is_empty() { - err!("No WebAuthn devices registered") + let creds = WebAuthn::get_allowed_creds(user_uuid, conn).await?; + let (server, client) = + PublicKeyCredentialRequestOptions::second_factor(&config::get_config().rp_id, creds) + .map_err(AggErr::SecondFactor)? + .start_ceremony() + .map_err(AggErr::RequestOptions)?; + match auth::get_auth_ceremonies().insert_or_replace_all_expired(server) { + InsertResult::Success => Ok(Json(serde_json::to_value(client)?)), + InsertResult::CapacityFull => Err(Error::from(String::from( + "too many active ceremonies. try again later", + ))), + InsertResult::Duplicate => Err(Error::from(String::from("duplicate webauthn challenge"))), + } +} +struct AuthenticationWrapper(Authentication); +impl<'de> Deserialize<'de> for AuthenticationWrapper { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + struct AuthenticationVisitor; + impl<'d> Visitor<'d> for AuthenticationVisitor { + type Value = AuthenticationWrapper; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthenticationWrapper") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + enum Field { + Id, + RawId, + Response, + Extensions, + Type, + } + impl<'e> Deserialize<'e> for Field { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + struct FieldVisitor; + impl<'f> Visitor<'f> for FieldVisitor { + type Value = Field; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!( + formatter, + "'{ID}', '{RAW_ID}', '{RESPONSE}', '{EXTENSIONS}', or '{TYPE}'" + ) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: SerdeErr, + { + match v { + ID => Ok(Field::Id), + RAW_ID => Ok(Field::RawId), + RESPONSE => Ok(Field::Response), + EXTENSIONS => Ok(Field::Extensions), + TYPE => Ok(Field::Type), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + struct Extensions; + impl<'e> Deserialize<'e> for Extensions { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + struct ExtensionsVisitor; + impl<'f> Visitor<'f> for ExtensionsVisitor { + type Value = Extensions; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("Extensions") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'f>, + { + struct Field; + impl<'g> Deserialize<'g> for Field { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'g>, + { + struct FieldVisitor; + impl<'h> Visitor<'h> for FieldVisitor { + type Value = Field; + fn expecting( + &self, + formatter: &mut Formatter<'_>, + ) -> fmt::Result + { + write!(formatter, "'{APPID}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: SerdeErr, + { + if v == APPID { + Ok(Field) + } else { + Err(E::unknown_field(v, FIELDS)) + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + let mut appid = false; + while map.next_key::<Field>()?.is_some() { + if appid { + return Err(SerdeErr::duplicate_field(APPID)); + } + appid = map.next_value::<bool>().and_then(|v| { + if v { + Err(SerdeErr::invalid_value( + Unexpected::Bool(true), + &"appid to be false", + )) + } else { + Ok(true) + } + })?; + } + Ok(Extensions) + } + } + const APPID: &str = "appid"; + const FIELDS: &[&str; 1] = &[APPID]; + deserializer.deserialize_struct( + "EmptyExtensions", + FIELDS, + ExtensionsVisitor, + ) + } + } + let mut id = None; + let mut raw_id = None; + let mut response = None; + let mut extensions = false; + let mut typ = false; + while let Some(key) = map.next_key()? { + match key { + Field::Id => { + if id.is_some() { + return Err(SerdeErr::duplicate_field(ID)); + } + id = map.next_value::<CredentialId<Vec<u8>>>().map(Some)?; + } + Field::RawId => { + if raw_id.is_some() { + return Err(SerdeErr::duplicate_field(RAW_ID)); + } + raw_id = map.next_value::<CredentialId<Vec<u8>>>().map(Some)?; + } + Field::Response => { + if response.is_some() { + return Err(SerdeErr::duplicate_field(RESPONSE)); + } + response = map.next_value().map(Some)?; + } + Field::Extensions => { + if extensions { + return Err(SerdeErr::duplicate_field(EXTENSIONS)); + } + extensions = map.next_value::<Extensions>().map(|_| true)?; + } + Field::Type => { + if typ { + return Err(SerdeErr::duplicate_field(TYPE)); + } + typ = map.next_value::<Type>().map(|_| true)?; + } + } + } + id.ok_or_else(|| SerdeErr::missing_field(ID)).and_then(|i| { + raw_id + .ok_or_else(|| SerdeErr::missing_field(RAW_ID)) + .and_then(|r| { + if i == r { + response + .ok_or_else(|| SerdeErr::missing_field(RESPONSE)) + .and_then(|resp| { + if extensions { + if typ { + Ok(AuthenticationWrapper(Authentication::new( + r, + resp, + AuthenticatorAttachment::None, + ))) + } else { + Err(SerdeErr::missing_field(TYPE)) + } + } else { + Err(SerdeErr::missing_field(EXTENSIONS)) + } + }) + } else { + Err(SerdeErr::invalid_value( + Unexpected::Bytes(r.as_ref()), + &format!("id: {:?} to match rawId", i.as_ref()).as_str(), + )) + } + }) + }) + } + } + const ID: &str = "id"; + const RAW_ID: &str = "rawId"; + const RESPONSE: &str = "response"; + const EXTENSIONS: &str = "extensions"; + const TYPE: &str = "type"; + const FIELDS: &[&str; 5] = &[ID, RAW_ID, RESPONSE, EXTENSIONS, TYPE]; + deserializer.deserialize_struct("RegistrationWrapper", FIELDS, AuthenticationVisitor) } - let (challenge, auth) = build_webauthn()?.start_securitykey_authentication(keys.as_slice())?; - WebAuthnChallenge::Auth(WebAuthnAuth::new(user_uuid.to_owned(), &auth)?) - .replace(conn) - .await?; - Ok(Json(serde_json::to_value(challenge.public_key)?)) } - pub async fn validate_webauthn_login( user_uuid: &str, response: &str, conn: &DbConn, ) -> EmptyResult { - let chall = WebAuthnAuth::find_by_user(user_uuid, conn) + let auth = serde_json::from_str::<AuthenticationWrapper>(response)?.0; + let uuid = Uuid::parse_str(user_uuid) + .map_err(|e| Error::from(e.to_string()))? + .into_bytes(); + let user_handle = UserHandle::try_from(uuid.as_slice()).map_err(AggErr::UserHandle)?; + let mut cred = WebAuthn::get_credential(auth.raw_id(), user_uuid, user_handle, conn) .await? - .ok_or_else(|| Error::from(String::from("no webauthn challenge")))?; - let security_key_authentication = chall.security_key_auth()?; - WebAuthnChallenge::Auth(chall).delete(conn).await?; - let resp = serde_json::from_str::<PublicKeyCredential>(response)?; - let auth = - build_webauthn()?.finish_securitykey_authentication(&resp, &security_key_authentication)?; - let mut web = WebAuthn::get_by_cred_id(&resp.id, conn) - .await? - .ok_or_else(|| Error::from(String::from("no matching webauthn entry")))?; - if auth.needs_update() { - let mut sec_key = web.security_key()?; - if let Some(update) = sec_key.update_credential(&auth) { - if update { - web.set_security_key(&sec_key)?; - web.update(conn).await?; - Ok(()) - } else { - unreachable!("webauthn credential no longer needs to be updated") - } - } else { - unreachable!("webauthn credential no longer matches challenge") - } + .ok_or_else(|| Error::from(String::from("credential does not exist")))?; + if auth::get_auth_ceremonies() + .take( + &CollectedClientData::from_client_data_json_relaxed::<false>( + auth.response().client_data_json(), + ) + .map_err(AggErr::SerdeJson)? + .challenge, + ) + .ok_or_else(|| Error::from(String::from("no authentication challenge")))? + .verify( + &config::get_config().rp_id, + &auth, + &mut cred, + &auth::get_auth_options(), + ) + .map_err(AggErr::AuthCeremony)? + { + WebAuthn::update(auth.raw_id(), cred.dynamic_state(), conn).await } else { Ok(()) } diff --git a/src/api/web.rs b/src/api/web.rs @@ -71,7 +71,7 @@ fn app_id() -> Cached<(ContentType, Json<Value>)> { // This leaves it unclear as to whether the path must be empty, // or whether it can be non-empty and will be ignored. To be on // the safe side, use a proper web origin (with empty path). - config::get_config().domain_origin(), + config::get_config().domain_url(), "ios:bundle-id:com.8bit.bitwarden", "android:apk-key-hash:dUGFzUzf3lmHSLBDBIv+WaFyZMI" ] }] diff --git a/src/auth.rs b/src/auth.rs @@ -9,8 +9,111 @@ use serde::de::DeserializeOwned; use serde::ser::Serialize; use std::fs::File; use std::io::{Read, Write}; -use std::sync::OnceLock; - +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; +use webauthn_rp::request::{ + auth::{ + AuthenticationServerState, AuthenticationVerificationOptions, + AuthenticatorAttachmentEnforcement, SignatureCounterEnforcement, + }, + register::{RegistrationServerState, RegistrationVerificationOptions}, + BackupReq, BuildIdentityHasher, FixedCapHashSet, +}; +static ALLOWED_ORIGINS: OnceLock<[&str; 1]> = OnceLock::new(); +#[inline] +fn init_allowed_origins() { + ALLOWED_ORIGINS + .set([config::get_config().domain_url()]) + .expect("ALLOWED_ORIGINS must only be initialized once"); +} +#[inline] +fn get_allowed_origins() -> &'static [&'static str] { + ALLOWED_ORIGINS + .get() + .expect("ALLOWED_ORIGINS must be initialized in main") + .as_slice() +} +static REG_CEREMONIES: OnceLock< + Arc<Mutex<FixedCapHashSet<RegistrationServerState, BuildIdentityHasher>>>, +> = OnceLock::new(); +#[inline] +fn init_reg_ceremonies() { + REG_CEREMONIES + .set(Arc::new(Mutex::new(FixedCapHashSet::new(10000)))) + .expect("REG_CEREMONIES must only be initialized once"); +} +#[inline] +pub fn get_reg_ceremonies( +) -> MutexGuard<'static, FixedCapHashSet<RegistrationServerState, BuildIdentityHasher>> { + REG_CEREMONIES + .get() + .expect("REG_CEREMONIES must be initialized in main") + .lock() + .unwrap() +} +static REG_OPTIONS: OnceLock<RegistrationVerificationOptions<'static, 'static, &str, &str>> = + OnceLock::new(); +#[inline] +fn init_reg_options() { + REG_OPTIONS + .set(RegistrationVerificationOptions { + allowed_origins: get_allowed_origins(), + allowed_top_origins: None, + backup_requirement: BackupReq::None, + error_on_unsolicited_extensions: true, + require_authenticator_attachment: false, + client_data_json_relaxed: true, + }) + .expect("REG_OPTIONS must only be initialized once"); +} +#[inline] +pub fn get_reg_options( +) -> &'static RegistrationVerificationOptions<'static, 'static, &'static str, &'static str> { + REG_OPTIONS + .get() + .expect("REG_OPTIONS must be initialized in main") +} +static AUTH_CEREMONIES: OnceLock< + Arc<Mutex<FixedCapHashSet<AuthenticationServerState, BuildIdentityHasher>>>, +> = OnceLock::new(); +#[inline] +fn init_auth_ceremonies() { + AUTH_CEREMONIES + .set(Arc::new(Mutex::new(FixedCapHashSet::new(10000)))) + .expect("AUTH_CEREMONIES must only be initialized once"); +} +#[inline] +pub fn get_auth_ceremonies( +) -> MutexGuard<'static, FixedCapHashSet<AuthenticationServerState, BuildIdentityHasher>> { + AUTH_CEREMONIES + .get() + .expect("AUTH_CEREMONIES must be initialized in main") + .lock() + .unwrap() +} +static AUTH_OPTIONS: OnceLock<AuthenticationVerificationOptions<'static, 'static, &str, &str>> = + OnceLock::new(); +#[inline] +fn init_auth_options() { + AUTH_OPTIONS + .set(AuthenticationVerificationOptions { + allowed_origins: get_allowed_origins(), + allowed_top_origins: None, + backup_requirement: None, + error_on_unsolicited_extensions: true, + auth_attachment_enforcement: AuthenticatorAttachmentEnforcement::Update(false), + client_data_json_relaxed: true, + update_uv: false, + sig_counter_enforcement: SignatureCounterEnforcement::Fail, + }) + .expect("AUTH_OPTIONS must only be initialized once"); +} +#[inline] +pub fn get_auth_options( +) -> &'static AuthenticationVerificationOptions<'static, 'static, &'static str, &'static str> { + AUTH_OPTIONS + .get() + .expect("AUTH_OPTIONS must be initialized in main") +} static DEFAULT_VALIDITY: OnceLock<TimeDelta> = OnceLock::new(); #[inline] fn init_default_validity() { @@ -41,7 +144,7 @@ static JWT_LOGIN_ISSUER: OnceLock<String> = OnceLock::new(); #[inline] fn init_jwt_login_issuer() { JWT_LOGIN_ISSUER - .set(format!("{}|login", config::get_config().domain_origin())) + .set(format!("{}|login", config::get_config().domain_url())) .expect("JWT_LOGIN_ISSUER must only be initialized once"); } #[inline] @@ -55,7 +158,7 @@ static JWT_INVITE_ISSUER: OnceLock<String> = OnceLock::new(); #[inline] fn init_jwt_invite_issuer() { JWT_INVITE_ISSUER - .set(format!("{}|invite", config::get_config().domain_origin())) + .set(format!("{}|invite", config::get_config().domain_url())) .expect("JWT_INVITE_ISSUER must only be initialized once"); } #[inline] @@ -69,7 +172,7 @@ static JWT_DELETE_ISSUER: OnceLock<String> = OnceLock::new(); #[inline] fn init_jwt_delete_issuer() { JWT_DELETE_ISSUER - .set(format!("{}|delete", config::get_config().domain_origin())) + .set(format!("{}|delete", config::get_config().domain_url())) .expect("JWT_DELETE_ISSUER must only be initialized once"); } #[inline] @@ -134,6 +237,11 @@ fn get_public_ed_key() -> &'static DecodingKey { } #[inline] pub fn init_values() { + init_allowed_origins(); + init_reg_ceremonies(); + init_reg_options(); + init_auth_ceremonies(); + init_auth_options(); init_default_validity(); init_jwt_header(); init_jwt_login_issuer(); diff --git a/src/config.rs b/src/config.rs @@ -10,6 +10,7 @@ use std::net::IpAddr; use std::sync::OnceLock; use toml::{self, de}; use url::{ParseError, Url}; +use webauthn_rp::request::{AsciiDomain, RpId}; static CONFIG: OnceLock<Config> = OnceLock::new(); #[inline] pub fn init_config() { @@ -92,7 +93,8 @@ pub struct Config { pub database_max_conns: NonZeroU8, pub database_timeout: u16, pub db_connection_retries: NonZeroU8, - domain: Url, + domain_url: Url, + pub rp_id: RpId, pub password_iterations: u32, pub rocket: rocket::Config, pub web_vault_enabled: bool, @@ -133,25 +135,18 @@ impl Config { if let Some(count) = config_file.workers { rocket.workers = usize::from(count.get()); } + let domain = + AsciiDomain::try_from(config_file.domain).map_err(|_e| ConfigErr::BadDomain)?; let url = format!( "https://{}{}", - config_file.domain, + domain.as_ref(), if config_file.port == 443 { String::new() } else { format!(":{}", config_file.port) } ); - let domain = Url::parse(url.as_str())?; - if domain - .domain() - // We only allow domains in the config file. - // Note currently this check is overly conservative and - // disallows any domains that `Url` will encode in Punycode. - .map_or(true, |dom| !dom.eq_ignore_ascii_case(&config_file.domain)) - { - return Err(ConfigErr::BadDomain); - } + let domain_url = Url::parse(url.as_str())?; Ok(Self { database_max_conns: config_file .database_max_conns @@ -160,7 +155,8 @@ impl Config { db_connection_retries: config_file .db_connection_retries .unwrap_or(NonZeroU8::new(15).unwrap()), - domain, + domain_url, + rp_id: RpId::Domain(domain), password_iterations: match config_file.password_iterations { None => 600_000, Some(count) => { @@ -183,7 +179,7 @@ impl Config { #[allow(clippy::arithmetic_side_effects, clippy::string_slice)] #[inline] pub fn domain_url(&self) -> &str { - let val = self.domain.as_str(); + let val = self.domain_url.as_str(); // The last Unicode scalar value is '/' which is a // single UTF-8 code unit, and we want to remove that. // Note if this changes in the future such that the last @@ -193,12 +189,4 @@ impl Config { // making this memory and logic safe. &val[..val.len() - 1] } - #[inline] - pub fn domain(&self) -> &str { - self.domain.domain().expect("impossible to error") - } - #[inline] - pub fn domain_origin(&self) -> String { - self.domain.origin().ascii_serialization() - } } diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs @@ -14,7 +14,5 @@ pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher}; pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization}; -pub use self::two_factor::{ - Totp, TwoFactorType, WebAuthn, WebAuthnAuth, WebAuthnChallenge, WebAuthnInfo, WebAuthnReg, -}; +pub use self::two_factor::{Totp, TwoFactorType, WebAuthn, WebAuthnInfo}; pub use self::user::{User, UserKdfType, UserStampException}; diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs @@ -1,38 +1,59 @@ use crate::{ api::EmptyResult, db::{ - schema::{totp, webauthn, webauthn_auth, webauthn_reg}, + schema::{totp, webauthn}, DbConn, FromDb, }, error::Error, }; use serde::ser::{Serialize, SerializeStruct, Serializer}; -use serde_json::de; use tokio::task; -use webauthn_rs::prelude::{ - CredentialID, SecurityKey, SecurityKeyAuthentication, SecurityKeyRegistration, +use webauthn_rp::{ + bin::{Decode, Encode}, + request::{ + auth::AllowedCredentials, register::UserHandle, Credentials, PublicKeyCredentialDescriptor, + }, + response::{ + register::{ + AuthenticatorExtensionOutputStaticState, CompressedP256PubKey, CompressedP384PubKey, + CompressedPubKey, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, RsaPubKey, + StaticState, UncompressedPubKey, + }, + AuthTransports, CredentialId, + }, + AggErr, AuthenticatedCredential, RegisteredCredential, }; db_object! { + /// Exactly one of the following is true: + /// * [`Self::ed25519_key`] is `Some`. + /// * [`Self::p256_x`] is `Some`. + /// * [`Self::p384_x`] is `Some`. + /// * [`Self::rsa_n`] is `Some`. + /// + /// [`Self::p256_x`] is `Some` iff [`Self::p256_y_is_odd`] is `Some`. [`Self::p384_x`] is `Some` iff + /// [`Self::p384_y_is_odd`] is `Some`. [`Self::rsa_n`] is `Some` iff [`Self::rsa_e`] is `Some`. + /// + /// [`Self::transports`] is actually a `u8`. [`Self::rsa_e`] is actually an `Option` of `u32` such that the + /// contained `i32` is converted to `u32` via `as`. [`Self::cred_protect`] is actually `0`, `1`, `2`, or `3`. #[derive(Insertable, Queryable)] #[diesel(table_name = webauthn)] pub struct WebAuthn { - credential_id: String, - pub user_uuid: String, - pub id: i64, - pub name: String, - security_key: String, - } - #[derive(Insertable, Queryable)] - #[diesel(table_name = webauthn_auth)] - pub struct WebAuthnAuth { - pub user_uuid: String, - data: String, - } - #[derive(Insertable, Queryable)] - #[diesel(table_name = webauthn_reg)] - pub struct WebAuthnReg { - pub user_uuid: String, - data: String, + credential_id: Vec<u8>, + transports: i16, + user_uuid: String, + ed25519_key: Option<[u8; 32]>, + p256_x: Option<[u8; 32]>, + p256_y_is_odd: Option<bool>, + p384_x: Option<[u8; 32]>, + p384_y_is_odd: Option<bool>, + rsa_n: Option<Vec<u8>>, + rsa_e: Option<i32>, + cred_protect: i16, + hmac_secret: Option<bool>, + dynamic_state: [u8; 7], + metadata: String, + id: i64, + name: String, } #[derive(Insertable, Queryable)] #[diesel(table_name = totp)] @@ -44,58 +65,70 @@ db_object! { } impl WebAuthn { pub fn new( + cred: &RegisteredCredential<'_, '_>, user_uuid: String, id: i64, name: String, - security_key: &SecurityKey, ) -> Result<Self, Error> { Ok(Self { - credential_id: security_key.cred_id().to_string(), + credential_id: cred.id().encode()?.to_owned(), + transports: i16::from(cred.transports().encode()?), user_uuid, + ed25519_key: match cred.static_state().credential_public_key { + UncompressedPubKey::Ed25519(ref key) => Some(key.into_inner().try_into().unwrap_or_else(|_e| unreachable!("there is a bug in webauthn_rp::response::register::Ed25519PubKey"))), + UncompressedPubKey::P256(_) + | UncompressedPubKey::P384(_) + | UncompressedPubKey::Rsa(_) => None, + }, + p256_x: match cred.static_state().credential_public_key { + UncompressedPubKey::P256(ref key) => Some(key.x().try_into().unwrap_or_else(|_e| unreachable!("there is a bug in webauthn_rp::response::register::UncompressedP256PubKey"))), + UncompressedPubKey::Ed25519(_) + | UncompressedPubKey::P384(_) + | UncompressedPubKey::Rsa(_) => None, + }, + p256_y_is_odd: match cred.static_state().credential_public_key { + UncompressedPubKey::P256(ref key) => Some(key.y()[31] & 1 == 1), + UncompressedPubKey::Ed25519(_) + | UncompressedPubKey::P384(_) + | UncompressedPubKey::Rsa(_) => None, + }, + p384_x: match cred.static_state().credential_public_key { + UncompressedPubKey::P384(ref key) => Some(key.x().try_into().unwrap_or_else(|_e| unreachable!("there is a bug in webauthn_rp::response::register::UncompressedP384PubKey"))), + UncompressedPubKey::Ed25519(_) + | UncompressedPubKey::P256(_) + | UncompressedPubKey::Rsa(_) => None, + }, + p384_y_is_odd: match cred.static_state().credential_public_key { + UncompressedPubKey::P384(ref key) => Some(key.y()[47] & 1 == 1), + UncompressedPubKey::Ed25519(_) + | UncompressedPubKey::P256(_) + | UncompressedPubKey::Rsa(_) => None, + }, + rsa_n: match cred.static_state().credential_public_key { + UncompressedPubKey::Rsa(ref key) => Some((*key.n()).to_owned()), + UncompressedPubKey::Ed25519(_) + | UncompressedPubKey::P256(_) + | UncompressedPubKey::P384(_) => None, + }, + rsa_e: match cred.static_state().credential_public_key { + UncompressedPubKey::Rsa(ref key) => Some(key.e() as i32), + UncompressedPubKey::Ed25519(_) + | UncompressedPubKey::P256(_) + | UncompressedPubKey::P384(_) => None, + }, + cred_protect: match cred.static_state().extensions.cred_protect { + CredentialProtectionPolicy::None => 0, + CredentialProtectionPolicy::UserVerificationOptional => 1, + CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList => 2, + CredentialProtectionPolicy::UserVerificationRequired => 3, + }, + hmac_secret: cred.static_state().extensions.hmac_secret, + dynamic_state: cred.dynamic_state().encode()?, + metadata: cred.metadata().into_json(), id, name, - security_key: serde_json::to_string(security_key)?, }) } - pub fn credential_id(&self) -> Result<CredentialID, Error> { - CredentialID::try_from(self.credential_id.as_str()) - .map_err(|()| Error::from(String::from("invalid credential ID"))) - } - pub fn security_key(&self) -> Result<SecurityKey, Error> { - serde_json::from_str(self.security_key.as_str()).map_err(Error::from) - } - pub fn set_security_key(&mut self, security_key: &SecurityKey) -> Result<(), Error> { - self.security_key = serde_json::to_string(security_key)?; - Ok(()) - } -} -impl WebAuthnAuth { - pub fn new( - user_uuid: String, - security_key_auth: &SecurityKeyAuthentication, - ) -> Result<Self, Error> { - Ok(Self { - user_uuid, - data: serde_json::to_string(security_key_auth)?, - }) - } - pub fn security_key_auth(&self) -> Result<SecurityKeyAuthentication, Error> { - serde_json::from_str(&self.data).map_err(Error::from) - } -} -impl WebAuthnReg { - pub fn new( - user_uuid: String, - security_key_reg: &SecurityKeyRegistration, - ) -> Result<Self, Error> { - Ok(Self { - user_uuid, - data: serde_json::to_string(security_key_reg)?, - }) - } - pub fn security_key_reg(&self) -> Result<SecurityKeyRegistration, Error> { - serde_json::from_str(&self.data).map_err(Error::from) - } } #[derive(Queryable)] pub struct WebAuthnInfo { @@ -114,11 +147,6 @@ impl Serialize for WebAuthnInfo { s.end() } } -/// Represents a WebAuthn challenge. -pub enum WebAuthnChallenge { - Auth(WebAuthnAuth), - Reg(WebAuthnReg), -} #[derive(Clone, Copy)] pub enum TwoFactorType { @@ -292,33 +320,49 @@ impl WebAuthnInfo { impl WebAuthn { #[allow(clippy::clone_on_ref_ptr)] - pub async fn get_all_security_keys( - user_uuid: &str, - conn: &DbConn, - ) -> Result<Vec<SecurityKey>, Error> { + async fn get_creds<T>(user_uuid: &str, conn: &DbConn) -> Result<T, Error> + where + T: Credentials, + PublicKeyCredentialDescriptor<Vec<u8>>: Into<T::Credential>, + { use diesel::prelude::{ExpressionMethods, QueryDsl, RunQueryDsl}; use diesel::result; let mut con_res = conn.conn.clone().lock_owned().await; let con = con_res.as_mut().expect("unable to get a pooled connection"); task::block_in_place(move || { webauthn::table - .select(webauthn::security_key) + .select((webauthn::credential_id, webauthn::transports)) .filter(webauthn::user_uuid.eq(user_uuid)) - .load::<String>(con) + .load::<(Vec<u8>, i16)>(con) .map_err(result::Error::into) - .and_then(|keys| { - let len = keys.len(); - keys.into_iter() - .try_fold(Vec::with_capacity(len), |mut sec_keys, key| { - de::from_str::<SecurityKey>(key.as_str()).map(|sec| { - sec_keys.push(sec); - sec_keys - }) + .and_then(|rows| { + let len = rows.len(); + rows.into_iter() + .try_fold(T::with_capacity(len), |mut creds, parts| { + let id = CredentialId::decode(parts.0).map_err(AggErr::CredentialId)?; + let transports = + AuthTransports::decode(u8::try_from(parts.1).map_err(|_e| { + Error::from(String::from("Encoded AuthTransports is not a u8")) + })?) + .map_err(AggErr::DecodeAuthTransports)?; + creds.push(PublicKeyCredentialDescriptor { id, transports }.into()); + Ok(creds) }) - .map_err(Error::from) }) }) } + pub async fn get_registered_creds( + user_uuid: &str, + conn: &DbConn, + ) -> Result<Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, Error> { + Self::get_creds(user_uuid, conn).await + } + pub async fn get_allowed_creds( + user_uuid: &str, + conn: &DbConn, + ) -> Result<AllowedCredentials, Error> { + Self::get_creds(user_uuid, conn).await + } #[allow(clippy::clone_on_ref_ptr)] pub async fn insert(self, conn: &DbConn) -> EmptyResult { use __sqlite_model::WebAuthnDb; @@ -343,15 +387,19 @@ impl WebAuthn { }) } #[allow(clippy::clone_on_ref_ptr)] - pub async fn update(self, conn: &DbConn) -> EmptyResult { + pub async fn update( + id: CredentialId<&[u8]>, + dynamic_state: DynamicState, + conn: &DbConn, + ) -> EmptyResult { use diesel::prelude::{ExpressionMethods, RunQueryDsl}; use diesel::result; let mut con_res = conn.conn.clone().lock_owned().await; let con = con_res.as_mut().expect("unable to get a pooled connection"); task::block_in_place(move || { diesel::update(webauthn::table) - .set(webauthn::security_key.eq(self.security_key)) - .filter(webauthn::credential_id.eq(self.credential_id)) + .set(webauthn::dynamic_state.eq(dynamic_state.encode()?)) + .filter(webauthn::credential_id.eq(id.into_inner())) .execute(con) .map_err(result::Error::into) .and_then(|count| { @@ -366,37 +414,21 @@ impl WebAuthn { }) } #[allow(clippy::clone_on_ref_ptr)] - pub async fn get_all_credentials_by_user( + pub async fn get_credential<'a, 'b>( + credential_id: CredentialId<&'a [u8]>, user_uuid: &str, + user_handle: UserHandle<&'b [u8]>, conn: &DbConn, - ) -> Result<Vec<CredentialID>, Error> { - use diesel::prelude::{ExpressionMethods, QueryDsl, RunQueryDsl}; - use diesel::result; - let mut con_res = conn.conn.clone().lock_owned().await; - let con = con_res.as_mut().expect("unable to get a pooled connection"); - task::block_in_place(move || { - webauthn::table - .select(webauthn::credential_id) - .filter(webauthn::user_uuid.eq(user_uuid)) - .load::<String>(con) - .map_err(result::Error::into) - .and_then(|ids| { - let len = ids.len(); - ids.into_iter() - .try_fold(Vec::with_capacity(len), |mut cred_ids, id| { - CredentialID::try_from(id.as_str()) - .map_err(|()| Error::from(String::from("invalid credential ID"))) - .map(|cred_id| { - cred_ids.push(cred_id); - cred_ids - }) - }) - }) - }) - } - #[allow(clippy::clone_on_ref_ptr)] - pub async fn get_by_cred_id(credential_id: &str, conn: &DbConn) -> Result<Option<Self>, Error> { - use __sqlite_model::WebAuthnDb; + ) -> Result< + Option< + AuthenticatedCredential< + 'a, + 'b, + CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>, + >, + >, + Error, + > { use diesel::prelude::{ExpressionMethods, QueryDsl, RunQueryDsl}; use diesel::result; use diesel::OptionalExtension; @@ -404,11 +436,115 @@ impl WebAuthn { let con = con_res.as_mut().expect("unable to get a pooled connection"); task::block_in_place(move || { webauthn::table - .filter(webauthn::credential_id.eq(credential_id)) - .first::<WebAuthnDb>(con) + .select(( + webauthn::ed25519_key, + webauthn::p256_x, + webauthn::p256_y_is_odd, + webauthn::p384_x, + webauthn::p384_y_is_odd, + webauthn::rsa_n, + webauthn::rsa_e, + webauthn::cred_protect, + webauthn::hmac_secret, + webauthn::dynamic_state, + )) + .filter(webauthn::credential_id.eq(credential_id.into_inner())) + .filter(webauthn::user_uuid.eq(user_uuid)) + .first::<( + Option<Vec<u8>>, + Option<Vec<u8>>, + Option<bool>, + Option<Vec<u8>>, + Option<bool>, + Option<Vec<u8>>, + Option<i32>, + i16, + Option<bool>, + Vec<u8>, + )>(con) .optional() .map_err(result::Error::into) - .map(FromDb::from_db) + .and_then(|row| { + row.map_or_else( + || Ok(None), + |r| { + let credential_public_key = r.0.map_or_else( + || { + r.1.map_or_else( + || { + r.3.map_or_else( + || { + r.5.ok_or_else(|| Error::from("Encoded RsaPubKey is invalid".to_owned())).and_then(|n| { + r.6.ok_or_else(|| Error::from("Encoded RsaPubKey is invalid".to_owned())).and_then(|e| { + RsaPubKey::try_from((n, e as u32)).map_err(|e| Error::from(e.to_string())).map(CompressedPubKey::Rsa) + }) + }) + }, + |k| { + if k.len() == 48 { + r.4.ok_or_else(|| Error::from("Encoded CompressedP384PubKey".to_owned())).map(|y_is_odd| { + let mut key = [0; 48]; + key.copy_from_slice(k.as_slice()); + CompressedPubKey::P384(CompressedP384PubKey::from((key, y_is_odd))) + }) + } else { + Err(Error::from("Encoded CompressedP384PubKey is invalid".to_owned())) + } + } + ) + }, + |k| { + if k.len() == 32 { + r.2.ok_or_else(|| Error::from("Encoded CompressedP256PubKey".to_owned())).map(|y_is_odd| { + let mut key = [0; 32]; + key.copy_from_slice(k.as_slice()); + CompressedPubKey::P256(CompressedP256PubKey::from((key, y_is_odd))) + }) + } else { + Err(Error::from("Encoded CompressedP256PubKey is invalid".to_owned())) + } + } + ) + }, + |k| { + if k.len() == 32 { + let mut key = [0; 32]; + key.copy_from_slice(k.as_slice()); + Ok(CompressedPubKey::Ed25519(Ed25519PubKey::from(key))) + } else { + Err(Error::from("Encoded Ed25519PubKey is invalid".to_owned())) + } + } + )?; + let cred_protect = match r.7 { + 0 => Ok(CredentialProtectionPolicy::None), + 1 => Ok(CredentialProtectionPolicy::UserVerificationOptional), + 2 => Ok(CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList), + 3 => Ok(CredentialProtectionPolicy::UserVerificationRequired), + _ => Err(Error::from("Encoded AuthTransports is invalid".to_owned())), + }?; + if r.9.len() == 7 { + let mut dec_data = [0; 7]; + dec_data.copy_from_slice(r.9.as_slice()); + AuthenticatedCredential::new( + credential_id, + user_handle, + StaticState { + credential_public_key, + extensions: AuthenticatorExtensionOutputStaticState { cred_protect, hmac_secret: r.8 }, + }, + DynamicState::decode(dec_data) + .map_err(AggErr::DecodeDynamicState)?, + ) + .map_err(AggErr::Credential) + .map_err(Error::from) + .map(Some) + } else { + Err(Error::from("Encoded DynamicState is invalid".to_owned())) + } + }, + ) + }) }) } #[allow(clippy::clone_on_ref_ptr)] @@ -440,144 +576,6 @@ impl WebAuthn { } } -impl WebAuthnAuth { - #[allow(clippy::clone_on_ref_ptr)] - pub async fn find_by_user(user_uuid: &str, conn: &DbConn) -> Result<Option<Self>, Error> { - use __sqlite_model::WebAuthnAuthDb; - use diesel::prelude::{ExpressionMethods, QueryDsl, RunQueryDsl}; - use diesel::result; - use diesel::OptionalExtension; - let mut con_res = conn.conn.clone().lock_owned().await; - let con = con_res.as_mut().expect("unable to get a pooled connection"); - task::block_in_place(move || { - webauthn_auth::table - .filter(webauthn_auth::user_uuid.eq(user_uuid)) - .first::<WebAuthnAuthDb>(con) - .optional() - .map_err(result::Error::into) - .map(FromDb::from_db) - }) - } -} - -impl WebAuthnReg { - #[allow(clippy::clone_on_ref_ptr)] - pub async fn find_by_user(user_uuid: &str, conn: &DbConn) -> Result<Option<Self>, Error> { - use __sqlite_model::WebAuthnRegDb; - use diesel::prelude::{ExpressionMethods, QueryDsl, RunQueryDsl}; - use diesel::result; - use diesel::OptionalExtension; - let mut con_res = conn.conn.clone().lock_owned().await; - let con = con_res.as_mut().expect("unable to get a pooled connection"); - task::block_in_place(move || { - webauthn_reg::table - .filter(webauthn_reg::user_uuid.eq(user_uuid)) - .first::<WebAuthnRegDb>(con) - .optional() - .map_err(result::Error::into) - .map(FromDb::from_db) - }) - } -} - -impl WebAuthnChallenge { - #[allow(clippy::clone_on_ref_ptr, clippy::shadow_unrelated)] - pub async fn delete_all(user_uuid: &str, conn: &DbConn) -> EmptyResult { - use diesel::prelude::{Connection, ExpressionMethods, RunQueryDsl}; - use diesel::result; - let mut con_res = conn.conn.clone().lock_owned().await; - let con = con_res.as_mut().expect("unable to get a pooled connection"); - task::block_in_place(move || { - con.transaction(|con| { - diesel::delete(webauthn_auth::table) - .filter(webauthn_auth::user_uuid.eq(user_uuid)) - .execute(con) - .and_then(|_| { - diesel::delete(webauthn_reg::table) - .filter(webauthn_reg::user_uuid.eq(user_uuid)) - .execute(con) - .map(|_| ()) - }) - .map_err(result::Error::into) - }) - }) - } - #[allow(clippy::clone_on_ref_ptr)] - pub async fn delete(self, conn: &DbConn) -> EmptyResult { - use diesel::prelude::{ExpressionMethods, RunQueryDsl}; - use diesel::result; - let mut con_res = conn.conn.clone().lock_owned().await; - let con = con_res.as_mut().expect("unable to get a pooled connection"); - task::block_in_place(move || { - match self { - Self::Auth(chal) => diesel::delete(webauthn_auth::table) - .filter(webauthn_auth::user_uuid.eq(chal.user_uuid.as_str())) - .execute(con), - Self::Reg(chal) => diesel::delete(webauthn_reg::table) - .filter(webauthn_reg::user_uuid.eq(chal.user_uuid.as_str())) - .execute(con), - } - .map_err(result::Error::into) - .and_then(|count| { - if count == 1 { - Ok(()) - } else { - Err(Error::from(String::from( - "exactly one webauthn challenge would not have been removed", - ))) - } - }) - }) - } - #[allow(clippy::clone_on_ref_ptr, clippy::shadow_unrelated)] - pub async fn replace(&self, conn: &DbConn) -> EmptyResult { - use __sqlite_model::{WebAuthnAuthDb, WebAuthnRegDb}; - use diesel::prelude::{Connection, ExpressionMethods, RunQueryDsl}; - use diesel::result; - let mut con_res = conn.conn.clone().lock_owned().await; - let con = con_res.as_mut().expect("unable to get a pooled connection"); - task::block_in_place(move || { - con.transaction(|con| { - match *self { - Self::Auth(ref chal) => diesel::update(webauthn_auth::table) - .set(webauthn_auth::data.eq(&chal.data)) - .filter(webauthn_auth::user_uuid.eq(chal.user_uuid.as_str())) - .execute(con), - Self::Reg(ref chal) => diesel::update(webauthn_reg::table) - .set(webauthn_reg::data.eq(&chal.data)) - .filter(webauthn_reg::user_uuid.eq(chal.user_uuid.as_str())) - .execute(con), - } - .map_err(result::Error::into) - .and_then(|count| { - if count == 0 { - match *self { - Self::Auth(ref chal) => diesel::insert_into(webauthn_auth::table) - .values(WebAuthnAuthDb::to_db(chal)) - .execute(con), - Self::Reg(ref chal) => diesel::insert_into(webauthn_reg::table) - .values(WebAuthnRegDb::to_db(chal)) - .execute(con), - } - .map_err(result::Error::into) - .and_then(|count| { - if count == 1 { - Ok(()) - } else { - Err(Error::from(String::from( - "exactly one webauthn challenge would not have been inserted/updated", - ))) - } - }) - } else { - Ok(()) - } - }) - }) - }) - } -} - impl Totp { #[allow(clippy::clone_on_ref_ptr, clippy::shadow_unrelated)] pub async fn replace(self, conn: &DbConn) -> EmptyResult { diff --git a/src/db/models/user.rs b/src/db/models/user.rs @@ -248,10 +248,7 @@ impl User { } } -use super::{ - Cipher, Device, Favorite, Folder, TwoFactorType, UserOrgType, UserOrganization, - WebAuthnChallenge, -}; +use super::{Cipher, Device, Favorite, Folder, TwoFactorType, UserOrgType, UserOrganization}; use crate::api::EmptyResult; use crate::db::DbConn; use crate::error::MapResult; @@ -343,7 +340,6 @@ impl User { Folder::delete_all_by_user(&self.uuid, conn).await?; Device::delete_all_by_user(&self.uuid, conn).await?; TwoFactorType::delete_all_by_user(&self.uuid, conn).await?; - WebAuthnChallenge::delete_all(&self.uuid, conn).await?; db_run! {conn: { diesel::delete(users::table.filter(users::uuid.eq(self.uuid))) .execute(conn) diff --git a/src/db/schema.rs b/src/db/schema.rs @@ -159,28 +159,24 @@ table! { table! { webauthn (credential_id) { - credential_id -> Text, + credential_id -> Binary, + transports -> SmallInt, user_uuid -> Text, + ed25519_key -> Nullable<Binary>, + p256_x -> Nullable<Binary>, + p256_y_is_odd -> Nullable<Bool>, + p384_x -> Nullable<Binary>, + p384_y_is_odd -> Nullable<Bool>, + rsa_n -> Nullable<Binary>, + rsa_e -> Nullable<Integer>, + cred_protect -> SmallInt, + hmac_secret -> Nullable<Bool>, + dynamic_state -> Binary, + metadata -> Text, id -> BigInt, name -> Text, - security_key -> Text, } } - -table! { - webauthn_auth (user_uuid) { - user_uuid -> Text, - data -> Text, - } -} - -table! { - webauthn_reg (user_uuid) { - user_uuid -> Text, - data -> Text, - } -} - joinable!(folders_ciphers -> ciphers (cipher_uuid)); joinable!(folders_ciphers -> folders (folder_uuid)); allow_tables_to_appear_in_same_query!( diff --git a/src/error.rs b/src/error.rs @@ -48,7 +48,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::prelude::WebauthnError as WebauthnErr; +use webauthn_rp::AggErr as WebAuthnErr; #[derive(Serialize)] struct Empty; @@ -78,7 +78,7 @@ make_error! { Rocket(RocketErr): _has_source, _api_error, DieselCon(DieselConErr): _has_source, _api_error, - Webauthn(WebauthnErr): _has_source, _api_error, + WebAuthn(WebAuthnErr): _has_source, _api_error, } // Error struct // Contains a String error message, meant for the user and an enum variant, with an error of different types. @@ -106,7 +106,7 @@ make_error! { Unveil(UnveilErr): _has_source, _api_error, DieselCon(DieselConErr): _has_source, _api_error, - Webauthn(WebauthnErr): _has_source, _api_error, + WebAuthn(WebAuthnErr): _has_source, _api_error, } #[cfg(not(all(feature = "priv_sep", target_os = "openbsd")))] impl From<Infallible> for Error { @@ -140,7 +140,7 @@ impl Debug for Error { | ErrorKind::OpenSSL(_) | ErrorKind::Rocket(_) | ErrorKind::DieselCon(_) - | ErrorKind::Webauthn(_) => unreachable!(), + | ErrorKind::WebAuthn(_) => unreachable!(), }, } } diff --git a/src/util.rs b/src/util.rs @@ -125,7 +125,7 @@ impl Cors { // If a match exists, return it. Otherwise, return None. fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option<String> { let origin = Self::get_header(headers, "Origin"); - let domain_origin = config::get_config().domain_origin(); + let domain_origin = config::get_config().domain_url(); let safari_extension_origin = "file://"; if origin == domain_origin || origin == safari_extension_origin { Some(origin)