Compare commits

..
Author SHA1 Message Date
Mathijs van Veluw f4696877b9 Update dependencies (#7882)
- Updated all the crates
- Updated JavaScript libraries
- Updated xx to v1.10.0
- Updated Rust to v1.99.1
- Updated GHA & Pre-Commit

Resolves #7877

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-10-22 12:47:30 +02:00
ump45nose 296599003c docs: fix connect-src description grammar in config.rs and .env.template (#7866) 2026-10-22 12:46:26 +02:00
ump45nose 970cc49182 api/icons: fix typos in the minimum PNG size comment (#7867) 2026-10-22 12:46:00 +02:00
Mathijs van Veluw 58a980f850 Add unsupported riscv64 architecture (#7859)
This PR makes it easy for users to build a Debian container images which
is able to run on a riscv64 architecture using a Debian Container, or if
needed, you can extract the binary from this container, but the user
needs to be sure the libraries are available and supported if using
Docker is not an option.

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-10-16 11:51:32 +02:00
Cassian433 64895524e4 Delete auth requests when deleting a user (#7837) 2026-10-10 19:58:40 +02:00
44 changed files with 563 additions and 1328 deletions

No files matched your search

+1 -1
View File
@@ -463,7 +463,7 @@
## Allowed connect-src (Know the risks!)
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
# Enable Rust Caching
- name: Rust Caching
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
+4 -4
View File
@@ -185,7 +185,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers
id: bake_vw
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0
env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@@ -222,7 +222,7 @@ jobs:
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: ${{ runner.temp }}/digests/*
@@ -242,7 +242,7 @@ jobs:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
- name: Upload binaries as artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -264,7 +264,7 @@ jobs:
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: ${{ runner.temp }}/digests
pattern: digests-*-${{ matrix.base_image }}
+1 -1
View File
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: 'trivy-results.sarif'
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
uses: crate-ci/typos@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.
+1 -1
View File
@@ -18,7 +18,7 @@ repos:
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
rev: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
hooks:
- id: typos
always_run: true
+3 -2
View File
@@ -14,8 +14,9 @@ extend-ignore-re = [
# In SMTP it's called HELO, so ignore it
"(?i)helo_name",
"Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption
"(?i)cose",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
"COSEKey",
"COSEAlgorithm",
# Ignore this specific string as it's valid
"Ensure they are valid OTPs",
# This word is misspelled upstream
Generated
+247 -238
View File
File diff suppressed because it is too large. Load diff
+12 -12
View File
@@ -91,7 +91,7 @@ dashmap = "6.2.1"
# Async futures
futures = "0.3.34"
tokio = { version = "1.53.2", features = [
tokio = { version = "1.54.1", features = [
"fs",
"io-util",
"net",
@@ -100,14 +100,14 @@ tokio = { version = "1.53.2", features = [
"signal",
"time",
] }
tokio-util = { version = "0.7.19", features = ["compat"] }
tokio-util = { version = "0.7.20", features = ["compat"] }
# A generic serialization/deserialization framework
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
serde_json = "1.0.154"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@@ -125,11 +125,11 @@ libsqlite3-sys = { version = "0.38.2", optional = true }
# Crypto-related libraries
rand = "0.10.3"
ring = "0.17.14"
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.27.0", features = ["v4"] }
uuid = { version = "1.28.0", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
@@ -215,7 +215,7 @@ bytes = "1.12.1"
svg-hush = "0.9.7"
# Cache function results (Used for version check and favicon fetching)
cached = { version = "4.0.1", features = ["async"] }
cached = { version = "4.0.3", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.2"
@@ -257,18 +257,18 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true }
opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
aws-config = { version = "1.13.0", optional = true, default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"rt-tokio",
"sso",
] }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.19.0", optional = true }
aws-credential-types = { version = "1.3.1", optional = true }
aws-smithy-runtime-api = { version = "1.19.1", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.2", optional = true }
+2 -2
View File
@@ -1,10 +1,10 @@
---
vault_version: "v2026.8.0"
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
# Cross Compile Docker Helper Scripts v1.9.0
# Cross Compile Docker Helper Scripts v1.10.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
xx_image_digest: "sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6"
# The `rust_version` variable is extracted from `rust-toolchain.toml`
# rust_version: x.yy.z # Rust version to be used
debian_version: trixie # Debian release name to be used
+4 -4
View File
@@ -32,10 +32,10 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c0
########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
## And for Alpine we define all build images here, they will only be loaded when actually used
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
+2 -2
View File
@@ -32,11 +32,11 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c0
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
## And these bash scripts do not have any significant difference if at all
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / /
ARG TARGETARCH
+1
View File
@@ -15,6 +15,7 @@ Some unsupported platforms for Debian based images. These are not built and test
- 386 (linux/386)
- ppc64le (linux/ppc64le)
- s390x (linux/s390x)
- riscv64 (linux/riscv64)
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
This ensures the container build process can run binaries from other architectures.<br>
+6
View File
@@ -148,6 +148,12 @@ target "debian-s390x" {
platforms = ["linux/s390x"]
tags = generate_tags("", "-s390x")
}
target "debian-riscv64" {
inherits = ["debian"]
platforms = ["linux/riscv64"]
tags = generate_tags("", "-riscv64")
}
// ==== End of unsupported Debian architecture targets ===
// A Group to build all platforms individually for local testing
+1 -1
View File
@@ -14,7 +14,7 @@ proc-macro = true
[dependencies]
quote = "1.0.47"
syn = "3.0.6"
syn = "3.0.9"
[lints]
workspace = true
Whitespace-only changes.
@@ -1,3 +0,0 @@
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT.
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL;
@@ -1,5 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -1,14 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
Whitespace-only changes.
@@ -1 +0,0 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -1,5 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -1,13 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Whitespace-only changes.
@@ -1 +0,0 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -1,5 +0,0 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -1,13 +0,0 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.99.0"
channel = "1.99.1"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
+66 -410
View File
@@ -22,8 +22,8 @@ use crate::{
models::{
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,
MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId,
UserKdfType,
},
},
mail,
@@ -42,7 +42,6 @@ pub fn routes() -> Vec<rocket::Route> {
post_profile,
put_avatar,
get_public_keys,
get_account_public_keys,
get_keys,
post_keys,
post_password,
@@ -114,9 +113,6 @@ pub struct RegisterData {
#[serde(alias = "userAsymmetricKeys")]
keys: Option<KeysData>,
// Supersedes `keys`, and the only way a v2 account can be registered.
account_keys: Option<AccountKeysData>,
master_password_hint: Option<String>,
organization_user_id: Option<MembershipId>,
@@ -128,6 +124,36 @@ pub struct RegisterData {
org_invite_token: Option<String>,
}
impl RegisterData {
fn hash(&self) -> String {
self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
}
fn kdf(&self) -> &KDFData {
self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
}
fn key(&self) -> String {
self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
}
// When comparing with salt, email need to be normalized:
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
fn unprocessable(&self) -> bool {
let mut unprocessable = false;
*self.compat.fold(
|_| &false,
|rdcu| {
let email = self.email.trim().to_lowercase();
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|| rdcu.master_password_authentication.salt != email
|| rdcu.master_password_unlock.salt != email;
&unprocessable
},
)
}
}
#[derive(Debug, Deserialize)]
struct RegisterDataOld {
#[serde(flatten)]
@@ -167,42 +193,6 @@ impl RegisterDataCompat {
RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu),
}
}
fn hash(&self) -> String {
self.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
}
fn kdf(&self) -> &KDFData {
self.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
}
fn key(&self) -> String {
self.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
}
/// The id of the user key, which only the current format carries.
fn key_id(&self) -> Option<KeyId> {
match self {
RegisterDataCompat::RegisterDataOld(_) => None,
RegisterDataCompat::RegisterDataCur(rdcu) => rdcu.master_password_unlock.contained_key_id.clone(),
}
}
// When comparing with salt, email need to be normalized:
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
fn unprocessable(&self, email: &str) -> bool {
let mut unprocessable = false;
*self.fold(
|_| &false,
|rdcu| {
let email = email.trim().to_lowercase();
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|| rdcu.master_password_authentication.salt != email
|| rdcu.master_password_unlock.salt != email;
&unprocessable
},
)
}
}
#[derive(Debug, Deserialize)]
@@ -212,198 +202,6 @@ struct KeysData {
public_key: String,
}
/// Upstream's implicit `[Required]` on a non-nullable string: present and not blank.
fn required(value: Option<String>, field: &str) -> ApiResult<String> {
match value {
Some(value) if !value.trim().is_empty() => Ok(value),
_ => err!(format!("The {field} field is required.")),
}
}
/// The `accountKeys` payload: a v1 key pair, or v2 with a signature key pair and security state.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs#L6-L50>
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AccountKeysData {
// Required like upstream, but only used without `public_key_encryption_key_pair`
user_key_encrypted_account_private_key: Option<String>,
account_public_key: Option<String>,
public_key_encryption_key_pair: Option<PublicKeyEncryptionKeyPairData>,
signature_key_pair: Option<SignatureKeyPairData>,
security_state: Option<SecurityStateData>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PublicKeyEncryptionKeyPairData {
wrapped_private_key: String,
public_key: String,
signed_public_key: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SignatureKeyPairData {
signature_algorithm: String,
wrapped_signing_key: String,
verifying_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SecurityStateData {
security_state: String,
security_version: i32,
}
struct ValidatedAccountKeys {
private_key: String,
public_key: String,
v2: Option<ValidatedV2AccountKeys>,
}
struct ValidatedV2AccountKeys {
signed_public_key: String,
signing_key: String,
verifying_key: String,
signature_algorithm: SignatureAlgorithm,
security_state: String,
security_version: i32,
}
impl AccountKeysData {
/// Upstream's model checks, plus the v2 fields all or none: no client can unlock a partial v2 state.
fn validate(self) -> ApiResult<ValidatedAccountKeys> {
let top_private_key =
required(self.user_key_encrypted_account_private_key, "UserKeyEncryptedAccountPrivateKey")?;
let top_public_key = required(self.account_public_key, "AccountPublicKey")?;
let (private_key, public_key, signed_public_key) = match self.public_key_encryption_key_pair {
Some(key_pair) => (
required(Some(key_pair.wrapped_private_key), "WrappedPrivateKey")?,
required(Some(key_pair.public_key), "PublicKey")?,
key_pair.signed_public_key,
),
// Older clients only send the top-level fields, which are always v1
None => (top_private_key, top_public_key, None),
};
if let Some(signature_key_pair) = &self.signature_key_pair {
required(Some(signature_key_pair.signature_algorithm.clone()), "SignatureAlgorithm")?;
required(Some(signature_key_pair.wrapped_signing_key.clone()), "WrappedSigningKey")?;
required(Some(signature_key_pair.verifying_key.clone()), "VerifyingKey")?;
}
if let Some(security_state) = &self.security_state {
required(Some(security_state.security_state.clone()), "SecurityState")?;
if security_state.security_state.encode_utf16().count() > 10_000 {
err!("The field SecurityState must be a string with a maximum length of 10000.")
}
}
let v2 = match (signed_public_key, self.signature_key_pair, self.security_state) {
(Some(signed_public_key), Some(signature_key_pair), Some(security_state)) => {
// Upstream fails on a null one, but takes an empty one that no client can unlock with
let signed_public_key = required(Some(signed_public_key), "SignedPublicKey")?;
let Some(signature_algorithm) = SignatureAlgorithm::parse(&signature_key_pair.signature_algorithm)
else {
err!(format!(
"Unsupported signature algorithm: {}",
signature_key_pair.signature_algorithm.escape_debug()
))
};
Some(ValidatedV2AccountKeys {
signed_public_key,
signing_key: signature_key_pair.wrapped_signing_key,
verifying_key: signature_key_pair.verifying_key,
signature_algorithm,
security_state: security_state.security_state,
security_version: security_state.security_version,
})
}
(None, None, None) => None,
_ => err!(
"Invalid account keys: the signed public key, signature key pair and security state must either all be present or all be absent"
),
};
Ok(ValidatedAccountKeys {
private_key,
public_key,
v2,
})
}
}
impl From<KeysData> for ValidatedAccountKeys {
fn from(keys: KeysData) -> Self {
Self {
private_key: keys.encrypted_private_key,
public_key: keys.public_key,
v2: None,
}
}
}
impl ValidatedAccountKeys {
/// The keys of a registration: `accountKeys` only for a v2 account, like upstream.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs#L77-L100>
fn for_registration(account_keys: Option<AccountKeysData>, keys: Option<KeysData>) -> ApiResult<Self> {
if let Some(account_keys) = account_keys {
let account_keys = account_keys.validate()?;
if account_keys.is_v2() {
return Ok(account_keys);
}
}
let Some(keys) = keys else {
err!("PublicKey and WrappedPrivateKey not found in RequestModel")
};
Ok(keys.into())
}
fn is_v2(&self) -> bool {
self.v2.is_some()
}
/// Sets the keys on the user, which then needs saving before [`Self::save_signature_key_pair`].
fn apply(&self, user: &mut User) -> EmptyResult {
user.private_key = Some(self.private_key.clone());
user.public_key = Some(self.public_key.clone());
user.signed_public_key = self.v2.as_ref().map(|v2| v2.signed_public_key.clone());
user.security_state = self.v2.as_ref().map(|v2| v2.security_state.clone());
user.security_version = self.v2.as_ref().map(|v2| v2.security_version);
Ok(())
}
/// Saves the signature key pair, which needs the user to exist for its foreign key.
async fn save_signature_key_pair(&self, user_id: &UserId, conn: &DbConn) -> EmptyResult {
// Skip if the account is v1, since v1 accounts don't have a signature key pair.
let Some(v2) = &self.v2 else {
return Ok(());
};
let mut key_pair = match UserSignatureKeyPair::find_by_user(user_id, conn).await {
Some(mut key_pair) => {
key_pair.signature_algorithm = v2.signature_algorithm as i32;
key_pair.signing_key.clone_from(&v2.signing_key);
key_pair.verifying_key.clone_from(&v2.verifying_key);
key_pair
}
None => UserSignatureKeyPair::new(
user_id.clone(),
v2.signature_algorithm,
v2.signing_key.clone(),
v2.verifying_key.clone(),
),
};
key_pair.save(conn).await
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MasterPasswordAuthentication {
@@ -424,19 +222,17 @@ pub struct MasterPasswordUnlock {
#[serde(alias = "masterKeyWrappedUserKey")]
key: String,
contained_key_id: Option<KeyId>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetPasswordData {
#[serde(flatten)]
compat: RegisterDataCompat,
kdf: KDFData,
key: String,
keys: Option<KeysData>,
// Supersedes `keys`, and the only way a v2 account can be initialized here.
account_keys: Option<AccountKeysData>,
master_password_hash: String,
master_password_hint: Option<String>,
org_identifier: Option<String>,
}
@@ -478,7 +274,7 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
let mut name = None;
let mut pending_emergency_access = None;
if data.compat.unprocessable(&data.email) {
if data.unprocessable() {
err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code);
}
@@ -547,11 +343,10 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
err!("The field Name must be a string with a maximum length of 50.");
}
// Check against the password hint setting and the keys here so if they fail,
// the user can retry without losing their invitation below.
// Check against the password hint setting here so if it fails, the user
// can retry without losing their invitation below.
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(password_hint.as_ref())?;
let account_keys = ValidatedAccountKeys::for_registration(data.account_keys, data.keys)?;
let mut user = match User::find_by_mail(&email, &conn).await {
Some(user) => {
@@ -598,9 +393,9 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
// Make sure we don't leave a lingering invitation.
Invitation::take(&email, &conn).await;
set_kdf_data(&mut user, data.compat.kdf())?;
set_kdf_data(&mut user, data.kdf())?;
user.set_password(&data.compat.hash(), Some(data.compat.key()), true, None, &conn).await?;
user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?;
user.password_hint = password_hint;
// Add extra fields if present
@@ -608,10 +403,9 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
user.name = name;
}
account_keys.apply(&mut user)?;
// Like upstream, only a v2 registration records the key id; v1 accounts report it through `user-key-id`
if account_keys.is_v2() {
user.key_id = data.compat.key_id();
if let Some(keys) = data.keys {
user.private_key = Some(keys.encrypted_private_key);
user.public_key = Some(keys.public_key);
}
if email_verified {
@@ -634,7 +428,6 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
}
user.save(&conn).await?;
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
// accept any open emergency access invitations
if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() {
@@ -648,81 +441,25 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
})))
}
/// Upstream's shape checks of the set-password body, on the raw JSON that the untagged compat would hide.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs#L75-L106>
fn validate_set_password_shape(body: &Value) -> EmptyResult {
let has = |name: &str| {
body.as_object().is_some_and(|o| o.iter().any(|(k, v)| k.eq_ignore_ascii_case(name) && !v.is_null()))
};
if has("accountKeys") && has("keys") {
err!("Cannot specify both AccountKeys and Keys. Provide exactly one keypair.")
}
let (authentication, unlock) = (has("masterPasswordAuthentication"), has("masterPasswordUnlock"));
if authentication != unlock {
err!(
"Must provide both MasterPasswordAuthentication and MasterPasswordUnlock together. Cannot provide one without the other."
)
}
if authentication && (has("masterPasswordHash") || has("key") || has("kdf")) {
err!(
"Cannot mix modern (MasterPasswordAuthentication/MasterPasswordUnlock) and legacy (MasterPasswordHash/Key/Kdf) fields. Provide one shape or the other."
)
}
Ok(())
}
#[post("/accounts/set-password", data = "<data>")]
async fn post_set_password(data: Json<Value>, headers: Headers, conn: DbConn) -> JsonResult {
let data = data.into_inner();
validate_set_password_shape(&data)?;
let Ok(data) = serde_json::from_value::<SetPasswordData>(data) else {
err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code)
};
if data.master_password_hint.as_ref().is_some_and(|h| h.encode_utf16().count() > 50) {
err!("The field MasterPasswordHint must be a string with a maximum length of 50.")
}
async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: SetPasswordData = data.into_inner();
let mut user = headers.user;
if user.private_key.is_some() || !user.password_hash.is_empty() {
err!("Account already initialized, cannot set password")
}
if data.compat.unprocessable(&user.email) {
err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code);
}
// Check against the password hint setting here so if it fails,
// the user can retry without losing their invitation below.
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(password_hint.as_ref())?;
// Like upstream, `accountKeys` only through the v2 JIT flow
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Auth/Controllers/AccountsController.cs#L314-L359>
let account_keys = match data.account_keys {
Some(account_keys) => {
if !matches!(data.compat, RegisterDataCompat::RegisterDataCur(_))
|| !crate::util::is_client_feature_flag_enabled(
"enable-account-encryption-v2-jit-password-registration",
)
{
err!("Request includes V2 AccountKeys but V2 encryption is not enabled.")
}
let account_keys = account_keys.validate()?;
if !account_keys.is_v2() {
err!("AccountKeys are only supported for V2 encryption.")
}
Some(account_keys)
}
None => data.keys.map(ValidatedAccountKeys::from),
};
set_kdf_data(&mut user, data.compat.kdf())?;
set_kdf_data(&mut user, &data.kdf)?;
user.set_password(
&data.compat.hash(),
Some(data.compat.key()),
&data.master_password_hash,
Some(data.key),
false,
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
&conn,
@@ -730,12 +467,9 @@ async fn post_set_password(data: Json<Value>, headers: Headers, conn: DbConn) ->
.await?;
user.password_hint = password_hint;
if let Some(ref account_keys) = account_keys {
account_keys.apply(&mut user)?;
// As in `register`, only a v2 account records the user key id here
if account_keys.is_v2() {
user.key_id = data.compat.key_id();
}
if let Some(keys) = data.keys {
user.private_key = Some(keys.encrypted_private_key);
user.public_key = Some(keys.public_key);
}
if let Some(identifier) = data.org_identifier
@@ -763,10 +497,6 @@ async fn post_set_password(data: Json<Value>, headers: Headers, conn: DbConn) ->
user.save(&conn).await?;
if let Some(account_keys) = account_keys {
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
}
Ok(Json(json!({
"object": "set-password",
})))
@@ -847,19 +577,8 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js
})))
}
#[get("/users/<user_id>/keys")]
async fn get_account_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult {
let user = match User::find_by_uuid(&user_id, &conn).await {
Some(user) if user.public_key.is_some() => user,
Some(_) => err_code!("User has no public_key", Status::NotFound.code),
None => err_code!("User doesn't exist", Status::NotFound.code),
};
Ok(Json(user.public_keys_json(&conn).await))
}
#[get("/accounts/keys")]
async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult {
fn get_keys(headers: Headers) -> JsonResult {
let user = headers.user;
// The SDK reads a 404 as the user having no key pair yet
@@ -871,66 +590,29 @@ async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult {
"key": (!user.akey.is_empty()).then_some(&user.akey),
"publicKey": user.public_key,
"privateKey": user.private_key,
"accountKeys": user.account_keys_json(&conn).await,
"accountKeys": user.account_keys_json(),
"object": "keys",
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PostKeysData {
// Required like upstream, even next to `accountKeys`
public_key: Option<String>,
encrypted_private_key: Option<String>,
account_keys: Option<AccountKeysData>,
// The id of the user key these account keys belong to, only honored for v2 `accountKeys`
user_key_id: Option<KeyId>,
}
#[post("/accounts/keys", data = "<data>")]
async fn post_keys(data: Json<PostKeysData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PostKeysData = data.into_inner();
async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: KeysData = data.into_inner();
let mut user = headers.user;
// Only for an account without keys, replacing them is what a key rotation does
if user.private_key.is_some() || user.public_key.is_some() {
err!("User has existing keypair")
}
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs#L11-L19>
let public_key = required(data.public_key, "PublicKey")?;
let encrypted_private_key = required(data.encrypted_private_key, "EncryptedPrivateKey")?;
user.private_key = Some(data.encrypted_private_key);
user.public_key = Some(data.public_key);
// `accountKeys` supersedes the flat keys when both are sent
let account_keys = match data.account_keys {
Some(account_keys) => {
let account_keys = account_keys.validate()?;
if !account_keys.is_v2() {
err!("AccountKeys are only supported for V2 encryption.")
}
// A client that predates key ids sends none, and reports it later through `user-key-id`
if data.user_key_id.is_some() {
user.key_id = data.user_key_id;
}
account_keys
}
None => KeysData {
encrypted_private_key,
public_key,
}
.into(),
};
account_keys.apply(&mut user)?;
user.save(&conn).await?;
account_keys.save_signature_key_pair(&user.uuid, &conn).await?;
Ok(Json(json!({
"key": (!user.akey.is_empty()).then_some(&user.akey),
"privateKey": user.private_key,
"publicKey": user.public_key,
"accountKeys": user.account_keys_json(&conn).await,
"object":"keys"
})))
}
@@ -969,8 +651,6 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
err!("Invalid master password salt")
}
validate_key_id_unchanged(&user, &unlock_data)?;
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
(new_master_password_hash, new_key)
@@ -1073,19 +753,6 @@ pub(super) struct UnlockData {
salt: String,
kdf: KDFData,
pub(super) master_key_wrapped_user_key: String,
contained_key_id: Option<KeyId>,
}
/// A password or KDF change keeps the user key, so its key id must be the current one, when both are known.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs#L27-L47>
fn validate_key_id_unchanged(user: &User, unlock_data: &UnlockData) -> EmptyResult {
if let (Some(current), Some(contained)) = (&user.key_id, &unlock_data.contained_key_id)
&& current != contained
{
err!("Invalid user key sent in master-password unlock data.")
}
Ok(())
}
#[derive(Deserialize)]
@@ -1106,8 +773,6 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
data.authentication_data.check(&headers.user, &data.unlock_data)?;
validate_key_id_unchanged(&headers.user, &data.unlock_data)?;
let mut user = headers.user;
set_kdf_data(&mut user, &data.unlock_data.kdf)?;
@@ -1283,14 +948,6 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
err!("Invalid password")
}
// Rotating v2 keys, or upgrading to them, would leave an account that can't be unlocked here
if headers.user.is_v2() {
err!("Key rotation is not supported for v2 accounts")
}
if !data.account_keys.user_key_encrypted_account_private_key.starts_with("2.") {
err!("The provided account private key was not wrapped with AES-256-CBC-HMAC")
}
// Validate the import before continuing
// Bitwarden does not process the import if there is one item invalid.
// Since we check for the size of the encrypted note length, we need to do that here to pre-validate it.
@@ -1416,9 +1073,8 @@ struct KeyIdData {
#[post("/accounts/key-management/user-key-id", data = "<data>")]
async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult {
let mut user = headers.user;
// Only a backfill for accounts that have none. Afterwards the id changes with the key, in a rotation.
if user.key_id.is_some() {
err!("User key id is already set.")
err_code!("Unexpected data", Status::UnprocessableEntity.code);
}
user.key_id = Some(data.into_inner().user_key_id);
@@ -2276,10 +1932,10 @@ mod tests {
)
.unwrap();
assert!(!data.compat.unprocessable(&data.email));
assert_eq!(data.compat.hash(), "hash");
assert_eq!(data.compat.key(), "key");
assert_eq!(data.compat.kdf().kdf_iterations, 600_000);
assert!(!data.unprocessable());
assert_eq!(data.hash(), "hash");
assert_eq!(data.key(), "key");
assert_eq!(data.kdf().kdf_iterations, 600_000);
assert!(data.keys.is_some());
assert_eq!(data.email_verification_token.as_deref(), Some("token"));
}
@@ -2301,10 +1957,10 @@ mod tests {
)
.unwrap();
assert!(!data.compat.unprocessable(&data.email));
assert_eq!(data.compat.hash(), "hash");
assert_eq!(data.compat.key(), "key");
assert_eq!(data.compat.kdf().kdf_iterations, 600_000);
assert!(!data.unprocessable());
assert_eq!(data.hash(), "hash");
assert_eq!(data.key(), "key");
assert_eq!(data.kdf().kdf_iterations, 600_000);
assert!(data.keys.is_some());
}
}
+82 -213
View File
@@ -23,8 +23,7 @@ use crate::{
models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
is_data_blob_encrypted,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@@ -190,19 +189,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"masterKeyEncryptedUserKey": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email,
"containedKeyId": headers.user.key_id,
"salt": headers.user.email
})
} else {
Value::Null
};
// Upstream omits these when unset rather than sending null
let mut user_decryption = json!({ "masterPasswordUnlock": master_password_unlock });
if let Some(key_id) = &headers.user.key_id {
user_decryption["userKeyId"] = json!(key_id);
}
Ok(Json(json!({
"profile": user_json,
"folders": folders_json,
@@ -212,7 +204,10 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": user_decryption,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"object": "sync"
})))
}
@@ -274,8 +269,7 @@ pub struct CipherData {
key: Option<String>,
// Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent
pub encrypted_for: Option<UserId>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
@@ -290,8 +284,7 @@ pub struct CipherData {
Passport = 8
*/
pub r#type: i32,
// Absent on a blob-encrypted cipher, whose name is sealed inside `data`
pub name: Option<String>,
pub name: String,
pub notes: Option<String>,
fields: Option<Value>,
@@ -305,9 +298,6 @@ pub struct CipherData {
drivers_license: Option<Value>,
passport: Option<Value>,
// The sealed blob of a v2 account's cipher, which replaces all of the fields above
data: Option<String>,
favorite: Option<bool>,
reprompt: Option<i32>,
@@ -329,79 +319,6 @@ pub struct CipherData {
archived_date: Option<String>,
}
/// A field of a [`CipherData`] that fails upstream's model validation.
#[derive(Debug)]
pub struct CipherValidationError {
pub field: &'static str,
pub message: String,
}
impl From<CipherValidationError> for crate::Error {
fn from(e: CipherValidationError) -> Self {
Self::new_msg(e.message)
}
}
/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1857-L1870>
fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult {
if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) {
err!("Cipher was not encrypted for the current user. Please try again.")
}
Ok(())
}
/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1886-L1911>
fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
validate_encrypted_for_user(data, &user.uuid)?;
if !is_org_cipher
&& let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id)
&& cipher_key_id != user_key_id
{
err!("Cipher was not encrypted with the current user key. Please try again.")
}
Ok(())
}
/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data`
const MAX_CIPHER_DATA_LENGTH: usize = 500_000;
impl CipherData {
/// Whether `data` is a blob rather than the per-type fields. Parses `data`, so keep the result.
pub fn is_blob(&self) -> bool {
self.data.as_deref().is_some_and(is_data_blob_encrypted)
}
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
/// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L91-L99>
pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> {
if let Some(data) = &self.data
&& data.len() > MAX_CIPHER_DATA_LENGTH
{
return Err(CipherValidationError {
field: "Data",
message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."),
});
}
// A blob carries the name inside it, so only the other formats need one
if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) {
return Err(CipherValidationError {
field: "Name",
message: String::from("The Name field is required."),
});
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartialCipherData {
@@ -435,14 +352,16 @@ async fn post_ciphers_create(
) -> JsonResult {
let mut data: ShareCipherData = data.into_inner();
validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
// This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below.
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
let mut cipher = Cipher::new(data.cipher.r#type);
let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone());
cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?;
@@ -466,7 +385,16 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner();
validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?;
if data.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
if let Some(cipher_key_id) = &data.encrypted_by_key_id
&& let Some(user_key_id) = &headers.user.key_id
&& cipher_key_id != user_key_id
{
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
}
// The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
@@ -474,7 +402,7 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt
// needed when creating a new cipher, so just ignore it unconditionally.
data.last_known_revision_date = None;
let mut cipher = Cipher::new(data.r#type);
let mut cipher = Cipher::new(data.r#type, data.name.clone());
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -498,44 +426,6 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &
Ok(())
}
/// The checks of saving a cipher, for callers that run them before writing anything else
async fn validate_cipher_update(
cipher: &Cipher,
data: &CipherData,
headers: &Headers,
conn: &DbConn,
ut: UpdateType,
) -> EmptyResult {
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
&& let Some(dt) = &data.last_known_revision_date
{
match NaiveDateTime::parse_from_str(dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
if let Some(folder_id) = &data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
Ok(())
}
pub async fn update_cipher_from_data(
cipher: &mut Cipher,
data: CipherData,
@@ -561,14 +451,32 @@ pub async fn update_cipher_from_data(
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
let is_blob = data.is_blob();
data.validate_content(is_blob)?;
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
&& let Some(dt) = data.last_known_revision_date
{
match NaiveDateTime::parse_from_str(&dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
// Check if this cipher is being transferred from a personal to an organization vault
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
@@ -599,6 +507,12 @@ pub async fn update_cipher_from_data(
cipher.user_uuid = Some(headers.user.uuid.clone());
}
if let Some(ref folder_id) = data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
// Modify attachments name and keys when rotating
if let Some(attachments) = data.attachments2 {
for (id, attachment) in attachments {
@@ -637,36 +551,26 @@ pub async fn update_cipher_from_data(
_ => err!("Invalid type"),
};
if let Some(blob) = data.data.filter(|_| is_blob) {
// A blob holds everything, the name included; the column can't be null, so it's left empty
// TODO: Make `ciphers.name` nullable and store `None` here instead.
cipher.name = String::new();
cipher.notes = None;
cipher.fields = None;
cipher.password_history = None;
cipher.data = blob;
} else {
let Some(mut type_data) = type_data_opt else {
err!("Data missing")
};
let type_data = if let Some(mut data) = type_data_opt {
// Remove the 'Response' key from the base object.
if let Some(data_obj) = type_data.as_object_mut() {
if let Some(data_obj) = data.as_object_mut() {
data_obj.remove("response");
}
// Remove the 'Response' key from every Uri.
if type_data["uris"].is_array() {
type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone());
}
// `validate_content` made sure there is a name
cipher.name = data.name.unwrap_or_default();
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.data = type_data.to_string();
}
data
} else {
err!("Data missing")
};
cipher.key = data.key;
cipher.name = data.name;
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.data = type_data.to_string();
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
cipher.save(conn).await?;
@@ -763,7 +667,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type);
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
}
@@ -831,9 +735,6 @@ async fn put_cipher(
err!("Cipher is not write accessible")
}
// Against the cipher we hold rather than the organization the client claims, like upstream
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -1092,7 +993,9 @@ async fn post_cipher_share(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
put_cipher_share(cipher_id, data, headers, conn, nt).await
let data: ShareCipherData = data.into_inner();
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
}
#[put("/ciphers/<cipher_id>/share", data = "<data>")]
@@ -1105,21 +1008,6 @@ async fn put_cipher_share(
) -> JsonResult {
let data: ShareCipherData = data.into_inner();
// Upstream's `PutShare` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L899-L912>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L498-L519>
if data.cipher.organization_id.is_none() {
err!("Cipher OrganizationId is required.")
}
if data.collection_ids.is_empty() {
err!("You must select at least one collection.")
}
if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid))
{
err_code!("Cipher doesn't exist", Status::NotFound.code)
}
validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?;
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
}
@@ -1139,33 +1027,18 @@ async fn put_cipher_share_selected(
) -> EmptyResult {
let mut data: ShareSelectedCipherData = data.into_inner();
// Upstream's `PutShareMany` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L560-L604>
if data.ciphers.is_empty() {
err!("You must select at least one cipher.")
}
if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) {
err!("All Ciphers must have an Id and OrganizationId.")
}
if data.ciphers.iter().map(|c| &c.organization_id).collect::<HashSet<_>>().len() != 1 {
err!("All ciphers must be for the same organization.")
}
if data.collection_ids.is_empty() {
err!("You must select at least one collection.")
}
for cipher in &data.ciphers {
cipher.validate_content(cipher.is_blob())?;
}
for cipher in &data.ciphers {
validate_encrypted_for_user(cipher, &headers.user.uuid)?;
}
let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await;
for cipher_data in &data.ciphers {
let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else {
err!("Trying to share ciphers that you do not own.")
};
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
if cipher.id.is_none() {
err!("Request missing ids field")
}
}
while let Some(cipher) = data.ciphers.pop() {
@@ -1212,20 +1085,6 @@ async fn share_cipher_by_uuid(
err!("Organization mismatch. Please resync the client before updating the cipher")
}
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
// For the same reason, the other checks of saving
data.cipher.validate_content(data.cipher.is_blob())?;
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
let mut shared_to_collections = vec![];
if let Some(organization_id) = &data.cipher.organization_id {
@@ -1244,6 +1103,16 @@ async fn share_cipher_by_uuid(
}
}
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
+6 -43
View File
@@ -8,7 +8,7 @@ use crate::{
CONFIG,
api::admin::FAKE_ADMIN_UUID,
api::{
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
},
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
@@ -1831,7 +1831,7 @@ async fn post_org_import(
cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type);
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(
&mut cipher,
cipher_data,
@@ -2771,26 +2771,8 @@ 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 {
// 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!({
"object": "organizationPublicKey",
"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 {
async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
@@ -2803,30 +2785,11 @@ async fn get_organization_public_key(
})))
}
/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org,
/// so it is made once and its private half dropped.
async fn fake_sso_org_public_key() -> ApiResult<String> {
static PUBLIC_KEY: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?;
Some(data_encoding::BASE64.encode(&der))
});
let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else {
err!("Failed to generate the organization key")
};
Ok(public_key)
}
// 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: OrgMemberHeaders, conn: DbConn) -> JsonResult {
get_organization_public_key(org_id, headers, conn).await
}
// Will allow to reset 2FA too
+1 -1
View File
@@ -524,7 +524,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
Ok(())
}) {
Ok(_) => {
// Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create
// Also check if the size is at least 67 bytes, which seems to be the smallest png I could create
if body.len() >= 67 {
// Check if the icon type is allowed, else try an icon from the list.
icon_type = get_icon_type(&body);
+24 -2
View File
@@ -551,7 +551,18 @@ async fn authenticated_response(
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let mut result = json!({
"access_token": auth_tokens.access_token(),
@@ -697,7 +708,18 @@ async fn user_api_key_login(
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
// Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires.
+1 -3
View File
@@ -772,7 +772,7 @@ make_config! {
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
allowed_iframe_ancestors: String, true, def, String::new();
/// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
/// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
allowed_connect_src: String, true, def, String::new();
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
@@ -1435,8 +1435,6 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Key Management Team
"biometrics-sdk-ipc",
"windows-native-credential-sync",
"enable-account-encryption-v2-jit-password-registration",
"pm-27278-v2-password-registration",
// Mobile Team
"pm-34171-card-scanner",
// Platform Team
+9
View File
@@ -166,6 +166,15 @@ impl AuthRequest {
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting auth requests for user")
})
.await
}
/// Marks the request as used to log in. Returns false when it already was, so it only works once.
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
let now = Utc::now().naive_utc();
+61 -103
View File
@@ -19,7 +19,7 @@ use crate::{
},
},
error::MapResult,
util::{LowerCase, convert_json_key_lcase_first},
util::LowerCase,
};
use macros::UuidFromParam;
@@ -63,18 +63,6 @@ pub struct Cipher {
pub reprompt: Option<i32>,
}
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Vault/Entities/Cipher.cs#L36-L41>
pub fn is_data_blob_encrypted(data: &str) -> bool {
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
}
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
fn is_blob_value(data: &Value) -> bool {
data.get("format_version").is_some()
}
pub enum RepromptType {
None = 0,
Password = 1,
@@ -82,8 +70,7 @@ pub enum RepromptType {
/// Local methods
impl Cipher {
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
pub fn new(atype: i32) -> Self {
pub fn new(atype: i32, name: String) -> Self {
let now = Utc::now().naive_utc();
Self {
@@ -97,7 +84,7 @@ impl Cipher {
key: None,
atype,
name: String::new(),
name,
notes: None,
fields: None,
@@ -123,11 +110,6 @@ impl Cipher {
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
}
if let Err(e) = cipher.validate_content(cipher.is_blob()) {
validation_errors
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
}
// Validate the password history if it contains `null` values and if so, return a warning
if let Some(Value::Array(password_history)) = &cipher.password_history {
for pwh in password_history {
@@ -172,10 +154,6 @@ impl Cipher {
) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date};
// Parsed once, since `data` can be large and this runs for every cipher in a sync
let type_data = serde_json::from_str::<Value>(&self.data);
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
let mut attachments_json: Value = Value::Null;
if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
@@ -272,9 +250,63 @@ impl Cipher {
})
.unwrap_or_default();
// Like upstream, fields and password history stored as NULL are sent as null, not `[]`
let fields_json = self.fields.is_some().then_some(fields_json);
let password_history_json = self.password_history.is_some().then_some(password_history_json);
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data)
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
@@ -371,84 +403,10 @@ impl Cipher {
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
if is_blob_encrypted {
// Like upstream, sent as-is with the structured fields null
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
json_object[key] = self.legacy_type_data_json(type_data);
}
json_object[key] = type_data_json;
Ok(json_object)
}
/// The per-type data of a legacy cipher, with fixups for values known to break clients.
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
use crate::util::validate_and_format_date;
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data {
convert_json_key_lcase_first(data)
} else {
warn!("Error parsing data field for {}", self.uuid);
Value::Object(serde_json::Map::new())
};
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
type_data_json
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
let mut user_uuids = Vec::new();
match self.user_uuid {
+1 -3
View File
@@ -17,12 +17,11 @@ mod two_factor;
mod two_factor_duo_context;
mod two_factor_incomplete;
mod user;
mod user_signature_key_pair;
pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
@@ -41,4 +40,3 @@ pub use self::two_factor::{TwoFactor, TwoFactorType};
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, UserStampException};
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
+8 -54
View File
@@ -19,8 +19,8 @@ use crate::{
use macros::UuidFromParam;
use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
UserSignatureKeyPair,
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
TwoFactorIncomplete,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -72,11 +72,6 @@ pub struct User {
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>,
// The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2`
pub signed_public_key: Option<String>,
pub security_state: Option<String>,
pub security_version: Option<i32>,
}
#[derive(Identifiable, Queryable, Insertable)]
@@ -164,17 +159,9 @@ impl User {
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None,
signed_public_key: None,
security_state: None,
security_version: None,
}
}
pub fn is_v2(&self) -> bool {
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
}
pub fn check_valid_password(&self, password: &str) -> bool {
crypto::verify_password_hash(
password.as_bytes(),
@@ -279,37 +266,18 @@ impl User {
!CONFIG.mail_enabled() || self.verified_at.is_some()
}
async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option<UserSignatureKeyPair> {
if !self.is_v2() {
return None;
}
UserSignatureKeyPair::find_by_user(&self.uuid, conn).await
}
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
pub async fn account_keys_json(&self, conn: &DbConn) -> Value {
pub fn account_keys_json(&self) -> Value {
if self.private_key.is_some() {
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (
json!(self.signed_public_key),
key_pair.to_json(),
json!({
"securityState": self.security_state,
"securityVersion": self.security_version,
}),
),
None => (Value::Null, Value::Null, Value::Null),
};
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"signedPublicKey": null,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": security_state,
"signatureKeyPair": signature_key_pair,
"securityState": null,
"signatureKeyPair": null,
"object": "privateKeys"
})
} else {
@@ -317,20 +285,6 @@ impl User {
}
}
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)),
None => (Value::Null, Value::Null),
};
json!({
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"verifyingKey": verifying_key,
"object": "publicKeys"
})
}
pub async fn to_json(&self, conn: &DbConn) -> Value {
let mut orgs_json = Vec::new();
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
@@ -351,7 +305,7 @@ impl User {
UserStatus::Enabled
};
let account_keys = self.account_keys_json(conn).await;
let account_keys = self.account_keys_json();
json!({
"_status": status as i32,
@@ -424,10 +378,10 @@ impl User {
Cipher::delete_all_by_user(&self.uuid, conn).await?;
Favorite::delete_all_by_user(&self.uuid, conn).await?;
Folder::delete_all_by_user(&self.uuid, conn).await?;
AuthRequest::delete_all_by_user(&self.uuid, conn).await?;
Device::delete_all_by_user(&self.uuid, conn).await?;
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?;
Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| {
-132
View File
@@ -1,132 +0,0 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::EmptyResult,
db::{DbConn, schema::user_signature_key_pairs},
error::MapResult,
util::get_uuid,
};
use super::UserId;
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql#L1-L15>
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = user_signature_key_pairs)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
pub struct UserSignatureKeyPair {
pub uuid: UserSignatureKeyPairId,
pub user_uuid: UserId,
pub signature_algorithm: i32,
/// The signing (private) key, wrapped by the user key.
pub signing_key: String,
/// The COSE-encoded public verifying key.
pub verifying_key: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs#L6-L10>
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignatureAlgorithm {
Ed25519 = 0,
MlDsa44 = 1,
}
impl SignatureAlgorithm {
pub fn parse(algorithm: &str) -> Option<Self> {
match algorithm {
"ed25519" => Some(Self::Ed25519),
"mldsa44" => Some(Self::MlDsa44),
_ => None,
}
}
}
/// Local methods
impl UserSignatureKeyPair {
pub fn new(
user_uuid: UserId,
signature_algorithm: SignatureAlgorithm,
signing_key: String,
verifying_key: String,
) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid: UserSignatureKeyPairId(get_uuid()),
user_uuid,
signature_algorithm: signature_algorithm as i32,
signing_key,
verifying_key,
created_at: now,
updated_at: now,
}
}
pub fn to_json(&self) -> Value {
json!({
"wrappedSigningKey": self.signing_key,
"verifyingKey": self.verifying_key,
"object": "signatureKeyPair",
})
}
}
/// Database methods
impl UserSignatureKeyPair {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
postgresql, sqlite {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(user_signature_key_pairs::user_uuid)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
}
}
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
user_signature_key_pairs::table
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))
.first::<Self>(conn)
.ok()
})
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting user signature key pairs")
})
.await
}
}
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserSignatureKeyPairId(String);
-17
View File
@@ -219,21 +219,6 @@ table! {
avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>,
key_id -> Nullable<Text>,
signed_public_key -> Nullable<Text>,
security_state -> Nullable<Text>,
security_version -> Nullable<Integer>,
}
}
table! {
user_signature_key_pairs (uuid) {
uuid -> Text,
user_uuid -> Text,
signature_algorithm -> Integer,
signing_key -> Text,
verifying_key -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
}
}
@@ -399,7 +384,6 @@ joinable!(collections_groups -> groups (groups_uuid));
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));
allow_tables_to_appear_in_same_query!(
archives,
@@ -426,5 +410,4 @@ allow_tables_to_appear_in_same_query!(
collections_groups,
event,
auth_requests,
user_signature_key_pairs,
);
+2 -2
View File
@@ -4,10 +4,10 @@
*
* To rebuild or modify this file with the latest versions of the included
* software please visit:
* https://datatables.net/download/#bs5/dt-3.1.3
* https://datatables.net/download/#bs5/dt-3.1.4
*
* Included libraries:
* DataTables 3.1.3
* DataTables 3.1.4
*/
/*! DataTables Bootstrap 5 integration
+13 -7
View File
@@ -4,13 +4,13 @@
*
* To rebuild or modify this file with the latest versions of the included
* software please visit:
* https://datatables.net/download/#bs5/dt-3.1.3
* https://datatables.net/download/#bs5/dt-3.1.4
*
* Included libraries:
* DataTables 3.1.3
* DataTables 3.1.4
*/
/*! DataTables 3.1.3
/*! DataTables 3.1.4
* Copyright (c) SpryMedia Ltd - datatables.net/license
*/
@@ -453,6 +453,9 @@ function assignDeep(out, ...inputs) {
continue;
}
for (const [key, value] of Object.entries(input)) {
if (key === '__proto__') {
continue;
}
if (Array.isArray(value)) {
if (!Array.isArray(out[key])) {
out[key] = [];
@@ -489,6 +492,9 @@ function assignDeep(out, ...inputs) {
function assignDeepObjects(out, extender, breakRefs = false) {
let val;
for (let prop in extender) {
if (prop === '__proto__') {
continue;
}
if (Object.prototype.hasOwnProperty.call(extender, prop)) {
val = extender[prop];
if (plainObject(val)) {
@@ -2694,7 +2700,7 @@ class Dom {
*/
prepend(content) {
return this.each(el => {
if (content instanceof Dom) {
if (arrayLike(content) || dom(content)) {
// Reverse the array, so if there are multiple elements, they
// end up being added sequentially, just like jQuery
let itemsReversed = Array.from(content).reverse();
@@ -8714,7 +8720,7 @@ const ext = {
/**
* DataTables build type (expanded by the download builder)
*/
builder: 'bs5/dt-3.1.3',
builder: 'bs5/dt-3.1.4',
/**
* Buttons. For use with the Buttons extension for DataTables. This is
* defined here so other extensions can define buttons regardless of load
@@ -8860,7 +8866,7 @@ const ext = {
* Software version
* @type string
*/
version: '3.1.3'
version: '3.1.4'
};
//
// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts
@@ -9559,7 +9565,7 @@ register('destroy()', function (remove) {
callbackFire(settings, 'destroy', 'destroy', [settings], true);
// If not being removed from the document, make all columns visible
if (!remove) {
new Api(settings).columns().visible();
new Api(settings).columns().visible(true);
}
// Container width change listener
if (settings.resizeObserver) {
-6
View File
@@ -839,12 +839,6 @@ pub fn parse_experimental_client_feature_flags(
.collect()
}
/// Whether the admin enabled this supported client feature flag
pub fn is_client_feature_flag_enabled(flag: &str) -> bool {
parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly)
.contains_key(flag)
}
/// TODO: This is extracted from IpAddr::is_global, which is unstable:
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged