You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
235 lines
7.6 KiB
235 lines
7.6 KiB
use rocket::{http::Status, serde::json::Json};
|
|
use serde_json::Value;
|
|
use webauthn_rs::prelude::{Passkey, PasskeyRegistration};
|
|
use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy, options::PubKeyCredParams};
|
|
|
|
use crate::{
|
|
CONFIG,
|
|
api::{
|
|
ApiResult, JsonResult, PasswordOrOtpData,
|
|
core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN},
|
|
},
|
|
auth::Headers,
|
|
db::{
|
|
DbConn,
|
|
models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId},
|
|
},
|
|
};
|
|
|
|
pub fn routes() -> Vec<rocket::Route> {
|
|
routes![get_webauthn, post_webauthn, post_webauthn_attestation_options, post_webauthn_delete]
|
|
}
|
|
|
|
#[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.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_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::ES384 as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::ES512 as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::RS256 as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::RS384 as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::RS512 as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::PS256 as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::PS384 as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::PS512 as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
PubKeyCredParams {
|
|
alg: COSEAlgorithm::EDDSA as i64,
|
|
type_: "public-key".to_string(),
|
|
},
|
|
];
|
|
|
|
// 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 = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
|
Some(tf) => {
|
|
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
|
tf.delete(&conn).await?;
|
|
WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?
|
|
}
|
|
None => 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_string()), &user.uuid, &conn)
|
|
.await?;
|
|
|
|
Ok(Status::Ok)
|
|
}
|
|
|