Add login with passkey

Co-authored-by: zUnixorn <77864446+zUnixorn@users.noreply.github.com>
Co-authored-by: a15355447898a <62456287+a15355447898a@users.noreply.github.com>
Co-authored-by: Sammy ETUR <50653464+samos667@users.noreply.github.com>
Co-authored-by: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>
Co-authored-by: Raphaël Roumezin <94066559+RaphaelRoumezin@users.noreply.github.com>
Co-authored-by: Keng <39216134+kengzzzz@users.noreply.github.com>
Co-authored-by: Akhil Babel <335565203+akh1lb@users.noreply.github.com>
This commit is contained in:
23 files changed
+1060 -30

No files matched your search

+6
View File
@@ -642,6 +642,12 @@
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
## Passkey login extra origins
## Origins that may log in with a passkey besides the vault and the official Chromium extensions (Chrome Web Store,
## Edge and Opera add-ons), separated by commas. For Bitwarden clients with an origin of their own, such as a
## self-built browser extension or an app.
# PASSKEY_LOGIN_EXTRA_ORIGINS=chrome-extension://<extension id>,android:apk-key-hash:<signing certificate hash>
###########################
### SMTP Email settings ###
###########################
+1 -1
View File
@@ -157,7 +157,7 @@ yubico_ng = { version = "1.0.0", default-features = false }
# WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db
# danger-credential-internals is needed to support U2F to Webauthn migration
webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
webauthn-rs = { version = "0.5.5", features = ["conditional-ui", "danger-allow-state-serialisation", "danger-credential-internals"] }
webauthn-rs-proto = "0.5.5"
webauthn-rs-core = "0.5.5"
serde_cbor_2 = { version = "0.13.0", optional = true }
@@ -0,0 +1 @@
DROP TABLE IF EXISTS webauthn_credentials;
@@ -0,0 +1,16 @@
CREATE TABLE webauthn_credentials (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL,
name TEXT NOT NULL,
credential_id VARCHAR(256) NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
last_challenge_issued_at BIGINT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (user_uuid, credential_id),
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS webauthn_credentials;
@@ -0,0 +1,15 @@
CREATE TABLE webauthn_credentials (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
name TEXT NOT NULL,
credential_id TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
last_challenge_issued_at BIGINT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (user_uuid, credential_id)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS webauthn_credentials;
@@ -0,0 +1,15 @@
CREATE TABLE webauthn_credentials (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
name TEXT NOT NULL,
credential_id TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
last_challenge_issued_at BIGINT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (user_uuid, credential_id)
);
+12 -5
View File
@@ -24,7 +24,7 @@ use crate::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership,
MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm,
User, UserId, UserKdfType, UserSignatureKeyPair,
User, UserId, UserKdfType, UserSignatureKeyPair, WebauthnCredential,
},
},
mail,
@@ -1229,7 +1229,7 @@ struct MasterPasswordUnlockData {
contained_key_id: Option<KeyId>,
}
/// The unlock data of both rotation endpoints; passkey and device keys are ignored, as we support neither.
/// The unlock data of both rotation endpoints. Device keys are ignored, as we don't support trusted devices.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/KeyManagement/Models/Requests/CommonUnlockDataRequestModel.cs#L7-L14>
#[derive(Deserialize)]
@@ -1237,8 +1237,7 @@ struct MasterPasswordUnlockData {
struct CommonUnlockData {
emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>,
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
#[expect(dead_code, reason = "Required like upstream, but unused")]
passkey_unlock_data: Vec<Value>,
passkey_unlock_data: Vec<super::webauthn_login::PasskeyUnlockData>,
#[expect(dead_code, reason = "Required like upstream, but unused")]
device_key_unlock_data: Vec<Value>,
v2_upgrade_token: Option<V2UpgradeTokenData>,
@@ -1481,7 +1480,7 @@ async fn get_key_rotation_data(headers: Headers, conn: DbConn) -> JsonResult {
"organizationPasswordResetKeyData": organization_data,
"emergencyAccessKeyData": emergency_access_data,
"trustedDeviceKeyData": [],
"passkeyKeyData": [],
"passkeyKeyData": super::webauthn_login::rotation_key_data(user_id, &conn).await,
"object": "keyRotationData",
})))
}
@@ -1643,6 +1642,7 @@ async fn rotate_account(data: RotateData, headers: Headers, conn: DbConn, nt: No
// We only rotate the reset password key if it is set.
existing_memberships.retain(|m| m.reset_password_key.is_some());
let mut existing_sends = Send::find_by_user(user_id, &conn).await;
let existing_passkeys = WebauthnCredential::find_by_user(user_id, &conn).await;
validate_rotation_data(
&data,
@@ -1701,6 +1701,9 @@ async fn rotate_account(data: RotateData, headers: Headers, conn: DbConn, nt: No
}
}
let rotated_passkeys =
super::webauthn_login::validate_rotation(existing_passkeys, &data.unlock_data.passkey_unlock_data)?;
// Saving's own checks, so that they can't fail halfway
for cipher_data in data.account_data.ciphers.iter().filter(|c| c.organization_id.is_none()) {
if let Some(cipher) = existing_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) {
@@ -1745,6 +1748,10 @@ async fn rotate_account(data: RotateData, headers: Headers, conn: DbConn, nt: No
}
}
for (passkey, encrypted_user_key, encrypted_public_key) in rotated_passkeys {
passkey.rotate_keys(encrypted_user_key, encrypted_public_key, &conn).await?;
}
// Update send data
for send_data in data.account_data.sends {
if let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) {
+3
View File
@@ -206,6 +206,9 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
if let Some(v2_upgrade_token) = headers.user.v2_upgrade_token_json() {
user_decryption["v2UpgradeToken"] = v2_upgrade_token;
}
if let Some(prf_options) = super::webauthn_login::sync_prf_options(&headers.user.uuid, &conn).await {
user_decryption["webAuthnPrfOptions"] = prf_options;
}
Ok(Json(json!({
"profile": user_json,
+3 -13
View File
@@ -8,6 +8,7 @@ mod folders;
mod organizations;
mod public;
mod sends;
pub mod webauthn_login;
pub use accounts::purge_auth_requests;
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
@@ -37,7 +38,7 @@ use crate::{
pub fn routes() -> Vec<Route> {
let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
let mut hibp_routes = routes![hibp_breach];
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
let mut meta_routes = routes![alive, now, version, config];
let mut routes = Vec::new();
routes.append(&mut accounts::routes());
@@ -47,6 +48,7 @@ pub fn routes() -> Vec<Route> {
routes.append(&mut folders::routes());
routes.append(&mut organizations::routes());
routes.append(&mut two_factor::routes());
routes.append(&mut webauthn_login::routes());
routes.append(&mut sends::routes());
routes.append(&mut public::routes());
routes.append(&mut eq_domains_routes);
@@ -197,18 +199,6 @@ fn version() -> Json<&'static str> {
Json(crate::VERSION.unwrap_or_default())
}
#[get("/webauthn")]
fn get_api_webauthn(_headers: Headers) -> Json<Value> {
// Prevent a 404 error, which also causes key-rotation issues
// It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support
// An empty list/data also works fine
Json(json!({
"object": "list",
"data": [],
"continuationToken": null
}))
}
#[get("/config")]
fn config() -> Json<Value> {
let domain = CONFIG.domain();
+2 -1
View File
@@ -194,7 +194,7 @@ struct EnableWebauthnData {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterPublicKeyCredentialCopy {
pub struct RegisterPublicKeyCredentialCopy {
pub id: String,
pub raw_id: Base64UrlSafeData,
pub response: AuthenticatorAttestationResponseRawCopy,
@@ -233,6 +233,7 @@ pub struct PublicKeyCredentialCopy {
pub id: String,
pub raw_id: Base64UrlSafeData,
pub response: AuthenticatorAssertionResponseRawCopy,
#[serde(default)]
pub extensions: AuthenticationExtensionsClientOutputs,
pub r#type: String,
}
+470
View File
@@ -0,0 +1,470 @@
//! Log in with passkey. The login itself is the `webauthn` grant in `identity.rs`.
//!
//! The WebAuthn state of each step isn't stored: it goes to the client in a signed token, which comes back with the
//! next request. `WebauthnCredential::last_challenge_issued_at` makes each assertion single use.
use std::{net::IpAddr, str::FromStr, time::Duration};
use chrono::{TimeDelta, Utc};
use rocket::{Route, http::Status, serde::json::Json};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
use url::Url;
use uuid::Uuid;
use webauthn_rs::{
Webauthn, WebauthnBuilder,
prelude::{CredentialID, DiscoverableAuthentication, DiscoverableKey, PasskeyRegistration},
};
use webauthn_rs_proto::{
AuthenticatorSelectionCriteria, PublicKeyCredential, ResidentKeyRequirement, UserVerificationPolicy,
};
use crate::{
CONFIG,
api::{
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData,
core::two_factor::webauthn::{PublicKeyCredentialCopy, RegisterPublicKeyCredentialCopy},
},
auth::{self, Headers, JWT_WEBAUTHN_ISSUER},
db::{
DbConn,
models::{User, UserId, WebauthnCredential, WebauthnCredentialId, WebauthnPrfStatus},
},
error::MapResult,
};
pub fn routes() -> Vec<Route> {
routes![
get_api_webauthn,
post_attestation_options,
post_assertion_options,
post_credential,
put_credential,
delete_credential
]
}
const MAX_CREDENTIALS_PER_USER: usize = 5;
/// The origins besides the vault that passkey assertions may come from. Without a trailing `/`: the origin of a request
/// has none, so they would never match.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs#L644-L651>
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Core/Constants.cs#L112-L118>
const WEBAUTHN_ORIGINS: [&str; 4] = [
// Chrome Web Store, which Brave, Vivaldi and the other Chromium browsers install from too
"chrome-extension://nngceckbapebfimnlniiiahkandclblb",
// Chrome Web Store, beta
"chrome-extension://hccnnhgbibccigepcmlgppchkpfdophk",
// Microsoft Edge Add-ons
"chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh",
// Opera add-ons
"chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn",
];
/// Built for each use, so that a change to `PASSKEY_LOGIN_EXTRA_ORIGINS` in the admin panel applies without a restart.
fn webauthn_login() -> ApiResult<Webauthn> {
let domain = CONFIG.domain();
let domain_origin = CONFIG.domain_origin();
let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default();
let rp_origin = Url::parse(&domain_origin).ok().map_res("Invalid DOMAIN")?;
let mut webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)?.rp_name(&domain).timeout(Duration::from_mins(1));
let configured = CONFIG.passkey_login_extra_origins();
let configured = configured.split(',').map(str::trim).filter(|o| !o.is_empty());
for origin in WEBAUTHN_ORIGINS.into_iter().chain(configured) {
// Invalid ones were refused when the config was loaded
if let Ok(origin) = Url::parse(origin.trim_end_matches('/')) {
webauthn = webauthn.append_allowed_origin(&origin);
}
}
Ok(webauthn.build()?)
}
/// Passkeys would bypass SSO, so SSO_ONLY refuses them.
pub fn passkey_login_available() -> bool {
CONFIG.is_webauthn_2fa_supported() && !(CONFIG.sso_enabled() && CONFIG.sso_only())
}
fn check_webauthn_supported() -> EmptyResult {
if !CONFIG.is_webauthn_2fa_supported() {
err!("Configured `DOMAIN` is not compatible with Webauthn")
}
Ok(())
}
fn check_passkey_creation_allowed() -> EmptyResult {
check_webauthn_supported()?;
if CONFIG.sso_enabled() && CONFIG.sso_only() {
err!("Passkeys cannot be created for your account. SSO login is required.")
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
enum TokenScope {
Authentication,
UpdateKeySet,
Registration,
}
impl TokenScope {
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs#L11-L12>
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs#L10-L14>
fn lifetime(self) -> TimeDelta {
match self {
TokenScope::Registration => TimeDelta::minutes(7),
TokenScope::Authentication | TokenScope::UpdateKeySet => TimeDelta::minutes(17),
}
}
}
#[derive(Serialize, Deserialize)]
struct StateClaims {
nbf: i64,
exp: i64,
iss: String,
scope: TokenScope,
/// The only user who may use the token, none for logging in
sub: Option<UserId>,
/// When the challenge was issued, in milliseconds, see `WebauthnCredential::last_challenge_issued_at`
issued_at: i64,
/// The webauthn-rs state
state: Value,
}
fn encode_state<T: Serialize>(scope: TokenScope, user: Option<&UserId>, state: &T) -> ApiResult<String> {
let now = Utc::now();
Ok(auth::encode_jwt(&StateClaims {
nbf: now.timestamp(),
exp: (now + scope.lifetime()).timestamp(),
iss: JWT_WEBAUTHN_ISSUER.to_string(),
scope,
sub: user.cloned(),
issued_at: now.timestamp_millis(),
state: serde_json::to_value(state)?,
}))
}
/// The token must be for this scope and this user, where `None` means a login token.
fn decode_state<T: DeserializeOwned>(token: &str, scope: TokenScope, user: Option<&UserId>) -> ApiResult<(i64, T)> {
let claims = match auth::decode_jwt::<StateClaims>(token, JWT_WEBAUTHN_ISSUER.to_string()) {
Ok(claims) if claims.scope == scope && claims.sub.as_ref() == user => claims,
_ => err!(
"The token associated with your request is invalid or has expired. A valid token is required to continue."
),
};
Ok((claims.issued_at, serde_json::from_value(claims.state)?))
}
fn assertion_options(scope: TokenScope, user: Option<&UserId>) -> JsonResult {
let (challenge, state) = webauthn_login()?.start_discoverable_authentication()?;
let token = encode_state(scope, user, &state)?;
let mut options = serde_json::to_value(challenge.public_key)?;
// The clients add the PRF extension themselves
options["extensions"] = Value::Null;
Ok(Json(json!({
"options": options,
"token": token,
"object": "webAuthnLoginAssertionOptions",
})))
}
pub fn login_assertion_options() -> JsonResult {
assertion_options(TokenScope::Authentication, None)
}
pub async fn assert_login(
token: &str,
device_response: &str,
ip: &IpAddr,
conn: &DbConn,
) -> ApiResult<(User, WebauthnCredential)> {
let (issued_at, state) = decode_state(token, TokenScope::Authentication, None)?;
let Ok(response) = serde_json::from_str::<PublicKeyCredentialCopy>(device_response) else {
err!("Invalid credential", format!("IP: {ip}."))
};
assert_credential(issued_at, state, &response.into(), ip, conn).await
}
/// On success, the token's challenge is marked as answered and the passkey's new counter stored.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs#L30-L82>
async fn assert_credential(
issued_at: i64,
state: DiscoverableAuthentication,
response: &PublicKeyCredential,
ip: &IpAddr,
conn: &DbConn,
) -> ApiResult<(User, WebauthnCredential)> {
let webauthn = webauthn_login()?;
let Ok((user_uuid, credential_id)) = webauthn.identify_discoverable_authentication(response) else {
err!("Invalid credential", format!("IP: {ip}."))
};
let Some(user) = User::find_by_uuid(&UserId::from(user_uuid.to_string()), conn).await else {
err!("Invalid credential", format!("IP: {ip}."))
};
let credential_id = data_encoding::BASE64URL_NOPAD.encode(credential_id);
let Some(credential) = WebauthnCredential::find_by_user_and_credential_id(&user.uuid, &credential_id, conn).await
else {
err!("Invalid credential", format!("IP: {ip}."))
};
let mut passkey = credential.passkey()?;
let Ok(result) = webauthn.finish_discoverable_authentication(response, state, &[DiscoverableKey::from(&passkey)])
else {
err!("Invalid credential", format!("IP: {ip}."))
};
passkey.update_credential(&result);
if !credential.use_challenge(issued_at, &passkey, conn).await? {
err!("Invalid credential", format!("IP: {ip}."))
}
Ok((user, credential))
}
/// The user key is wrapped by an RSA key, the other keys by a symmetric one.
fn validate_prf_key(value: &str, field: &str, user_key: bool) -> EmptyResult {
let valid_form = if user_key {
crate::util::is_valid_rsa_encrypted_string(value)
} else {
crate::util::is_valid_symmetric_encrypted_string(value)
};
if !valid_form || value.len() > 2000 {
err!(format!("The field {field} is not a valid encrypted string."))
}
Ok(())
}
#[get("/webauthn")]
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> Json<Value> {
let credentials: Vec<Value> = WebauthnCredential::find_by_user(&headers.user.uuid, &conn)
.await
.iter()
.map(WebauthnCredential::to_json)
.collect();
Json(json!({
"object": "list",
"data": credentials,
"continuationToken": null,
}))
}
#[post("/webauthn/attestation-options", data = "<data>")]
async fn post_attestation_options(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
check_passkey_creation_allowed()?;
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, true, &conn).await?;
let exclude = WebauthnCredential::find_by_user(&user.uuid, &conn)
.await
.iter()
.filter_map(|c| data_encoding::BASE64URL_NOPAD.decode(c.credential_id.as_bytes()).ok())
.map(CredentialID::from)
.collect();
let (mut challenge, state) = webauthn_login()?.start_passkey_registration(
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"),
&user.email,
user.display_name(),
Some(exclude),
)?;
// Logging in without a username needs a discoverable credential, which webauthn-rs doesn't ask for
challenge.public_key.authenticator_selection = Some(AuthenticatorSelectionCriteria {
authenticator_attachment: None,
resident_key: Some(ResidentKeyRequirement::Required),
require_resident_key: true,
user_verification: UserVerificationPolicy::Required,
});
let token = encode_state(TokenScope::Registration, Some(&user.uuid), &state)?;
let mut options = serde_json::to_value(challenge.public_key)?;
// The clients add the PRF extension themselves
options["extensions"] = Value::Null;
Ok(Json(json!({
"options": options,
"token": token,
"object": "webauthnCredentialCreateOptions",
})))
}
/// The first step of [`put_credential`].
#[post("/webauthn/assertion-options", data = "<data>")]
async fn post_assertion_options(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
check_webauthn_supported()?;
let data: PasswordOrOtpData = data.into_inner();
data.validate(&headers.user, true, &conn).await?;
assertion_options(TokenScope::UpdateKeySet, Some(&headers.user.uuid))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateCredentialData {
device_response: RegisterPublicKeyCredentialCopy,
name: String,
token: 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_credential(data: Json<CreateCredentialData>, headers: Headers, conn: DbConn) -> JsonResult {
check_passkey_creation_allowed()?;
let data: CreateCredentialData = data.into_inner();
let user = headers.user;
let (_, state): (_, PasskeyRegistration) = decode_state(&data.token, TokenScope::Registration, Some(&user.uuid))?;
if data.name.trim().is_empty() || data.name.encode_utf16().count() > 50 {
err!("The field Name must be a string with a maximum length of 50.")
}
for (value, field, user_key) in [
(&data.encrypted_user_key, "EncryptedUserKey", true),
(&data.encrypted_public_key, "EncryptedPublicKey", false),
(&data.encrypted_private_key, "EncryptedPrivateKey", false),
] {
if let Some(value) = value {
validate_prf_key(value, field, user_key)?;
}
}
let existing = WebauthnCredential::find_by_user(&user.uuid, &conn).await;
if existing.len() >= MAX_CREDENTIALS_PER_USER {
err!("Unable to complete WebAuthn registration.")
}
let Ok(passkey) = webauthn_login()?.finish_passkey_registration(&data.device_response.into(), &state) else {
err!("Unable to complete WebAuthn registration.")
};
let credential = WebauthnCredential::new(
user.uuid,
data.name,
&passkey,
data.supports_prf,
data.encrypted_user_key,
data.encrypted_public_key,
data.encrypted_private_key,
)?;
// A credential id is unique per user
if credential.credential_id.len() > 256 || existing.iter().any(|c| c.credential_id == credential.credential_id) {
err!("Unable to complete WebAuthn registration.")
}
credential.insert(&conn).await?;
Ok(Json(credential.to_json()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateCredentialData {
device_response: PublicKeyCredentialCopy,
token: String,
encrypted_user_key: String,
encrypted_public_key: String,
encrypted_private_key: String,
}
/// Turns on vault unlock for an existing passkey that supports PRF.
#[put("/webauthn", data = "<data>")]
async fn put_credential(data: Json<UpdateCredentialData>, headers: Headers, conn: DbConn) -> EmptyResult {
check_webauthn_supported()?;
let data: UpdateCredentialData = data.into_inner();
validate_prf_key(&data.encrypted_user_key, "EncryptedUserKey", true)?;
validate_prf_key(&data.encrypted_public_key, "EncryptedPublicKey", false)?;
validate_prf_key(&data.encrypted_private_key, "EncryptedPrivateKey", false)?;
let (issued_at, state) = decode_state(&data.token, TokenScope::UpdateKeySet, Some(&headers.user.uuid))?;
let (user, credential) =
assert_credential(issued_at, state, &data.device_response.into(), &headers.ip.ip, &conn).await?;
// The assertion names its own user, which must be the caller
if user.uuid != headers.user.uuid || !credential.supports_prf {
err!("Unable to update credential.")
}
credential.set_prf_keys(data.encrypted_user_key, data.encrypted_public_key, data.encrypted_private_key, &conn).await
}
#[post("/webauthn/<id>/delete", data = "<data>")]
async fn delete_credential(
id: WebauthnCredentialId,
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
) -> EmptyResult {
let data: PasswordOrOtpData = data.into_inner();
data.validate(&headers.user, true, &conn).await?;
if !WebauthnCredential::delete_by_uuid_and_user(&id, &headers.user.uuid, &conn).await? {
err_code!("Credential not found.", Status::NotFound.code)
}
Ok(())
}
/// `None` without any, so that the field is left out.
pub async fn sync_prf_options(user_uuid: &UserId, conn: &DbConn) -> Option<Value> {
let options: Vec<Value> = WebauthnCredential::find_by_user(user_uuid, conn)
.await
.iter()
.filter_map(WebauthnCredential::to_sync_prf_option)
.collect();
(!options.is_empty()).then_some(Value::Array(options))
}
pub async fn rotation_key_data(user_uuid: &UserId, conn: &DbConn) -> Vec<Value> {
WebauthnCredential::find_by_user(user_uuid, conn)
.await
.iter()
.filter(|c| c.prf_status() == WebauthnPrfStatus::Enabled)
.map(|c| {
json!({
"id": c.uuid,
"encryptedPublicKey": c.encrypted_public_key,
"encryptedUserKey": c.encrypted_user_key,
"object": "passkeyKeyData",
})
})
.collect()
}
/// One entry of a rotation's `passkeyUnlockData`.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyUnlockData {
id: WebauthnCredentialId,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
}
/// The new user key and public key of every passkey with a PRF key set.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs#L22-L55>
pub fn validate_rotation(
existing: Vec<WebauthnCredential>,
unlock_data: &[PasskeyUnlockData],
) -> ApiResult<Vec<(WebauthnCredential, String, String)>> {
let mut rotated = Vec::new();
for credential in existing.into_iter().filter(|c| c.prf_status() == WebauthnPrfStatus::Enabled) {
let Some(data) = unlock_data.iter().find(|d| d.id == credential.uuid) else {
err!("All existing webauthn prf keys must be included in the rotation.")
};
let Some(encrypted_user_key) = data.encrypted_user_key.clone() else {
err!("WebAuthn prf keys must have user-key during rotation.")
};
let Some(encrypted_public_key) = data.encrypted_public_key.clone() else {
err!("WebAuthn prf keys must have public-key during rotation.")
};
validate_prf_key(&encrypted_user_key, "EncryptedUserKey", true)?;
validate_prf_key(&encrypted_public_key, "EncryptedPublicKey", false)?;
rotated.push((credential, encrypted_user_key, encrypted_public_key));
}
Ok(rotated)
}
+96 -1
View File
@@ -19,6 +19,7 @@ use crate::{
two_factor::{
authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey,
},
webauthn_login,
},
identity_master_password_policy,
push::register_push_device,
@@ -49,6 +50,7 @@ pub fn routes() -> Vec<Route> {
register_verification_email_clicked,
register_finish,
prevalidate,
get_webauthn_login_assertion_options,
authorize,
oidcsignin,
oidcsignin_error
@@ -66,7 +68,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
refresh_login(data, &conn, &client_header.ip).await
}
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"password" | "webauthn" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"password" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.password.as_ref(), "password cannot be blank")?;
@@ -98,6 +100,18 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
sso_login(data, &mut user_id, &conn, &client_header).await
}
"authorization_code" => err!("SSO sign-in is not available"),
"webauthn" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.token.as_ref(), "token cannot be blank")?;
check_is_some(data.device_response.as_ref(), "deviceResponse cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
passkey_login(data, &mut user_id, &conn, &client_header).await
}
"send_access" => {
crate::ratelimit::check_limit_unauthenticated(&client_header.ip.ip)?;
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
@@ -520,6 +534,70 @@ async fn password_login(
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
}
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs#L50-L53>
async fn passkey_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
client_header: &ClientHeaders,
) -> JsonResult {
let ip = &client_header.ip;
AuthMethod::Webauthn.check_scope(data.scope.as_ref())?;
crate::ratelimit::check_limit_login(&ip.ip)?;
if !webauthn_login::passkey_login_available() {
err!("Passkey login is not available")
}
let (user, credential) = webauthn_login::assert_login(
data.token.as_deref().unwrap_or_default(),
data.device_response.as_deref().unwrap_or_default(),
&ip.ip,
conn,
)
.await?;
// For the event log, only now that the assertion is verified
*user_id = Some(user.uuid.clone());
let log_username = user.email.escape_debug();
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
check_client_version(&user, client_header)?;
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
err!(
"Please verify your email before trying again.",
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
let mut device = get_device(&data, conn, &user).await?;
// The authenticator's user verification replaces two-step login, but an organization's two-step login policy still
// applies, as for passwords
if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
enforce_2fa_policy(&user, &user.uuid, device.atype, &ip.ip, conn).await?;
}
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Webauthn, data.client_id);
let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?;
if let Some(prf_option) = credential.to_login_prf_option() {
result.0["UserDecryptionOptions"]["WebAuthnPrfOption"] = prf_option;
}
Ok(result)
}
/// Like upstream's `ClientVersionValidator`, every login needs the `Bitwarden-Client-Version` header, and a V2 account
/// ([`User::has_v2_encryption`]) a client of at least 2026.10.1 (2026.10.2 for the desktop app). Called right after the
/// credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2.
@@ -1260,6 +1338,13 @@ struct ConnectData {
// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,
// Needed for grant_type = "webauthn", with the assertion as JSON
#[field(name = uncased("token"))]
token: Option<String>,
#[field(name = uncased("device_response"))]
#[field(name = uncased("deviceresponse"))]
device_response: Option<String>,
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {
@@ -1268,6 +1353,16 @@ fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
Ok(())
}
#[get("/accounts/webauthn/assertion-options")]
fn get_webauthn_login_assertion_options(ip: ClientIp) -> JsonResult {
if !webauthn_login::passkey_login_available() {
err!("Passkey login is not available")
}
// Rate limited: anyone can call it, and each call signs a token
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
webauthn_login::login_assertion_options()
}
#[get("/sso/prevalidate")]
fn prevalidate() -> JsonResult {
if CONFIG.sso_enabled() {
+1
View File
@@ -99,6 +99,7 @@ fn vaultwarden_css() -> EtagCached<String> {
"sends_allowed": CONFIG.sends_allowed(),
"remember_2fa_disabled": CONFIG.disable_2fa_remember(),
"password_hints_allowed": CONFIG.password_hints_allowed(),
"passkey_login_available": crate::api::core::webauthn_login::passkey_login_available(),
"signup_disabled": CONFIG.is_signup_disabled(),
"sso_enabled": CONFIG.sso_enabled(),
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
+7 -3
View File
@@ -66,6 +66,7 @@ static JWT_FILE_DOWNLOAD_ISSUER: LazyLock<String> =
static JWT_REGISTER_VERIFY_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin()));
pub static JWT_WEBAUTHN_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|webauthn", CONFIG.domain_origin()));
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
@@ -1410,6 +1411,7 @@ pub enum AuthMethod {
Password,
Sso,
UserApiKey,
Webauthn,
}
impl AuthMethod {
@@ -1417,7 +1419,7 @@ impl AuthMethod {
match self {
AuthMethod::OrgApiKey => "api.organization".to_owned(),
AuthMethod::UserApiKey => "api".to_owned(),
AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(),
AuthMethod::Password | AuthMethod::Sso | AuthMethod::Webauthn => "api offline_access".to_owned(),
}
}
@@ -1542,8 +1544,10 @@ pub async fn refresh_tokens(
sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await?
}
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
AuthMethod::Password | AuthMethod::Webauthn if CONFIG.sso_enabled() && CONFIG.sso_only() => {
err!("SSO is now required, Login again")
}
AuthMethod::Password | AuthMethod::Webauthn => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
_ => err!("Invalid auth method, cannot refresh token"),
};
+17
View File
@@ -747,6 +747,11 @@ make_config! {
/// TOTP codes of the previous and next 30 seconds will be invalid.
authenticator_disable_time_drift: bool, true, def, false;
/// Passkey login extra origins |> Origins that may log in with a passkey besides the vault and the official Chromium
/// extensions (Chrome Web Store, Edge and Opera add-ons), separated by commas. For Bitwarden clients with an origin
/// of their own, such as a self-built extension (`chrome-extension://<id>`) or an app (`android:apk-key-hash:<hash>`).
passkey_login_extra_origins: String, true, def, String::new();
/// Customize the enabled feature flags on the clients |> This is a comma separated list of feature flags to enable.
experimental_client_feature_flags: String, false, def, String::new();
@@ -1065,6 +1070,18 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
err!("`SIGNUPS_DOMAINS_WHITELIST` contains empty tokens");
}
for origin in cfg.passkey_login_extra_origins.split(',').map(str::trim).filter(|o| !o.is_empty()) {
// A scheme and host (and port), or an opaque one for apps, without a path, query or fragment
let valid = Url::parse(origin.trim_end_matches('/')).is_ok_and(|url| {
url.query().is_none()
&& url.fragment().is_none()
&& (url.cannot_be_a_base() || url.path() == "/" || url.path().is_empty())
});
if !valid {
err!(format!("`PASSKEY_LOGIN_EXTRA_ORIGINS` contains an invalid origin: {origin}"));
}
}
let org_creation_users = cfg.org_creation_users.trim().to_lowercase();
if !(org_creation_users.is_empty() || org_creation_users == "all" || org_creation_users == "none")
&& org_creation_users.split(',').any(|u| !u.contains('@'))
+2
View File
@@ -18,6 +18,7 @@ mod two_factor_duo_context;
mod two_factor_incomplete;
mod user;
mod user_signature_key_pair;
mod webauthn_credential;
pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId};
@@ -46,3 +47,4 @@ pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType};
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
pub use self::webauthn_credential::{WebauthnCredential, WebauthnCredentialId, WebauthnPrfStatus};
+2 -1
View File
@@ -21,7 +21,7 @@ use macros::UuidFromParam;
use super::{
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
TwoFactorIncomplete, UserSignatureKeyPair,
TwoFactorIncomplete, UserSignatureKeyPair, WebauthnCredential,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -502,6 +502,7 @@ impl User {
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?;
WebauthnCredential::delete_all_by_user(&self.uuid, conn).await?;
Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| {
+340
View File
@@ -0,0 +1,340 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use serde_json::Value;
use webauthn_rs::prelude::Passkey;
use crate::{
api::{ApiResult, EmptyResult},
db::{DbConn, schema::webauthn_credentials},
error::MapResult,
util::get_uuid,
};
use macros::UuidFromParam;
use super::UserId;
#[derive(Identifiable, Queryable, Insertable, Selectable)]
#[diesel(table_name = webauthn_credentials)]
#[diesel(primary_key(uuid))]
pub struct WebauthnCredential {
pub uuid: WebauthnCredentialId,
pub user_uuid: UserId,
pub name: String,
/// Base64url without padding, as the clients send it
pub credential_id: String,
/// The webauthn-rs `Passkey`, as JSON
pub credential: String,
pub supports_prf: bool,
/// The user key, wrapped by the public key of the PRF key pair
pub encrypted_user_key: Option<String>,
/// The public key of the PRF key pair, wrapped by the user key, so that a key rotation can re-wrap the user key
pub encrypted_public_key: Option<String>,
/// The private key of the PRF key pair, wrapped by the key the authenticator's PRF output derives
pub encrypted_private_key: Option<String>,
/// When the newest challenge this credential answered was issued, in milliseconds since the Unix epoch. An assertion
/// must answer a newer one, which makes it single use. A server clock that goes backwards (or another instance's
/// clock running behind) refuses the passkey's logins until it catches up.
pub last_challenge_issued_at: Option<i64>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Core/Auth/Enums/WebAuthnPrfStatus.cs#L3-L8>
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WebauthnPrfStatus {
Enabled = 0,
Supported = 1,
Unsupported = 2,
}
/// Local methods
impl WebauthnCredential {
pub fn new(
user_uuid: UserId,
name: String,
passkey: &Passkey,
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
) -> ApiResult<Self> {
let now = Utc::now().naive_utc();
Ok(Self {
uuid: WebauthnCredentialId(get_uuid()),
user_uuid,
name,
credential_id: data_encoding::BASE64URL_NOPAD.encode(passkey.cred_id()),
credential: serde_json::to_string(passkey)?,
supports_prf,
encrypted_user_key,
encrypted_public_key,
encrypted_private_key,
last_challenge_issued_at: None,
created_at: now,
updated_at: now,
})
}
pub fn passkey(&self) -> ApiResult<Passkey> {
Ok(serde_json::from_str(&self.credential)?)
}
pub fn prf_status(&self) -> WebauthnPrfStatus {
if !self.supports_prf {
WebauthnPrfStatus::Unsupported
} else if self.encrypted_user_key.is_some()
&& self.encrypted_private_key.is_some()
&& self.encrypted_public_key.is_some()
{
WebauthnPrfStatus::Enabled
} else {
WebauthnPrfStatus::Supported
}
}
pub fn to_json(&self) -> Value {
json!({
"id": self.uuid,
"name": self.name,
"prfStatus": self.prf_status() as i32,
"encryptedUserKey": self.encrypted_user_key,
"encryptedPublicKey": self.encrypted_public_key,
"object": "webauthnCredential",
})
}
/// The transports are empty, since they aren't stored.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Models/Response/SyncResponseModel.cs#L69-L77>
pub fn to_sync_prf_option(&self) -> Option<Value> {
(self.prf_status() == WebauthnPrfStatus::Enabled).then(|| {
json!({
"encryptedPrivateKey": self.encrypted_private_key,
"encryptedUserKey": self.encrypted_user_key,
"credentialId": self.credential_id,
"transports": [],
})
})
}
/// The same option in the login response, which uses PascalCase.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs#L63-L76>
pub fn to_login_prf_option(&self) -> Option<Value> {
(self.prf_status() == WebauthnPrfStatus::Enabled).then(|| {
json!({
"EncryptedPrivateKey": self.encrypted_private_key,
"EncryptedUserKey": self.encrypted_user_key,
"CredentialId": self.credential_id,
"Transports": [],
})
})
}
}
/// Database methods
impl WebauthnCredential {
pub async fn insert(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::insert_into(webauthn_credentials::table).values(self).execute(conn).map_res("Error saving passkey")
})
.await
}
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
webauthn_credentials::table
.filter(webauthn_credentials::user_uuid.eq(user_uuid))
.order(webauthn_credentials::created_at)
.load::<Self>(conn)
.expect("Error loading passkeys")
})
.await
}
pub async fn find_by_user_and_credential_id(
user_uuid: &UserId,
credential_id: &str,
conn: &DbConn,
) -> Option<Self> {
conn.run(move |conn| {
webauthn_credentials::table
.filter(webauthn_credentials::user_uuid.eq(user_uuid))
.filter(webauthn_credentials::credential_id.eq(credential_id))
.first::<Self>(conn)
.ok()
})
.await
}
/// `false` when the credential is gone or already answered a challenge issued at the same time or later. A single
/// statement, so that two concurrent uses of one assertion can't both pass.
pub async fn use_challenge(&self, issued_at: i64, passkey: &Passkey, conn: &DbConn) -> ApiResult<bool> {
let credential = serde_json::to_string(passkey)?;
let now = Utc::now().naive_utc();
let updated: usize = conn
.run(move |conn| {
diesel::update(
webauthn_credentials::table.filter(webauthn_credentials::uuid.eq(&self.uuid)).filter(
webauthn_credentials::last_challenge_issued_at
.is_null()
.or(webauthn_credentials::last_challenge_issued_at.lt(issued_at)),
),
)
.set((
webauthn_credentials::credential.eq(credential),
webauthn_credentials::last_challenge_issued_at.eq(Some(issued_at)),
webauthn_credentials::updated_at.eq(now),
))
.execute(conn)
.map_res("Error saving passkey")
})
.await?;
Ok(updated == 1)
}
pub async fn set_prf_keys(
&self,
encrypted_user_key: String,
encrypted_public_key: String,
encrypted_private_key: String,
conn: &DbConn,
) -> EmptyResult {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
diesel::update(webauthn_credentials::table.filter(webauthn_credentials::uuid.eq(&self.uuid)))
.set((
webauthn_credentials::encrypted_user_key.eq(Some(encrypted_user_key)),
webauthn_credentials::encrypted_public_key.eq(Some(encrypted_public_key)),
webauthn_credentials::encrypted_private_key.eq(Some(encrypted_private_key)),
webauthn_credentials::updated_at.eq(now),
))
.execute(conn)
.map_res("Error saving passkey")
})
.await
}
/// The private key stays: the PRF key wraps it, not the user key.
pub async fn rotate_keys(
&self,
encrypted_user_key: String,
encrypted_public_key: String,
conn: &DbConn,
) -> EmptyResult {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
diesel::update(webauthn_credentials::table.filter(webauthn_credentials::uuid.eq(&self.uuid)))
.set((
webauthn_credentials::encrypted_user_key.eq(Some(encrypted_user_key)),
webauthn_credentials::encrypted_public_key.eq(Some(encrypted_public_key)),
webauthn_credentials::updated_at.eq(now),
))
.execute(conn)
.map_res("Error saving passkey")
})
.await
}
/// `false` when the user has no credential with that id.
pub async fn delete_by_uuid_and_user(
uuid: &WebauthnCredentialId,
user_uuid: &UserId,
conn: &DbConn,
) -> ApiResult<bool> {
let deleted: usize = conn
.run(move |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 deleting passkey")
})
.await?;
Ok(deleted == 1)
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(webauthn_credentials::table.filter(webauthn_credentials::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting passkeys")
})
.await
}
}
#[derive(
Clone,
Debug,
AsRef,
Deref,
DieselNewType,
Display,
From,
FromForm,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
UuidFromParam,
)]
pub struct WebauthnCredentialId(String);
#[cfg(all(test, sqlite))]
mod tests {
use super::*;
use crate::db::{models::User, test_db};
/// A passkey as webauthn-rs stores it, from its own test data
const PASSKEY: &str = r#"{"cred":{"cred_id":"uZcVDBVS68E_MtAgeQpElJxldF_6cY9sSvbWqx_qRh8wiu42lyRBRmh5yFeD_r9k130dMbFHBHI9RTFgdJQIzQ","cred":{"type_":"ES256","key":{"EC_EC2":{"curve":"SECP256R1","x":[194,126,127,109,252,23,131,21,252,6,223,99,44,254,140,27,230,17,94,5,133,28,104,41,144,69,171,149,161,26,200,243],"y":[143,123,183,156,24,178,21,248,117,159,162,69,171,52,188,252,26,59,6,47,103,92,19,58,117,103,249,0,219,8,95,196]}}},"counter":2,"user_verified":true,"backup_eligible":false,"backup_state":false,"registration_policy":"required","extensions":{"cred_protect":"NotRequested","hmac_create_secret":"NotRequested"},"attestation":{"data":"None","metadata":"None"},"attestation_format":"None"}}"#;
#[test]
fn assertions_are_single_use_and_key_writes_leave_the_rest() {
test_db::run(async |db| {
let conn = db.conn();
let mut user = User::new("passkey@example.com", None);
user.save(&conn).await.unwrap();
let passkey: Passkey = serde_json::from_str(PASSKEY).unwrap();
let credential = WebauthnCredential::new(
user.uuid.clone(),
"Key".into(),
&passkey,
true,
Some("4.user".into()),
Some("2.public".into()),
Some("2.private".into()),
)
.unwrap();
credential.insert(&conn).await.unwrap();
let stored = async || {
WebauthnCredential::find_by_user_and_credential_id(&user.uuid, &credential.credential_id, &conn)
.await
.unwrap()
};
// A challenge is answered once, and an older one can't follow a newer one
assert!(credential.use_challenge(1_000, &passkey, &conn).await.unwrap());
assert!(!credential.use_challenge(1_000, &passkey, &conn).await.unwrap());
assert!(credential.use_challenge(2_000, &passkey, &conn).await.unwrap());
assert!(!credential.use_challenge(1_500, &passkey, &conn).await.unwrap());
// A rotation keeps the private key, which the PRF key wraps, and the challenge
credential.rotate_keys("4.user2".into(), "2.public2".into(), &conn).await.unwrap();
let rotated = stored().await;
assert_eq!(rotated.encrypted_user_key.as_deref(), Some("4.user2"));
assert_eq!(rotated.encrypted_public_key.as_deref(), Some("2.public2"));
assert_eq!(rotated.encrypted_private_key.as_deref(), Some("2.private"));
assert_eq!(rotated.last_challenge_issued_at, Some(2_000));
assert_eq!(rotated.credential, credential.credential);
// Deleted, it can't answer any challenge
assert!(WebauthnCredential::delete_by_uuid_and_user(&credential.uuid, &user.uuid, &conn).await.unwrap());
assert!(!credential.use_challenge(3_000, &passkey, &conn).await.unwrap());
});
}
}
+19
View File
@@ -379,6 +379,23 @@ table! {
}
}
table! {
webauthn_credentials (uuid) {
uuid -> Text,
user_uuid -> Text,
name -> Text,
credential_id -> Text,
credential -> Text,
supports_prf -> Bool,
encrypted_user_key -> Nullable<Text>,
encrypted_public_key -> Nullable<Text>,
encrypted_private_key -> Nullable<Text>,
last_challenge_issued_at -> Nullable<BigInt>,
created_at -> Timestamp,
updated_at -> Timestamp,
}
}
joinable!(archives -> users (user_uuid));
joinable!(archives -> ciphers (cipher_uuid));
joinable!(attachments -> ciphers (cipher_uuid));
@@ -411,6 +428,7 @@ joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid));
joinable!(user_signature_key_pairs -> users (user_uuid));
joinable!(webauthn_credentials -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
archives,
@@ -438,4 +456,5 @@ allow_tables_to_appear_in_same_query!(
event,
auth_requests,
user_signature_key_pairs,
webauthn_credentials,
);
+11 -5
View File
@@ -48,17 +48,23 @@ a[href$="/settings/sponsored-families"] {
}
{{/if}}
/* Hide the `Log in with passkey` settings */
{{#unless webauthn_2fa_supported}}
/* Hide the `Log in with passkey` settings when the `DOMAIN` can't use passkeys */
app-user-layout app-password-settings app-webauthn-login-settings {
@extend %vw-hide;
}
/* Hide Log in with passkey on the login page */
.vw-passkey-login {
{{/unless}}
{{#unless passkey_login_available}}
/* Hide Log in with passkey on the login page and adding passkeys. SSO_ONLY keeps the list, so that passkeys can still
be removed. */
.vw-passkey-login,
app-user-layout app-password-settings app-webauthn-login-settings > button {
@extend %vw-hide;
}
{{/unless}}
/* Hide the or text followed by the two buttons hidden above */
{{#if (or (not sso_enabled) sso_only)}}
/* Hide the or text when no button follows it */
{{#if (and (or (not sso_enabled) sso_only) (not passkey_login_available))}}
.vw-or-text {
@extend %vw-hide;
}
+19
View File
@@ -1011,6 +1011,25 @@ pub fn is_valid_rsa_encrypted_string(value: &str) -> bool {
&& parts.iter().all(|part| !part.is_empty() && data_encoding::BASE64.decode(part.as_bytes()).is_ok())
}
/// Whether `value` has the form of a key wrapped by a symmetric key: AES-CBC-HMAC (type 2, a 16-byte IV, the
/// ciphertext and a 32-byte MAC) or COSE (type 7, one piece), each piece canonical, padded base64.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Core/Utilities/EncryptedStringAttribute.cs#L19-L29>
pub fn is_valid_symmetric_encrypted_string(value: &str) -> bool {
let (lengths, rest): (&[Option<usize>], &str) = match value.split_once('.') {
Some(("2", rest)) => (&[Some(16), None, Some(32)], rest),
Some(("7", rest)) => (&[None], rest),
_ => return false,
};
let parts: Vec<&str> = rest.split('|').collect();
parts.len() == lengths.len()
&& parts.iter().zip(lengths).all(|(part, length)| {
data_encoding::BASE64
.decode(part.as_bytes())
.is_ok_and(|bytes| !bytes.is_empty() && length.is_none_or(|length| bytes.len() == length))
})
}
/// These are some tests to check that the implementations match
/// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17
/// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct