committed by
GitHub
22 changed files with 811 additions and 57 deletions
@ -0,0 +1 @@ |
|||
DROP TABLE webauthn_credentials; |
|||
@ -0,0 +1,10 @@ |
|||
CREATE TABLE webauthn_credentials ( |
|||
uuid VARCHAR(40) NOT NULL PRIMARY KEY, |
|||
user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), |
|||
name TEXT NOT NULL, |
|||
credential TEXT NOT NULL, |
|||
supports_prf BOOLEAN NOT NULL DEFAULT 0, |
|||
encrypted_user_key TEXT, |
|||
encrypted_public_key TEXT, |
|||
encrypted_private_key TEXT |
|||
); |
|||
@ -0,0 +1 @@ |
|||
DROP TABLE webauthn_credentials; |
|||
@ -0,0 +1,10 @@ |
|||
CREATE TABLE webauthn_credentials ( |
|||
uuid VARCHAR(40) NOT NULL PRIMARY KEY, |
|||
user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), |
|||
name TEXT NOT NULL, |
|||
credential TEXT NOT NULL, |
|||
supports_prf BOOLEAN NOT NULL DEFAULT FALSE, |
|||
encrypted_user_key TEXT, |
|||
encrypted_public_key TEXT, |
|||
encrypted_private_key TEXT |
|||
); |
|||
@ -0,0 +1 @@ |
|||
DROP TABLE webauthn_credentials; |
|||
@ -0,0 +1,10 @@ |
|||
CREATE TABLE webauthn_credentials ( |
|||
uuid TEXT NOT NULL PRIMARY KEY, |
|||
user_uuid TEXT NOT NULL REFERENCES users(uuid), |
|||
name TEXT NOT NULL, |
|||
credential TEXT NOT NULL, |
|||
supports_prf BOOLEAN NOT NULL DEFAULT 0, |
|||
encrypted_user_key TEXT, |
|||
encrypted_public_key TEXT, |
|||
encrypted_private_key TEXT |
|||
); |
|||
@ -0,0 +1,286 @@ |
|||
use std::sync::LazyLock; |
|||
|
|||
use rocket::{http::Status, serde::json::Json}; |
|||
use serde_json::Value; |
|||
use webauthn_rs::{ |
|||
Webauthn, WebauthnBuilder, |
|||
prelude::{Passkey, PasskeyRegistration}, |
|||
}; |
|||
use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy, options::PubKeyCredParams}; |
|||
|
|||
use crate::{ |
|||
CONFIG, |
|||
api::{ApiResult, JsonResult, PasswordOrOtpData, core::two_factor::webauthn::RegisterPublicKeyCredentialCopy}, |
|||
auth::Headers, |
|||
db::{ |
|||
DbConn, |
|||
models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId, WebauthnCredentialPrfStatus}, |
|||
}, |
|||
}; |
|||
|
|||
pub static WEBAUTHN_PASSWORDLESS: LazyLock<Webauthn> = LazyLock::new(|| { |
|||
let domain = CONFIG.domain(); |
|||
let domain_origin = CONFIG.domain_origin(); |
|||
let rp_id = url::Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); |
|||
let rp_origin = url::Url::parse(&domain_origin).unwrap(); |
|||
|
|||
let mut webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) |
|||
.expect("Creating WebauthnBuilder failed") |
|||
.rp_name(&domain) |
|||
.timeout(tokio::time::Duration::from_mins(1)); |
|||
|
|||
for origin in [ |
|||
"chrome-extension://nngceckbapebfimnlniiiahkandclblb", // Chrome Web Store
|
|||
"chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh", // Edge Add-ons
|
|||
] { |
|||
let origin = url::Url::parse(origin).expect("origin should parse"); |
|||
webauthn = webauthn.append_allowed_origin(&origin); |
|||
} |
|||
|
|||
webauthn.build().expect("Building Webauthn failed") |
|||
}); |
|||
|
|||
pub fn routes() -> Vec<rocket::Route> { |
|||
routes![get_webauthn, post_webauthn, post_webauthn_attestation_options, post_webauthn_delete] |
|||
} |
|||
|
|||
pub fn webauthn_prf_option(wac: &WebauthnCredential, pascal_case: bool) -> Option<Value> { |
|||
if !matches!(wac.get_prf_status(), WebauthnCredentialPrfStatus::Enabled) { |
|||
return None; |
|||
} |
|||
|
|||
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; |
|||
|
|||
if pascal_case { |
|||
Some(json!({ |
|||
"CredentialId": passkey.cred_id().to_owned(), |
|||
"Transports": [], |
|||
"EncryptedPrivateKey": wac.encrypted_private_key.as_ref()?, |
|||
"EncryptedUserKey": wac.encrypted_user_key.as_ref()?, |
|||
})) |
|||
} else { |
|||
Some(json!({ |
|||
"credentialId": passkey.cred_id().to_owned(), |
|||
"transports": [], |
|||
"encryptedPrivateKey": wac.encrypted_private_key.as_ref()?, |
|||
"encryptedUserKey": wac.encrypted_user_key.as_ref()?, |
|||
})) |
|||
} |
|||
} |
|||
|
|||
#[get("/webauthn")] |
|||
async fn get_webauthn(headers: Headers, conn: DbConn) -> JsonResult { |
|||
if !CONFIG.passkey_login_allowed() { |
|||
// The web vault will query this endpoint whether passkey login is allowed or not, so we should return an empty list instead of an error.
|
|||
return Ok(Json(json!({ |
|||
"object": "list", |
|||
"data": [], |
|||
"continuationToken": null |
|||
}))); |
|||
} |
|||
|
|||
let user = headers.user; |
|||
|
|||
let data: Vec<WebauthnCredential> = WebauthnCredential::find_all_by_user(&user.uuid, &conn).await; |
|||
let data = data |
|||
.into_iter() |
|||
.map(|wac| { |
|||
json!({ |
|||
"id": wac.uuid, |
|||
"name": wac.name, |
|||
"prfStatus": wac.get_prf_status() as u8, |
|||
"encryptedUserKey": wac.encrypted_user_key, |
|||
"encryptedPublicKey": wac.encrypted_public_key, |
|||
"object": "webauthnCredential", |
|||
}) |
|||
}) |
|||
.collect::<Value>(); |
|||
|
|||
Ok(Json(json!({ |
|||
"object": "list", |
|||
"data": data, |
|||
"continuationToken": null |
|||
}))) |
|||
} |
|||
|
|||
#[post("/webauthn/attestation-options", data = "<data>")] |
|||
async fn post_webauthn_attestation_options( |
|||
data: Json<PasswordOrOtpData>, |
|||
headers: Headers, |
|||
conn: DbConn, |
|||
) -> JsonResult { |
|||
if !CONFIG.passkey_login_allowed() { |
|||
err!("Passkey login is not allowed") |
|||
} |
|||
|
|||
let data: PasswordOrOtpData = data.into_inner(); |
|||
let user = headers.user; |
|||
|
|||
data.validate(&user, false, &conn).await?; |
|||
|
|||
let all_creds: Vec<WebauthnCredential> = WebauthnCredential::find_all_by_user(&user.uuid, &conn).await; |
|||
let existing_cred_ids: Vec<_> = all_creds |
|||
.into_iter() |
|||
.filter_map(|wac| { |
|||
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; |
|||
Some(passkey.cred_id().to_owned()) |
|||
}) |
|||
.collect(); |
|||
|
|||
let user_uuid = uuid::Uuid::parse_str(&user.uuid).expect("Failed to parse user UUID"); |
|||
|
|||
let (mut challenge, state) = WEBAUTHN_PASSWORDLESS.start_passkey_registration( |
|||
user_uuid, |
|||
&user.email, |
|||
user.display_name(), |
|||
Some(existing_cred_ids), |
|||
)?; |
|||
|
|||
// For passkey login, we need discoverable credentials (resident keys).
|
|||
// start_passkey_registration() defaults to require_resident_key=false, but passkey login
|
|||
// requires the credential to be discoverable (resident) so the authenticator can find it
|
|||
// without the server providing allowCredentials.
|
|||
if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { |
|||
asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; |
|||
asc.require_resident_key = true; |
|||
asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required); |
|||
} |
|||
|
|||
// Safely clear the extensions field to match Bitwarden behavior
|
|||
if let Some(exts) = challenge.public_key.extensions.as_mut() { |
|||
exts.cred_props = None; |
|||
exts.uvm = None; |
|||
exts.cred_protect = None; |
|||
exts.hmac_create_secret = None; |
|||
exts.min_pin_length = None; |
|||
} |
|||
|
|||
// Set algorithms list to {ES,RS,PS}{256,384,512},EDDSA to match Bitwarden behavior
|
|||
challenge.public_key.pub_key_cred_params = vec![ |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::ES256 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::ES384 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::ES512 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::RS256 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::RS384 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::RS512 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::PS256 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::PS384 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::PS512 as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
PubKeyCredParams { |
|||
alg: COSEAlgorithm::EDDSA as i64, |
|||
type_: "public-key".to_owned(), |
|||
}, |
|||
]; |
|||
|
|||
// Persist the registration state in the database (same pattern as 2FA webauthn)
|
|||
TwoFactor::new(user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge, serde_json::to_string(&state)?) |
|||
.save(&conn) |
|||
.await?; |
|||
|
|||
let mut options = serde_json::to_value(challenge.public_key)?; |
|||
options["status"] = "ok".into(); |
|||
options["errorMessage"] = "".into(); |
|||
|
|||
Ok(Json(json!({ |
|||
"options": options, |
|||
"object": "webauthnCredentialCreateOptions" |
|||
}))) |
|||
} |
|||
|
|||
#[derive(Debug, Deserialize)] |
|||
#[serde(rename_all = "camelCase")] |
|||
struct WebAuthnLoginCredentialCreateRequest { |
|||
device_response: RegisterPublicKeyCredentialCopy, |
|||
name: String, |
|||
supports_prf: bool, |
|||
encrypted_user_key: Option<String>, |
|||
encrypted_public_key: Option<String>, |
|||
encrypted_private_key: Option<String>, |
|||
} |
|||
|
|||
#[post("/webauthn", data = "<data>")] |
|||
async fn post_webauthn( |
|||
data: Json<WebAuthnLoginCredentialCreateRequest>, |
|||
headers: Headers, |
|||
conn: DbConn, |
|||
) -> ApiResult<Status> { |
|||
if !CONFIG.passkey_login_allowed() { |
|||
err!("Passkey login is not allowed") |
|||
} |
|||
|
|||
let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); |
|||
let user = headers.user; |
|||
|
|||
// Retrieve and delete the saved challenge state from the database
|
|||
let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; |
|||
let credential = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { |
|||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; |
|||
tf.delete(&conn).await?; |
|||
WEBAUTHN_PASSWORDLESS.finish_passkey_registration(&data.device_response.into(), &state)? |
|||
} else { |
|||
err!("No registration challenge found. Please try again.") |
|||
}; |
|||
|
|||
WebauthnCredential::new( |
|||
user.uuid, |
|||
data.name, |
|||
serde_json::to_string(&credential)?, |
|||
data.supports_prf, |
|||
data.encrypted_user_key, |
|||
data.encrypted_public_key, |
|||
data.encrypted_private_key, |
|||
) |
|||
.save(&conn) |
|||
.await?; |
|||
|
|||
Ok(Status::Ok) |
|||
} |
|||
|
|||
#[post("/webauthn/<uuid>/delete", data = "<data>")] |
|||
async fn post_webauthn_delete( |
|||
data: Json<PasswordOrOtpData>, |
|||
uuid: &str, |
|||
headers: Headers, |
|||
conn: DbConn, |
|||
) -> ApiResult<Status> { |
|||
if !CONFIG.passkey_login_allowed() { |
|||
err!("Passkey login is not allowed") |
|||
} |
|||
|
|||
let data: PasswordOrOtpData = data.into_inner(); |
|||
let user = headers.user; |
|||
|
|||
data.validate(&user, false, &conn).await?; |
|||
|
|||
WebauthnCredential::delete_by_uuid_and_user(&WebauthnCredentialId::from(uuid.to_owned()), &user.uuid, &conn) |
|||
.await?; |
|||
|
|||
Ok(Status::Ok) |
|||
} |
|||
@ -0,0 +1,153 @@ |
|||
use derive_more::{AsRef, Deref, Display, From}; |
|||
use diesel::prelude::*; |
|||
use macros::UuidFromParam; |
|||
|
|||
use crate::api::EmptyResult; |
|||
use crate::db::DbConn; |
|||
use crate::db::schema::webauthn_credentials; |
|||
use crate::error::MapResult; |
|||
|
|||
use super::UserId; |
|||
|
|||
#[derive(num_derive::FromPrimitive, Serialize)] |
|||
pub enum WebauthnCredentialPrfStatus { |
|||
Enabled = 0, |
|||
Disabled = 1, |
|||
NotSupported = 2, |
|||
} |
|||
|
|||
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)] |
|||
#[diesel(table_name = webauthn_credentials)] |
|||
#[diesel(treat_none_as_null = true)] |
|||
#[diesel(primary_key(uuid))] |
|||
pub struct WebauthnCredential { |
|||
pub uuid: WebauthnCredentialId, |
|||
pub user_uuid: UserId, |
|||
pub name: String, |
|||
pub credential: String, |
|||
pub supports_prf: bool, |
|||
pub encrypted_user_key: Option<String>, |
|||
pub encrypted_public_key: Option<String>, |
|||
pub encrypted_private_key: Option<String>, |
|||
} |
|||
|
|||
/// Local methods
|
|||
impl WebauthnCredential { |
|||
pub fn new( |
|||
user_uuid: UserId, |
|||
name: String, |
|||
credential: String, |
|||
supports_prf: bool, |
|||
encrypted_user_key: Option<String>, |
|||
encrypted_public_key: Option<String>, |
|||
encrypted_private_key: Option<String>, |
|||
) -> Self { |
|||
Self { |
|||
uuid: WebauthnCredentialId(crate::util::get_uuid()), |
|||
user_uuid, |
|||
name, |
|||
credential, |
|||
supports_prf, |
|||
encrypted_user_key, |
|||
encrypted_public_key, |
|||
encrypted_private_key, |
|||
} |
|||
} |
|||
|
|||
pub fn get_prf_status(&self) -> WebauthnCredentialPrfStatus { |
|||
if self.supports_prf { |
|||
if self.encrypted_user_key.is_some() |
|||
&& self.encrypted_public_key.is_some() |
|||
&& self.encrypted_private_key.is_some() |
|||
{ |
|||
WebauthnCredentialPrfStatus::Enabled |
|||
} else { |
|||
WebauthnCredentialPrfStatus::Disabled |
|||
} |
|||
} else { |
|||
WebauthnCredentialPrfStatus::NotSupported |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// Database methods
|
|||
impl WebauthnCredential { |
|||
pub async fn save(&self, conn: &DbConn) -> EmptyResult { |
|||
db_run! { conn: { |
|||
diesel::insert_into(webauthn_credentials::table) |
|||
.values(self) |
|||
.execute(conn) |
|||
.map_res("Error saving webauthn_credential") |
|||
}} |
|||
} |
|||
|
|||
pub async fn find_all_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> { |
|||
db_run! { conn: { |
|||
webauthn_credentials::table |
|||
.filter(webauthn_credentials::user_uuid.eq(user_uuid)) |
|||
.load::<Self>(conn) |
|||
.unwrap_or_default() |
|||
}} |
|||
} |
|||
|
|||
pub async fn delete_by_uuid_and_user( |
|||
uuid: &WebauthnCredentialId, |
|||
user_uuid: &UserId, |
|||
conn: &DbConn, |
|||
) -> EmptyResult { |
|||
db_run! { conn: { |
|||
diesel::delete( |
|||
webauthn_credentials::table |
|||
.filter(webauthn_credentials::uuid.eq(uuid)) |
|||
.filter(webauthn_credentials::user_uuid.eq(user_uuid)), |
|||
) |
|||
.execute(conn) |
|||
.map_res("Error removing webauthn_credential") |
|||
}} |
|||
} |
|||
|
|||
pub async fn update_credential_by_uuid( |
|||
uuid: &WebauthnCredentialId, |
|||
credential: String, |
|||
conn: &DbConn, |
|||
) -> EmptyResult { |
|||
db_run! { conn: { |
|||
diesel::update( |
|||
webauthn_credentials::table |
|||
.filter(webauthn_credentials::uuid.eq(uuid)), |
|||
) |
|||
.set(webauthn_credentials::credential.eq(credential)) |
|||
.execute(conn) |
|||
.map_res("Error updating credential for webauthn_credential") |
|||
}} |
|||
} |
|||
|
|||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { |
|||
db_run! { conn: { |
|||
diesel::delete( |
|||
webauthn_credentials::table |
|||
.filter(webauthn_credentials::user_uuid.eq(user_uuid)), |
|||
) |
|||
.execute(conn) |
|||
.map_res("Error deleting all webauthn_credentials for user") |
|||
}} |
|||
} |
|||
} |
|||
|
|||
#[derive(
|
|||
Clone, |
|||
Debug, |
|||
AsRef, |
|||
Deref, |
|||
DieselNewType, |
|||
Display, |
|||
From, |
|||
FromForm, |
|||
Hash, |
|||
PartialEq, |
|||
Eq, |
|||
Serialize, |
|||
Deserialize, |
|||
UuidFromParam, |
|||
)] |
|||
pub struct WebauthnCredentialId(String); |
|||
Loading…
Reference in new issue