From 037005074a7e61f863ac1c6bc711a5c28b30082f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Sun, 15 Nov 2026 13:26:18 +0100 Subject: [PATCH] Add login with passkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .env.template | 6 + Cargo.toml | 2 +- .../down.sql | 1 + .../up.sql | 16 + .../down.sql | 1 + .../up.sql | 15 + .../down.sql | 1 + .../up.sql | 15 + src/api/core/accounts.rs | 17 +- src/api/core/ciphers.rs | 3 + src/api/core/mod.rs | 16 +- src/api/core/two_factor/webauthn.rs | 3 +- src/api/core/webauthn_login.rs | 470 ++++++++++++++++++ src/api/identity.rs | 97 +++- src/api/web.rs | 1 + src/auth.rs | 10 +- src/config.rs | 17 + src/db/models/mod.rs | 2 + src/db/models/user.rs | 3 +- src/db/models/webauthn_credential.rs | 340 +++++++++++++ src/db/schema.rs | 19 + .../templates/scss/vaultwarden.scss.hbs | 16 +- src/util.rs | 19 + 23 files changed, 1060 insertions(+), 30 deletions(-) create mode 100644 migrations/mysql/2026-11-15-120000_add_webauthn_credentials/down.sql create mode 100644 migrations/mysql/2026-11-15-120000_add_webauthn_credentials/up.sql create mode 100644 migrations/postgresql/2026-11-15-120000_add_webauthn_credentials/down.sql create mode 100644 migrations/postgresql/2026-11-15-120000_add_webauthn_credentials/up.sql create mode 100644 migrations/sqlite/2026-11-15-120000_add_webauthn_credentials/down.sql create mode 100644 migrations/sqlite/2026-11-15-120000_add_webauthn_credentials/up.sql create mode 100644 src/api/core/webauthn_login.rs create mode 100644 src/db/models/webauthn_credential.rs diff --git a/.env.template b/.env.template index cc195375..40f5db13 100644 --- a/.env.template +++ b/.env.template @@ -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://,android:apk-key-hash: + ########################### ### SMTP Email settings ### ########################### diff --git a/Cargo.toml b/Cargo.toml index e677b162..56e90817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/migrations/mysql/2026-11-15-120000_add_webauthn_credentials/down.sql b/migrations/mysql/2026-11-15-120000_add_webauthn_credentials/down.sql new file mode 100644 index 00000000..554afad3 --- /dev/null +++ b/migrations/mysql/2026-11-15-120000_add_webauthn_credentials/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS webauthn_credentials; diff --git a/migrations/mysql/2026-11-15-120000_add_webauthn_credentials/up.sql b/migrations/mysql/2026-11-15-120000_add_webauthn_credentials/up.sql new file mode 100644 index 00000000..65bf0682 --- /dev/null +++ b/migrations/mysql/2026-11-15-120000_add_webauthn_credentials/up.sql @@ -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 +); diff --git a/migrations/postgresql/2026-11-15-120000_add_webauthn_credentials/down.sql b/migrations/postgresql/2026-11-15-120000_add_webauthn_credentials/down.sql new file mode 100644 index 00000000..554afad3 --- /dev/null +++ b/migrations/postgresql/2026-11-15-120000_add_webauthn_credentials/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS webauthn_credentials; diff --git a/migrations/postgresql/2026-11-15-120000_add_webauthn_credentials/up.sql b/migrations/postgresql/2026-11-15-120000_add_webauthn_credentials/up.sql new file mode 100644 index 00000000..9bd7e57b --- /dev/null +++ b/migrations/postgresql/2026-11-15-120000_add_webauthn_credentials/up.sql @@ -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) +); diff --git a/migrations/sqlite/2026-11-15-120000_add_webauthn_credentials/down.sql b/migrations/sqlite/2026-11-15-120000_add_webauthn_credentials/down.sql new file mode 100644 index 00000000..554afad3 --- /dev/null +++ b/migrations/sqlite/2026-11-15-120000_add_webauthn_credentials/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS webauthn_credentials; diff --git a/migrations/sqlite/2026-11-15-120000_add_webauthn_credentials/up.sql b/migrations/sqlite/2026-11-15-120000_add_webauthn_credentials/up.sql new file mode 100644 index 00000000..30c0cf36 --- /dev/null +++ b/migrations/sqlite/2026-11-15-120000_add_webauthn_credentials/up.sql @@ -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) +); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 414cdc16..444f6b7c 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -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, } -/// 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: #[derive(Deserialize)] @@ -1237,8 +1237,7 @@ struct MasterPasswordUnlockData { struct CommonUnlockData { emergency_access_unlock_data: Vec, organization_account_recovery_unlock_data: Vec, - #[expect(dead_code, reason = "Required like upstream, but unused")] - passkey_unlock_data: Vec, + passkey_unlock_data: Vec, #[expect(dead_code, reason = "Required like upstream, but unused")] device_key_unlock_data: Vec, v2_upgrade_token: Option, @@ -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)) { diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 78c27b39..2e079ff5 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -206,6 +206,9 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option Vec { 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 { 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 { - // 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 { let domain = CONFIG.domain(); diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 7eab5546..c142be2e 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -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, } diff --git a/src/api/core/webauthn_login.rs b/src/api/core/webauthn_login.rs new file mode 100644 index 00000000..b9f8bbdb --- /dev/null +++ b/src/api/core/webauthn_login.rs @@ -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 { + 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: +/// Ref: +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 { + 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: + /// Ref: + 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, + /// 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(scope: TokenScope, user: Option<&UserId>, state: &T) -> ApiResult { + 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(token: &str, scope: TokenScope, user: Option<&UserId>) -> ApiResult<(i64, T)> { + let claims = match auth::decode_jwt::(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::(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: +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 { + let credentials: Vec = 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 = "")] +async fn post_attestation_options(data: Json, 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 = "")] +async fn post_assertion_options(data: Json, 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, + encrypted_public_key: Option, + encrypted_private_key: Option, +} + +#[post("/webauthn", data = "")] +async fn post_credential(data: Json, 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 = "")] +async fn put_credential(data: Json, 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//delete", data = "")] +async fn delete_credential( + id: WebauthnCredentialId, + data: Json, + 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 { + let options: Vec = 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 { + 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, + encrypted_public_key: Option, +} + +/// The new user key and public key of every passkey with a PRF key set. +/// +/// Ref: +pub fn validate_rotation( + existing: Vec, + unlock_data: &[PasskeyUnlockData], +) -> ApiResult> { + 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) +} diff --git a/src/api/identity.rs b/src/api/identity.rs index d7ae5068..8ee789ba 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -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 { 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, 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, 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: +async fn passkey_login( + data: ConnectData, + user_id: &mut Option, + 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, password_hash_b64: Option, + + // Needed for grant_type = "webauthn", with the assertion as JSON + #[field(name = uncased("token"))] + token: Option, + #[field(name = uncased("device_response"))] + #[field(name = uncased("deviceresponse"))] + device_response: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { @@ -1268,6 +1353,16 @@ fn check_is_some(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() { diff --git a/src/api/web.rs b/src/api/web.rs index 2818b5b7..734d337d 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -99,6 +99,7 @@ fn vaultwarden_css() -> EtagCached { "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(), diff --git a/src/auth.rs b/src/auth.rs index 0a43b41b..a84637ae 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -66,6 +66,7 @@ static JWT_FILE_DOWNLOAD_ISSUER: LazyLock = static JWT_REGISTER_VERIFY_ISSUER: LazyLock = LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin())); static JWT_2FA_REMEMBER_ISSUER: LazyLock = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin())); +pub static JWT_WEBAUTHN_ISSUER: LazyLock = LazyLock::new(|| format!("{}|webauthn", CONFIG.domain_origin())); static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock = 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"), }; diff --git a/src/config.rs b/src/config.rs index 165ead41..da3fbb2a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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://`) or an app (`android:apk-key-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('@')) diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index e4e77c4d..527f0df4 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -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}; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 49ea67b7..37869440 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -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| { diff --git a/src/db/models/webauthn_credential.rs b/src/db/models/webauthn_credential.rs new file mode 100644 index 00000000..e4c8ba88 --- /dev/null +++ b/src/db/models/webauthn_credential.rs @@ -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, + /// 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, + /// The private key of the PRF key pair, wrapped by the key the authenticator's PRF output derives + pub encrypted_private_key: Option, + /// 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, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +/// Ref: +#[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, + encrypted_public_key: Option, + encrypted_private_key: Option, + ) -> ApiResult { + 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 { + 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: + pub fn to_sync_prf_option(&self) -> Option { + (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: + pub fn to_login_prf_option(&self) -> Option { + (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 { + conn.run(move |conn| { + webauthn_credentials::table + .filter(webauthn_credentials::user_uuid.eq(user_uuid)) + .order(webauthn_credentials::created_at) + .load::(conn) + .expect("Error loading passkeys") + }) + .await + } + + pub async fn find_by_user_and_credential_id( + user_uuid: &UserId, + credential_id: &str, + conn: &DbConn, + ) -> Option { + conn.run(move |conn| { + webauthn_credentials::table + .filter(webauthn_credentials::user_uuid.eq(user_uuid)) + .filter(webauthn_credentials::credential_id.eq(credential_id)) + .first::(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 { + 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 { + 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()); + }); + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs index 98792674..02d6540a 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -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, + encrypted_public_key -> Nullable, + encrypted_private_key -> Nullable, + last_challenge_issued_at -> Nullable, + 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, ); diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index a4c270bc..e98ee4ea 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -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; } diff --git a/src/util.rs b/src/util.rs index 409867b3..a1880e57 100644 --- a/src/util.rs +++ b/src/util.rs @@ -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: +pub fn is_valid_symmetric_encrypted_string(value: &str) -> bool { + let (lengths, rest): (&[Option], &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