Compare commits

..
71 changed files with 2301 additions and 1240 deletions

No files matched your search

+4 -4
View File
@@ -78,7 +78,7 @@
# DB_CONNECTION_RETRIES=15
## Database timeout
## Timeout in seconds when acquiring database connection
## Timeout when acquiring database connection
# DATABASE_TIMEOUT=30
## Database idle timeout
@@ -173,7 +173,7 @@
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
## Number of days to retain events stored in the database.
## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled!
## If unset (the default), events are kept indefinitely and the scheduled job is disabled!
# EVENTS_DAYS_RETAIN=
##
## Cron schedule of the job that cleans old auth requests from the auth request.
@@ -233,7 +233,7 @@
# USER_SEND_LIMIT=
## Number of days to wait before auto-deleting a trashed item.
## If unset (the default) or 0, trashed items are not auto-deleted.
## If unset (the default), trashed items are not auto-deleted.
## This setting applies globally, so make sure to inform all users of any changes to this setting.
# TRASH_AUTO_DELETE_DAYS=
@@ -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 for URLs which can be loaded using script interfaces like the Forwarded email alias feature
## Allows other domains to 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@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
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@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
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@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
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: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
hooks:
- id: typos
always_run: true
+2 -3
View File
@@ -14,9 +14,8 @@ 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, ignore these specific items
"COSEKey",
"COSEAlgorithm",
# COSE Is short for CBOR Object Signing and Encryption
"(?i)cose",
# Ignore this specific string as it's valid
"Ensure they are valid OTPs",
# This word is misspelled upstream
Generated
+238 -247
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.54.1", features = [
tokio = { version = "1.53.2", features = [
"fs",
"io-util",
"net",
@@ -100,14 +100,14 @@ tokio = { version = "1.54.1", features = [
"signal",
"time",
] }
tokio-util = { version = "0.7.20", features = ["compat"] }
tokio-util = { version = "0.7.19", features = ["compat"] }
# A generic serialization/deserialization framework
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
serde_json = "1.0.151"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
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.46", features = ["ring", "std"], default-features = false }
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.28.0", features = ["v4"] }
uuid = { version = "1.27.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.3", features = ["async"] }
cached = { version = "4.0.1", 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.5", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true }
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 }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.13.0", optional = true, default-features = false, features = [
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"rt-tokio",
"sso",
] }
aws-credential-types = { version = "1.3.1", optional = true }
aws-smithy-runtime-api = { version = "1.19.1", optional = true }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.19.0", 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.10.0
# Cross Compile Docker Helper Scripts v1.9.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:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6"
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
# 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.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
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
########################## 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:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / /
ARG TARGETARCH
-1
View File
@@ -15,7 +15,6 @@ 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,12 +148,6 @@ 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.9"
syn = "3.0.6"
[lints]
workspace = true
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- 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;
@@ -0,0 +1,5 @@
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;
@@ -0,0 +1,14 @@
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
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -0,0 +1,5 @@
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;
@@ -0,0 +1,13 @@
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
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -0,0 +1,5 @@
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;
@@ -0,0 +1,13 @@
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
);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.99.1"
channel = "1.99.0"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
+2 -9
View File
@@ -201,15 +201,12 @@ fn post_admin_login(
// If the token is invalid, redirect to login page
if validate_token(&data.token) {
// If the token received is valid, generate JWT and save it as a cookie
let claims = match generate_admin_claims() {
Ok(claims) => claims,
Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))),
};
let claims = generate_admin_claims();
let jwt = encode_jwt(&claims);
let cookie = Cookie::build((COOKIE_NAME, jwt))
.path(admin_path())
.max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime())))
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
.same_site(SameSite::Strict)
.http_only(true)
.secure(secure.https);
@@ -782,8 +779,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
.into_keys()
.collect();
let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", ");
let diagnostics_json = json!({
"dns_resolved": dns_resolved,
"current_release": VERSION,
@@ -806,9 +801,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
"db_version": get_sql_server_version(&conn).await,
"admin_url": format!("{}/diagnostics", admin_url()),
"overrides": &CONFIG.get_overrides().join(", "),
"config_file_status": &CONFIG.get_config_file_status(),
"template_overrides": check_template_overrides().join(", "),
"invalid_env_vars": invalid_env_vars,
"invalid_feature_flags": invalid_feature_flags,
"host_arch": env::consts::ARCH,
"host_os": env::consts::OS,
+943 -170
View File
File diff suppressed because it is too large. Load diff
+273 -84
View File
@@ -23,7 +23,8 @@ 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, UserId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
is_data_blob_encrypted,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@@ -189,12 +190,22 @@ 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
"salt": headers.user.master_password_salt(),
"containedKeyId": headers.user.key_id,
})
} 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);
}
if let Some(v2_upgrade_token) = headers.user.v2_upgrade_token_json() {
user_decryption["v2UpgradeToken"] = v2_upgrade_token;
}
Ok(Json(json!({
"profile": user_json,
"folders": folders_json,
@@ -204,10 +215,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"userDecryption": user_decryption,
"object": "sync"
})))
}
@@ -269,7 +277,8 @@ pub struct CipherData {
key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent
pub encrypted_for: Option<UserId>,
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
@@ -284,7 +293,8 @@ pub struct CipherData {
Passport = 8
*/
pub r#type: i32,
pub name: String,
// Absent on a blob-encrypted cipher, whose name is sealed inside `data`
pub name: Option<String>,
pub notes: Option<String>,
fields: Option<Value>,
@@ -298,6 +308,9 @@ 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>,
@@ -319,6 +332,98 @@ 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)
}
/// Saving's last checks: a known type, and its data unless it's a blob.
pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult {
let type_data = match self.r#type {
1 => &self.login,
2 => &self.secure_note,
3 => &self.card,
4 => &self.identity,
5 => &self.ssh_key,
6 => &self.bank_account,
7 => &self.drivers_license,
8 => &self.passport,
_ => err!("Invalid type"),
};
if !is_blob && type_data.is_none() {
err!("Data missing")
}
Ok(())
}
/// 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 {
@@ -352,16 +457,14 @@ async fn post_ciphers_create(
) -> JsonResult {
let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
// 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, data.cipher.name.clone());
let mut cipher = Cipher::new(data.cipher.r#type);
cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?;
@@ -385,16 +488,7 @@ 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();
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);
}
validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?;
// 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`,
@@ -402,7 +496,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, data.name.clone());
let mut cipher = Cipher::new(data.r#type);
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?))
@@ -426,6 +520,44 @@ 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,
@@ -434,6 +566,40 @@ pub async fn update_cipher_from_data(
conn: &DbConn,
nt: &Notify<'_>,
ut: UpdateType,
) -> EmptyResult {
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut).await
}
/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply.
pub async fn rotate_cipher_data(
cipher: &mut Cipher,
data: CipherData,
headers: &Headers,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None).await
}
/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved
pub async fn validate_rotated_cipher(
cipher: &Cipher,
data: &CipherData,
headers: &Headers,
conn: &DbConn,
) -> EmptyResult {
validate_cipher_update(cipher, data, headers, conn, UpdateType::None).await
}
async fn save_cipher_data(
cipher: &mut Cipher,
data: CipherData,
headers: &Headers,
shared_to_collections: Option<Vec<CollectionId>>,
conn: &DbConn,
nt: &Notify<'_>,
ut: UpdateType,
) -> EmptyResult {
// Cleanup cipher data, like removing the 'Response' key.
// This key is somewhere generated during Javascript so no way for us this fix this.
@@ -449,34 +615,14 @@ pub async fn update_cipher_from_data(
json_data
}
enforce_personal_ownership_policy(Some(&data), headers, conn).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(_) => (),
}
}
let is_blob = data.is_blob();
data.validate_content(is_blob)?;
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
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();
@@ -507,12 +653,6 @@ 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 {
@@ -551,26 +691,36 @@ pub async fn update_cipher_from_data(
_ => err!("Invalid type"),
};
let type_data = if let Some(mut data) = type_data_opt {
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")
};
// Remove the 'Response' key from the base object.
if let Some(data_obj) = data.as_object_mut() {
if let Some(data_obj) = type_data.as_object_mut() {
data_obj.remove("response");
}
// Remove the 'Response' key from every Uri.
if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone());
if type_data["uris"].is_array() {
type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
}
data
} else {
err!("Data missing")
};
// `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();
}
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?;
@@ -667,7 +817,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, cipher_data.name.clone());
let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
}
@@ -735,6 +885,9 @@ 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?))
@@ -993,9 +1146,7 @@ async fn post_cipher_share(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let data: ShareCipherData = data.into_inner();
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
put_cipher_share(cipher_id, data, headers, conn, nt).await
}
#[put("/ciphers/<cipher_id>/share", data = "<data>")]
@@ -1008,6 +1159,21 @@ 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
}
@@ -1027,18 +1193,35 @@ 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 {
if cipher.id.is_none() {
err!("Request missing ids field")
}
let is_blob = cipher.is_blob();
cipher.validate_content(is_blob)?;
cipher.validate_type_data(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?;
}
while let Some(cipher) = data.ciphers.pop() {
@@ -1085,6 +1268,22 @@ 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
let is_blob = data.cipher.is_blob();
data.cipher.validate_content(is_blob)?;
data.cipher.validate_type_data(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 {
@@ -1103,16 +1302,6 @@ 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?))
+101 -173
View File
@@ -1,4 +1,4 @@
use chrono::Utc;
use chrono::{TimeDelta, Utc};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
@@ -15,8 +15,8 @@ use crate::{
db::{
DbConn, DbPool,
models::{
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
},
},
mail,
@@ -136,21 +136,19 @@ async fn post_emergency_access(
let data: EmergencyAccessUpdateData = data.into_inner();
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
err!("Invalid emergency access type.")
};
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
else {
err!("Emergency access not valid.")
};
emergency_access.atype = new_type as i32;
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
new_type as i32
} else {
err!("Invalid emergency access type.")
};
emergency_access.atype = new_type;
emergency_access.wait_time_days = data.wait_time_days;
if data.key_encrypted.is_some() {
emergency_access.key_encrypted = data.key_encrypted;
@@ -212,11 +210,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let email = data.email.to_lowercase();
let wait_time_days = data.wait_time_days;
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
new_type as i32
} else {
err!("Invalid emergency access type.")
};
@@ -262,8 +260,6 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
err!(format!("Grantee user already invited: {}", &grantee_user.email))
}
let new_type = new_type as i32;
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
let mut new_emergency_access =
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
new_emergency_access.save(&conn).await?;
@@ -296,7 +292,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon
};
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
err!("The grantee user is already accepted or confirmed for emergency access");
err!("The grantee user is already accepted or confirmed to the organization");
}
let Some(email) = emergency_access.email.clone() else {
@@ -457,24 +453,11 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
else {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email)
)
err!("Emergency access not valid.")
};
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email)
)
}
if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days)
)
err!("Emergency access not valid.")
}
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
@@ -507,17 +490,11 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
else {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email)
)
err!("Emergency access not valid.")
};
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email)
)
err!("Emergency access not valid.")
}
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
@@ -770,71 +747,52 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
return;
}
let Ok(conn) = pool.get().await else {
error!("Failed to get DB connection for emergency time-outs!");
return;
};
if let Ok(conn) = pool.get().await {
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
if emergency_access_list.is_empty() {
debug!("No emergency request timeout to approve");
return;
}
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
match emer.is_recovery_allowed(&now) {
Ok(true) => {} // Recovery is allowed
Ok(false) => continue,
Err(e) => {
error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
continue;
}
if emergency_access_list.is_empty() {
debug!("No emergency request timeout to approve");
}
// Get grantor user to send Accepted email
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
continue;
};
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
let recovery_allowed_at =
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap();
if recovery_allowed_at.le(&now) {
// Only update the access status
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
.await
.expect("Unable to update emergency access status");
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
error!("Missing grantee for emergency access '{}'", emer.uuid);
continue;
};
if CONFIG.mail_enabled() {
// get grantor user to send Accepted email
let grantor_user =
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
// Get grantee user to send Accepted email
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
continue;
};
// get grantee user to send Accepted email
let grantee_user =
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
.await
.expect("Grantee user not found");
// Only update the access status
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
continue;
}
mail::send_emergency_access_recovery_timed_out(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
)
.await
.expect("Error on sending email");
if CONFIG.mail_enabled() {
if let Err(e) = mail::send_emergency_access_recovery_timed_out(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
)
.await
{
error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid);
}
if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await
{
error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid);
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name)
.await
.expect("Error on sending email");
}
}
}
} else {
error!("Failed to get DB connection while searching emergency request timed out");
}
}
@@ -844,85 +802,55 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
return;
}
let Ok(conn) = pool.get().await else {
error!("Failed to get DB connection for emergency reminders!");
return;
};
if let Ok(conn) = pool.get().await {
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
if emergency_access_list.is_empty() {
debug!("No emergency request reminder notification to send");
return;
}
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
let Ok(deadline_at) = emer.deadline_at() else {
error!("Invalid deadline for emergency access '{}'", emer.uuid);
continue;
};
let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
continue;
};
// Only send reminders during the last 24 hours, else continue
if now < final_reminder_at || now >= deadline_at {
continue;
if emergency_access_list.is_empty() {
debug!("No emergency request reminder notification to send");
}
// Check if we did not sent a recovery mail in the past 24h already, else skip it.
// In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
// This is to prevent possible missed reminders, this might cause an extra reminder,
// but that is better then no reminder at all.
let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
} else {
now
};
let now = Utc::now().naive_utc();
for mut emer in emergency_access_list {
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
// Calculate the day before the recovery will become active
let final_recovery_reminder_at =
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap();
// Calculate if a day has passed since the previous notification, else no notification has been sent before
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
last_notification_at + TimeDelta::try_days(1).unwrap()
} else {
now
};
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
// Only update the last notification date
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
emer.update_last_notification_date_and_save(&now, &conn)
.await
.expect("Unable to update emergency access notification date");
// Only send a reminder if the `next_reminder_at` is less then or equal to `now`
// Else we can skip the current emergency access this run.
if next_reminder_at.gt(&now) {
continue;
}
// Get grantor user to send Reminder email
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
continue;
};
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
error!("Missing grantee for emergency access '{}'", emer.uuid);
continue;
};
// Get grantee user to send Reminder email
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
continue;
};
// Only update the last notification date
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await {
error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
continue;
}
if CONFIG.mail_enabled()
&& let Err(e) = mail::send_emergency_access_recovery_reminder(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
"1", // This notification is only triggered one day before the activation
)
.await
{
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
if CONFIG.mail_enabled() {
// get grantor user to send Accepted email
let grantor_user =
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
// get grantee user to send Accepted email
let grantee_user =
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
.await
.expect("Grantee user not found");
mail::send_emergency_access_recovery_reminder(
&grantor_user.email,
&grantee_user.name,
emer.get_type_as_str(),
"1", // This notification is only triggered one day before the activation
)
.await
.expect("Error on sending email");
}
}
}
} else {
error!("Failed to get DB connection while searching emergency notification reminder");
}
}
+5 -8
View File
@@ -347,17 +347,14 @@ async fn log_event_impl(
pub async fn event_cleanup_job(pool: DbPool) {
debug!("Start events cleanup job");
if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
debug!("events_days_retain is not configured or set to 0, skipping");
if CONFIG.events_days_retain().is_none() {
debug!("events_days_retain is not configured, abort");
return;
}
let Ok(conn) = pool.get().await else {
if let Ok(conn) = pool.get().await {
Event::clean_events(&conn).await.ok();
} else {
error!("Failed to get DB connection while trying to cleanup the events table");
return;
};
if let Err(e) = Event::clean_events(&conn).await {
error!("Failed to cleanup the events table: {e}");
}
}
+43 -6
View File
@@ -8,7 +8,7 @@ use crate::{
CONFIG,
api::admin::FAKE_ADMIN_UUID,
api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
ApiResult, 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, cipher_data.name.clone());
let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data(
&mut cipher,
cipher_data,
@@ -2771,8 +2771,26 @@ 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: OrgMemberHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid {
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 {
err!("Organization not found", "Organization id's do not match");
}
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
@@ -2785,11 +2803,30 @@ async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberH
})))
}
/// 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: OrgMemberHeaders, conn: DbConn) -> JsonResult {
get_organization_public_key(org_id, headers, conn).await
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
}
// Will allow to reset 2FA too
+2 -2
View File
@@ -294,7 +294,7 @@ pub async fn validate_email_code_str(
if !crypto::ct_eq(issued_token, token) {
email_data.add_attempt();
if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
if email_data.attempts >= CONFIG.email_attempts_limit() {
email_data.reset_token();
}
twofactor.data = email_data.to_json();
@@ -313,7 +313,7 @@ pub async fn validate_email_code_str(
twofactor.save(conn).await?;
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
let max_time = i64::from(CONFIG.email_expiration_time());
let max_time = CONFIG.email_expiration_time().cast_signed();
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
err!(
"Token has expired",
+4 -14
View File
@@ -196,7 +196,7 @@ pub async fn enforce_2fa_policy_for_org(
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
debug!("Sending notifications for incomplete 2FA logins");
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
return;
}
@@ -206,21 +206,11 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
};
let now = Utc::now().naive_utc();
let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
return;
};
let Some(time_before) = now.checked_sub_signed(time_limit) else {
error!("Failed to calculate the timestamp for sending incomplete 2fa attempts");
return;
};
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
let time_before = now - time_limit;
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
for login in incomplete_logins {
let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
error!("User '{}', not found!", login.user_uuid);
continue;
};
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found");
info!(
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
user.email, login.ip_address
+4 -5
View File
@@ -128,24 +128,23 @@ pub async fn validate_protected_action_otp(
.await
.map_res("Protected action token not found, try sending the code again or restart the process")?;
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
pa_data.add_attempt();
pa.data = pa_data.to_json();
// Fail after x attempts if the token has been used too many times.
// Don't delete it, as we use it to keep track of attempts.
if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
if pa_data.attempts >= CONFIG.email_attempts_limit() {
err!("Token has expired")
}
// Check if the token has expired (Using the email 2fa expiration time)
let max_time = i64::from(CONFIG.email_expiration_time());
let max_time = CONFIG.email_expiration_time().cast_signed();
if pa_data.time_since_sent().num_seconds() > max_time {
pa.delete(conn).await?;
err!("Token has expired")
}
if !crypto::ct_eq(&pa_data.token, otp) {
pa_data.add_attempt();
pa.data = pa_data.to_json();
pa.save(conn).await?;
err!("Token is invalid")
}
+4 -4
View File
@@ -62,7 +62,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
// Generate the cookie store
let cookie_store = Arc::new(Jar::default());
let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout()));
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
let pool_idle_timeout = Duration::from_secs(10);
// Reuse the client between requests
get_reqwest_client_builder(true)
@@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
None
}
async fn file_is_expired(path: &str, ttl: u32) -> Result<bool, Error> {
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
let meta = operator.stat(path).await?;
let modified =
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
let age = SystemTime::now().duration_since(modified.into())?;
Ok(ttl > 0 && u64::from(ttl) <= age.as_secs())
Ok(ttl > 0 && ttl <= age.as_secs())
}
async fn icon_is_negcached(path: &str) -> bool {
@@ -524,7 +524,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
Ok(())
}) {
Ok(_) => {
// Also check if the size is at least 67 bytes, which seems to be the smallest png I could create
// Also check if the size is atleast 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);
+6 -28
View File
@@ -447,9 +447,9 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
if user.last_verifying_at.is_none()
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
> i64::from(CONFIG.signups_verify_resend_time())
> CONFIG.signups_verify_resend_time().cast_signed()
{
let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
if resend_limit == 0 || user.login_verify_count < resend_limit {
// We want to send another email verification if we require signups to verify
// their email address, and we haven't sent them a reminder in a while...
@@ -545,24 +545,13 @@ async fn authenticated_response(
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
"Salt": user.master_password_salt()
})
} else {
Value::Null
};
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 account_keys = user.account_keys_json(conn).await;
let mut result = json!({
"access_token": auth_tokens.access_token(),
@@ -702,24 +691,13 @@ async fn user_api_key_login(
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
"Salt": user.master_password_salt()
})
} else {
Value::Null
};
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 account_keys = user.account_keys_json(conn).await;
// Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires.
+1 -1
View File
@@ -23,7 +23,7 @@ pub use crate::api::{
icons::routes as icons_routes,
identity::routes as identity_routes,
notifications::routes as notifications_routes,
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
push::{
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
unregister_push_device,
+26 -6
View File
@@ -411,23 +411,35 @@ impl WebSocketUsers {
}
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
self.send_logout_with_reason(user, acting_device, None, conn).await;
}
/// A logout that clients may handle differently depending on `reason`
pub async fn send_logout_with_reason(
&self,
user: &User,
acting_device: Option<&Device>,
reason: Option<LogOutReason>,
conn: &DbConn,
) {
// Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED {
return;
}
let acting_device_id = acting_device.map(|d| d.uuid.clone());
let data = create_update(
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
UpdateType::LogOut,
acting_device_id,
);
let mut payload =
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))];
if let Some(reason) = reason {
payload.push(("Reason".into(), (reason as i32).into()));
}
let data = create_update(payload, UpdateType::LogOut, acting_device_id);
if CONFIG.enable_websocket() {
self.send_update(&user.uuid, &data).await;
}
if CONFIG.push_enabled() {
push_logout(user, acting_device, conn).await;
push_logout(user, acting_device, reason, conn).await;
}
}
@@ -695,6 +707,14 @@ fn create_ping() -> Vec<u8> {
serialize(&Value::Array(vec![6.into()]))
}
/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Enums/PushNotificationLogOutReason.cs#L3-L7>
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum LogOutReason {
KeyRotation = 1,
}
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum UpdateType {
+4 -3
View File
@@ -12,7 +12,7 @@ use tokio::sync::RwLock;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, UpdateType},
api::{ApiResult, EmptyResult, LogOutReason, UpdateType},
db::{
DbConn,
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
@@ -188,7 +188,7 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
}
}
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>, conn: &DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid,
@@ -198,7 +198,8 @@ pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbC
"type": UpdateType::LogOut as i32,
"payload": {
"userId": user.uuid,
"date": format_date(&user.updated_at)
"date": format_date(&user.updated_at),
"reason": reason.map(|r| r as i32),
},
"clientType": null,
"installationId": null
+30 -67
View File
@@ -34,7 +34,7 @@ use crate::{
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId, UserStampException,
SendFileId, SendId, User, UserId,
},
},
error::Error,
@@ -314,17 +314,6 @@ impl LoginJwtClaims {
}
}
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
};
let Some(exp) = now.checked_add_signed(hours_delta) else {
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
};
Ok(exp)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InviteJwtClaims {
// Not before
@@ -348,19 +337,19 @@ pub fn generate_invite_claims(
org_id: OrganizationId,
member_id: MembershipId,
invited_by_email: Option<String>,
) -> Result<InviteJwtClaims, Error> {
) -> InviteJwtClaims {
let time_now = Utc::now();
let exp = calculate_invite_expire_hours(&time_now)?;
Ok(InviteJwtClaims {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
InviteJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_INVITE_ISSUER.to_string(),
sub: user_id,
email,
org_id,
member_id,
invited_by_email,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -386,19 +375,19 @@ pub fn generate_emergency_access_invite_claims(
emer_id: EmergencyAccessId,
grantor_name: String,
grantor_email: String,
) -> Result<EmergencyAccessInviteJwtClaims, Error> {
) -> EmergencyAccessInviteJwtClaims {
let time_now = Utc::now();
let exp = calculate_invite_expire_hours(&time_now)?;
Ok(EmergencyAccessInviteJwtClaims {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
EmergencyAccessInviteJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
sub: user_id,
email,
emer_id,
grantor_name,
grantor_email,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -532,43 +521,36 @@ impl BasicJwtClaims {
}
}
pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
let time_now = Utc::now();
let exp = calculate_invite_expire_hours(&time_now)?;
Ok(BasicJwtClaims {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_DELETE_ISSUER.to_string(),
sub: uuid,
})
}
}
pub fn generate_verify_email_claims(user_id: &UserId) -> Result<BasicJwtClaims, Error> {
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
let time_now = Utc::now();
let exp = calculate_invite_expire_hours(&time_now)?;
Ok(BasicJwtClaims {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
sub: user_id.to_string(),
})
}
}
pub fn generate_admin_claims() -> Result<BasicJwtClaims, Error> {
pub fn generate_admin_claims() -> BasicJwtClaims {
let time_now = Utc::now();
let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else {
err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
};
let Some(exp) = time_now.checked_add_signed(minutes_delta) else {
err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
};
Ok(BasicJwtClaims {
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: exp.timestamp(),
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
iss: JWT_ADMIN_ISSUER.to_string(),
sub: "admin_panel".to_owned(),
})
}
}
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
@@ -703,31 +685,12 @@ impl<'r> FromRequest<'r> for Headers {
};
if user.security_stamp != claims.sstamp {
if let Some(stamp_exception) =
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
{
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
err_handler!("Error getting current route for stamp exception")
};
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
err_handler!("Error getting current route for stamp exception")
};
// Check if the stamp exception has expired first.
// Then, check if the current route matches any of the allowed routes.
// After that check the stamp in exception matches the one in the claims.
if Utc::now().timestamp() > stamp_exception.expire {
// If the stamp exception has been expired remove it from the database.
// This prevents checking this stamp exception for new requests.
let mut user = user;
user.reset_stamp_exception();
if let Err(e) = user.save(&conn).await {
error!("Error updating user: {e:#?}");
}
err_handler!("Stamp exception is expired")
} else if !stamp_exception.routes.contains(&current_route.to_owned()) {
err_handler!("Invalid security stamp: Current route and exception route do not match")
} else if stamp_exception.security_stamp != claims.sstamp {
err_handler!("Invalid security stamp for matched stamp exception")
}
} else {
// Expired exceptions are only dropped on reset; writing them back here could undo a newer reset
if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) {
err_handler!("Invalid security stamp")
}
}
+41 -110
View File
@@ -111,7 +111,6 @@ macro_rules! make_config {
_usr: ConfigBuilder,
_overrides: Vec<&'static str>,
_config_file_status: ConfigFileStatus,
}
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
@@ -260,18 +259,11 @@ macro_rules! make_config {
builder
}
async fn from_file() -> Result<Option<Self>, Error> {
async fn from_file() -> Result<Self, Error> {
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
let config_bytes = match operator.read(&CONFIG_FILENAME).await {
Ok(bytes) => bytes,
// No config file is fine, should not return any error
Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None),
// Any other error might be an issue and should be passed back to the caller
Err(e) => return Err(e.into()),
};
let builder = serde_json::from_slice(&config_bytes.to_vec())?;
let config_bytes = operator.read(&CONFIG_FILENAME).await?;
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
Ok(Some(builder))
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
}
fn clear_non_editable(&mut self) {
@@ -448,8 +440,6 @@ macro_rules! make_config {
"domain_origin",
"domain_path",
"domain",
"duo_ikey",
"duo_host",
"helo_name",
"org_creation_users",
"signups_domains_whitelist",
@@ -461,8 +451,6 @@ macro_rules! make_config {
"sso_authority",
"sso_callback_path",
"sso_client_id",
"yubico_client_id",
"yubico_server",
];
let cfg = {
@@ -495,10 +483,6 @@ macro_rules! make_config {
};
overrides
}
pub fn get_config_file_status(&self) -> ConfigFileStatus {
self.inner.read().unwrap()._config_file_status
}
}
};
}
@@ -555,7 +539,7 @@ make_config! {
jobs {
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
/// Set to 0 to globally disable scheduled jobs.
job_poll_interval_ms: u32, false, def, 30_000;
job_poll_interval_ms: u64, false, def, 30_000;
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
/// Defaults to hourly. Set blank to disable this job.
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
@@ -611,19 +595,19 @@ make_config! {
/// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org.
org_attachment_limit: i64, true, option;
/// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends.
user_send_limit: i64, true, option;
user_send_limit: i64, true, option;
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
/// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make
/// sure to inform all users of any changes to this setting.
trash_auto_delete_days: u16, true, option;
trash_auto_delete_days: i64, true, option;
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
/// where the correct master password was provided but the required 2FA step was not completed,
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
/// This setting applies globally to all users.
incomplete_2fa_time_limit: u16, true, def, 3;
incomplete_2fa_time_limit: i64, true, def, 3;
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
@@ -636,9 +620,9 @@ make_config! {
/// this will prevent logins from succeeding until the address has been verified
signups_verify: bool, true, def, false;
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
signups_verify_resend_time: u32, true, def, 3_600;
signups_verify_resend_time: u64, true, def, 3_600;
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
signups_verify_resend_limit: u16, true, def, 6;
signups_verify_resend_limit: u32, true, def, 6;
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
signups_domains_whitelist: String, true, def, String::new();
/// Enable event logging |> Enables event logging for organizations.
@@ -650,7 +634,7 @@ make_config! {
invitations_allowed: bool, true, def, true;
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
/// email verification token and deletion request token will expire (must be at least 1)
invitation_expiration_hours: u16, false, def, 120;
invitation_expiration_hours: u32, false, def, 120;
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
emergency_access_allowed: bool, true, def, true;
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
@@ -671,8 +655,8 @@ make_config! {
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
/// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely.
events_days_retain: u16, false, option;
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
events_days_retain: i64, false, option;
},
client {
@@ -710,13 +694,13 @@ make_config! {
/// Temporary redirects are useful while testing different icon services, but once a service
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
/// are currently better supported by the Bitwarden clients.
icon_redirect_code: u16, true, def, 302;
icon_redirect_code: u32, true, def, 302;
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
icon_cache_ttl: u32, true, def, 2_592_000;
icon_cache_ttl: u64, true, def, 2_592_000;
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
icon_cache_negttl: u32, true, def, 259_200;
icon_cache_negttl: u64, true, def, 259_200;
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
icon_download_timeout: u16, true, def, 10;
icon_download_timeout: u64, true, def, 10;
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
icon_blacklist_regex: String, false, option;
@@ -767,11 +751,11 @@ make_config! {
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
db_connection_retries: u32, false, def, 15;
/// Timeout in seconds when acquiring database connection
database_timeout: u32, false, def, 30;
/// Timeout when acquiring database connection
database_timeout: u64, false, def, 30;
/// Timeout in seconds before idle connections to the database are closed
database_idle_timeout: u32, false, def, 600;
database_idle_timeout: u64, false, def, 600;
/// Database connection max pool size
database_max_conns: u32, false, def, 10;
@@ -788,26 +772,26 @@ 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 for URLs which can be loaded using script interfaces like the Forwarded email alias feature
/// 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: 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
login_ratelimit_seconds: u16, false, def, 60;
login_ratelimit_seconds: u64, false, def, 60;
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
login_ratelimit_max_burst: u16, false, def, 10;
login_ratelimit_max_burst: u32, false, def, 10;
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
unauthenticated_ratelimit_seconds: u16, false, def, 60;
unauthenticated_ratelimit_seconds: u64, false, def, 60;
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
unauthenticated_ratelimit_max_burst: u16, false, def, 50;
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
admin_ratelimit_seconds: u16, false, def, 300;
admin_ratelimit_seconds: u64, false, def, 300;
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
admin_ratelimit_max_burst: u16, false, def, 3;
admin_ratelimit_max_burst: u32, false, def, 3;
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
admin_session_lifetime: u32, true, def, 20;
admin_session_lifetime: i64, true, def, 20;
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false;
@@ -860,8 +844,8 @@ make_config! {
sso_master_password_policy: String, true, option;
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
sso_auth_only_not_session: bool, true, def, false;
/// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
sso_client_cache_expiration: u32, true, def, 0;
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
sso_client_cache_expiration: u64, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false;
},
@@ -919,11 +903,11 @@ make_config! {
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
smtp_auth_mechanism: String, true, option;
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
smtp_timeout: u16, true, def, 15;
smtp_timeout: u64, true, def, 15;
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
helo_name: String, true, option;
/// Embed images as email attachments.
smtp_embed_images: bool, true, def, true;
smtp_embed_images: bool, true, def, true;
/// _smtp_img_src
_smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
/// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
@@ -941,9 +925,9 @@ make_config! {
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
email_token_size: u8, true, def, 6;
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
email_expiration_time: u32, true, def, 600;
email_expiration_time: u64, true, def, 600;
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
email_attempts_limit: u8, true, def, 3;
email_attempts_limit: u64, true, def, 3;
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
email_2fa_enforce_on_verified_invite: bool, true, def, false;
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
@@ -999,15 +983,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
}
if cfg.database_min_conns > cfg.database_max_conns {
err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`.");
}
if cfg.database_timeout < 1 {
err!("`DATABASE_TIMEOUT` must be at least 1 second");
}
if cfg.database_idle_timeout < 1 {
err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second");
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
}
if let Some(log_file) = &cfg.log_file
@@ -1298,30 +1274,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
}
if cfg.login_ratelimit_seconds < 1 {
err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.login_ratelimit_max_burst < 1 {
err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1");
}
if cfg.admin_ratelimit_seconds < 1 {
err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.admin_ratelimit_max_burst < 1 {
err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1");
}
if cfg.unauthenticated_ratelimit_seconds < 1 {
err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.unauthenticated_ratelimit_max_burst < 1 {
err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1");
}
if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => {
@@ -1483,6 +1435,11 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Key Management Team
"biometrics-sdk-ipc",
"windows-native-credential-sync",
"pm-30144-sdk-key-rotation",
"pm-31050-no-logout-key-upgrade-rotation",
"enable-account-encryption-v2-jit-password-registration",
"pm-27278-v2-password-registration",
"force-upgrade-v2-encryption",
// Mobile Team
"pm-34171-card-scanner",
// Platform Team
@@ -1491,34 +1448,11 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"pm-32009-new-item-types",
];
#[derive(Clone, Copy, Serialize)]
pub enum ConfigFileStatus {
NotFound,
Loaded,
Error,
}
impl Config {
pub async fn load() -> Result<Self, Error> {
// Loading from env and file
let env = ConfigBuilder::from_env();
let (usr, config_file_status) = match ConfigBuilder::from_file().await {
Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded),
Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound),
Err(e) => {
println!(
"[ERROR] Failed to load configuration from `{}`\n\
#####################################################################################\n\
# The file seems to exist but might contain invalid JSON or invalid values. #\n\
# Check the syntax and make sure every value is valid and within its allowed range. #\n\
# This file is not used until those errors are resolved. #\n\
#####################################################################################\n\
{e:?}\n",
*CONFIG_FILE
);
(ConfigBuilder::default(), ConfigFileStatus::Error)
}
};
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
// Create merged config, config file overwrites env
let mut overrides = Vec::new();
@@ -1538,7 +1472,6 @@ impl Config {
_env: env,
_usr: usr,
_overrides: overrides,
_config_file_status: config_file_status,
}),
})
}
@@ -1572,7 +1505,6 @@ impl Config {
writer.config = config;
writer._usr = builder;
writer._overrides = overrides;
writer._config_file_status = ConfigFileStatus::Loaded;
}
//Save to file
@@ -1664,7 +1596,6 @@ impl Config {
writer.config = config;
writer._usr = usr;
writer._overrides = Vec::new();
writer._config_file_status = ConfigFileStatus::NotFound;
}
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
+3 -3
View File
@@ -212,8 +212,8 @@ impl DbPool {
let pool = Pool::builder()
.max_size(max_conns)
.min_idle(Some(CONFIG.database_min_conns()))
.idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
.connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout())))
.connection_timeout(Duration::from_secs(CONFIG.database_timeout()))
.connection_customizer(Box::new(DbConnOptions {
init_stmts: conn_type.get_init_stmts(),
}))
@@ -233,7 +233,7 @@ impl DbPool {
// Get a connection from the pool
pub async fn get(&self) -> Result<DbConn, Error> {
let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
let duration = Duration::from_secs(CONFIG.database_timeout());
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
Ok(p) => p.expect("Semaphore should be open"),
Err(_) => {
+1 -10
View File
@@ -166,15 +166,6 @@ 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();
@@ -197,7 +188,7 @@ impl AuthRequest {
pub async fn purge_expired_auth_requests(conn: &DbConn) {
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15);
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
for auth_request in Self::find_created_before(&expiry_time, conn).await {
auth_request.delete(conn).await.ok();
}
+109 -77
View File
@@ -19,7 +19,7 @@ use crate::{
},
},
error::MapResult,
util::LowerCase,
util::{LowerCase, convert_json_key_lcase_first},
};
use macros::UuidFromParam;
@@ -63,6 +63,18 @@ 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,
@@ -70,7 +82,8 @@ pub enum RepromptType {
/// Local methods
impl Cipher {
pub fn new(atype: i32, name: String) -> Self {
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
pub fn new(atype: i32) -> Self {
let now = Utc::now().naive_utc();
Self {
@@ -84,7 +97,7 @@ impl Cipher {
key: None,
atype,
name,
name: String::new(),
notes: None,
fields: None,
@@ -110,6 +123,11 @@ 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 {
@@ -154,6 +172,10 @@ 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)
@@ -250,63 +272,9 @@ impl Cipher {
})
.unwrap_or_default();
// 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;
}
// 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);
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) {
@@ -403,10 +371,84 @@ impl Cipher {
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
json_object[key] = type_data_json;
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);
}
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 {
@@ -494,22 +536,12 @@ impl Cipher {
/// Purge all ciphers that are old enough to be auto-deleted.
pub async fn purge_trash(conn: &DbConn) {
let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
debug!("trash_auto_delete_days is not configured or set to 0, skipping");
return;
};
let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
error!("Unable to calculate auto delete days!");
return;
};
let now = Utc::now().naive_utc();
let Some(dt) = now.checked_sub_signed(days) else {
error!("Unable to calculate trash purge date!");
return;
};
for cipher in Self::find_deleted_before(&dt, conn).await {
cipher.delete(conn).await.ok();
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() {
let now = Utc::now().naive_utc();
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap();
for cipher in Self::find_deleted_before(&dt, conn).await {
cipher.delete(conn).await.ok();
}
}
}
+17 -50
View File
@@ -1,4 +1,4 @@
use chrono::{NaiveDateTime, TimeDelta, Utc};
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use serde_json::Value;
@@ -112,26 +112,8 @@ impl EmergencyAccess {
"object": "emergencyAccessGranteeDetails",
}))
}
pub fn deadline_at(&self) -> Result<NaiveDateTime, crate::Error> {
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?;
TimeDelta::try_days(i64::from(self.wait_time_days))
.and_then(|w| initiated_at.checked_add_signed(w))
.map_res("Emergency access deadline is invalid")
}
pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result<bool, crate::Error> {
Ok(self.deadline_at()?.le(now))
}
}
pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive<i32> = 1..=90;
#[derive(Copy, Clone)]
pub enum EmergencyAccessType {
View = 0,
@@ -187,42 +169,27 @@ impl EmergencyAccess {
pub async fn update_access_status_and_save(
&mut self,
from_status: i32,
to_status: i32,
status: i32,
date: &NaiveDateTime,
conn: &DbConn,
) -> EmptyResult {
let uuid = self.uuid.clone();
let updated_at = *date;
let res = conn
.run(move |conn| {
crate::util::retry(
|| {
diesel::update(
emergency_access::table
.filter(emergency_access::uuid.eq(&uuid))
.filter(emergency_access::status.eq(from_status)),
)
.set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at)))
.execute(conn)
},
10,
)
})
.await;
match res {
Ok(1) => {} // One record updated, which is valid
Ok(_) => err!("Emergency access status was changed already"),
Err(e) => err!("Error updating emergency access status", format!("{e}")),
}
self.status = to_status;
self.updated_at = updated_at;
// Update the grantee so that it will refresh it's status.
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
Ok(())
self.status = status;
date.clone_into(&mut self.updated_at);
conn.run(move |conn| {
crate::util::retry(
|| {
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
.execute(conn)
},
10,
)
.map_res("Error updating emergency access status")
})
.await
}
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
+11 -17
View File
@@ -347,23 +347,17 @@ impl Event {
}
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else {
return Ok(());
};
let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else {
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
};
let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else {
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
};
conn.run(move |conn| {
diesel::delete(event::table.filter(event::event_date.lt(dt)))
.execute(conn)
.map_res("Error cleaning old events")
})
.await
if let Some(days_to_retain) = CONFIG.events_days_retain() {
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
conn.run(move |conn| {
diesel::delete(event::table.filter(event::event_date.lt(dt)))
.execute(conn)
.map_res("Error cleaning old events")
})
.await
} else {
Ok(())
}
}
}
+5 -5
View File
@@ -17,16 +17,15 @@ 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};
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{
EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType,
};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
pub use self::event::{Event, EventType};
pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId};
@@ -41,4 +40,5 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
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::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType};
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
+3
View File
@@ -54,6 +54,8 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
pub v2_upgrade_token: Option<String>,
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -271,6 +273,7 @@ impl Membership {
atype: MembershipType::User as i32,
reset_password_key: None,
external_id: None,
v2_upgrade_token: None,
}
}
+2 -2
View File
@@ -37,7 +37,7 @@ impl TwoFactorIncomplete {
ip: &ClientIp,
conn: &DbConn,
) -> EmptyResult {
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
return Ok(());
}
@@ -66,7 +66,7 @@ impl TwoFactorIncomplete {
}
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
return Ok(());
}
+122 -16
View File
@@ -6,6 +6,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::EmptyResult,
auth::DEFAULT_ACCESS_VALIDITY,
crypto,
db::{
DbConn,
@@ -19,8 +20,8 @@ use crate::{
use macros::UuidFromParam;
use super::{
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
TwoFactorIncomplete,
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
UserSignatureKeyPair,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -72,6 +73,13 @@ 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>,
/// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us.
pub v2_upgrade_token: Option<String>,
}
#[derive(Identifiable, Queryable, Insertable)]
@@ -100,13 +108,28 @@ enum UserStatus {
_Disabled = 2,
}
/// A previous security stamp that is still accepted, until `expire`.
#[derive(Serialize, Deserialize)]
pub struct UserStampException {
pub routes: Vec<String>,
/// The routes the stamp is still accepted on, or `None` for any route.
pub routes: Option<Vec<String>>,
pub security_stamp: String,
pub expire: i64,
}
impl UserStampException {
pub fn is_expired(&self) -> bool {
Utc::now().timestamp() > self.expire
}
/// Whether this exception lets a token carrying `security_stamp` through on `route`.
pub fn allows(&self, security_stamp: &str, route: &str) -> bool {
!self.is_expired()
&& self.security_stamp == security_stamp
&& self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route))
}
}
/// Local methods
impl User {
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
@@ -159,9 +182,18 @@ 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,
v2_upgrade_token: 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(),
@@ -171,6 +203,7 @@ impl User {
)
}
/// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email
pub fn master_password_salt(&self) -> String {
self.email.trim().to_lowercase()
}
@@ -225,10 +258,33 @@ impl User {
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
self.security_stamp = get_uuid();
// Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace
let mut exceptions = self.stamp_exceptions();
exceptions.retain(|e| e.routes.is_some() && !e.is_expired());
self.set_stamp_exceptions(&exceptions);
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
Ok(())
}
/// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh.
pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult {
let previous_stamp = self.security_stamp.clone();
let mut grace: Vec<UserStampException> =
self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect();
self.reset_security_stamp(conn).await?;
grace.push(UserStampException {
routes: None,
security_stamp: previous_stamp,
expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(),
});
let mut exceptions = self.stamp_exceptions();
exceptions.extend(grace);
self.set_stamp_exceptions(&exceptions);
Ok(())
}
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
///
/// # Arguments
@@ -237,17 +293,30 @@ impl User {
/// After these 2 minutes this stamp will expire.
///
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
let stamp_exception = UserStampException {
routes: route_exception,
self.set_stamp_exceptions(&[UserStampException {
routes: Some(route_exception),
security_stamp: self.security_stamp.clone(),
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
};
self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default());
}]);
}
/// Resets the stamp_exception to prevent re-use of the previous security-stamp
pub fn reset_stamp_exception(&mut self) {
self.stamp_exception = None;
/// The previous security stamps that are still accepted, expired ones included.
pub fn stamp_exceptions(&self) -> Vec<UserStampException> {
let Some(stored) = self.stamp_exception.as_deref() else {
return Vec::new();
};
// Before key rotations kept sessions alive, only a single route exception was stored.
serde_json::from_str::<Vec<UserStampException>>(stored)
.or_else(|_| serde_json::from_str::<UserStampException>(stored).map(|e| vec![e]))
.unwrap_or_default()
}
fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) {
self.stamp_exception = if exceptions.is_empty() {
None
} else {
Some(serde_json::to_string(&exceptions).unwrap_or_default())
};
}
pub fn display_name(&self) -> &str {
@@ -266,18 +335,37 @@ 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 fn account_keys_json(&self) -> Value {
pub async fn account_keys_json(&self, conn: &DbConn) -> 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": null,
"signedPublicKey": signed_public_key,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": null,
"signatureKeyPair": null,
"securityState": security_state,
"signatureKeyPair": signature_key_pair,
"object": "privateKeys"
})
} else {
@@ -285,6 +373,24 @@ 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 fn v2_upgrade_token_json(&self) -> Option<Value> {
self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok())
}
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 {
@@ -305,7 +411,7 @@ impl User {
UserStatus::Enabled
};
let account_keys = self.account_keys_json();
let account_keys = self.account_keys_json(conn).await;
json!({
"_status": status as i32,
@@ -378,10 +484,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
@@ -0,0 +1,132 @@
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);
+19
View File
@@ -219,6 +219,22 @@ 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>,
v2_upgrade_token -> Nullable<Text>,
}
}
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,
}
}
@@ -244,6 +260,7 @@ table! {
atype -> Integer,
reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>,
v2_upgrade_token -> Nullable<Text>,
}
}
@@ -384,6 +401,7 @@ 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,
@@ -410,4 +428,5 @@ allow_tables_to_appear_in_same_query!(
collections_groups,
event,
auth_requests,
user_signature_key_pairs,
);
+6 -6
View File
@@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
.port(CONFIG.smtp_port())
.timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout()))));
.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout())));
// Determine security
let smtp_client = if CONFIG.smtp_security() == *"off" {
@@ -169,7 +169,7 @@ pub async fn send_password_hint(address: &str, hint: Option<String>) -> EmptyRes
}
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_delete_claims(user_id.to_string())?;
let claims = generate_delete_claims(user_id.to_string());
let delete_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text(
@@ -187,7 +187,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult
}
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_verify_email_claims(user_id)?;
let claims = generate_verify_email_claims(user_id);
let verify_email_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text(
@@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
}
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_verify_email_claims(user_id)?;
let claims = generate_verify_email_claims(user_id);
let verify_email_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text(
@@ -292,7 +292,7 @@ pub async fn send_invite(
org_id.clone(),
member_id.clone(),
invited_by_email,
)?;
);
let invite_token = encode_jwt(&claims);
let mut query = url::Url::parse("https://query.builder").unwrap();
{
@@ -351,7 +351,7 @@ pub async fn send_emergency_access_invite(
emer_id.clone(),
String::from(grantor_name),
String::from(grantor_email),
)?;
);
// Build the query here to ensure proper escaping
let mut query = url::Url::parse("https://query.builder").unwrap();
+2 -4
View File
@@ -729,7 +729,7 @@ fn schedule_jobs(pool: db::DbPool) {
// Cleanup the event table of records x days old.
if CONFIG.org_events_enabled()
&& !CONFIG.event_cleanup_schedule().is_empty()
&& CONFIG.events_days_retain().is_some_and(|d| d != 0)
&& CONFIG.events_days_retain().is_some()
{
sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || {
runtime.spawn(api::event_cleanup_job(pool.clone()));
@@ -754,9 +754,7 @@ fn schedule_jobs(pool: db::DbPool) {
// tick, the one that was added earlier will run first.
loop {
sched.tick();
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from(
CONFIG.job_poll_interval_ms(),
))));
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms())));
}
})
.expect("Error spawning job scheduler thread");
+6 -6
View File
@@ -7,20 +7,20 @@ use crate::{CONFIG, Error};
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds()));
let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst");
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
});
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds()));
let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst");
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
});
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds()));
let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst()))
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst())
.expect("Non-zero unauthenticated ratelimit burst");
RateLimiter::keyed(
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
+1 -1
View File
@@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_own
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
moka::sync::Cache::builder()
.max_capacity(1)
.time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration())))
.time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration()))
.build()
});
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
+2 -9
View File
@@ -93,9 +93,7 @@ async function generateSupportString(event, dj) {
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
supportString += `* Database type: ${dj.db_type}\n`;
supportString += `* Database version: ${dj.db_version}\n`;
supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`;
supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n";
supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`;
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`;
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
if (dj.ip_header_exists) {
@@ -118,7 +116,7 @@ async function generateSupportString(event, dj) {
}
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
if (dj.invalid_feature_flags != "") {
supportString += "* Invalid feature flags: true :x:\n";
supportString += "* Invalid feature flags: true\n";
}
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
@@ -134,11 +132,6 @@ async function generateSupportString(event, dj) {
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
supportString += "<details><summary>Show Config & Details</summary>\n";
// Show invalid environment variables if they exists
if (dj.invalid_env_vars != "") {
supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`;
}
// Add overrides if they exists
if (dj.overrides != "") {
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
+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.4
* https://datatables.net/download/#bs5/dt-3.1.3
*
* Included libraries:
* DataTables 3.1.4
* DataTables 3.1.3
*/
/*! DataTables Bootstrap 5 integration
+7 -13
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.4
* https://datatables.net/download/#bs5/dt-3.1.3
*
* Included libraries:
* DataTables 3.1.4
* DataTables 3.1.3
*/
/*! DataTables 3.1.4
/*! DataTables 3.1.3
* Copyright (c) SpryMedia Ltd - datatables.net/license
*/
@@ -453,9 +453,6 @@ 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] = [];
@@ -492,9 +489,6 @@ 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)) {
@@ -2700,7 +2694,7 @@ class Dom {
*/
prepend(content) {
return this.each(el => {
if (arrayLike(content) || dom(content)) {
if (content instanceof Dom) {
// Reverse the array, so if there are multiple elements, they
// end up being added sequentially, just like jQuery
let itemsReversed = Array.from(content).reverse();
@@ -8720,7 +8714,7 @@ const ext = {
/**
* DataTables build type (expanded by the download builder)
*/
builder: 'bs5/dt-3.1.4',
builder: 'bs5/dt-3.1.3',
/**
* Buttons. For use with the Buttons extension for DataTables. This is
* defined here so other extensions can define buttons regardless of load
@@ -8866,7 +8860,7 @@ const ext = {
* Software version
* @type string
*/
version: '3.1.4'
version: '3.1.3'
};
//
// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts
@@ -9565,7 +9559,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(true);
new Api(settings).columns().visible();
}
// Container width change listener
if (settings.resizeObserver) {
+3 -17
View File
@@ -69,26 +69,12 @@
</dd>
<dt class="col-sm-5">Uses config.json</dt>
<dd class="col-sm-7">
{{#if (eq page_data.config_file_status "Loaded")}}
{{#if page_data.overrides}}
<span class="d-inline"><b>Yes</b></span>
{{#if page_data.overrides}}
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.&#013;&#010;{{page_data.overrides}}">Details</span>
{{/if}}
{{~else if (eq page_data.config_file_status "Error")}}
<span class="d-inline"><b>Errors</b></span>
<span class="badge bg-danger abbr-badge" title="Config file could not be loaded and was ignored. Check server logs for details.">Details</span>
{{~else}}
<span class="d-inline"><b>No</b></span>
{{/if}}
</dd>
<dt class="col-sm-5">Environment variables are valid</dt>
<dd class="col-sm-7">
{{#if page_data.invalid_env_vars}}
<span class="d-inline"><b>No</b></span>
<span class="badge bg-danger abbr-badge" title="Invalid environment variables are found and defaults are used instead.&#013;&#010;{{page_data.invalid_env_vars}}">Details</span>
{{/if}}
{{#unless page_data.invalid_env_vars}}
<span class="d-block"><b>Yes</b></span>
{{#unless page_data.overrides}}
<span class="d-block"><b>No</b></span>
{{/unless}}
</dd>
<dt class="col-sm-5">Uses custom templates</dt>
+12 -20
View File
@@ -210,7 +210,7 @@ impl Fairing for Cors {
pub struct Cached<R> {
response: R,
is_immutable: bool,
ttl: u32,
ttl: u64,
}
impl<R> Cached<R> {
@@ -230,7 +230,7 @@ impl<R> Cached<R> {
}
}
pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached<R> {
pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached<R> {
Self {
response,
is_immutable,
@@ -251,7 +251,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
res.set_raw_header("Cache-Control", cache_control_header);
let time_now = Local::now();
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap();
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap();
res.set_raw_header("Expires", format_datetime_http(&expiry_time));
Ok(res)
}
@@ -447,34 +447,20 @@ pub fn get_env_str_value(key: &str) -> Option<String> {
}
}
pub static INVALID_ENV_VARS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
pub fn get_env<V>(key: &'static str) -> Option<V>
pub fn get_env<V>(key: &str) -> Option<V>
where
V: FromStr,
{
let value = get_env_str_value(key);
let value_is_some = value.is_some();
let parsed: Option<V> = try_parse_string(value);
if parsed.is_none() && value_is_some {
println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead.");
INVALID_ENV_VARS.lock().unwrap().push(key);
}
parsed
try_parse_string(get_env_str_value(key))
}
pub fn get_env_bool(key: &'static str) -> Option<bool> {
pub fn get_env_bool(key: &str) -> Option<bool> {
const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"];
const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"];
match get_env_str_value(key) {
Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true),
Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false),
Some(_) => {
println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead.");
INVALID_ENV_VARS.lock().unwrap().push(key);
None
}
_ => None,
}
}
@@ -853,6 +839,12 @@ 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