commit a44749e827958adec946e74389f008507f4567aa
parent 9ce8b4dfc519b132ae06050c3f62286627388292
Author: Zack Newman <zack@philomathiclife.com>
Date: Sun, 8 Dec 2024 10:37:01 -0700
use webauthn_rp
Diffstat:
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, ®istration)?)
- .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, ®istration)?;
- 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)