mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-16 00:15:56 +02:00
V2 Encryption followups (#7963)
* Add the account recovery V2 upgrade routes
GET /organizations/{orgId}/users/keys/pending-v2-upgrades lists, a page at a
time, the memberships whose account recovery key still wraps the member's V1
user key after an upgrade to V2 encryption, with the upgrade token the admin
re-wraps it from. POST .../v2-upgrades stores the re-wrapped keys in one
transaction, or unenrolls the members whose token holds no usable key. Only
Owners and Admins, an Admin never for Owners, and a membership that changed
since the read is skipped. GET /organizations/{orgId}/private-key gives the
organization's private key these need, to everyone with Manage users as
upstream. The SDK's encrypted migrator calls all three.
Enrolling in or withdrawing from account recovery drops a leftover upgrade
token, which would otherwise get a member who re-enrolled after the upgrade
unenrolled.
* Check membership for the organization public key in the handler
The public key routes serve the placeholder SSO organization's key to users
without an organization, so they couldn't require the member guard, but it
still ran for every request: it logged a membership error for each legitimate
v2 JIT password setup and authenticated the request twice. The routes now
authenticate once, return the placeholder key first, and check the membership
themselves, with the same rule as the guard.
* List attachments in sync with a stable URL
Sync and the cipher responses list each attachment with an unsigned URL of
upstream's form, which no longer changes on every sync nor costs a presign per
attachment on S3, and which isn't served without a token. The attachment routes
sign it: the cipher route and its admin variant, and the emergency access view's
route the web vault downloads through, which is added. The admin variant also
signs it for a member who can open the item themselves, because the web vault's
fallback to the listed URL no longer works.
* Seed the SDK live tests' vectors with their attachments
Sync now lists attachments with a stable URL, so the rotation test's before and after comparison holds with them.
Only the legacy attachments without a key of their own stay out: the SDK refuses to rotate a vault holding one.
This commit is contained in:
1 parent
81ec0966cb
commit
bd7beab76e
9 files changed
+400
-45
No files matched your search
@@ -1244,10 +1244,11 @@ struct CommonUnlockData {
|
||||
v2_upgrade_token: Option<V2UpgradeTokenData>,
|
||||
}
|
||||
|
||||
/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us.
|
||||
/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. Stored on the user and
|
||||
/// the memberships as this serializes, and read back with it (the account recovery V2 upgrade routes).
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct V2UpgradeTokenData {
|
||||
pub(crate) struct V2UpgradeTokenData {
|
||||
wrapped_user_key1: String,
|
||||
wrapped_user_key2: String,
|
||||
}
|
||||
|
||||
@@ -1565,15 +1565,16 @@ async fn get_attachment_data(
|
||||
};
|
||||
|
||||
if scope == CipherAccessScope::OrganizationAdmin {
|
||||
// NotFound as in Bitwarden, the web vault then falls back to the attachment's own URL
|
||||
// NotFound as in Bitwarden. Its web vault then falls back to the attachment's URL from the listing, which isn't
|
||||
// served here without a token, so a member who can open the item themselves gets the signed URL instead.
|
||||
let Some(org_id) = cipher.organization_uuid.as_ref() else {
|
||||
err_code!("Cipher is not owned by an organization", Status::NotFound.code)
|
||||
};
|
||||
if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn)
|
||||
let is_admin = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn)
|
||||
.await
|
||||
.is_some_and(|member| member.can_edit_any_collection())
|
||||
|| !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await
|
||||
{
|
||||
&& cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await;
|
||||
if !is_admin && !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await {
|
||||
err_code!("Cipher is not accessible", Status::NotFound.code)
|
||||
}
|
||||
} else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await {
|
||||
@@ -1581,7 +1582,9 @@ async fn get_attachment_data(
|
||||
}
|
||||
|
||||
match Attachment::find_by_id(&attachment_id, &conn).await {
|
||||
Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)),
|
||||
Some(attachment) if cipher_id == attachment.cipher_uuid => {
|
||||
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
|
||||
}
|
||||
Some(_) => err!("Attachment doesn't belong to cipher"),
|
||||
None => err!("Attachment doesn't exist"),
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ use crate::{
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
|
||||
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||
Attachment, AttachmentId, Cipher, CipherId, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess,
|
||||
EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType,
|
||||
OrgPolicy, TwoFactor, User, UserId,
|
||||
},
|
||||
},
|
||||
mail,
|
||||
@@ -42,6 +43,7 @@ pub fn routes() -> Vec<Route> {
|
||||
takeover_emergency_access,
|
||||
password_emergency_access,
|
||||
view_emergency_access,
|
||||
get_emergency_access_attachment,
|
||||
policies_emergency_access,
|
||||
]
|
||||
}
|
||||
@@ -616,6 +618,46 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con
|
||||
})))
|
||||
}
|
||||
|
||||
/// An attachment of the grantor's for a grantee with approved view access, with a `url` to download it from: the view
|
||||
/// lists the attachments with one that only names the file. Only the grantor's own ciphers, the ones the view lists.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Auth/Controllers/EmergencyAccessController.cs#L219-L226>
|
||||
#[get("/emergency-access/<emer_id>/<cipher_id>/attachment/<attachment_id>")]
|
||||
async fn get_emergency_access_attachment(
|
||||
emer_id: EmergencyAccessId,
|
||||
cipher_id: CipherId,
|
||||
attachment_id: AttachmentId,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let Some(emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
if cipher.user_uuid.as_ref() != Some(&emergency_access.grantor_uuid) || cipher.organization_uuid.is_some() {
|
||||
err!("Cipher is not accessible")
|
||||
}
|
||||
|
||||
match Attachment::find_by_id(&attachment_id, &conn).await {
|
||||
Some(attachment) if attachment.cipher_uuid == cipher_id => {
|
||||
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
|
||||
}
|
||||
Some(_) => err!("Attachment doesn't belong to cipher"),
|
||||
None => err!("Attachment doesn't exist"),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/takeover")]
|
||||
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
+181
-22
@@ -21,7 +21,7 @@ use crate::{
|
||||
models::{
|
||||
Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
|
||||
CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser,
|
||||
Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
|
||||
Invitation, KeyId, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
|
||||
Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
||||
custom_roles_enabled,
|
||||
},
|
||||
@@ -31,7 +31,7 @@ use crate::{
|
||||
util::{NumberOrString, convert_json_key_lcase_first},
|
||||
};
|
||||
|
||||
use super::accounts::{AuthenticationData, UnlockData};
|
||||
use super::accounts::{AuthenticationData, UnlockData, V2UpgradeTokenData};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![
|
||||
@@ -94,6 +94,9 @@ pub fn routes() -> Vec<Route> {
|
||||
get_group_members,
|
||||
put_reset_password_enrollment,
|
||||
get_reset_password_details,
|
||||
get_pending_v2_upgrades,
|
||||
post_v2_upgrades,
|
||||
get_organization_private_key,
|
||||
put_recover_account,
|
||||
get_org_export,
|
||||
post_api_key,
|
||||
@@ -3236,12 +3239,7 @@ struct OrganizationUserRecoverAccountRequest {
|
||||
// But the clients do not seem to use this at all
|
||||
// Just add it here in case they will
|
||||
#[get("/organizations/<org_id>/public-key")]
|
||||
async fn get_organization_public_key(
|
||||
org_id: OrganizationId,
|
||||
headers: Headers,
|
||||
member: Result<OrgMemberHeaders, &'static str>,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
async fn get_organization_public_key(org_id: OrganizationId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
// SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway
|
||||
if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() {
|
||||
return Ok(Json(json!({
|
||||
@@ -3249,14 +3247,16 @@ async fn get_organization_public_key(
|
||||
"publicKey": fake_sso_org_public_key().await?,
|
||||
})));
|
||||
}
|
||||
let member = match member {
|
||||
Ok(member) => member,
|
||||
// The guard already logged it
|
||||
Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)),
|
||||
};
|
||||
|
||||
if org_id != member.membership.org_uuid {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
// Only for members, as `OrgMemberHeaders` checks it, but here, so that the fake org above neither logs a
|
||||
// membership error nor authenticates the request twice: a known role, and not revoked (invited members are,
|
||||
// while joining)
|
||||
let is_member = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await.is_some_and(|member| {
|
||||
MembershipType::from_i32(member.atype).is_some()
|
||||
&& MembershipStatus::from_i32(member.status).is_some_and(|status| status != MembershipStatus::Revoked)
|
||||
});
|
||||
if !is_member {
|
||||
err_code!("You need to be a Member of the Organization to call this endpoint", Status::Unauthorized.code)
|
||||
}
|
||||
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
||||
err!("Organization not found")
|
||||
@@ -3285,13 +3285,8 @@ async fn fake_sso_org_public_key() -> ApiResult<String> {
|
||||
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
|
||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
|
||||
#[get("/organizations/<org_id>/keys")]
|
||||
async fn get_organization_keys(
|
||||
org_id: OrganizationId,
|
||||
headers: Headers,
|
||||
member: Result<OrgMemberHeaders, &'static str>,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
get_organization_public_key(org_id, headers, member, conn).await
|
||||
async fn get_organization_keys(org_id: OrganizationId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
get_organization_public_key(org_id, headers, conn).await
|
||||
}
|
||||
|
||||
// Will allow to reset 2FA too
|
||||
@@ -3412,6 +3407,167 @@ async fn recover_account(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct PendingV2UpgradesQuery {
|
||||
#[field(name = "continuationToken")]
|
||||
continuation_token: Option<MembershipId>,
|
||||
}
|
||||
|
||||
/// The memberships whose account recovery key an Owner or Admin still has to re-wrap with the member's V2 user key,
|
||||
/// one page at a time: the client posts each page to `v2-upgrades` and reads the next one with the continuation
|
||||
/// token, until it is null. An Admin gets every member but the Owners, whose key material only an Owner can read.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Controllers/OrganizationUsersKeysController.cs#L46-L83>
|
||||
#[get("/organizations/<org_id>/users/keys/pending-v2-upgrades?<query..>")]
|
||||
async fn get_pending_v2_upgrades(
|
||||
org_id: OrganizationId,
|
||||
query: PendingV2UpgradesQuery,
|
||||
headers: AdminHeaders,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
let include_owners = headers.membership_type == MembershipType::Owner;
|
||||
let pending =
|
||||
Membership::find_pending_v2_upgrades(&org_id, include_owners, query.continuation_token.as_ref(), &conn).await;
|
||||
|
||||
let continuation_token = if i64::try_from(pending.len()).is_ok_and(|len| len == Membership::MAX_V2_UPGRADES) {
|
||||
pending.last().map(|upgrade| upgrade.member_id.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let data: Vec<Value> = pending
|
||||
.into_iter()
|
||||
.map(|upgrade| {
|
||||
json!({
|
||||
"object": "organizationUserPendingV2Upgrade",
|
||||
"organizationUserId": upgrade.member_id,
|
||||
"userKeyId": upgrade.user_key_id,
|
||||
"accountRecoveryKey": upgrade.account_recovery_key,
|
||||
// Null when the stored token can't be read: it holds no usable key, so the admin unenrolls the member
|
||||
"v2UpgradeToken": serde_json::from_str::<V2UpgradeTokenData>(&upgrade.v2_upgrade_token).ok(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"continuationToken": continuation_token,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct V2UpgradesData {
|
||||
upgrades: Vec<V2UpgradeData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct V2UpgradeData {
|
||||
organization_user_id: MembershipId,
|
||||
/// The id of the user key the new account recovery key wraps, which must still be the member's
|
||||
user_key_id: KeyId,
|
||||
/// The member's V2 user key wrapped with the organization's public key, or none to unenroll the member (when the
|
||||
/// upgrade token holds no usable key)
|
||||
account_recovery_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Stores the account recovery keys an Owner or Admin re-wrapped with members' V2 user keys, or unenrolls the members
|
||||
/// whose entry has no key. Every member named must be one the caller can recover in this organization. A membership
|
||||
/// that changed since the read (rotated again, unenrolled, upgraded already, promoted) is skipped and stays pending.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Controllers/OrganizationUsersKeysController.cs#L85-L108>
|
||||
#[post("/organizations/<org_id>/users/keys/v2-upgrades", data = "<data>")]
|
||||
async fn post_v2_upgrades(
|
||||
org_id: OrganizationId,
|
||||
data: Json<V2UpgradesData>,
|
||||
headers: AdminHeaders,
|
||||
conn: DbConn,
|
||||
) -> EmptyResult {
|
||||
if org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
let upgrades = data.into_inner().upgrades;
|
||||
if upgrades.is_empty() || i64::try_from(upgrades.len()).map_or(true, |len| len > Membership::MAX_V2_UPGRADES) {
|
||||
err!(format!("The field Upgrades must contain between 1 and {} entries.", Membership::MAX_V2_UPGRADES))
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
if !upgrades.iter().all(|upgrade| seen.insert(&upgrade.organization_user_id)) {
|
||||
err!("An organization user can only be upgraded once per request.")
|
||||
}
|
||||
// A key or its absence: the token the key could be rebuilt from is consumed with the write, so a malformed key is
|
||||
// refused rather than stored
|
||||
if upgrades
|
||||
.iter()
|
||||
.filter_map(|upgrade| upgrade.account_recovery_key.as_deref())
|
||||
.any(|key| !crate::util::is_valid_rsa_encrypted_string(key))
|
||||
{
|
||||
err!("The field AccountRecoveryKey is not a valid encrypted string.")
|
||||
}
|
||||
|
||||
let include_owners = headers.membership_type == MembershipType::Owner;
|
||||
let mut members = Vec::with_capacity(upgrades.len());
|
||||
for upgrade in &upgrades {
|
||||
match Membership::find_by_uuid_and_org(&upgrade.organization_user_id, &org_id, &conn).await {
|
||||
Some(member) if include_owners || member.atype != MembershipType::Owner => members.push(member),
|
||||
_ => err_code!("Resource not found.", Status::NotFound.code),
|
||||
}
|
||||
}
|
||||
|
||||
// Every write in one transaction, as upstream's single statement, so a failure leaves the whole batch pending
|
||||
let applied = conn
|
||||
.transaction(async || {
|
||||
let mut applied = Vec::with_capacity(members.len());
|
||||
for (member, upgrade) in members.iter().zip(&upgrades) {
|
||||
let key = upgrade.account_recovery_key.as_deref();
|
||||
if member.apply_v2_upgrade(include_owners, &upgrade.user_key_id, key, &conn).await? {
|
||||
applied.push((member, upgrade));
|
||||
}
|
||||
}
|
||||
Ok(applied)
|
||||
})
|
||||
.await?;
|
||||
|
||||
for (member, upgrade) in applied {
|
||||
User::update_uuid_revision(&member.user_uuid, &conn).await;
|
||||
// A re-wrapped key isn't logged: the member stays enrolled
|
||||
if upgrade.account_recovery_key.is_none() {
|
||||
log_event(
|
||||
EventType::OrganizationUserResetPasswordWithdraw,
|
||||
&upgrade.organization_user_id,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The organization's private key, wrapped with the organization key: the account recovery V2 upgrade (the SDK's
|
||||
/// encrypted migrator) needs it to unwrap the members' V1 user keys. Open to everyone with Manage users, as upstream.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L473-L484>
|
||||
#[get("/organizations/<org_id>/private-key")]
|
||||
async fn get_organization_private_key(org_id: OrganizationId, headers: ManageUsersHeaders, conn: DbConn) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
||||
err_code!("Resource not found.", Status::NotFound.code)
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"object": "organizationPrivateKey",
|
||||
"privateKey": org.private_key,
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/users/<member_id>/reset-password-details")]
|
||||
async fn get_reset_password_details(
|
||||
org_id: OrganizationId,
|
||||
@@ -3524,6 +3680,9 @@ async fn put_reset_password_enrollment(
|
||||
}
|
||||
|
||||
membership.reset_password_key = reset_password_key;
|
||||
// A new key already wraps the current user key and a withdrawal leaves none. An upgrade token left from an earlier
|
||||
// V2 upgrade would only get the member unenrolled by the account recovery V2 upgrade.
|
||||
membership.v2_upgrade_token = None;
|
||||
membership.save(&conn).await?;
|
||||
|
||||
let event_type = if membership.reset_password_key.is_some() {
|
||||
|
||||
Reference in new issue
Block a user