mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-15 17:45:56 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83706df68 | ||
|
|
037005074a |
No files matched your search
@@ -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
@@ -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 }
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Dropping the permission would silently take it away from the Custom members holding it. So while there are any,
|
||||
-- this fails on the duplicate key below, before anything is dropped: take it away first, or restore the backup
|
||||
-- taken before the upgrade.
|
||||
-- A failed attempt leaves the temporary table on this connection. Clear it before retrying.
|
||||
DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade;
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Manage account recovery: take it away first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Manage account recovery: take it away first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE manage_reset_password LIMIT 1;
|
||||
DROP TEMPORARY TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_reset_password;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- The Manage account recovery permission of the Custom member role. Like the other permissions it only takes
|
||||
-- effect on a Custom membership (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and nobody starts with it.
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_reset_password BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -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
|
||||
);
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
-- Dropping the permission would silently take it away from the Custom members holding it. So while there are any,
|
||||
-- this fails on the duplicate key below: take it away first, or restore the backup taken before the upgrade.
|
||||
-- It needs to run in one transaction, as Diesel does.
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Manage account recovery: take it away first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Manage account recovery: take it away first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE manage_reset_password LIMIT 1;
|
||||
DROP TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_reset_password;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- The Manage account recovery permission of the Custom member role. Like the other permissions it only takes
|
||||
-- effect on a Custom membership (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and nobody starts with it.
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_reset_password BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Dropping the permission would silently take it away from the Custom members holding it. So while there are any,
|
||||
-- this fails on the duplicate key below: take it away first, or restore the backup taken before the upgrade.
|
||||
-- It needs to run in one transaction, as Diesel does.
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Manage account recovery: take it away first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Manage account recovery: take it away first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE manage_reset_password LIMIT 1;
|
||||
DROP TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_reset_password;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- The Manage account recovery permission of the Custom member role. Like the other permissions it only takes
|
||||
-- effect on a Custom membership (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and nobody starts with it.
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_reset_password BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -13,9 +13,8 @@ use crate::{
|
||||
},
|
||||
auth::{
|
||||
AccessImportExportHeaders, AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders,
|
||||
CollectionUsersReadHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageResetPasswordHeaders,
|
||||
ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may,
|
||||
decode_invite,
|
||||
CollectionUsersReadHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders,
|
||||
ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite,
|
||||
},
|
||||
db::{
|
||||
DbConn,
|
||||
@@ -1008,18 +1007,13 @@ async fn get_members(
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
|
||||
// As in Bitwarden, Manage account recovery alone only lists the members whose account it can recover
|
||||
let recovery_only = !headers.membership.has_full_access()
|
||||
&& !headers.membership.has_custom_permission(CustomPermission::ManageUsers);
|
||||
if recovery_only && !headers.membership.has_custom_permission(CustomPermission::ManageResetPassword) {
|
||||
if !headers.membership.has_full_access() && !headers.membership.has_custom_permission(CustomPermission::ManageUsers)
|
||||
{
|
||||
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
||||
}
|
||||
|
||||
let mut users_json = Vec::new();
|
||||
for u in Membership::find_by_org(&org_id, &conn).await {
|
||||
if recovery_only && (!u.has_status(MembershipStatus::Confirmed) || u.reset_password_key.is_none()) {
|
||||
continue;
|
||||
}
|
||||
// The user can be a manager instead of an admin, but we've checked above that they have full access
|
||||
users_json.push(
|
||||
u.to_json_details_for_admin(
|
||||
@@ -1042,7 +1036,7 @@ async fn get_members(
|
||||
async fn post_org_keys(
|
||||
org_id: OrganizationId,
|
||||
data: Json<OrgKeyData>,
|
||||
headers: ManageResetPasswordHeaders,
|
||||
headers: AdminHeaders,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
@@ -2889,10 +2883,9 @@ async fn get_groups_data(
|
||||
|| headers.membership.has_custom_permission(CustomPermission::ManageUsers)
|
||||
|| headers.membership.has_custom_permission(CustomPermission::ManageGroups)
|
||||
} else {
|
||||
// Reports and account recovery need the basic group list, not group details or management rights.
|
||||
// Reports need the basic group list, not group details or management rights.
|
||||
has_full_access
|
||||
|| headers.membership.has_custom_permission(CustomPermission::AccessReports)
|
||||
|| headers.membership.has_custom_permission(CustomPermission::ManageResetPassword)
|
||||
|| may_assign_collection_access(&headers.membership, &conn).await
|
||||
};
|
||||
if !allowed {
|
||||
@@ -3329,7 +3322,7 @@ async fn get_organization_keys(org_id: OrganizationId, headers: Headers, conn: D
|
||||
async fn put_recover_account(
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
headers: ManageResetPasswordHeaders,
|
||||
headers: AdminHeaders,
|
||||
data: Json<OrganizationUserRecoverAccountRequest>,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
@@ -3340,7 +3333,7 @@ async fn put_recover_account(
|
||||
async fn recover_account(
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
headers: ManageResetPasswordHeaders,
|
||||
headers: AdminHeaders,
|
||||
req: OrganizationUserRecoverAccountRequest,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
@@ -3360,7 +3353,7 @@ async fn recover_account(
|
||||
err!("User not found")
|
||||
};
|
||||
|
||||
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers.membership, &conn).await?;
|
||||
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?;
|
||||
|
||||
if member.reset_password_key.is_none() {
|
||||
err!("Password reset not or not correctly enrolled");
|
||||
@@ -3430,14 +3423,12 @@ async fn recover_account(
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
|
||||
for (reset, event_type) in [
|
||||
(req.reset_master_password, EventType::OrganizationUserAdminResetPassword),
|
||||
(req.reset_two_factor, EventType::OrganizationUserAdminResetTwoFactor),
|
||||
] {
|
||||
if reset {
|
||||
log_event(event_type, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||
.await;
|
||||
if req.reset_master_password {
|
||||
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
|
||||
}
|
||||
|
||||
if req.reset_two_factor {
|
||||
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -3608,7 +3599,7 @@ async fn get_organization_private_key(org_id: OrganizationId, headers: ManageUse
|
||||
async fn get_reset_password_details(
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
headers: ManageResetPasswordHeaders,
|
||||
headers: AdminHeaders,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
@@ -3626,7 +3617,7 @@ async fn get_reset_password_details(
|
||||
err!("User not found")
|
||||
};
|
||||
|
||||
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers.membership, &conn).await?;
|
||||
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?;
|
||||
|
||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs#L190
|
||||
Ok(Json(json!({
|
||||
@@ -3645,7 +3636,7 @@ async fn get_reset_password_details(
|
||||
async fn check_reset_password_applicable_and_permissions(
|
||||
org_id: &OrganizationId,
|
||||
member_id: &MembershipId,
|
||||
caller: &Membership,
|
||||
headers: &AdminHeaders,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
check_reset_password_applicable(org_id, conn).await?;
|
||||
@@ -3654,25 +3645,11 @@ async fn check_reset_password_applicable_and_permissions(
|
||||
err!("Reset target user not found")
|
||||
};
|
||||
|
||||
if may_recover_account(caller, &target_user) {
|
||||
Ok(())
|
||||
} else {
|
||||
err!("No permission to reset this user's password")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `caller` may recover the account of `target`, who must not have more authority, as in Bitwarden: Owners
|
||||
/// may recover every member, Admins every member but Owners, and Custom members with Manage account recovery Users
|
||||
/// and the Custom members whose permissions they all have.
|
||||
fn may_recover_account(caller: &Membership, target: &Membership) -> bool {
|
||||
if caller.has_type(MembershipType::Owner) {
|
||||
true
|
||||
} else if caller.has_type(MembershipType::Admin) {
|
||||
target.atype <= MembershipType::Admin
|
||||
} else {
|
||||
caller.has_custom_permission(CustomPermission::ManageResetPassword)
|
||||
&& custom_member_manages(target.atype)
|
||||
&& caller.has_custom_permissions_of(target)
|
||||
// Resetting user must be higher/equal to user to reset
|
||||
match headers.membership_type {
|
||||
MembershipType::Owner => Ok(()),
|
||||
MembershipType::Admin if target_user.atype <= MembershipType::Admin => Ok(()),
|
||||
_ => err!("No permission to reset this user's password"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4058,54 +4035,4 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(sqlite)]
|
||||
#[test]
|
||||
fn account_recovery_authorization() {
|
||||
test_db::run(async |db| {
|
||||
use CustomPermission::{ManageResetPassword, ManageUsers};
|
||||
use MembershipType::*;
|
||||
let conn = db.conn();
|
||||
let org = test_db::organization(&conn).await;
|
||||
let mut members = Vec::new();
|
||||
for (role, permissions) in [
|
||||
(Owner, vec![]),
|
||||
(Admin, vec![]),
|
||||
(Manager, vec![]),
|
||||
(User, vec![]),
|
||||
(Custom, vec![ManageResetPassword]),
|
||||
(Custom, vec![ManageResetPassword, ManageUsers]),
|
||||
] {
|
||||
members.push(test_db::member(&conn, &org.uuid, role, &permissions).await.1);
|
||||
}
|
||||
// Who may recover whom: a row per caller, a column per target, both in the order above
|
||||
for (caller, row) in members.iter().zip(["111111", "011111", "000000", "000000", "000110", "000111"]) {
|
||||
for (target, allowed) in members.iter().zip(row.chars()) {
|
||||
assert_eq!(
|
||||
may_recover_account(caller, target),
|
||||
allowed == '1',
|
||||
"{} {}",
|
||||
caller.atype,
|
||||
target.atype
|
||||
);
|
||||
}
|
||||
}
|
||||
// Manage account recovery alone lists only the confirmed members enrolled in account recovery
|
||||
for (i, status) in [(2, MembershipStatus::Accepted), (3, MembershipStatus::Confirmed)] {
|
||||
members[i].status = status as i32;
|
||||
members[i].reset_password_key = Some("key".into());
|
||||
members[i].save(&conn).await.unwrap();
|
||||
}
|
||||
let data = GetOrgUserData {
|
||||
include_collections: None,
|
||||
include_groups: None,
|
||||
};
|
||||
let users = get_members(data, org.uuid.clone(), directory_headers(&members[4], &conn).await, db.conn());
|
||||
assert_eq!(
|
||||
users.await.unwrap().0["data"],
|
||||
json!([members[3].to_json_details_for_admin(false, false, &conn).await])
|
||||
);
|
||||
assert!(get_groups(org.uuid.clone(), directory_headers(&members[4], &conn).await, db.conn()).await.is_ok());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+16
-11
@@ -27,14 +27,14 @@ use rocket::{
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::ApiResult,
|
||||
api::{ApiResult, core::log_event},
|
||||
config::PathType,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
AttachmentId, CipherId, Collection, CollectionId, CustomPermission, Device, DeviceId, DeviceType,
|
||||
EmergencyAccessId, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||
SendFileId, SendId, User, UserId, custom_roles_enabled,
|
||||
EmergencyAccessId, EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId,
|
||||
OrganizationId, SendFileId, SendId, User, UserId, custom_roles_enabled,
|
||||
},
|
||||
},
|
||||
error::Error,
|
||||
@@ -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();
|
||||
@@ -854,6 +855,12 @@ pub struct AdminHeaders {
|
||||
pub org_id: OrganizationId,
|
||||
}
|
||||
|
||||
impl AdminHeaders {
|
||||
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
|
||||
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for AdminHeaders {
|
||||
type Error = &'static str;
|
||||
@@ -941,11 +948,6 @@ permission_headers!(
|
||||
AccessImportExportHeaders,
|
||||
CustomPermission::AccessImportExport
|
||||
);
|
||||
permission_headers!(
|
||||
/// For the routes that recover the accounts of the members of an organization.
|
||||
ManageResetPasswordHeaders,
|
||||
CustomPermission::ManageResetPassword
|
||||
);
|
||||
|
||||
// col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
|
||||
// but there could be cases where it is a query value.
|
||||
@@ -1409,6 +1411,7 @@ pub enum AuthMethod {
|
||||
Password,
|
||||
Sso,
|
||||
UserApiKey,
|
||||
Webauthn,
|
||||
}
|
||||
|
||||
impl AuthMethod {
|
||||
@@ -1416,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1541,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"),
|
||||
};
|
||||
|
||||
|
||||
@@ -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('@'))
|
||||
|
||||
@@ -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};
|
||||
@@ -81,7 +81,6 @@ pub struct Membership {
|
||||
access_event_logs: bool,
|
||||
access_import_export: bool,
|
||||
access_reports: bool,
|
||||
manage_reset_password: bool,
|
||||
|
||||
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
|
||||
pub v2_upgrade_token: Option<String>,
|
||||
@@ -255,7 +254,6 @@ pub enum CustomPermission {
|
||||
AccessEventLogs,
|
||||
AccessImportExport,
|
||||
AccessReports,
|
||||
ManageResetPassword,
|
||||
}
|
||||
|
||||
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
|
||||
@@ -271,11 +269,10 @@ const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
|
||||
CustomPermission::AccessEventLogs,
|
||||
CustomPermission::AccessImportExport,
|
||||
CustomPermission::AccessReports,
|
||||
CustomPermission::ManageResetPassword,
|
||||
];
|
||||
|
||||
impl CustomPermission {
|
||||
pub const ALL: [CustomPermission; 10] = [
|
||||
pub const ALL: [CustomPermission; 9] = [
|
||||
CustomPermission::ManageUsers,
|
||||
CustomPermission::ManageGroups,
|
||||
CustomPermission::ManagePolicies,
|
||||
@@ -285,7 +282,6 @@ impl CustomPermission {
|
||||
CustomPermission::AccessEventLogs,
|
||||
CustomPermission::AccessImportExport,
|
||||
CustomPermission::AccessReports,
|
||||
CustomPermission::ManageResetPassword,
|
||||
];
|
||||
|
||||
/// The key of the permission in the `permissions` object of the Bitwarden clients.
|
||||
@@ -300,7 +296,6 @@ impl CustomPermission {
|
||||
CustomPermission::AccessEventLogs => "accessEventLogs",
|
||||
CustomPermission::AccessImportExport => "accessImportExport",
|
||||
CustomPermission::AccessReports => "accessReports",
|
||||
CustomPermission::ManageResetPassword => "manageResetPassword",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +468,6 @@ impl Membership {
|
||||
access_event_logs: false,
|
||||
access_import_export: false,
|
||||
access_reports: false,
|
||||
manage_reset_password: false,
|
||||
|
||||
v2_upgrade_token: None,
|
||||
}
|
||||
@@ -490,7 +484,6 @@ impl Membership {
|
||||
CustomPermission::AccessEventLogs => &mut self.access_event_logs,
|
||||
CustomPermission::AccessImportExport => &mut self.access_import_export,
|
||||
CustomPermission::AccessReports => &mut self.access_reports,
|
||||
CustomPermission::ManageResetPassword => &mut self.manage_reset_password,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,7 +553,6 @@ impl Membership {
|
||||
CustomPermission::AccessEventLogs => self.access_event_logs,
|
||||
CustomPermission::AccessImportExport => self.access_import_export,
|
||||
CustomPermission::AccessReports => self.access_reports,
|
||||
CustomPermission::ManageResetPassword => self.manage_reset_password,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,12 +571,6 @@ impl Membership {
|
||||
self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission)
|
||||
}
|
||||
|
||||
/// Whether this member may act with every Custom role permission stored for `other`, as Bitwarden's
|
||||
/// `Permissions.Includes`.
|
||||
pub fn has_custom_permissions_of(&self, other: &Membership) -> bool {
|
||||
CustomPermission::ALL.into_iter().all(|p| !other.custom_permission(p) || self.has_custom_permission(p))
|
||||
}
|
||||
|
||||
/// The permissions the "Manage all collections" a legacy Manager is shown with stands for: their `access_all`.
|
||||
pub fn legacy_manager_permissions(&self) -> HashSet<CustomPermission> {
|
||||
if self.has_type(MembershipType::Manager) && self.access_all {
|
||||
@@ -611,6 +597,7 @@ impl Membership {
|
||||
let shown = self.shown_custom_permissions();
|
||||
let mut permissions = json!({
|
||||
"manageSso": false, // Not supported
|
||||
"manageResetPassword": false,
|
||||
"manageScim": false, // Not supported (Not AGPLv3 Licensed)
|
||||
});
|
||||
for permission in CustomPermission::ALL {
|
||||
@@ -1733,8 +1720,7 @@ mod tests {
|
||||
DeleteAnyCollection,
|
||||
AccessEventLogs,
|
||||
AccessImportExport,
|
||||
AccessReports,
|
||||
ManageResetPassword
|
||||
AccessReports
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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, with the key from its own test data
|
||||
const PASSKEY: &str = r#"{"cred":{"cred_id":"dmF1bHR3YXJkZW4gdGVzdCBwYXNza2V5","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
-1
@@ -269,7 +269,6 @@ table! {
|
||||
access_event_logs -> Bool,
|
||||
access_import_export -> Bool,
|
||||
access_reports -> Bool,
|
||||
manage_reset_password -> Bool,
|
||||
v2_upgrade_token -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
@@ -380,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));
|
||||
@@ -412,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,
|
||||
@@ -439,4 +456,5 @@ allow_tables_to_appear_in_same_query!(
|
||||
event,
|
||||
auth_requests,
|
||||
user_signature_key_pairs,
|
||||
webauthn_credentials,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -99,8 +105,9 @@ app-security > app-two-factor-setup > form {
|
||||
|
||||
/* Hide unsupported Custom Role options */
|
||||
{{#if custom_roles_enabled}}
|
||||
/* Not supported: Manage SSO */
|
||||
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]) {
|
||||
/* Not supported: Manage SSO and Manage account recovery */
|
||||
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]),
|
||||
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageResetPassword"]) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{else}}
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
Reference in new issue
Block a user