mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-13 15:48:36 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c7621975 | ||
|
|
2e0c8b6213 | ||
|
|
7c82fbed0c | ||
|
|
d28c8339fb | ||
|
|
5ad0c6d41a |
No files matched your search
@@ -411,6 +411,9 @@
|
|||||||
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
|
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
|
||||||
## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers.
|
## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers.
|
||||||
## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet.
|
## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet.
|
||||||
|
## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1)
|
||||||
|
## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0)
|
||||||
|
## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one.
|
||||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||||
|
|
||||||
## Require new device emails. When a user logs in an email is required to be sent.
|
## Require new device emails. When a user logs in an email is required to be sent.
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ jobs:
|
|||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --profile ci --features postgresql
|
cargo test --profile ci --features postgresql
|
||||||
|
|
||||||
|
# The test seeder only exists with this feature, which no release build has
|
||||||
|
- name: "test features: sqlite,unsafe_test_mode"
|
||||||
|
id: test_unsafe_test_mode
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
run: |
|
||||||
|
cargo test --profile ci --features sqlite,unsafe_test_mode
|
||||||
# End Run cargo tests
|
# End Run cargo tests
|
||||||
|
|
||||||
|
|
||||||
@@ -167,6 +174,12 @@ jobs:
|
|||||||
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
||||||
run: |
|
run: |
|
||||||
cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
|
cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
|
||||||
|
|
||||||
|
- name: "clippy features: sqlite,unsafe_test_mode"
|
||||||
|
id: clippy_unsafe_test_mode
|
||||||
|
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
||||||
|
run: |
|
||||||
|
cargo clippy --profile ci --features sqlite,unsafe_test_mode
|
||||||
# End Run cargo clippy
|
# End Run cargo clippy
|
||||||
|
|
||||||
|
|
||||||
@@ -190,7 +203,9 @@ jobs:
|
|||||||
TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
|
TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
|
||||||
TEST_MYSQL: ${{ steps.test_mysql.outcome }}
|
TEST_MYSQL: ${{ steps.test_mysql.outcome }}
|
||||||
TEST_POSTGRESQL: ${{ steps.test_postgresql.outcome }}
|
TEST_POSTGRESQL: ${{ steps.test_postgresql.outcome }}
|
||||||
|
TEST_UNSAFE_TEST_MODE: ${{ steps.test_unsafe_test_mode.outcome }}
|
||||||
CLIPPY: ${{ steps.clippy.outcome }}
|
CLIPPY: ${{ steps.clippy.outcome }}
|
||||||
|
CLIPPY_UNSAFE_TEST_MODE: ${{ steps.clippy_unsafe_test_mode.outcome }}
|
||||||
FMT: ${{ steps.formatting.outcome }}
|
FMT: ${{ steps.formatting.outcome }}
|
||||||
run: |
|
run: |
|
||||||
echo "### :x: Checks Failed!" >> "${GITHUB_STEP_SUMMARY}"
|
echo "### :x: Checks Failed!" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
@@ -203,7 +218,9 @@ jobs:
|
|||||||
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
echo "|test (sqlite,unsafe_test_mode)|${TEST_UNSAFE_TEST_MODE}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
echo "|clippy (sqlite,unsafe_test_mode)|${CLIPPY_UNSAFE_TEST_MODE}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
|
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
name: SDK live tests
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
# Runs the live-server end-to-end tests of bitwarden/sdk-internal against this branch, see tools/sdk-live-tests/run.sh.
|
||||||
|
# It builds the SDK too, so it only runs by hand and on changes to the test seeder or the runner.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sdk_ref:
|
||||||
|
description: "Branch, tag or commit of bitwarden/sdk-internal to test with"
|
||||||
|
required: true
|
||||||
|
default: "main"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/sdk-live-tests.yml"
|
||||||
|
- "src/api/test_seeder.rs"
|
||||||
|
- "tools/sdk-live-tests/**"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sdk-live-tests:
|
||||||
|
name: SDK live tests
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- name: "Install dependencies Ubuntu"
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential libssl-dev pkg-config
|
||||||
|
|
||||||
|
- name: "Checkout"
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: "Checkout sdk-internal"
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
repository: bitwarden/sdk-internal
|
||||||
|
ref: ${{ inputs.sdk_ref || 'main' }}
|
||||||
|
path: sdk-internal
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Each checkout pins its own toolchain in rust-toolchain.toml
|
||||||
|
- name: "Install toolchains"
|
||||||
|
run: |
|
||||||
|
rustup toolchain install
|
||||||
|
cd sdk-internal
|
||||||
|
rustup toolchain install
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
rustup component add rust-src
|
||||||
|
|
||||||
|
- name: "Cache Rust builds"
|
||||||
|
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||||
|
with:
|
||||||
|
workspaces: |
|
||||||
|
. -> target
|
||||||
|
sdk-internal -> target
|
||||||
|
|
||||||
|
- name: "Setup Node"
|
||||||
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version-file: sdk-internal/.nvmrc
|
||||||
|
|
||||||
|
- name: "Run the SDK live tests"
|
||||||
|
run: tools/sdk-live-tests/run.sh --sdk-dir sdk-internal
|
||||||
|
|
||||||
|
- name: "Upload logs"
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: sdk-live-tests-logs
|
||||||
|
path: target/sdk-live-tests/**/*.log
|
||||||
+2
-3
@@ -14,9 +14,8 @@ extend-ignore-re = [
|
|||||||
# In SMTP it's called HELO, so ignore it
|
# In SMTP it's called HELO, so ignore it
|
||||||
"(?i)helo_name",
|
"(?i)helo_name",
|
||||||
"Server name sent during.+HELO",
|
"Server name sent during.+HELO",
|
||||||
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
|
# COSE Is short for CBOR Object Signing and Encryption
|
||||||
"COSEKey",
|
"(?i)cose",
|
||||||
"COSEAlgorithm",
|
|
||||||
# Ignore this specific string as it's valid
|
# Ignore this specific string as it's valid
|
||||||
"Ensure they are valid OTPs",
|
"Ensure they are valid OTPs",
|
||||||
# This word is misspelled upstream
|
# This word is misspelled upstream
|
||||||
|
|||||||
Generated
+1
@@ -6270,6 +6270,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_cbor_2",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"subtle",
|
"subtle",
|
||||||
"svg-hush",
|
"svg-hush",
|
||||||
|
|||||||
+7
-1
@@ -57,6 +57,10 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
|
|||||||
# Currently only used to enable rusts official ip support
|
# Currently only used to enable rusts official ip support
|
||||||
unstable = []
|
unstable = []
|
||||||
|
|
||||||
|
# Test builds only, never in a release or a deployment: adds a seeder API that creates accounts without any checks,
|
||||||
|
# for the end-to-end tests (`tools/sdk-live-tests/`)
|
||||||
|
unsafe_test_mode = ["dep:serde_cbor_2"]
|
||||||
|
|
||||||
[target."cfg(unix)".dependencies]
|
[target."cfg(unix)".dependencies]
|
||||||
nix = { version = "0.31.3", features = ["fs"] }
|
nix = { version = "0.31.3", features = ["fs"] }
|
||||||
# Logging
|
# Logging
|
||||||
@@ -107,7 +111,7 @@ serde = { version = "1.0.229", features = ["derive"] }
|
|||||||
serde_json = "1.0.154"
|
serde_json = "1.0.154"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] }
|
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
|
||||||
diesel_migrations = "2.3.2"
|
diesel_migrations = "2.3.2"
|
||||||
|
|
||||||
derive_more = { version = "2.1.1", features = [
|
derive_more = { version = "2.1.1", features = [
|
||||||
@@ -157,6 +161,8 @@ yubico_ng = { version = "1.0.0", default-features = false }
|
|||||||
webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||||
webauthn-rs-proto = "0.5.5"
|
webauthn-rs-proto = "0.5.5"
|
||||||
webauthn-rs-core = "0.5.5"
|
webauthn-rs-core = "0.5.5"
|
||||||
|
# Only for `unsafe_test_mode`, to read the algorithm of a COSE key; already used by webauthn-rs
|
||||||
|
serde_cbor_2 = { version = "0.13.0", optional = true }
|
||||||
|
|
||||||
# Handling of URL's for WebAuthn and favicons
|
# Handling of URL's for WebAuthn and favicons
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|||||||
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
|
||||||
|
);
|
||||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
|||||||
|
-- Key rotations before this kept the id of the old user key, so every new
|
||||||
|
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||||
|
-- all of them; clients record their current key's id again when it's empty.
|
||||||
|
UPDATE users SET key_id = 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,3 @@
|
|||||||
|
-- TEXT has no size limit here, only MySQL needed the change.
|
||||||
|
-- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op.
|
||||||
|
SELECT 1;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
|||||||
|
-- Key rotations before this kept the id of the old user key, so every new
|
||||||
|
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||||
|
-- all of them; clients record their current key's id again when it's empty.
|
||||||
|
UPDATE users SET key_id = 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
|
||||||
|
);
|
||||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
|||||||
|
-- Key rotations before this kept the id of the old user key, so every new
|
||||||
|
-- cipher was refused from then on. Stale ids can't be told apart, so clear
|
||||||
|
-- all of them; clients record their current key's id again when it's empty.
|
||||||
|
UPDATE users SET key_id = 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
@@ -445,7 +445,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notif
|
|||||||
if res.is_ok() {
|
if res.is_ok() {
|
||||||
nt.send_logout(&user, None, &conn).await;
|
nt.send_logout(&user, None, &conn).await;
|
||||||
if has_push_device {
|
if has_push_device {
|
||||||
push_logout_to_relay(&user, None);
|
push_logout_to_relay(&user, None, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+971
-173
File diff suppressed because it is too large.
Load diff
+351
-84
@@ -24,6 +24,7 @@ use crate::{
|
|||||||
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
||||||
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
||||||
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
|
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
|
||||||
|
is_data_blob_encrypted,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
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
|
// 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,
|
"masterKeyEncryptedUserKey": headers.user.akey,
|
||||||
"masterKeyWrappedUserKey": headers.user.akey,
|
"masterKeyWrappedUserKey": headers.user.akey,
|
||||||
"salt": headers.user.email
|
"salt": headers.user.master_password_salt(),
|
||||||
|
"containedKeyId": headers.user.key_id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Value::Null
|
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!({
|
Ok(Json(json!({
|
||||||
"profile": user_json,
|
"profile": user_json,
|
||||||
"folders": folders_json,
|
"folders": folders_json,
|
||||||
@@ -204,10 +215,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
"ciphers": ciphers_json,
|
"ciphers": ciphers_json,
|
||||||
"domains": domains_json,
|
"domains": domains_json,
|
||||||
"sends": sends_json,
|
"sends": sends_json,
|
||||||
"userDecryption": {
|
"userDecryption": user_decryption,
|
||||||
"masterPasswordUnlock": master_password_unlock,
|
|
||||||
"userKeyId": headers.user.key_id,
|
|
||||||
},
|
|
||||||
"object": "sync"
|
"object": "sync"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -269,7 +277,8 @@ pub struct CipherData {
|
|||||||
|
|
||||||
key: Option<String>,
|
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
|
// Added in web-v2025.8.1, Optional for compat
|
||||||
pub encrypted_by_key_id: Option<KeyId>,
|
pub encrypted_by_key_id: Option<KeyId>,
|
||||||
|
|
||||||
@@ -284,7 +293,8 @@ pub struct CipherData {
|
|||||||
Passport = 8
|
Passport = 8
|
||||||
*/
|
*/
|
||||||
pub r#type: i32,
|
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>,
|
pub notes: Option<String>,
|
||||||
fields: Option<Value>,
|
fields: Option<Value>,
|
||||||
|
|
||||||
@@ -298,6 +308,9 @@ pub struct CipherData {
|
|||||||
drivers_license: Option<Value>,
|
drivers_license: Option<Value>,
|
||||||
passport: 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>,
|
favorite: Option<bool>,
|
||||||
reprompt: Option<i32>,
|
reprompt: Option<i32>,
|
||||||
|
|
||||||
@@ -319,6 +332,98 @@ pub struct CipherData {
|
|||||||
archived_date: Option<String>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PartialCipherData {
|
pub struct PartialCipherData {
|
||||||
@@ -352,16 +457,14 @@ async fn post_ciphers_create(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let mut data: ShareCipherData = data.into_inner();
|
let mut data: ShareCipherData = data.into_inner();
|
||||||
|
|
||||||
if data.cipher.encrypted_for != headers.user.uuid {
|
validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
|
||||||
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This check is usually only needed in update_cipher_from_data(), but we
|
// 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
|
// need it here as well to avoid creating an empty cipher in the call to
|
||||||
// cipher.save() below.
|
// cipher.save() below.
|
||||||
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
|
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.user_uuid = Some(headers.user.uuid.clone());
|
||||||
cipher.save(&conn).await?;
|
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 {
|
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
let mut data: CipherData = data.into_inner();
|
let mut data: CipherData = data.into_inner();
|
||||||
|
|
||||||
if data.encrypted_for != headers.user.uuid {
|
validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?;
|
||||||
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cipher_key_id) = &data.encrypted_by_key_id
|
|
||||||
&& let Some(user_key_id) = &headers.user.key_id
|
|
||||||
&& cipher_key_id != user_key_id
|
|
||||||
{
|
|
||||||
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The web/browser clients set this field to null as expected, but the
|
// 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`,
|
// 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.
|
// needed when creating a new cipher, so just ignore it unconditionally.
|
||||||
data.last_known_revision_date = None;
|
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?;
|
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?))
|
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(())
|
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 '{}': {err}", dt.escape_debug()),
|
||||||
|
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(
|
pub async fn update_cipher_from_data(
|
||||||
cipher: &mut Cipher,
|
cipher: &mut Cipher,
|
||||||
data: CipherData,
|
data: CipherData,
|
||||||
@@ -434,6 +566,40 @@ pub async fn update_cipher_from_data(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
ut: UpdateType,
|
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 {
|
) -> EmptyResult {
|
||||||
// Cleanup cipher data, like removing the 'Response' key.
|
// Cleanup cipher data, like removing the 'Response' key.
|
||||||
// This key is somewhere generated during Javascript so no way for us this fix this.
|
// This key is somewhere generated during Javascript so no way for us this fix this.
|
||||||
@@ -449,34 +615,15 @@ pub async fn update_cipher_from_data(
|
|||||||
json_data
|
json_data
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
let is_blob = data.is_blob();
|
||||||
|
data.validate_content(is_blob)?;
|
||||||
// Check that the client isn't updating an existing cipher with stale data.
|
data.validate_type_data(is_blob)?;
|
||||||
// And only perform this check when not importing ciphers, else the date/time check will fail.
|
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
|
||||||
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 '{}': {err}", dt.escape_debug()),
|
|
||||||
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
|
||||||
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
|
||||||
}
|
|
||||||
Ok(_) => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
|
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
|
||||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
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
|
// 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();
|
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
|
||||||
|
|
||||||
@@ -507,12 +654,6 @@ pub async fn update_cipher_from_data(
|
|||||||
cipher.user_uuid = Some(headers.user.uuid.clone());
|
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
|
// Modify attachments name and keys when rotating
|
||||||
if let Some(attachments) = data.attachments2 {
|
if let Some(attachments) = data.attachments2 {
|
||||||
for (id, attachment) in attachments {
|
for (id, attachment) in attachments {
|
||||||
@@ -551,26 +692,36 @@ pub async fn update_cipher_from_data(
|
|||||||
_ => err!("Invalid type"),
|
_ => 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.
|
// 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");
|
data_obj.remove("response");
|
||||||
}
|
}
|
||||||
// Remove the 'Response' key from every Uri.
|
// Remove the 'Response' key from every Uri.
|
||||||
if data["uris"].is_array() {
|
if type_data["uris"].is_array() {
|
||||||
data["uris"] = clean_cipher_data(data["uris"].clone());
|
type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
|
||||||
}
|
}
|
||||||
data
|
|
||||||
} else {
|
// `validate_content` made sure there is a name
|
||||||
err!("Data missing")
|
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.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.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
|
||||||
|
|
||||||
cipher.save(conn).await?;
|
cipher.save(conn).await?;
|
||||||
@@ -610,6 +761,92 @@ pub async fn update_cipher_from_data(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores a personal cipher that is already encrypted, as [`save_cipher_data`] stores it but without its checks, events
|
||||||
|
/// and pushes, for the test seeder (`unsafe_test_mode`). `folder_ids` maps the cipher's folder to a seeded one.
|
||||||
|
#[cfg(feature = "unsafe_test_mode")]
|
||||||
|
pub async fn seed_cipher(
|
||||||
|
encrypted: Value,
|
||||||
|
user_id: &UserId,
|
||||||
|
folder_ids: &HashMap<FolderId, FolderId>,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> ApiResult<CipherId> {
|
||||||
|
// Not in the request model, but a trashed cipher has to stay trashed
|
||||||
|
let deleted_at = encrypted.get("deletedDate").and_then(Value::as_str).map(parse_seed_date).transpose()?;
|
||||||
|
let data: CipherData = serde_json::from_value(encrypted)?;
|
||||||
|
let is_blob = data.is_blob();
|
||||||
|
data.validate_content(is_blob)?;
|
||||||
|
data.validate_type_data(is_blob)?;
|
||||||
|
|
||||||
|
let mut cipher = Cipher::new(data.r#type);
|
||||||
|
cipher.user_uuid = Some(user_id.clone());
|
||||||
|
cipher.deleted_at = deleted_at;
|
||||||
|
let type_data = match data.r#type {
|
||||||
|
1 => data.login,
|
||||||
|
2 => data.secure_note,
|
||||||
|
3 => data.card,
|
||||||
|
4 => data.identity,
|
||||||
|
5 => data.ssh_key,
|
||||||
|
6 => data.bank_account,
|
||||||
|
7 => data.drivers_license,
|
||||||
|
8 => data.passport,
|
||||||
|
_ => err!("Invalid type"),
|
||||||
|
};
|
||||||
|
if let Some(blob) = data.data.filter(|_| is_blob) {
|
||||||
|
cipher.data = blob;
|
||||||
|
} else {
|
||||||
|
let Some(type_data) = type_data else {
|
||||||
|
err!("Data missing")
|
||||||
|
};
|
||||||
|
cipher.name = data.name.unwrap_or_default();
|
||||||
|
cipher.notes = data.notes;
|
||||||
|
cipher.fields = data.fields.map(|f| f.to_string());
|
||||||
|
cipher.password_history = data.password_history.map(|p| p.to_string());
|
||||||
|
cipher.data = type_data.to_string();
|
||||||
|
}
|
||||||
|
cipher.key = data.key;
|
||||||
|
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
|
||||||
|
cipher.save(conn).await?;
|
||||||
|
|
||||||
|
let folder_id = data.folder_id.and_then(|id| folder_ids.get(&id).cloned());
|
||||||
|
cipher.move_to_folder(folder_id, user_id, conn).await?;
|
||||||
|
cipher.set_favorite(data.favorite, user_id, conn).await?;
|
||||||
|
if let Some(archived_date) = data.archived_date {
|
||||||
|
cipher.set_archived_at(parse_seed_date(&archived_date)?, user_id, conn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the metadata: the files of a seeded cipher's attachments don't exist
|
||||||
|
for attachment in data.attachments.as_ref().and_then(Value::as_array).into_iter().flatten() {
|
||||||
|
let (Some(id), Some(file_name)) = (attachment["id"].as_str(), attachment["fileName"].as_str()) else {
|
||||||
|
err!("A seeded attachment needs an id and a file name")
|
||||||
|
};
|
||||||
|
// Clients send the size as a string
|
||||||
|
let size = match &attachment["size"] {
|
||||||
|
Value::Number(size) => size.as_i64(),
|
||||||
|
Value::String(size) => size.parse().ok(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
// Saving is an upsert, and destroying the play deletes the attachment: an id that exists isn't the seeding's
|
||||||
|
if id.is_empty() || !id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
|
||||||
|
err!(format!("Invalid attachment id {}", id.escape_debug()))
|
||||||
|
}
|
||||||
|
let id = AttachmentId(id.to_owned());
|
||||||
|
if Attachment::find_by_id(&id, conn).await.is_some() {
|
||||||
|
err!(format!("An attachment with id {id} already exists"))
|
||||||
|
}
|
||||||
|
let key = attachment["key"].as_str().map(str::to_owned);
|
||||||
|
Attachment::new(id, cipher.uuid.clone(), file_name.to_owned(), size.unwrap_or(0), key).save(conn).await?;
|
||||||
|
}
|
||||||
|
Ok(cipher.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "unsafe_test_mode")]
|
||||||
|
fn parse_seed_date(date: &str) -> ApiResult<NaiveDateTime> {
|
||||||
|
match chrono::DateTime::parse_from_rfc3339(date) {
|
||||||
|
Ok(date) => Ok(date.naive_utc()),
|
||||||
|
Err(e) => err!(format!("Invalid date {}: {e}", date.escape_debug())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ImportData {
|
struct ImportData {
|
||||||
@@ -670,7 +907,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
|
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
|
||||||
cipher_data.organization_id = None;
|
cipher_data.organization_id = None;
|
||||||
|
|
||||||
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?;
|
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,6 +980,9 @@ async fn put_cipher(
|
|||||||
err!("Cipher is not write accessible")
|
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())?;
|
||||||
|
|
||||||
// Moving a cipher into an organization is only possible through the share endpoints
|
// Moving a cipher into an organization is only possible through the share endpoints
|
||||||
if cipher.organization_uuid != data.organization_id {
|
if cipher.organization_uuid != data.organization_id {
|
||||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||||
@@ -1019,13 +1259,20 @@ async fn put_cipher_share(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let data: ShareCipherData = data.into_inner();
|
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() {
|
if data.cipher.organization_id.is_none() {
|
||||||
err!("Cipher OrganizationId is required.")
|
err!("Cipher OrganizationId is required.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.collection_ids.is_empty() {
|
if data.collection_ids.is_empty() {
|
||||||
err!("You must select at least one collection.")
|
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
|
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
|
||||||
}
|
}
|
||||||
@@ -1085,21 +1332,35 @@ async fn put_cipher_share_selected(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let data: ShareSelectedCipherData = data.into_inner();
|
let 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() {
|
if data.ciphers.is_empty() {
|
||||||
err!("You must select at least one cipher.")
|
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() {
|
if data.collection_ids.is_empty() {
|
||||||
err!("You must select at least one collection.")
|
err!("You must select at least one collection.")
|
||||||
}
|
}
|
||||||
|
|
||||||
for cipher in &data.ciphers {
|
for cipher in &data.ciphers {
|
||||||
if cipher.id.is_none() {
|
let is_blob = cipher.is_blob();
|
||||||
err!("Request missing ids field")
|
cipher.validate_content(is_blob)?;
|
||||||
}
|
cipher.validate_type_data(is_blob)?;
|
||||||
if cipher.organization_id.is_none() {
|
}
|
||||||
err!("Cipher OrganizationId is required.")
|
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?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut responses = Vec::with_capacity(data.ciphers.len());
|
let mut responses = Vec::with_capacity(data.ciphers.len());
|
||||||
@@ -1161,6 +1422,22 @@ async fn share_cipher_by_uuid(
|
|||||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
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![];
|
let mut shared_to_collections = vec![];
|
||||||
|
|
||||||
if let Some(organization_id) = &data.cipher.organization_id {
|
if let Some(organization_id) = &data.cipher.organization_id {
|
||||||
@@ -1179,16 +1456,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?;
|
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?))
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciph
|
|||||||
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
||||||
pub use events::{event_cleanup_job, log_event, log_user_event};
|
pub use events::{event_cleanup_job, log_event, log_user_event};
|
||||||
pub use sends::purge_sends;
|
pub use sends::purge_sends;
|
||||||
|
#[cfg(feature = "unsafe_test_mode")]
|
||||||
|
pub use {ciphers::seed_cipher, sends::seed_send};
|
||||||
|
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
|
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::admin::FAKE_ADMIN_UUID,
|
api::admin::FAKE_ADMIN_UUID,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
||||||
},
|
},
|
||||||
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
|
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
|
||||||
@@ -1866,7 +1866,7 @@ async fn post_org_import(
|
|||||||
cipher_data.folder_id = None;
|
cipher_data.folder_id = None;
|
||||||
// Replace the client-provided, unvalidated organizationId with the real target org
|
// Replace the client-provided, unvalidated organizationId with the real target org
|
||||||
cipher_data.organization_id = Some(org_id.clone());
|
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(
|
update_cipher_from_data(
|
||||||
&mut cipher,
|
&mut cipher,
|
||||||
cipher_data,
|
cipher_data,
|
||||||
@@ -2869,8 +2869,26 @@ struct OrganizationUserRecoverAccountRequest {
|
|||||||
// But the clients do not seem to use this at all
|
// But the clients do not seem to use this at all
|
||||||
// Just add it here in case they will
|
// Just add it here in case they will
|
||||||
#[get("/organizations/<org_id>/public-key")]
|
#[get("/organizations/<org_id>/public-key")]
|
||||||
async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
|
async fn get_organization_public_key(
|
||||||
if org_id != headers.membership.org_uuid {
|
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");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
||||||
@@ -2883,11 +2901,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
|
// 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
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
|
||||||
#[get("/organizations/<org_id>/keys")]
|
#[get("/organizations/<org_id>/keys")]
|
||||||
async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
|
async fn get_organization_keys(
|
||||||
get_organization_public_key(org_id, headers, conn).await
|
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
|
// Will allow to reset 2FA too
|
||||||
|
|||||||
@@ -126,6 +126,23 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores a text Send that is already encrypted, as [`create_send`] does, for the test seeder (`unsafe_test_mode`).
|
||||||
|
/// Its deletion date becomes a week from now: a recorded one can be years ahead, more than a Send may be.
|
||||||
|
#[cfg(feature = "unsafe_test_mode")]
|
||||||
|
pub async fn seed_send(mut encrypted: Value, user_id: &UserId, conn: &DbConn) -> ApiResult<SendId> {
|
||||||
|
let Some(fields) = encrypted.as_object_mut() else {
|
||||||
|
err!("A seeded Send has to be a JSON object")
|
||||||
|
};
|
||||||
|
fields.insert("deletionDate".to_owned(), serde_json::to_value(Utc::now() + TimeDelta::try_days(7).unwrap())?);
|
||||||
|
let data: SendData = serde_json::from_value(encrypted)?;
|
||||||
|
if data.r#type != SendType::Text as i32 {
|
||||||
|
err!("Only text Sends can be seeded: a file Send needs its file")
|
||||||
|
}
|
||||||
|
let mut send = create_send(data, user_id.clone())?;
|
||||||
|
send.save(conn).await?;
|
||||||
|
Ok(send.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
||||||
let data_val = if data.r#type == SendType::Text as i32 {
|
let data_val = if data.r#type == SendType::Text as i32 {
|
||||||
data.text
|
data.text
|
||||||
|
|||||||
+133
-35
@@ -29,7 +29,7 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
|
AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError,
|
||||||
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
||||||
TwoFactorType, User, UserId,
|
TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
@@ -77,14 +77,14 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
|||||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
password_login(data, &mut user_id, &conn, &client_header.ip).await
|
password_login(data, &mut user_id, &conn, &client_header).await
|
||||||
}
|
}
|
||||||
"client_credentials" => {
|
"client_credentials" => {
|
||||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
||||||
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
||||||
|
|
||||||
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
api_key_login(data, &mut user_id, &conn, &client_header).await
|
||||||
}
|
}
|
||||||
"authorization_code" if CONFIG.sso_enabled() => {
|
"authorization_code" if CONFIG.sso_enabled() => {
|
||||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||||
@@ -95,7 +95,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
|||||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
sso_login(data, &mut user_id, &conn, &client_header.ip).await
|
sso_login(data, &mut user_id, &conn, &client_header).await
|
||||||
}
|
}
|
||||||
"authorization_code" => err!("SSO sign-in is not available"),
|
"authorization_code" => err!("SSO sign-in is not available"),
|
||||||
"send_access" => {
|
"send_access" => {
|
||||||
@@ -179,7 +179,13 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After exchanging the code we need to check first if 2FA is needed before continuing
|
// After exchanging the code we need to check first if 2FA is needed before continuing
|
||||||
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn sso_login(
|
||||||
|
data: ConnectData,
|
||||||
|
user_id: &mut Option<UserId>,
|
||||||
|
conn: &DbConn,
|
||||||
|
client_header: &ClientHeaders,
|
||||||
|
) -> JsonResult {
|
||||||
|
let ip = &client_header.ip;
|
||||||
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||||
|
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
@@ -317,6 +323,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
|
let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
|
||||||
|
// Before saving, so that a client that can't log in doesn't create the account
|
||||||
|
check_client_version(&user, client_header)?;
|
||||||
user.verified_at = Some(now);
|
user.verified_at = Some(now);
|
||||||
user.save(conn).await?;
|
user.save(conn).await?;
|
||||||
|
|
||||||
@@ -325,6 +333,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
|||||||
(user, device, None, None)
|
(user, device, None, None)
|
||||||
}
|
}
|
||||||
Some((user, _)) if !user.enabled => {
|
Some((user, _)) if !user.enabled => {
|
||||||
|
// Set in both arms, not only after the match, so that their failed logins are logged
|
||||||
|
*user_id = Some(user.uuid.clone());
|
||||||
err!(
|
err!(
|
||||||
"This user has been disabled",
|
"This user has been disabled",
|
||||||
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
||||||
@@ -334,6 +344,9 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Some((mut user, sso_user)) => {
|
Some((mut user, sso_user)) => {
|
||||||
|
*user_id = Some(user.uuid.clone());
|
||||||
|
check_client_version(&user, client_header)?;
|
||||||
|
|
||||||
let mut device = get_device(&data, conn, &user).await?;
|
let mut device = get_device(&data, conn, &user).await?;
|
||||||
|
|
||||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
|
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
|
||||||
@@ -368,7 +381,13 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
|||||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn password_login(
|
||||||
|
data: ConnectData,
|
||||||
|
user_id: &mut Option<UserId>,
|
||||||
|
conn: &DbConn,
|
||||||
|
client_header: &ClientHeaders,
|
||||||
|
) -> JsonResult {
|
||||||
|
let ip = &client_header.ip;
|
||||||
// Validate scope
|
// Validate scope
|
||||||
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||||
|
|
||||||
@@ -438,6 +457,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_client_version(&user, client_header)?;
|
||||||
|
|
||||||
// Change the KDF Iterations (only when not logging in with an auth request)
|
// Change the KDF Iterations (only when not logging in with an auth request)
|
||||||
if data.auth_request.is_none() {
|
if data.auth_request.is_none() {
|
||||||
kdf_upgrade(&mut user, password, conn).await?;
|
kdf_upgrade(&mut user, password, conn).await?;
|
||||||
@@ -499,6 +520,66 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
|||||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like upstream's `ClientVersionValidator`, every login needs the `Bitwarden-Client-Version` header, and a V2 account
|
||||||
|
/// ([`User::has_v2_encryption`]) a client of at least 2026.10.1 (2026.10.2 for the desktop app). Called right after the
|
||||||
|
/// credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs#L33-L79>
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Core/KeyManagement/Constants.cs#L5-L6>
|
||||||
|
fn check_client_version(user: &User, client_header: &ClientHeaders) -> EmptyResult {
|
||||||
|
let Some((error, description)) = client_version_error(
|
||||||
|
user.has_v2_encryption(),
|
||||||
|
client_header.client_version.as_ref(),
|
||||||
|
client_header.device_type,
|
||||||
|
) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
Err(crate::error::Error::from((
|
||||||
|
// Escaped: on a first SSO login the email comes from the provider and isn't validated yet
|
||||||
|
format!("IP: {}. Username: {}. {error}", client_header.ip.ip, user.email.escape_debug()),
|
||||||
|
json!({
|
||||||
|
"error": error,
|
||||||
|
"error_description": description,
|
||||||
|
"ErrorModel": { "Message": description, "Object": "error" },
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.with_event(crate::error::ErrorEvent {
|
||||||
|
event: EventType::UserFailedLogIn,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The error id and message of [`check_client_version`], `None` when the client may log in.
|
||||||
|
fn client_version_error(
|
||||||
|
is_v2: bool,
|
||||||
|
client_version: Option<&semver::Version>,
|
||||||
|
device_type: i32,
|
||||||
|
) -> Option<(&'static str, &'static str)> {
|
||||||
|
const MIN_V2_CLIENT: semver::Version = semver::Version::new(2026, 10, 1);
|
||||||
|
const MIN_V2_DESKTOP_CLIENT: semver::Version = semver::Version::new(2026, 10, 2);
|
||||||
|
|
||||||
|
let Some(version) = client_version else {
|
||||||
|
return Some((
|
||||||
|
"version_header_missing",
|
||||||
|
"No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if !is_v2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Upstream's desktop device types
|
||||||
|
// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Libraries/Clients/DeviceTypes.cs#L14-L20>
|
||||||
|
let is_desktop = matches!(
|
||||||
|
DeviceType::from_i32(device_type),
|
||||||
|
DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop | DeviceType::Uwp
|
||||||
|
);
|
||||||
|
let min_version = if is_desktop {
|
||||||
|
&MIN_V2_DESKTOP_CLIENT
|
||||||
|
} else {
|
||||||
|
&MIN_V2_CLIENT
|
||||||
|
};
|
||||||
|
(version < min_version).then_some(("invalid_client_version", "Please update your app to continue using Bitwarden"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn authenticated_response(
|
async fn authenticated_response(
|
||||||
user: &User,
|
user: &User,
|
||||||
device: &mut Device,
|
device: &mut Device,
|
||||||
@@ -546,24 +627,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
|
// 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,
|
"MasterKeyEncryptedUserKey": user.akey,
|
||||||
"MasterKeyWrappedUserKey": user.akey,
|
"MasterKeyWrappedUserKey": user.akey,
|
||||||
"Salt": user.email
|
"Salt": user.master_password_salt()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = if user.private_key.is_some() {
|
let account_keys = user.account_keys_json(conn).await;
|
||||||
json!({
|
|
||||||
"publicKeyEncryptionKeyPair": {
|
|
||||||
"wrappedPrivateKey": user.private_key,
|
|
||||||
"publicKey": user.public_key,
|
|
||||||
"Object": "publicKeyEncryptionKeyPair"
|
|
||||||
},
|
|
||||||
"Object": "privateKeys"
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Value::Null
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
"access_token": auth_tokens.access_token(),
|
"access_token": auth_tokens.access_token(),
|
||||||
@@ -598,7 +668,13 @@ async fn authenticated_response(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
async fn api_key_login(
|
||||||
|
data: ConnectData,
|
||||||
|
user_id: &mut Option<UserId>,
|
||||||
|
conn: &DbConn,
|
||||||
|
client_header: &ClientHeaders,
|
||||||
|
) -> JsonResult {
|
||||||
|
let ip = &client_header.ip;
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
@@ -610,7 +686,7 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
|
|||||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||||
|
|
||||||
user_api_key_login(data, user_id, conn, ip).await
|
user_api_key_login(data, user_id, conn, client_header).await
|
||||||
}
|
}
|
||||||
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
||||||
_ => err!("Scope not supported"),
|
_ => err!("Scope not supported"),
|
||||||
@@ -621,8 +697,9 @@ async fn user_api_key_login(
|
|||||||
data: ConnectData,
|
data: ConnectData,
|
||||||
user_id: &mut Option<UserId>,
|
user_id: &mut Option<UserId>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
ip: &ClientIp,
|
client_header: &ClientHeaders,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
let ip = &client_header.ip;
|
||||||
// Get the user via the client_id
|
// Get the user via the client_id
|
||||||
let client_id = data.client_id.as_ref().unwrap();
|
let client_id = data.client_id.as_ref().unwrap();
|
||||||
let Some(client_user_id) = client_id.strip_prefix("user.") else {
|
let Some(client_user_id) = client_id.strip_prefix("user.") else {
|
||||||
@@ -659,6 +736,8 @@ async fn user_api_key_login(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_client_version(&user, client_header)?;
|
||||||
|
|
||||||
let mut device = get_device(&data, conn, &user).await?;
|
let mut device = get_device(&data, conn, &user).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() && device.is_new() {
|
if CONFIG.mail_enabled() && device.is_new() {
|
||||||
@@ -703,24 +782,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
|
// 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,
|
"MasterKeyEncryptedUserKey": user.akey,
|
||||||
"MasterKeyWrappedUserKey": user.akey,
|
"MasterKeyWrappedUserKey": user.akey,
|
||||||
"Salt": user.email
|
"Salt": user.master_password_salt()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = if user.private_key.is_some() {
|
let account_keys = user.account_keys_json(conn).await;
|
||||||
json!({
|
|
||||||
"publicKeyEncryptionKeyPair": {
|
|
||||||
"wrappedPrivateKey": user.private_key,
|
|
||||||
"publicKey": user.public_key,
|
|
||||||
"Object": "publicKeyEncryptionKeyPair"
|
|
||||||
},
|
|
||||||
"Object": "privateKeys"
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Value::Null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Note: No refresh_token is returned. The CLI just repeats the
|
// Note: No refresh_token is returned. The CLI just repeats the
|
||||||
// client_credentials login flow when the existing token expires.
|
// client_credentials login flow when the existing token expires.
|
||||||
@@ -1351,3 +1419,33 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
|
|||||||
|
|
||||||
Ok(Redirect::temporary(String::from(auth_url)))
|
Ok(Redirect::temporary(String::from(auth_url)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_version_error() {
|
||||||
|
let version = |v| semver::Version::parse(v).unwrap();
|
||||||
|
let web = DeviceType::ChromeBrowser as i32;
|
||||||
|
let desktop = DeviceType::WindowsDesktop as i32;
|
||||||
|
// Every login needs the header
|
||||||
|
assert_eq!(client_version_error(false, None, web).map(|e| e.0), Some("version_header_missing"));
|
||||||
|
assert_eq!(client_version_error(true, None, desktop).map(|e| e.0), Some("version_header_missing"));
|
||||||
|
// V1 accounts log in from any version
|
||||||
|
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), web), None);
|
||||||
|
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), desktop), None);
|
||||||
|
// V2 accounts need 2026.10.1, and 2026.10.2 for the desktop app
|
||||||
|
assert_eq!(
|
||||||
|
client_version_error(true, Some(&version("2026.10.0")), web).map(|e| e.0),
|
||||||
|
Some("invalid_client_version")
|
||||||
|
);
|
||||||
|
assert_eq!(client_version_error(true, Some(&version("2026.10.1")), web), None);
|
||||||
|
assert_eq!(
|
||||||
|
client_version_error(true, Some(&version("2026.10.1")), desktop).map(|e| e.0),
|
||||||
|
Some("invalid_client_version")
|
||||||
|
);
|
||||||
|
assert_eq!(client_version_error(true, Some(&version("2026.10.2")), DeviceType::Uwp as i32), None);
|
||||||
|
assert_eq!(client_version_error(true, Some(&version("2026.11.0")), desktop), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -4,8 +4,13 @@ mod icons;
|
|||||||
mod identity;
|
mod identity;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod push;
|
mod push;
|
||||||
|
#[cfg(feature = "unsafe_test_mode")]
|
||||||
|
mod test_seeder;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
|
#[cfg(feature = "unsafe_test_mode")]
|
||||||
|
pub use test_seeder::routes as test_seeder_routes;
|
||||||
|
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -23,7 +28,7 @@ pub use crate::api::{
|
|||||||
icons::routes as icons_routes,
|
icons::routes as icons_routes,
|
||||||
identity::routes as identity_routes,
|
identity::routes as identity_routes,
|
||||||
notifications::routes as notifications_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::{
|
||||||
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
|
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
|
||||||
register_push_device, unregister_push_device,
|
register_push_device, unregister_push_device,
|
||||||
|
|||||||
@@ -412,23 +412,35 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
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
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
||||||
let data = create_update(
|
let mut payload =
|
||||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))];
|
||||||
UpdateType::LogOut,
|
if let Some(reason) = reason {
|
||||||
acting_device_id,
|
payload.push(("Reason".into(), (reason as i32).into()));
|
||||||
);
|
}
|
||||||
|
let data = create_update(payload, UpdateType::LogOut, acting_device_id);
|
||||||
|
|
||||||
if CONFIG.enable_websocket() {
|
if CONFIG.enable_websocket() {
|
||||||
self.send_update(&user.uuid, &data).await;
|
self.send_update(&user.uuid, &data).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.push_enabled() {
|
if CONFIG.push_enabled() {
|
||||||
push_logout(user, acting_device, conn).await;
|
push_logout(user, acting_device, reason, conn).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +704,14 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
|||||||
serialize(&value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
||||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
pub enum UpdateType {
|
pub enum UpdateType {
|
||||||
|
|||||||
+6
-5
@@ -12,7 +12,7 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, UpdateType},
|
api::{ApiResult, EmptyResult, LogOutReason, UpdateType},
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||||
@@ -192,15 +192,15 @@ 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 {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
push_logout_to_relay(user, acting_device);
|
push_logout_to_relay(user, acting_device, reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sends the logout without checking for push devices first.
|
// Sends the logout without checking for push devices first.
|
||||||
// Needed after deleting a user, when the devices are already gone from the database.
|
// Needed after deleting a user, when the devices are already gone from the database.
|
||||||
pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) {
|
pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>) {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
"organizationId": (),
|
"organizationId": (),
|
||||||
@@ -209,7 +209,8 @@ pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) {
|
|||||||
"type": UpdateType::LogOut as i32,
|
"type": UpdateType::LogOut as i32,
|
||||||
"payload": {
|
"payload": {
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
"date": format_date(&user.updated_at)
|
"date": format_date(&user.updated_at),
|
||||||
|
"reason": reason.map(|r| r as i32),
|
||||||
},
|
},
|
||||||
"clientType": null,
|
"clientType": null,
|
||||||
"installationId": null
|
"installationId": null
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
//! A seeder API for the end-to-end tests, only in builds with the `unsafe_test_mode` feature: it creates accounts with
|
||||||
|
//! no checks at all, so it must never be in a release or a deployment.
|
||||||
|
//!
|
||||||
|
//! It answers the calls the SDK's live tests make to upstream's `util/SeederApi` (`POST /seed` with a scene,
|
||||||
|
//! `DELETE /seed/{playId}`), under `/test/seeder`. Each request needs HTTP basic auth with `TEST_SEEDER_USERNAME` and
|
||||||
|
//! `TEST_SEEDER_PASSWORD` from the environment; without both, every route answers 404. Seeding needs an `X-Play-Id`
|
||||||
|
//! header, and destroying a play deletes the accounts seeded under it. Plays are kept in memory, which is enough for a
|
||||||
|
//! test server that starts on an empty database.
|
||||||
|
//!
|
||||||
|
//! Scenes:
|
||||||
|
//! - `SingleUserScene`: a V1 account for an email and password, under a mangled address like upstream's, with
|
||||||
|
//! PBKDF2 at upstream's default of 5000 iterations.
|
||||||
|
//! - `TestVectorUserScene`: one of the SDK's user test vectors (`test-vectors/users/*.json`), stored as it is: the
|
||||||
|
//! account and its vault are already encrypted. Unlike upstream, the address isn't mangled, since our master
|
||||||
|
//! password salt is the email, so a vector can be seeded once at a time. Text Sends are stored too.
|
||||||
|
//!
|
||||||
|
//! Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/SeederApi/Controllers/SeedController.cs#L11-L107>
|
||||||
|
//! Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/Seeder/Scenes/SingleUserScene.cs#L38-L154>
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
num::NonZeroU32,
|
||||||
|
sync::{LazyLock, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use data_encoding::{BASE64, HEXLOWER};
|
||||||
|
use openssl::{
|
||||||
|
pkey::PKey,
|
||||||
|
rsa::Rsa,
|
||||||
|
symm::{Cipher, encrypt},
|
||||||
|
};
|
||||||
|
use ring::{hkdf, hmac, pbkdf2};
|
||||||
|
use rocket::{
|
||||||
|
Request, Route,
|
||||||
|
http::Status,
|
||||||
|
request::{FromRequest, Outcome},
|
||||||
|
serde::json::Json,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{
|
||||||
|
ApiResult, JsonResult,
|
||||||
|
core::{seed_cipher, seed_send},
|
||||||
|
},
|
||||||
|
crypto,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{Folder, FolderId, KeyId, SignatureAlgorithm, User, UserId, UserKdfType, UserSignatureKeyPair},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
routes![seed, destroy_play]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accounts seeded under each play id.
|
||||||
|
static PLAYS: LazyLock<Mutex<HashMap<String, Vec<UserId>>>> = LazyLock::new(Mutex::default);
|
||||||
|
|
||||||
|
/// HTTP basic auth with `TEST_SEEDER_USERNAME` and `TEST_SEEDER_PASSWORD`.
|
||||||
|
struct SeederAuth;
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for SeederAuth {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
let (Ok(username), Ok(password)) =
|
||||||
|
(std::env::var("TEST_SEEDER_USERNAME"), std::env::var("TEST_SEEDER_PASSWORD"))
|
||||||
|
else {
|
||||||
|
return Outcome::Error((Status::NotFound, "The test seeder is off"));
|
||||||
|
};
|
||||||
|
if username.is_empty() || password.is_empty() {
|
||||||
|
return Outcome::Error((Status::NotFound, "The test seeder is off"));
|
||||||
|
}
|
||||||
|
let expected = format!("Basic {}", BASE64.encode(format!("{username}:{password}").as_bytes()));
|
||||||
|
match request.headers().get_one("Authorization") {
|
||||||
|
Some(given) if crypto::ct_eq(given, expected) => Outcome::Success(Self),
|
||||||
|
_ => Outcome::Error((Status::Unauthorized, "Invalid test seeder credentials")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `X-Play-Id` header, which names the play a seeding belongs to.
|
||||||
|
struct PlayId(String);
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for PlayId {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
match request.headers().get_one("X-Play-Id").map(str::trim) {
|
||||||
|
Some(play_id) if !play_id.is_empty() => Outcome::Success(Self(play_id.to_owned())),
|
||||||
|
_ => Outcome::Error((Status::BadRequest, "Seeding needs an X-Play-Id header")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SeedRequest {
|
||||||
|
template: String,
|
||||||
|
arguments: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/seed", data = "<data>")]
|
||||||
|
async fn seed(data: Json<SeedRequest>, _auth: SeederAuth, play: PlayId, conn: DbConn) -> JsonResult {
|
||||||
|
let data = data.into_inner();
|
||||||
|
let arguments = data.arguments.unwrap_or(Value::Null);
|
||||||
|
let response = match data.template.as_str() {
|
||||||
|
"SingleUserScene" => single_user_scene(serde_json::from_value(arguments)?, &play.0, &conn).await?,
|
||||||
|
"TestVectorUserScene" => test_vector_user_scene(serde_json::from_value(arguments)?, &play.0, &conn).await?,
|
||||||
|
other => err_code!(format!("Unknown scene {}", other.escape_debug()), Status::NotFound.code),
|
||||||
|
};
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records an account in its play as soon as it's saved, so that destroying the play removes it even when the rest of
|
||||||
|
/// the seeding fails.
|
||||||
|
fn track(play: &str, user_id: &UserId) {
|
||||||
|
PLAYS.lock().unwrap().entry(play.to_owned()).or_default().push(user_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the accounts seeded under `play_id`; an unknown play is a no-op, like upstream.
|
||||||
|
#[delete("/seed/<play_id>")]
|
||||||
|
async fn destroy_play(play_id: &str, _auth: SeederAuth, conn: DbConn) -> JsonResult {
|
||||||
|
let user_ids = PLAYS.lock().unwrap().remove(play_id).unwrap_or_default();
|
||||||
|
let mut remaining = Vec::new();
|
||||||
|
let mut error = None;
|
||||||
|
for user_id in &user_ids {
|
||||||
|
let Some(user) = User::find_by_uuid(user_id, &conn).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Err(e) = user.delete(&conn).await {
|
||||||
|
remaining.push(user_id.clone());
|
||||||
|
error.get_or_insert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(error) = error {
|
||||||
|
// Kept in the play, so that destroying it again retries them
|
||||||
|
PLAYS.lock().unwrap().entry(play_id.to_owned()).or_default().extend(remaining);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "userIds": user_ids })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SingleUserRequest {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
email_verified: bool,
|
||||||
|
kdf_iterations: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
enroll_in_email2_fa: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
corrupt_private_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/Seeder/Models/UserSeed.cs#L55>
|
||||||
|
const DEFAULT_KDF_ITERATIONS: u32 = 5_000;
|
||||||
|
|
||||||
|
async fn single_user_scene(request: SingleUserRequest, play: &str, conn: &DbConn) -> ApiResult<Value> {
|
||||||
|
if request.enroll_in_email2_fa || request.corrupt_private_key {
|
||||||
|
err!("enrollInEmail2Fa and corruptPrivateKey aren't supported")
|
||||||
|
}
|
||||||
|
let iterations = request.kdf_iterations.unwrap_or(DEFAULT_KDF_ITERATIONS);
|
||||||
|
if !(5_000..=2_000_000).contains(&iterations) {
|
||||||
|
err!("kdfIterations must be between 5000 and 2000000")
|
||||||
|
}
|
||||||
|
let Some((local, domain)) = request.email.split_once('@') else {
|
||||||
|
err!("The email has no @")
|
||||||
|
};
|
||||||
|
// Like upstream's ManglerService: unique addresses for every seeding
|
||||||
|
let mangled = format!("{}+{local}@{domain}", HEXLOWER.encode(&crypto::get_random_bytes::<4>()));
|
||||||
|
|
||||||
|
let mut user = User::new(&mangled, None);
|
||||||
|
let keys = V1Keys::new(&request.password, &user.master_password_salt(), iterations)?;
|
||||||
|
user.client_kdf_type = UserKdfType::Pbkdf2 as i32;
|
||||||
|
user.client_kdf_iter = iterations.cast_signed();
|
||||||
|
user.client_kdf_memory = None;
|
||||||
|
user.client_kdf_parallelism = None;
|
||||||
|
user.private_key = Some(keys.private_key.clone());
|
||||||
|
user.public_key = Some(keys.public_key.clone());
|
||||||
|
if request.email_verified {
|
||||||
|
user.verified_at = Some(Utc::now().naive_utc());
|
||||||
|
}
|
||||||
|
user.set_password(&keys.master_password_hash, Some(keys.wrapped_user_key.clone()), false, None, conn).await?;
|
||||||
|
user.save(conn).await?;
|
||||||
|
track(play, &user.uuid);
|
||||||
|
|
||||||
|
let response = json!({
|
||||||
|
"mangleMap": mangle_map(&request.email, &user.email),
|
||||||
|
"result": {
|
||||||
|
"userId": user.uuid,
|
||||||
|
"kdf": "PBKDF2_SHA256",
|
||||||
|
"kdfIterations": iterations,
|
||||||
|
"key": keys.wrapped_user_key,
|
||||||
|
"publicKey": keys.public_key,
|
||||||
|
"privateKey": keys.private_key,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a V1 client makes when it registers, from the master password.
|
||||||
|
struct V1Keys {
|
||||||
|
/// The master password authentication hash, which the client logs in with.
|
||||||
|
master_password_hash: String,
|
||||||
|
/// The user key, wrapped by the stretched master key.
|
||||||
|
wrapped_user_key: String,
|
||||||
|
/// The PKCS#8 RSA private key, wrapped by the user key.
|
||||||
|
private_key: String,
|
||||||
|
/// The SPKI RSA public key.
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl V1Keys {
|
||||||
|
fn new(password: &str, salt: &str, iterations: u32) -> ApiResult<Self> {
|
||||||
|
let master_key = pbkdf2_sha256(password.as_bytes(), salt.as_bytes(), iterations);
|
||||||
|
let master_password_hash = BASE64.encode(&pbkdf2_sha256(&master_key, password.as_bytes(), 1));
|
||||||
|
let stretched_enc = hkdf_expand(&master_key, b"enc");
|
||||||
|
let stretched_mac = hkdf_expand(&master_key, b"mac");
|
||||||
|
|
||||||
|
let user_key = crypto::get_random_bytes::<64>();
|
||||||
|
let wrapped_user_key = enc_string(&user_key, &stretched_enc, &stretched_mac)?;
|
||||||
|
|
||||||
|
let key_pair = PKey::from_rsa(Rsa::generate(2048)?)?;
|
||||||
|
let private_key = enc_string(&key_pair.private_key_to_pkcs8()?, &user_key[..32], &user_key[32..])?;
|
||||||
|
let public_key = BASE64.encode(&key_pair.public_key_to_der()?);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
master_password_hash,
|
||||||
|
wrapped_user_key,
|
||||||
|
private_key,
|
||||||
|
public_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pbkdf2_sha256(secret: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] {
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
let iterations = NonZeroU32::new(iterations).expect("iterations aren't zero");
|
||||||
|
pbkdf2::derive(pbkdf2::PBKDF2_HMAC_SHA256, iterations, salt, secret, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HKDF-Expand with `key` as the pseudorandom key, as clients stretch the master key.
|
||||||
|
fn hkdf_expand(key: &[u8; 32], info: &[u8]) -> [u8; 32] {
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
hkdf::Prk::new_less_safe(hkdf::HKDF_SHA256, key)
|
||||||
|
.expand(&[info], hkdf::HKDF_SHA256)
|
||||||
|
.and_then(|okm| okm.fill(&mut out))
|
||||||
|
.expect("32 bytes is a valid HKDF-SHA256 length");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `AesCbc256_HmacSha256_B64` (type 2) EncString.
|
||||||
|
fn enc_string(plaintext: &[u8], enc_key: &[u8], mac_key: &[u8]) -> ApiResult<String> {
|
||||||
|
let iv = crypto::get_random_bytes::<16>();
|
||||||
|
let ciphertext = encrypt(Cipher::aes_256_cbc(), enc_key, Some(&iv), plaintext)?;
|
||||||
|
let mac = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, mac_key), &[iv.as_slice(), &ciphertext].concat());
|
||||||
|
Ok(format!("2.{}|{}|{}", BASE64.encode(&iv), BASE64.encode(&ciphertext), BASE64.encode(mac.as_ref())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user test vector; only what a server holds is read from it.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/sdk-internal/blob/ee307a55d5604e96b983595c6379cc9f76ca2f87/e2e/wasm/vectors/load.ts>
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TestVector {
|
||||||
|
name: String,
|
||||||
|
account: VectorAccount,
|
||||||
|
unlock_methods: Vec<Value>,
|
||||||
|
master_password_authentication_hash: Option<String>,
|
||||||
|
raw_cryptographic_state: VectorRawCryptographicState,
|
||||||
|
#[serde(default)]
|
||||||
|
vault: VectorVault,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct VectorAccount {
|
||||||
|
email: String,
|
||||||
|
security_version: Option<i32>,
|
||||||
|
account_cryptographic_state: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The public halves the vector records; its private halves are never read.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct VectorRawCryptographicState {
|
||||||
|
public_key: String,
|
||||||
|
verifying_key: Option<String>,
|
||||||
|
user_key_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct VectorVault {
|
||||||
|
#[serde(default)]
|
||||||
|
ciphers: Vec<VectorItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
folders: Vec<VectorItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
sends: Vec<VectorItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct VectorItem {
|
||||||
|
id: String,
|
||||||
|
encrypted: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upstream's scene, not merged there yet:
|
||||||
|
/// <https://github.com/bitwarden/server/blob/9614f3f54f78ad57090a1fd5228f9c2fac5cbfe2/util/Seeder/Scenes/TestVectorUserScene.cs#L43-L361>
|
||||||
|
async fn test_vector_user_scene(vector: TestVector, play: &str, conn: &DbConn) -> ApiResult<Value> {
|
||||||
|
let Some(unlock) =
|
||||||
|
vector.unlock_methods.iter().find_map(|m| m.pointer("/masterPasswordUnlock/master_password_unlock"))
|
||||||
|
else {
|
||||||
|
err!(format!("Vector {} has no master password unlock method", vector.name.escape_debug()))
|
||||||
|
};
|
||||||
|
let Some(hash) = vector.master_password_authentication_hash.as_deref() else {
|
||||||
|
err!(format!("Vector {} records no master password authentication hash", vector.name.escape_debug()))
|
||||||
|
};
|
||||||
|
let (Some(wrapped_user_key), Some(salt)) = (unlock["masterKeyWrappedUserKey"].as_str(), unlock["salt"].as_str())
|
||||||
|
else {
|
||||||
|
err!("The master password unlock method has no wrapped user key or salt")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut user = User::new(&vector.account.email, Some(vector.name.clone()));
|
||||||
|
if user.master_password_salt() != salt {
|
||||||
|
err!("The vector's master password salt isn't its email, which is the only salt Vaultwarden supports")
|
||||||
|
}
|
||||||
|
if User::find_by_mail(&user.email, conn).await.is_some() {
|
||||||
|
err!("This vector is already seeded: destroy its play first, its address can't be mangled")
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_kdf(&mut user, &unlock["kdf"])?;
|
||||||
|
let state = &vector.account.account_cryptographic_state;
|
||||||
|
let (v2, private_key) = match (state.get("V1"), state.get("V2")) {
|
||||||
|
(Some(v1), None) => (None, &v1["private_key"]),
|
||||||
|
(None, Some(v2)) => (Some(v2), &v2["private_key"]),
|
||||||
|
_ => err!("The account cryptographic state is neither V1 nor V2"),
|
||||||
|
};
|
||||||
|
user.private_key = Some(json_str(private_key, "private_key")?);
|
||||||
|
user.public_key = Some(vector.raw_cryptographic_state.public_key.clone());
|
||||||
|
let key_id = vector.raw_cryptographic_state.user_key_id.as_deref().or_else(|| unlock["containedKeyId"].as_str());
|
||||||
|
user.key_id = key_id.map(|id| serde_json::from_value::<KeyId>(json!(id))).transpose()?;
|
||||||
|
let signature_key_pair = match v2 {
|
||||||
|
Some(v2) => {
|
||||||
|
user.signed_public_key = Some(json_str(&v2["signed_public_key"], "signed_public_key")?);
|
||||||
|
user.security_state = Some(json_str(&v2["security_state"], "security_state")?);
|
||||||
|
user.security_version = Some(vector.account.security_version.unwrap_or(2));
|
||||||
|
let Some(verifying_key) = vector.raw_cryptographic_state.verifying_key.clone() else {
|
||||||
|
err!("A V2 vector needs its verifying key")
|
||||||
|
};
|
||||||
|
let algorithm = cose_signature_algorithm(&verifying_key)?;
|
||||||
|
Some((algorithm, json_str(&v2["signing_key"], "signing_key")?, verifying_key))
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
user.verified_at = Some(Utc::now().naive_utc());
|
||||||
|
user.set_password(hash, Some(wrapped_user_key.to_owned()), false, None, conn).await?;
|
||||||
|
user.save(conn).await?;
|
||||||
|
track(play, &user.uuid);
|
||||||
|
if let Some((algorithm, signing_key, verifying_key)) = signature_key_pair {
|
||||||
|
UserSignatureKeyPair::new(user.uuid.clone(), algorithm, signing_key, verifying_key).save(conn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The vault, already encrypted, under fresh ids
|
||||||
|
let mut folder_ids = HashMap::new();
|
||||||
|
let mut folder_ids_by_vector_id = serde_json::Map::new();
|
||||||
|
for folder in &vector.vault.folders {
|
||||||
|
let mut saved = Folder::new(user.uuid.clone(), json_str(&folder.encrypted["name"], "name")?);
|
||||||
|
saved.save(conn).await?;
|
||||||
|
folder_ids.insert(serde_json::from_value::<FolderId>(json!(folder.id))?, saved.uuid.clone());
|
||||||
|
folder_ids_by_vector_id.insert(folder.id.clone(), json!(saved.uuid));
|
||||||
|
}
|
||||||
|
let mut cipher_ids = serde_json::Map::new();
|
||||||
|
for cipher in vector.vault.ciphers {
|
||||||
|
let cipher_id = seed_cipher(cipher.encrypted, &user.uuid, &folder_ids, conn).await?;
|
||||||
|
cipher_ids.insert(cipher.id, json!(cipher_id));
|
||||||
|
}
|
||||||
|
for send in vector.vault.sends {
|
||||||
|
seed_send(send.encrypted, &user.uuid, conn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = json!({
|
||||||
|
"mangleMap": mangle_map(&vector.account.email, &user.email),
|
||||||
|
"result": {
|
||||||
|
"userId": user.uuid,
|
||||||
|
"email": user.email,
|
||||||
|
"cipherIds": cipher_ids,
|
||||||
|
"folderIds": folder_ids_by_vector_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vector's KDF: `{"pBKDF2": {"iterations"}}` or `{"argon2id": {"iterations", "memory", "parallelism"}}`.
|
||||||
|
fn apply_kdf(user: &mut User, kdf: &Value) -> ApiResult<()> {
|
||||||
|
let int = |value: &Value| value.as_i64().and_then(|v| i32::try_from(v).ok());
|
||||||
|
if let Some(iterations) = kdf.pointer("/pBKDF2/iterations").and_then(int) {
|
||||||
|
user.client_kdf_type = UserKdfType::Pbkdf2 as i32;
|
||||||
|
user.client_kdf_iter = iterations;
|
||||||
|
user.client_kdf_memory = None;
|
||||||
|
user.client_kdf_parallelism = None;
|
||||||
|
} else if let Some(argon2) = kdf.get("argon2id") {
|
||||||
|
let Some(iterations) = int(&argon2["iterations"]) else {
|
||||||
|
err!("The Argon2id KDF has no iterations")
|
||||||
|
};
|
||||||
|
user.client_kdf_type = UserKdfType::Argon2id as i32;
|
||||||
|
user.client_kdf_iter = iterations;
|
||||||
|
user.client_kdf_memory = int(&argon2["memory"]);
|
||||||
|
user.client_kdf_parallelism = int(&argon2["parallelism"]);
|
||||||
|
} else {
|
||||||
|
err!("Unknown KDF")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signature algorithm a COSE verifying key names in its `alg` (label 3) entry.
|
||||||
|
fn cose_signature_algorithm(verifying_key: &str) -> ApiResult<SignatureAlgorithm> {
|
||||||
|
const ALG: i128 = 3;
|
||||||
|
const EDDSA: i128 = -8;
|
||||||
|
const ML_DSA_44: i128 = -48;
|
||||||
|
|
||||||
|
let Ok(bytes) = BASE64.decode(verifying_key.as_bytes()) else {
|
||||||
|
err!("The verifying key isn't base64")
|
||||||
|
};
|
||||||
|
let Ok(serde_cbor_2::Value::Map(entries)) = serde_cbor_2::from_slice::<serde_cbor_2::Value>(&bytes) else {
|
||||||
|
err!("The verifying key isn't a COSE key")
|
||||||
|
};
|
||||||
|
match entries.get(&serde_cbor_2::Value::Integer(ALG)) {
|
||||||
|
Some(serde_cbor_2::Value::Integer(EDDSA)) => Ok(SignatureAlgorithm::Ed25519),
|
||||||
|
Some(serde_cbor_2::Value::Integer(ML_DSA_44)) => Ok(SignatureAlgorithm::MlDsa44),
|
||||||
|
_ => err!("The verifying key's algorithm isn't Ed25519 or ML-DSA-44"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upstream's map from the address a scene was asked for to the one it stored.
|
||||||
|
fn mangle_map(requested: &str, stored: &str) -> Value {
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
map.insert(requested.to_owned(), json!(stored));
|
||||||
|
Value::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_str(value: &Value, field: &str) -> ApiResult<String> {
|
||||||
|
let Some(value) = value.as_str() else {
|
||||||
|
err!(format!("The vector's {field} is missing"))
|
||||||
|
};
|
||||||
|
Ok(value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use openssl::symm::decrypt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// From sdk-internal's `test-vectors/users/v1-pbkdf2-min-iterations.json` (d9119f7af)
|
||||||
|
const EMAIL: &str = "v1-pbkdf2-min-iterations@test.bitwarden.com";
|
||||||
|
const PASSWORD: &str = "password-v1-pbkdf2-min-iterations";
|
||||||
|
const ITERATIONS: u32 = 5_000;
|
||||||
|
const AUTHENTICATION_HASH: &str = "4HcwTg4bQKEbuINCJGMqrij1yipl63Ugu/RazH2y7Qw=";
|
||||||
|
const WRAPPED_USER_KEY: &str = "2.tl+DrZdn+fVyBVu+0c/Gvg==|9ivOLNtKbvirNaUnkGsEtysp9de53ibTSu88HLkDOGLig3t8nhvK5JIqxSY1/ukcr2Y/Ly9OJ12tXUGjIlJ/PtIEIdYj3gZDwhAflPUwtW8=|FD5WuIvviib3yWYcI3slmqBSqMf38Z0CMwttPG1w5DY=";
|
||||||
|
const USER_KEY: &str = "49LghK4AVIybWSZFikb4nAn/BofxxBIoXv83EJWOP4taVMbR5XCfrbF2m/eUmfZdeFW7VybPYSGT/bmv5eeMBg==";
|
||||||
|
|
||||||
|
/// Decrypts a type 2 EncString, checking its MAC.
|
||||||
|
fn dec_string(enc_string: &str, enc_key: &[u8], mac_key: &[u8]) -> Vec<u8> {
|
||||||
|
let parts: Vec<Vec<u8>> = enc_string
|
||||||
|
.strip_prefix("2.")
|
||||||
|
.unwrap()
|
||||||
|
.split('|')
|
||||||
|
.map(|part| BASE64.decode(part.as_bytes()).unwrap())
|
||||||
|
.collect();
|
||||||
|
let [iv, ciphertext, mac] = parts.as_slice() else {
|
||||||
|
panic!("not three parts")
|
||||||
|
};
|
||||||
|
let key = hmac::Key::new(hmac::HMAC_SHA256, mac_key);
|
||||||
|
hmac::verify(&key, &[iv.as_slice(), ciphertext].concat(), mac).unwrap();
|
||||||
|
decrypt(Cipher::aes_256_cbc(), enc_key, Some(iv), ciphertext).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derives_the_vectors_keys() {
|
||||||
|
let master_key = pbkdf2_sha256(PASSWORD.as_bytes(), EMAIL.as_bytes(), ITERATIONS);
|
||||||
|
assert_eq!(BASE64.encode(&pbkdf2_sha256(&master_key, PASSWORD.as_bytes(), 1)), AUTHENTICATION_HASH);
|
||||||
|
|
||||||
|
let user_key =
|
||||||
|
dec_string(WRAPPED_USER_KEY, &hkdf_expand(&master_key, b"enc"), &hkdf_expand(&master_key, b"mac"));
|
||||||
|
assert_eq!(BASE64.encode(&user_key), USER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn makes_keys_a_client_can_open() {
|
||||||
|
let keys = V1Keys::new(PASSWORD, EMAIL, ITERATIONS).unwrap();
|
||||||
|
assert_eq!(keys.master_password_hash, AUTHENTICATION_HASH);
|
||||||
|
|
||||||
|
let master_key = pbkdf2_sha256(PASSWORD.as_bytes(), EMAIL.as_bytes(), ITERATIONS);
|
||||||
|
let user_key =
|
||||||
|
dec_string(&keys.wrapped_user_key, &hkdf_expand(&master_key, b"enc"), &hkdf_expand(&master_key, b"mac"));
|
||||||
|
assert_eq!(user_key.len(), 64);
|
||||||
|
|
||||||
|
let pkcs8 = dec_string(&keys.private_key, &user_key[..32], &user_key[32..]);
|
||||||
|
let private_key = PKey::private_key_from_pkcs8(&pkcs8).unwrap();
|
||||||
|
assert_eq!(BASE64.encode(&private_key.public_key_to_der().unwrap()), keys.public_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-25
@@ -34,7 +34,7 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||||
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||||
SendFileId, SendId, User, UserId, UserStampException,
|
SendFileId, SendId, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -632,6 +632,9 @@ impl<'r> FromRequest<'r> for Host {
|
|||||||
pub struct ClientHeaders {
|
pub struct ClientHeaders {
|
||||||
pub device_type: i32,
|
pub device_type: i32,
|
||||||
pub ip: ClientIp,
|
pub ip: ClientIp,
|
||||||
|
/// `Bitwarden-Client-Version`, `None` when missing or not a version. Unlike the [`ClientVersion`] guard, this
|
||||||
|
/// doesn't log an error, as not every client sends it.
|
||||||
|
pub client_version: Option<semver::Version>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
@@ -648,10 +651,13 @@ impl<'r> FromRequest<'r> for ClientHeaders {
|
|||||||
.get_one("device-type")
|
.get_one("device-type")
|
||||||
.and_then(|d| d.parse().ok())
|
.and_then(|d| d.parse().ok())
|
||||||
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
||||||
|
let client_version =
|
||||||
|
request.headers().get_one("Bitwarden-Client-Version").and_then(|v| semver::Version::parse(v).ok());
|
||||||
|
|
||||||
Outcome::Success(ClientHeaders {
|
Outcome::Success(ClientHeaders {
|
||||||
device_type,
|
device_type,
|
||||||
ip,
|
ip,
|
||||||
|
client_version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -707,31 +713,12 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if user.security_stamp != claims.sstamp {
|
if user.security_stamp != claims.sstamp {
|
||||||
if let Some(stamp_exception) =
|
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
|
||||||
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
|
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.
|
// Expired exceptions are only dropped on reset; writing them back here could undo a newer reset
|
||||||
// Then, check if the current route matches any of the allowed routes.
|
if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) {
|
||||||
// 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(¤t_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 {
|
|
||||||
err_handler!("Invalid security stamp")
|
err_handler!("Invalid security stamp")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1516,6 +1516,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
// Key Management Team
|
// Key Management Team
|
||||||
"biometrics-sdk-ipc",
|
"biometrics-sdk-ipc",
|
||||||
"windows-native-credential-sync",
|
"windows-native-credential-sync",
|
||||||
|
"enable-account-encryption-v2-jit-password-registration",
|
||||||
|
"pm-27278-v2-password-registration",
|
||||||
// Mobile Team
|
// Mobile Team
|
||||||
"pm-34171-card-scanner",
|
"pm-34171-card-scanner",
|
||||||
// Platform Team
|
// Platform Team
|
||||||
|
|||||||
+108
-61
@@ -19,7 +19,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
util::LowerCase,
|
util::{LowerCase, convert_json_key_lcase_first},
|
||||||
};
|
};
|
||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
@@ -63,6 +63,18 @@ pub struct Cipher {
|
|||||||
pub reprompt: Option<i32>,
|
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 {
|
pub enum RepromptType {
|
||||||
None = 0,
|
None = 0,
|
||||||
Password = 1,
|
Password = 1,
|
||||||
@@ -70,7 +82,8 @@ pub enum RepromptType {
|
|||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl Cipher {
|
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();
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -84,7 +97,7 @@ impl Cipher {
|
|||||||
key: None,
|
key: None,
|
||||||
|
|
||||||
atype,
|
atype,
|
||||||
name,
|
name: String::new(),
|
||||||
|
|
||||||
notes: None,
|
notes: None,
|
||||||
fields: None,
|
fields: None,
|
||||||
@@ -110,6 +123,16 @@ impl Cipher {
|
|||||||
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
|
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let is_blob = cipher.is_blob();
|
||||||
|
if let Err(e) = cipher.validate_content(is_blob) {
|
||||||
|
validation_errors
|
||||||
|
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
|
||||||
|
}
|
||||||
|
if let Err(e) = cipher.validate_type_data(is_blob) {
|
||||||
|
validation_errors
|
||||||
|
.insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
// Validate the password history if it contains `null` values and if so, return a warning
|
// 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 {
|
if let Some(Value::Array(password_history)) = &cipher.password_history {
|
||||||
for pwh in password_history {
|
for pwh in password_history {
|
||||||
@@ -154,6 +177,10 @@ impl Cipher {
|
|||||||
) -> Result<Value, crate::Error> {
|
) -> Result<Value, crate::Error> {
|
||||||
use crate::util::{format_date, validate_and_format_date};
|
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;
|
let mut attachments_json: Value = Value::Null;
|
||||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
|
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
|
||||||
@@ -250,63 +277,9 @@ impl Cipher {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Get the type_data or a default to an empty json object '{}'.
|
// Like upstream, fields and password history stored as NULL are sent as null, not `[]`
|
||||||
// If not passing an empty object, mobile clients will crash.
|
let fields_json = self.fields.is_some().then_some(fields_json);
|
||||||
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data)
|
let password_history_json = self.password_history.is_some().then_some(password_history_json);
|
||||||
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
|
|
||||||
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
|
|
||||||
|
|
||||||
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
|
|
||||||
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
|
|
||||||
if self.atype == 1 {
|
|
||||||
// Upstream always has an `uri` key/value
|
|
||||||
type_data_json["uri"] = Value::Null;
|
|
||||||
if let Some(uris) = type_data_json["uris"].as_array_mut()
|
|
||||||
&& !uris.is_empty()
|
|
||||||
{
|
|
||||||
// Fix uri match values first, they are only allowed to be a number or null
|
|
||||||
// If it is a string, convert it to an int or null if that fails
|
|
||||||
for uri in &mut *uris {
|
|
||||||
if uri["match"].is_string() {
|
|
||||||
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
|
|
||||||
Ok(n) => json!(n),
|
|
||||||
_ => Value::Null,
|
|
||||||
};
|
|
||||||
uri["match"] = match_value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
type_data_json["uri"] = uris[0]["uri"].clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if `passwordRevisionDate` is a valid date, else convert it
|
|
||||||
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
|
|
||||||
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix secure note issues when data is invalid
|
|
||||||
// This breaks at least the native mobile clients
|
|
||||||
if self.atype == 2 {
|
|
||||||
match type_data_json {
|
|
||||||
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
|
|
||||||
_ => {
|
|
||||||
type_data_json = json!({"type": 0});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix invalid SSH Entries
|
|
||||||
// This breaks at least the native mobile client if invalid
|
|
||||||
// The only way to fix this is by setting type_data_json to `null`
|
|
||||||
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
|
|
||||||
if self.atype == 5
|
|
||||||
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|
|
||||||
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|
|
||||||
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
|
|
||||||
{
|
|
||||||
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
|
|
||||||
type_data_json = Value::Null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
|
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) {
|
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
|
||||||
@@ -403,10 +376,84 @@ impl Cipher {
|
|||||||
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
|
_ => 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)
|
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> {
|
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
|
||||||
let mut user_uuids = Vec::new();
|
let mut user_uuids = Vec::new();
|
||||||
match self.user_uuid {
|
match self.user_uuid {
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ mod two_factor;
|
|||||||
mod two_factor_duo_context;
|
mod two_factor_duo_context;
|
||||||
mod two_factor_incomplete;
|
mod two_factor_incomplete;
|
||||||
mod user;
|
mod user;
|
||||||
|
mod user_signature_key_pair;
|
||||||
|
|
||||||
pub use self::archive::Archive;
|
pub use self::archive::Archive;
|
||||||
pub use self::attachment::{Attachment, AttachmentId};
|
pub use self::attachment::{Attachment, AttachmentId};
|
||||||
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
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::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
||||||
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
||||||
pub use self::emergency_access::{
|
pub use self::emergency_access::{
|
||||||
@@ -41,4 +42,5 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
|||||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
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};
|
||||||
@@ -69,6 +69,9 @@ pub struct Membership {
|
|||||||
access_event_logs: bool,
|
access_event_logs: bool,
|
||||||
access_import_export: bool,
|
access_import_export: bool,
|
||||||
access_reports: bool,
|
access_reports: bool,
|
||||||
|
|
||||||
|
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
|
||||||
|
pub v2_upgrade_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -407,6 +410,8 @@ impl Membership {
|
|||||||
access_event_logs: false,
|
access_event_logs: false,
|
||||||
access_import_export: false,
|
access_import_export: false,
|
||||||
access_reports: false,
|
access_reports: false,
|
||||||
|
|
||||||
|
v2_upgrade_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+185
-28
@@ -6,6 +6,7 @@ use serde_json::Value;
|
|||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
|
auth::DEFAULT_ACCESS_VALIDITY,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
@@ -20,7 +21,7 @@ use macros::UuidFromParam;
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
|
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
|
||||||
TwoFactorIncomplete,
|
TwoFactorIncomplete, UserSignatureKeyPair,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
#[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 external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
|
||||||
|
|
||||||
pub key_id: Option<KeyId>,
|
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)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
@@ -100,13 +108,28 @@ enum UserStatus {
|
|||||||
_Disabled = 2,
|
_Disabled = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A previous security stamp that is still accepted, until `expire`.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct UserStampException {
|
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 security_stamp: String,
|
||||||
pub expire: i64,
|
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
|
/// Local methods
|
||||||
impl User {
|
impl User {
|
||||||
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
||||||
@@ -159,9 +182,31 @@ impl User {
|
|||||||
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
||||||
|
|
||||||
key_id: None,
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upstream's `HasV2Encryption`: a private key wrapped as `CoseEncrypt0B64` (type 7) and security version 2.
|
||||||
|
/// Upstream only uses it to decide which accounts need a v2-capable client.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/8da186ad73f80d37157fe6fd42323e4284e83ec1/src/Core/Entities/User.cs#L245-L278>
|
||||||
|
pub fn has_v2_encryption(&self) -> bool {
|
||||||
|
self.security_version == Some(2)
|
||||||
|
&& self
|
||||||
|
.private_key
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|private_key| private_key.split_once('.'))
|
||||||
|
.is_some_and(|(enc_type, _)| enc_type.parse::<u8>() == Ok(7))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn check_valid_password(&self, password: &str) -> bool {
|
pub fn check_valid_password(&self, password: &str) -> bool {
|
||||||
crypto::verify_password_hash(
|
crypto::verify_password_hash(
|
||||||
password.as_bytes(),
|
password.as_bytes(),
|
||||||
@@ -171,6 +216,7 @@ impl User {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email
|
||||||
pub fn master_password_salt(&self) -> String {
|
pub fn master_password_salt(&self) -> String {
|
||||||
self.email.trim().to_lowercase()
|
self.email.trim().to_lowercase()
|
||||||
}
|
}
|
||||||
@@ -225,10 +271,33 @@ impl User {
|
|||||||
|
|
||||||
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
self.security_stamp = get_uuid();
|
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?;
|
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
||||||
Ok(())
|
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.
|
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -237,17 +306,30 @@ impl User {
|
|||||||
/// After these 2 minutes this stamp will expire.
|
/// After these 2 minutes this stamp will expire.
|
||||||
///
|
///
|
||||||
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
||||||
let stamp_exception = UserStampException {
|
self.set_stamp_exceptions(&[UserStampException {
|
||||||
routes: route_exception,
|
routes: Some(route_exception),
|
||||||
security_stamp: self.security_stamp.clone(),
|
security_stamp: self.security_stamp.clone(),
|
||||||
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
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
|
/// The previous security stamps that are still accepted, expired ones included.
|
||||||
pub fn reset_stamp_exception(&mut self) {
|
pub fn stamp_exceptions(&self) -> Vec<UserStampException> {
|
||||||
self.stamp_exception = None;
|
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 {
|
pub fn display_name(&self) -> &str {
|
||||||
@@ -266,18 +348,37 @@ impl User {
|
|||||||
!CONFIG.mail_enabled() || self.verified_at.is_some()
|
!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
|
/// 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() {
|
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!({
|
json!({
|
||||||
"publicKeyEncryptionKeyPair": {
|
"publicKeyEncryptionKeyPair": {
|
||||||
"wrappedPrivateKey": self.private_key,
|
"wrappedPrivateKey": self.private_key,
|
||||||
"publicKey": self.public_key,
|
"publicKey": self.public_key,
|
||||||
"signedPublicKey": null,
|
"signedPublicKey": signed_public_key,
|
||||||
"object": "publicKeyEncryptionKeyPair",
|
"object": "publicKeyEncryptionKeyPair",
|
||||||
},
|
},
|
||||||
"securityState": null,
|
"securityState": security_state,
|
||||||
"signatureKeyPair": null,
|
"signatureKeyPair": signature_key_pair,
|
||||||
"object": "privateKeys"
|
"object": "privateKeys"
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -285,6 +386,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 {
|
pub async fn to_json(&self, conn: &DbConn) -> Value {
|
||||||
let mut orgs_json = Vec::new();
|
let mut orgs_json = Vec::new();
|
||||||
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||||
@@ -305,7 +424,7 @@ impl User {
|
|||||||
UserStatus::Enabled
|
UserStatus::Enabled
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = self.account_keys_json();
|
let account_keys = self.account_keys_json(conn).await;
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"_status": status as i32,
|
"_status": status as i32,
|
||||||
@@ -382,6 +501,7 @@ impl User {
|
|||||||
Device::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?;
|
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
TwoFactorIncomplete::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
|
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
||||||
|
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
@@ -553,25 +673,26 @@ impl Invitation {
|
|||||||
pub struct UserId(String);
|
pub struct UserId(String);
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Clone,
|
Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, AsRef, Deref, Display, From, UuidFromParam,
|
||||||
Debug,
|
|
||||||
DieselNewType,
|
|
||||||
FromForm,
|
|
||||||
PartialEq,
|
|
||||||
Eq,
|
|
||||||
Hash,
|
|
||||||
Serialize,
|
|
||||||
Deserialize,
|
|
||||||
AsRef,
|
|
||||||
Deref,
|
|
||||||
Display,
|
|
||||||
From,
|
|
||||||
UuidFromParam,
|
|
||||||
)]
|
)]
|
||||||
#[deref(forward)]
|
#[deref(forward)]
|
||||||
#[from(forward)]
|
#[from(forward)]
|
||||||
pub struct KeyId(String);
|
pub struct KeyId(String);
|
||||||
|
|
||||||
|
/// Like upstream, a key id is 32 lowercase hex characters. Key ids are compared as strings, so one in any other
|
||||||
|
/// format would never match the id the clients send.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/8da186ad73f80d37157fe6fd42323e4284e83ec1/src/Core/KeyManagement/Models/Data/KeyId.cs#L20-L42>
|
||||||
|
impl<'de> serde::Deserialize<'de> for KeyId {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let key_id = String::deserialize(deserializer)?;
|
||||||
|
if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
|
||||||
|
return Err(serde::de::Error::custom("Key id must be 32 lowercase hex characters"));
|
||||||
|
}
|
||||||
|
Ok(Self(key_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SsoUser {
|
impl SsoUser {
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
@@ -625,3 +746,39 @@ impl SsoUser {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_id_format() {
|
||||||
|
let key_id: KeyId = serde_json::from_str(r#""0123456789abcdef0123456789abcdef""#).unwrap();
|
||||||
|
assert_eq!(key_id.as_ref(), "0123456789abcdef0123456789abcdef");
|
||||||
|
|
||||||
|
for invalid in ["", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef", "0123456789abcdef0123456789abcdeg"]
|
||||||
|
{
|
||||||
|
assert!(serde_json::from_str::<KeyId>(&format!("\"{invalid}\"")).is_err(), "{invalid:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// An absent key id stays optional
|
||||||
|
assert_eq!(serde_json::from_str::<Option<KeyId>>("null").unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_v2_encryption() {
|
||||||
|
let user = |private_key: Option<&str>, security_version: Option<i32>| {
|
||||||
|
let mut user = User::new("user@example.com", None);
|
||||||
|
user.private_key = private_key.map(String::from);
|
||||||
|
user.security_version = security_version;
|
||||||
|
user
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(user(Some("7.cose"), Some(2)).has_v2_encryption());
|
||||||
|
assert!(!user(Some("7.cose"), Some(1)).has_v2_encryption());
|
||||||
|
assert!(!user(Some("7.cose"), None).has_v2_encryption());
|
||||||
|
assert!(!user(Some("2.iv|data|mac"), Some(2)).has_v2_encryption());
|
||||||
|
assert!(!user(Some("7"), Some(2)).has_v2_encryption());
|
||||||
|
assert!(!user(None, Some(2)).has_v2_encryption());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -219,6 +219,22 @@ table! {
|
|||||||
avatar_color -> Nullable<Text>,
|
avatar_color -> Nullable<Text>,
|
||||||
external_id -> Nullable<Text>,
|
external_id -> Nullable<Text>,
|
||||||
key_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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +269,7 @@ table! {
|
|||||||
access_event_logs -> Bool,
|
access_event_logs -> Bool,
|
||||||
access_import_export -> Bool,
|
access_import_export -> Bool,
|
||||||
access_reports -> Bool,
|
access_reports -> Bool,
|
||||||
|
v2_upgrade_token -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +410,7 @@ joinable!(collections_groups -> groups (groups_uuid));
|
|||||||
joinable!(event -> users_organizations (uuid));
|
joinable!(event -> users_organizations (uuid));
|
||||||
joinable!(auth_requests -> users (user_uuid));
|
joinable!(auth_requests -> users (user_uuid));
|
||||||
joinable!(sso_users -> users (user_uuid));
|
joinable!(sso_users -> users (user_uuid));
|
||||||
|
joinable!(user_signature_key_pairs -> users (user_uuid));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(
|
allow_tables_to_appear_in_same_query!(
|
||||||
archives,
|
archives,
|
||||||
@@ -419,4 +437,5 @@ allow_tables_to_appear_in_same_query!(
|
|||||||
collections_groups,
|
collections_groups,
|
||||||
event,
|
event,
|
||||||
auth_requests,
|
auth_requests,
|
||||||
|
user_signature_key_pairs,
|
||||||
);
|
);
|
||||||
+11
-1
@@ -577,9 +577,19 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
|||||||
.limit("data-form", 525.megabytes()) // This needs to match the maximum allowed file size for Send
|
.limit("data-form", 525.megabytes()) // This needs to match the maximum allowed file size for Send
|
||||||
.limit("file", 525.megabytes()); // This needs to match the maximum allowed file size for attachments
|
.limit("file", 525.megabytes()); // This needs to match the maximum allowed file size for attachments
|
||||||
|
|
||||||
|
let rocket = rocket::custom(config);
|
||||||
|
#[cfg(feature = "unsafe_test_mode")]
|
||||||
|
let rocket = {
|
||||||
|
warn!(
|
||||||
|
"This build has `unsafe_test_mode`: its test seeder at /test/seeder creates accounts without any checks. \
|
||||||
|
Never use it outside of tests!"
|
||||||
|
);
|
||||||
|
rocket.mount([basepath, "/test/seeder"].concat(), api::test_seeder_routes())
|
||||||
|
};
|
||||||
|
|
||||||
// If adding more paths here, consider also adding them to
|
// If adding more paths here, consider also adding them to
|
||||||
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
||||||
let instance = rocket::custom(config)
|
let instance = rocket
|
||||||
.mount([basepath, "/"].concat(), api::web_routes())
|
.mount([basepath, "/"].concat(), api::web_routes())
|
||||||
.mount([basepath, "/api"].concat(), api::core_routes())
|
.mount([basepath, "/api"].concat(), api::core_routes())
|
||||||
.mount([basepath, "/admin"].concat(), api::admin_routes())
|
.mount([basepath, "/admin"].concat(), api::admin_routes())
|
||||||
|
|||||||
@@ -899,6 +899,12 @@ pub fn parse_experimental_client_feature_flags(
|
|||||||
.collect()
|
.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:
|
/// 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
|
/// 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
|
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged
|
||||||
|
|||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs the live-server end-to-end tests of bitwarden/sdk-internal (e2e/wasm) against vaultwarden.
|
||||||
|
#
|
||||||
|
# Usage: tools/sdk-live-tests/run.sh --sdk-dir <path> [--skip-sdk-build] [--mode vectors|seeder] [-- <vitest args>]
|
||||||
|
#
|
||||||
|
# The server is built with `unsafe_test_mode`, whose test seeder (src/api/test_seeder.rs) creates the test accounts.
|
||||||
|
# - vectors: for SDK versions whose live tests log in to one given account. Every user test vector with a master
|
||||||
|
# password gets a fresh server, is seeded there with its vault, and the tests run on it (they rotate its keys).
|
||||||
|
# - seeder: for SDK versions whose tests seed their own accounts through BW_LIVE_SEEDER_URL. One server, one run.
|
||||||
|
# The mode follows what the SDK's e2e/wasm/live/config.ts reads, unless --mode says otherwise. Arguments after `--` go
|
||||||
|
# to vitest, e.g. `-- tests/user-crypto-management/rotate-user-keys.live.test.ts` to run one file.
|
||||||
|
#
|
||||||
|
# Every vector runs even after a failure; a summary follows, and the exit code is 1 if any failed. The server logs are
|
||||||
|
# in <target dir>/sdk-live-tests/. BW_LIVE_CLIENT_VERSION defaults to 2026.10.1, the minimum for V2 accounts.
|
||||||
|
#
|
||||||
|
# Needs: the SDK's Rust toolchain with the wasm32-unknown-unknown target and rust-src, Node.js >= 22.12, curl, python3.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VW_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
TARGET_DIR="${CARGO_TARGET_DIR:-${VW_DIR}/target}"
|
||||||
|
OUT_DIR="${TARGET_DIR}/sdk-live-tests"
|
||||||
|
PORT=8099
|
||||||
|
URL="http://127.0.0.1:${PORT}"
|
||||||
|
SEEDER_URL="${URL}/test/seeder"
|
||||||
|
SEEDER_USERNAME="sdk-live-tests"
|
||||||
|
SEEDER_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')"
|
||||||
|
SKIP=3 # seed_vector.py's exit code for a vector the tests can't use
|
||||||
|
export BW_LIVE_CLIENT_VERSION="${BW_LIVE_CLIENT_VERSION:-2026.10.1}"
|
||||||
|
|
||||||
|
SDK_DIR=""
|
||||||
|
BUILD_SDK=1
|
||||||
|
MODE=""
|
||||||
|
VITEST_ARGS=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--sdk-dir) SDK_DIR="$(cd "$2" && pwd)"; shift 2 ;;
|
||||||
|
--skip-sdk-build) BUILD_SDK=0; shift ;;
|
||||||
|
--mode) MODE="$2"; shift 2 ;;
|
||||||
|
--) shift; VITEST_ARGS=("$@"); break ;;
|
||||||
|
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "${SDK_DIR}" ]] || { echo "--sdk-dir is required" >&2; exit 2; }
|
||||||
|
TESTS_DIR="${SDK_DIR}/e2e/wasm"
|
||||||
|
[[ -f "${TESTS_DIR}/live/config.ts" ]] || { echo "${TESTS_DIR}/live/config.ts not found: no live tests in this SDK" >&2; exit 2; }
|
||||||
|
if [[ -z "${MODE}" ]]; then
|
||||||
|
if grep -q BW_LIVE_SEEDER_URL "${TESTS_DIR}/live/config.ts"; then MODE=seeder; else MODE=vectors; fi
|
||||||
|
fi
|
||||||
|
[[ "${MODE}" == vectors || "${MODE}" == seeder ]] || { echo "--mode must be vectors or seeder" >&2; exit 2; }
|
||||||
|
echo "Mode: ${MODE}, client version ${BW_LIVE_CLIENT_VERSION}"
|
||||||
|
|
||||||
|
(cd "${VW_DIR}" && cargo build --features sqlite,unsafe_test_mode)
|
||||||
|
if [[ ${BUILD_SDK} -eq 1 ]]; then
|
||||||
|
bash "${SDK_DIR}/crates/bitwarden-wasm-internal/build.sh"
|
||||||
|
(cd "${TESTS_DIR}" && npm ci)
|
||||||
|
fi
|
||||||
|
|
||||||
|
server=""
|
||||||
|
trap 'if [[ -n "${server}" ]]; then kill "${server}" 2>/dev/null || true; fi' EXIT
|
||||||
|
|
||||||
|
# Starts a server on an empty database in $OUT_DIR/$1
|
||||||
|
start_server() {
|
||||||
|
local data_dir="${OUT_DIR}/$1"
|
||||||
|
rm -rf "${data_dir}" && mkdir -p "${data_dir}"
|
||||||
|
if curl -s -o /dev/null "${URL}/alive"; then
|
||||||
|
echo "Something already answers on port ${PORT}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
ENV_FILE=/dev/null DATA_FOLDER="${data_dir}" DATABASE_URL="sqlite://${data_dir}/db.sqlite3" \
|
||||||
|
ROCKET_ADDRESS=127.0.0.1 ROCKET_PORT="${PORT}" DOMAIN="${URL}" WEB_VAULT_ENABLED=false \
|
||||||
|
SIGNUPS_ALLOWED=true SIGNUPS_VERIFY=false LOGIN_RATELIMIT_MAX_BURST=1000 \
|
||||||
|
TEST_SEEDER_USERNAME="${SEEDER_USERNAME}" TEST_SEEDER_PASSWORD="${SEEDER_PASSWORD}" \
|
||||||
|
"${TARGET_DIR}/debug/vaultwarden" > "${data_dir}/vaultwarden.log" 2>&1 &
|
||||||
|
server=$!
|
||||||
|
local i
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if ! kill -0 "${server}" 2>/dev/null; then break; fi
|
||||||
|
if curl -sf -o /dev/null "${URL}/alive"; then return 0; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "The server didn't start; the end of ${data_dir}/vaultwarden.log:" >&2
|
||||||
|
tail -50 "${data_dir}/vaultwarden.log" >&2
|
||||||
|
stop_server
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_server() {
|
||||||
|
if [[ -n "${server}" ]]; then
|
||||||
|
kill "${server}" 2>/dev/null || true
|
||||||
|
wait "${server}" 2>/dev/null || true
|
||||||
|
server=""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_tests() {
|
||||||
|
(cd "${TESTS_DIR}" && BW_LIVE_SERVER_URL="${URL}" "$@" npm run test:live -- "${VITEST_ARGS[@]}")
|
||||||
|
}
|
||||||
|
|
||||||
|
results=()
|
||||||
|
failed=0
|
||||||
|
if [[ "${MODE}" == seeder ]]; then
|
||||||
|
if start_server seeder && run_tests env BW_LIVE_SEEDER_URL="${SEEDER_URL}" \
|
||||||
|
BW_LIVE_SEEDER_USERNAME="${SEEDER_USERNAME}" BW_LIVE_SEEDER_PASSWORD="${SEEDER_PASSWORD}"; then
|
||||||
|
results+=("seeder: pass")
|
||||||
|
else
|
||||||
|
results+=("seeder: FAIL")
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
stop_server
|
||||||
|
else
|
||||||
|
for vector_file in "${SDK_DIR}"/test-vectors/users/*.json; do
|
||||||
|
vector="$(basename "${vector_file}" .json)"
|
||||||
|
echo "=== ${vector}"
|
||||||
|
if ! start_server "${vector}"; then
|
||||||
|
results+=("${vector}: FAIL (server didn't start)")
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
status=0
|
||||||
|
credentials="$(python3 -I "${VW_DIR}/tools/sdk-live-tests/seed_vector.py" "${vector_file}" "${SEEDER_URL}" \
|
||||||
|
"${SEEDER_USERNAME}" "${SEEDER_PASSWORD}")" || status=$?
|
||||||
|
if [[ ${status} -eq ${SKIP} ]]; then
|
||||||
|
results+=("${vector}: skip (${credentials})")
|
||||||
|
elif [[ ${status} -ne 0 ]]; then
|
||||||
|
results+=("${vector}: FAIL (seeding)")
|
||||||
|
failed=1
|
||||||
|
elif run_tests env BW_LIVE_EMAIL="$(sed -n 1p <<< "${credentials}")" \
|
||||||
|
BW_LIVE_PASSWORD="$(sed -n 2p <<< "${credentials}")"; then
|
||||||
|
results+=("${vector}: pass")
|
||||||
|
else
|
||||||
|
results+=("${vector}: FAIL")
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
stop_server
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Summary (logs in ${OUT_DIR})"
|
||||||
|
printf '%s\n' "${results[@]}"
|
||||||
|
exit "${failed}"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Seeds one of the SDK's user test vectors, vault included, through vaultwarden's test seeder.
|
||||||
|
|
||||||
|
Usage: seed_vector.py <vector.json> <seeder url> <seeder username> <seeder password>
|
||||||
|
|
||||||
|
Prints the account's email and password, one per line. Exits 3, printing why, for a vector the tests can't log in
|
||||||
|
with (no master password), and 1 if the seeder refuses it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
SKIP = 3
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
vector_file, seeder_url, username, password = sys.argv[1:5]
|
||||||
|
with open(vector_file, encoding="utf-8") as f:
|
||||||
|
vector = json.load(f)
|
||||||
|
|
||||||
|
has_master_password = any("masterPasswordUnlock" in method for method in vector.get("unlockMethods", []))
|
||||||
|
if not has_master_password or "password" not in vector.get("account", {}):
|
||||||
|
print("no master password")
|
||||||
|
return SKIP
|
||||||
|
|
||||||
|
# Without attachments: the rotation test compares the vault before and after, and our sync gives each attachment
|
||||||
|
# a link with a fresh download token every time (upstream's is the same on every sync). A vector's legacy
|
||||||
|
# attachments, without a key of their own, would also make the SDK refuse to rotate at all.
|
||||||
|
for cipher in vector.get("vault", {}).get("ciphers", []):
|
||||||
|
cipher.get("encrypted", {}).pop("attachments", None)
|
||||||
|
|
||||||
|
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{seeder_url}/seed",
|
||||||
|
data=json.dumps({"template": "TestVectorUserScene", "arguments": vector}).encode(),
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {credentials}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Play-Id": f"sdk-live-tests-{uuid.uuid4()}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
result = json.load(response)["result"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"The seeder refused {vector_file}: {e.code} {e.read().decode(errors='replace')}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(result["email"])
|
||||||
|
print(vector["account"]["password"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user