|
|
|
@ -6,13 +6,14 @@ use crate::{ |
|
|
|
auth::Headers, |
|
|
|
crypto::ct_eq, |
|
|
|
db::{ |
|
|
|
models::{EventType, TwoFactor, TwoFactorType, UserId}, |
|
|
|
models::{EventType, TwoFactor, TwoFactorType, User, UserId}, |
|
|
|
DbConn, |
|
|
|
}, |
|
|
|
error::Error, |
|
|
|
util::NumberOrString, |
|
|
|
CONFIG, |
|
|
|
}; |
|
|
|
use data_encoding::BASE64URL_NOPAD; |
|
|
|
use rocket::serde::json::Json; |
|
|
|
use rocket::Route; |
|
|
|
use serde_json::Value; |
|
|
|
@ -21,7 +22,10 @@ use std::sync::LazyLock; |
|
|
|
use std::time::Duration; |
|
|
|
use url::Url; |
|
|
|
use uuid::Uuid; |
|
|
|
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}; |
|
|
|
use webauthn_rs::prelude::{ |
|
|
|
Base64UrlSafeData, Credential, DiscoverableAuthentication, DiscoverableKey, Passkey, PasskeyAuthentication, |
|
|
|
PasskeyRegistration, |
|
|
|
}; |
|
|
|
use webauthn_rs::{Webauthn, WebauthnBuilder}; |
|
|
|
use webauthn_rs_proto::{ |
|
|
|
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, |
|
|
|
@ -72,10 +76,32 @@ pub struct U2FRegistration { |
|
|
|
#[derive(Debug, Serialize, Deserialize)] |
|
|
|
pub struct WebauthnRegistration { |
|
|
|
pub id: i32, |
|
|
|
#[serde(default)] |
|
|
|
pub api_id: Option<String>, |
|
|
|
pub name: String, |
|
|
|
pub migrated: bool, |
|
|
|
|
|
|
|
pub credential: Passkey, |
|
|
|
#[serde(default)] |
|
|
|
pub supports_prf: bool, |
|
|
|
pub encrypted_user_key: Option<String>, |
|
|
|
pub encrypted_public_key: Option<String>, |
|
|
|
pub encrypted_private_key: Option<String>, |
|
|
|
} |
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)] |
|
|
|
#[serde(rename_all = "PascalCase")] |
|
|
|
pub struct WebauthnPrfDecryptionOption { |
|
|
|
pub encrypted_private_key: String, |
|
|
|
pub encrypted_user_key: String, |
|
|
|
pub credential_id: String, |
|
|
|
pub transports: Vec<String>, |
|
|
|
} |
|
|
|
|
|
|
|
#[derive(Debug, Clone)] |
|
|
|
pub struct WebauthnDiscoverableLoginResult { |
|
|
|
pub user_id: UserId, |
|
|
|
pub prf_decryption_option: Option<WebauthnPrfDecryptionOption>, |
|
|
|
} |
|
|
|
|
|
|
|
impl WebauthnRegistration { |
|
|
|
@ -104,6 +130,48 @@ impl WebauthnRegistration { |
|
|
|
self.credential = cred.into(); |
|
|
|
changed |
|
|
|
} |
|
|
|
|
|
|
|
pub fn prf_status(&self) -> i32 { |
|
|
|
if !self.supports_prf { |
|
|
|
// Unsupported
|
|
|
|
2 |
|
|
|
} else if self.encrypted_user_key.is_some() |
|
|
|
&& self.encrypted_public_key.is_some() |
|
|
|
&& self.encrypted_private_key.is_some() |
|
|
|
{ |
|
|
|
// Enabled
|
|
|
|
0 |
|
|
|
} else { |
|
|
|
// Supported
|
|
|
|
1 |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
fn ensure_api_id(&mut self) -> bool { |
|
|
|
if self.api_id.is_none() { |
|
|
|
self.api_id = Some(Uuid::new_v4().to_string()); |
|
|
|
return true; |
|
|
|
} |
|
|
|
false |
|
|
|
} |
|
|
|
|
|
|
|
pub fn login_credential_api_id(&self) -> String { |
|
|
|
self.api_id.clone().unwrap_or_else(|| self.id.to_string()) |
|
|
|
} |
|
|
|
|
|
|
|
pub fn matches_login_credential_id(&self, id: &str) -> bool { |
|
|
|
self.api_id.as_deref() == Some(id) || self.id.to_string() == id |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
fn normalize_optional_secret(value: Option<String>) -> Option<String> { |
|
|
|
value.and_then(|s| { |
|
|
|
if s.trim().is_empty() { |
|
|
|
None |
|
|
|
} else { |
|
|
|
Some(s) |
|
|
|
} |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
#[post("/two-factor/get-webauthn", data = "<data>")] |
|
|
|
@ -180,10 +248,12 @@ struct EnableWebauthnData { |
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)] |
|
|
|
#[serde(rename_all = "camelCase")] |
|
|
|
struct RegisterPublicKeyCredentialCopy { |
|
|
|
pub struct RegisterPublicKeyCredentialCopy { |
|
|
|
pub id: String, |
|
|
|
pub raw_id: Base64UrlSafeData, |
|
|
|
pub response: AuthenticatorAttestationResponseRawCopy, |
|
|
|
#[serde(default, alias = "clientExtensionResults")] |
|
|
|
pub extensions: RegistrationExtensionsClientOutputs, |
|
|
|
pub r#type: String, |
|
|
|
} |
|
|
|
|
|
|
|
@ -208,7 +278,7 @@ impl From<RegisterPublicKeyCredentialCopy> for RegisterPublicKeyCredential { |
|
|
|
transports: None, |
|
|
|
}, |
|
|
|
type_: r.r#type, |
|
|
|
extensions: RegistrationExtensionsClientOutputs::default(), |
|
|
|
extensions: r.extensions, |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -281,10 +351,15 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con |
|
|
|
// TODO: Check for repeated ID's
|
|
|
|
registrations.push(WebauthnRegistration { |
|
|
|
id: data.id.into_i32()?, |
|
|
|
api_id: None, |
|
|
|
name: data.name, |
|
|
|
migrated: false, |
|
|
|
|
|
|
|
credential, |
|
|
|
supports_prf: false, |
|
|
|
encrypted_user_key: None, |
|
|
|
encrypted_public_key: None, |
|
|
|
encrypted_private_key: None, |
|
|
|
}); |
|
|
|
|
|
|
|
// Save the registrations and return them
|
|
|
|
@ -374,6 +449,205 @@ pub async fn get_webauthn_registrations( |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
pub async fn get_webauthn_login_registrations(user_id: &UserId, conn: &DbConn) -> Result<Vec<WebauthnRegistration>, Error> { |
|
|
|
let type_ = TwoFactorType::WebauthnLoginCredential as i32; |
|
|
|
let Some(mut tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { |
|
|
|
return Ok(Vec::new()); |
|
|
|
}; |
|
|
|
|
|
|
|
let mut registrations: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?; |
|
|
|
let mut changed = false; |
|
|
|
for registration in &mut registrations { |
|
|
|
changed |= registration.ensure_api_id(); |
|
|
|
} |
|
|
|
|
|
|
|
if changed { |
|
|
|
tf.data = serde_json::to_string(®istrations)?; |
|
|
|
tf.save(conn).await?; |
|
|
|
} |
|
|
|
|
|
|
|
Ok(registrations) |
|
|
|
} |
|
|
|
|
|
|
|
pub async fn generate_webauthn_attestation_options(user: &User, conn: &DbConn) -> Result<(Value, String), Error> { |
|
|
|
let registrations = get_webauthn_login_registrations(&user.uuid, conn) |
|
|
|
.await? |
|
|
|
.into_iter() |
|
|
|
.map(|r| r.credential.cred_id().to_owned()) |
|
|
|
.collect(); |
|
|
|
|
|
|
|
let (challenge, state) = WEBAUTHN.start_passkey_registration( |
|
|
|
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
|
|
|
&user.email, |
|
|
|
user.display_name(), |
|
|
|
Some(registrations), |
|
|
|
)?; |
|
|
|
|
|
|
|
let mut options = serde_json::to_value(challenge.public_key)?; |
|
|
|
if let Some(obj) = options.as_object_mut() { |
|
|
|
obj.insert("userVerification".to_string(), Value::String("required".to_string())); |
|
|
|
|
|
|
|
let auth_sel = obj |
|
|
|
.entry("authenticatorSelection") |
|
|
|
.or_insert_with(|| Value::Object(serde_json::Map::new())); |
|
|
|
if let Some(auth_sel_obj) = auth_sel.as_object_mut() { |
|
|
|
auth_sel_obj.insert("requireResidentKey".to_string(), Value::Bool(true)); |
|
|
|
auth_sel_obj.insert("residentKey".to_string(), Value::String("required".to_string())); |
|
|
|
auth_sel_obj.insert("userVerification".to_string(), Value::String("required".to_string())); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
let mut state = serde_json::to_value(&state)?; |
|
|
|
if let Some(rs) = state.get_mut("rs").and_then(Value::as_object_mut) { |
|
|
|
rs.insert("policy".to_string(), Value::String("required".to_string())); |
|
|
|
} |
|
|
|
|
|
|
|
Ok((options, serde_json::to_string(&state)?)) |
|
|
|
} |
|
|
|
|
|
|
|
pub async fn create_webauthn_login_credential( |
|
|
|
user_id: &UserId, |
|
|
|
serialized_state: &str, |
|
|
|
name: String, |
|
|
|
device_response: RegisterPublicKeyCredentialCopy, |
|
|
|
supports_prf: bool, |
|
|
|
encrypted_user_key: Option<String>, |
|
|
|
encrypted_public_key: Option<String>, |
|
|
|
encrypted_private_key: Option<String>, |
|
|
|
conn: &DbConn, |
|
|
|
) -> EmptyResult { |
|
|
|
const MAX_CREDENTIALS_PER_USER: usize = 5; |
|
|
|
|
|
|
|
let state: PasskeyRegistration = serde_json::from_str(serialized_state)?; |
|
|
|
let credential = WEBAUTHN.finish_passkey_registration(&device_response.into(), &state)?; |
|
|
|
|
|
|
|
let encrypted_user_key = normalize_optional_secret(encrypted_user_key); |
|
|
|
let encrypted_public_key = normalize_optional_secret(encrypted_public_key); |
|
|
|
let encrypted_private_key = normalize_optional_secret(encrypted_private_key); |
|
|
|
|
|
|
|
let mut registrations = get_webauthn_login_registrations(user_id, conn).await?; |
|
|
|
if registrations.len() >= MAX_CREDENTIALS_PER_USER { |
|
|
|
err!("Unable to complete WebAuthn registration.") |
|
|
|
} |
|
|
|
|
|
|
|
// Avoid duplicate credential IDs.
|
|
|
|
if registrations.iter().any(|r| ct_eq(r.credential.cred_id(), credential.cred_id())) { |
|
|
|
err!("Unable to complete WebAuthn registration.") |
|
|
|
} |
|
|
|
|
|
|
|
let id = registrations.iter().map(|r| r.id).max().unwrap_or(0).saturating_add(1); |
|
|
|
registrations.push(WebauthnRegistration { |
|
|
|
id, |
|
|
|
api_id: Some(Uuid::new_v4().to_string()), |
|
|
|
name, |
|
|
|
migrated: false, |
|
|
|
credential, |
|
|
|
supports_prf, |
|
|
|
encrypted_user_key, |
|
|
|
encrypted_public_key, |
|
|
|
encrypted_private_key, |
|
|
|
}); |
|
|
|
|
|
|
|
TwoFactor::new( |
|
|
|
user_id.clone(), |
|
|
|
TwoFactorType::WebauthnLoginCredential, |
|
|
|
serde_json::to_string(®istrations)?, |
|
|
|
) |
|
|
|
.save(conn) |
|
|
|
.await?; |
|
|
|
|
|
|
|
Ok(()) |
|
|
|
} |
|
|
|
|
|
|
|
pub async fn update_webauthn_login_credential_keys( |
|
|
|
user_id: &UserId, |
|
|
|
serialized_state: &str, |
|
|
|
device_response: PublicKeyCredentialCopy, |
|
|
|
encrypted_user_key: String, |
|
|
|
encrypted_public_key: String, |
|
|
|
encrypted_private_key: String, |
|
|
|
conn: &DbConn, |
|
|
|
) -> EmptyResult { |
|
|
|
if encrypted_user_key.trim().is_empty() |
|
|
|
|| encrypted_public_key.trim().is_empty() |
|
|
|
|| encrypted_private_key.trim().is_empty() |
|
|
|
{ |
|
|
|
err!("Unable to update credential.") |
|
|
|
} |
|
|
|
|
|
|
|
let state: DiscoverableAuthentication = serde_json::from_str(serialized_state)?; |
|
|
|
let rsp: PublicKeyCredential = device_response.into(); |
|
|
|
|
|
|
|
let (asserted_uuid, credential_id) = WEBAUTHN.identify_discoverable_authentication(&rsp)?; |
|
|
|
let asserted_user_id: UserId = asserted_uuid.to_string().into(); |
|
|
|
if asserted_user_id != *user_id { |
|
|
|
err!("Invalid credential.") |
|
|
|
} |
|
|
|
|
|
|
|
let mut registrations = get_webauthn_login_registrations(user_id, conn).await?; |
|
|
|
let Some(registration) = registrations |
|
|
|
.iter_mut() |
|
|
|
.find(|r| ct_eq(r.credential.cred_id().as_slice(), credential_id)) |
|
|
|
else { |
|
|
|
err!("Invalid credential.") |
|
|
|
}; |
|
|
|
|
|
|
|
let discoverable_key: DiscoverableKey = registration.credential.clone().into(); |
|
|
|
let authentication_result = |
|
|
|
WEBAUTHN.finish_discoverable_authentication(&rsp, state, &[discoverable_key])?; |
|
|
|
|
|
|
|
if !registration.supports_prf { |
|
|
|
err!("Unable to update credential.") |
|
|
|
} |
|
|
|
|
|
|
|
registration.encrypted_user_key = Some(encrypted_user_key); |
|
|
|
registration.encrypted_public_key = Some(encrypted_public_key); |
|
|
|
registration.encrypted_private_key = Some(encrypted_private_key); |
|
|
|
registration.credential.update_credential(&authentication_result); |
|
|
|
|
|
|
|
TwoFactor::new( |
|
|
|
user_id.clone(), |
|
|
|
TwoFactorType::WebauthnLoginCredential, |
|
|
|
serde_json::to_string(®istrations)?, |
|
|
|
) |
|
|
|
.save(conn) |
|
|
|
.await?; |
|
|
|
|
|
|
|
Ok(()) |
|
|
|
} |
|
|
|
|
|
|
|
pub async fn delete_webauthn_login_credential(user_id: &UserId, id: &str, conn: &DbConn) -> EmptyResult { |
|
|
|
let Some(mut tf) = |
|
|
|
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginCredential as i32, conn).await |
|
|
|
else { |
|
|
|
err!("Credential not found.") |
|
|
|
}; |
|
|
|
|
|
|
|
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?; |
|
|
|
let Some(item_pos) = data.iter().position(|r| r.matches_login_credential_id(id)) else { |
|
|
|
err!("Credential not found.") |
|
|
|
}; |
|
|
|
|
|
|
|
let removed_item = data.remove(item_pos); |
|
|
|
tf.data = serde_json::to_string(&data)?; |
|
|
|
tf.save(conn).await?; |
|
|
|
drop(tf); |
|
|
|
|
|
|
|
// If entry is migrated from u2f, delete the u2f entry as well.
|
|
|
|
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::U2f as i32, conn).await { |
|
|
|
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) { |
|
|
|
Ok(d) => d, |
|
|
|
Err(_) => err!("Error parsing U2F data"), |
|
|
|
}; |
|
|
|
|
|
|
|
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); |
|
|
|
u2f.data = serde_json::to_string(&data)?; |
|
|
|
u2f.save(conn).await?; |
|
|
|
} |
|
|
|
|
|
|
|
Ok(()) |
|
|
|
} |
|
|
|
|
|
|
|
pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonResult { |
|
|
|
// Load saved credentials
|
|
|
|
let creds: Vec<Passkey> = |
|
|
|
@ -463,6 +737,94 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
pub fn generate_webauthn_discoverable_login() -> Result<(Value, String), Error> { |
|
|
|
let (response, state) = WEBAUTHN.start_discoverable_authentication()?; |
|
|
|
let mut options = serde_json::to_value(response.public_key)?; |
|
|
|
if let Some(obj) = options.as_object_mut() { |
|
|
|
obj.insert("userVerification".to_string(), Value::String("required".to_string())); |
|
|
|
let need_empty_allow_credentials = match obj.get("allowCredentials") { |
|
|
|
Some(value) => value.is_null(), |
|
|
|
None => true, |
|
|
|
}; |
|
|
|
if need_empty_allow_credentials { |
|
|
|
obj.insert("allowCredentials".to_string(), Value::Array(Vec::new())); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
let mut state = serde_json::to_value(&state)?; |
|
|
|
if let Some(ast) = state.get_mut("ast").and_then(Value::as_object_mut) { |
|
|
|
ast.insert("policy".to_string(), Value::String("required".to_string())); |
|
|
|
} |
|
|
|
|
|
|
|
Ok((options, serde_json::to_string(&state)?)) |
|
|
|
} |
|
|
|
|
|
|
|
pub async fn validate_webauthn_discoverable_login( |
|
|
|
serialized_state: &str, |
|
|
|
response: &str, |
|
|
|
conn: &DbConn, |
|
|
|
) -> Result<WebauthnDiscoverableLoginResult, Error> { |
|
|
|
let state: DiscoverableAuthentication = serde_json::from_str(serialized_state)?; |
|
|
|
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?; |
|
|
|
let rsp: PublicKeyCredential = rsp.into(); |
|
|
|
|
|
|
|
let (uuid, credential_id) = WEBAUTHN.identify_discoverable_authentication(&rsp)?; |
|
|
|
let user_id: UserId = uuid.to_string().into(); |
|
|
|
|
|
|
|
let mut registrations = get_webauthn_login_registrations(&user_id, conn).await?; |
|
|
|
let Some(registration_idx) = registrations |
|
|
|
.iter() |
|
|
|
.position(|r| ct_eq(r.credential.cred_id().as_slice(), credential_id)) |
|
|
|
else { |
|
|
|
err!("Invalid credential.") |
|
|
|
}; |
|
|
|
|
|
|
|
let discoverable_key: DiscoverableKey = registrations[registration_idx].credential.clone().into(); |
|
|
|
let authentication_result = |
|
|
|
WEBAUTHN.finish_discoverable_authentication(&rsp, state, &[discoverable_key])?; |
|
|
|
|
|
|
|
// Keep signature counters in sync.
|
|
|
|
let credential_updated = { |
|
|
|
let registration = &mut registrations[registration_idx]; |
|
|
|
registration.credential.update_credential(&authentication_result) == Some(true) |
|
|
|
}; |
|
|
|
if credential_updated { |
|
|
|
TwoFactor::new( |
|
|
|
user_id.clone(), |
|
|
|
TwoFactorType::WebauthnLoginCredential, |
|
|
|
serde_json::to_string(®istrations)?, |
|
|
|
) |
|
|
|
.save(conn) |
|
|
|
.await?; |
|
|
|
} |
|
|
|
|
|
|
|
let prf_decryption_option = { |
|
|
|
let registration = ®istrations[registration_idx]; |
|
|
|
if registration.supports_prf { |
|
|
|
match ( |
|
|
|
registration.encrypted_private_key.clone(), |
|
|
|
registration.encrypted_user_key.clone(), |
|
|
|
registration.encrypted_public_key.as_ref(), |
|
|
|
) { |
|
|
|
(Some(encrypted_private_key), Some(encrypted_user_key), Some(_)) => Some(WebauthnPrfDecryptionOption { |
|
|
|
encrypted_private_key, |
|
|
|
encrypted_user_key, |
|
|
|
credential_id: BASE64URL_NOPAD.encode(registration.credential.cred_id().as_slice()), |
|
|
|
transports: Vec::new(), |
|
|
|
}), |
|
|
|
_ => None, |
|
|
|
} |
|
|
|
} else { |
|
|
|
None |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
Ok(WebauthnDiscoverableLoginResult { |
|
|
|
user_id, |
|
|
|
prf_decryption_option, |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
async fn check_and_update_backup_eligible( |
|
|
|
user_id: &UserId, |
|
|
|
rsp: &PublicKeyCredential, |
|
|
|
|