mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-13 15:48:36 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c7621975 | ||
|
|
2e0c8b6213 |
No files matched your search
@@ -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
|
||||||
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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
+305
-575
File diff suppressed because it is too large.
Load diff
@@ -10,9 +10,7 @@ use crate::{
|
|||||||
auth::{AdminHeaders, Headers},
|
auth::{AdminHeaders, Headers},
|
||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
|
||||||
Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
util::parse_date,
|
util::parse_date,
|
||||||
@@ -222,7 +220,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
// user can actually access it instead of trusting the provided cipher uuid.
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(cipher_uuid) = &event.cipher_id
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||||
&& cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await
|
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
||||||
&& let Some(org_id) = cipher.organization_uuid
|
&& let Some(org_id) = cipher.organization_uuid
|
||||||
{
|
{
|
||||||
log_event_impl(
|
log_event_impl(
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
|
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
|
||||||
CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser,
|
CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
|
||||||
Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
|
||||||
Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
|
||||||
custom_roles_enabled,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -48,7 +47,6 @@ pub fn routes() -> Vec<Route> {
|
|||||||
bulk_delete_organization_collections,
|
bulk_delete_organization_collections,
|
||||||
post_bulk_collections,
|
post_bulk_collections,
|
||||||
get_org_details,
|
get_org_details,
|
||||||
get_assigned_org_details,
|
|
||||||
get_org_domain_sso_verified,
|
get_org_domain_sso_verified,
|
||||||
get_members,
|
get_members,
|
||||||
send_invite,
|
send_invite,
|
||||||
@@ -843,7 +841,7 @@ async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: Db
|
|||||||
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
|
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !headers.membership.can_edit_any_collection() {
|
if !headers.membership.has_full_access() {
|
||||||
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,12 +859,7 @@ async fn get_org_details_impl(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Result<Value, crate::Error> {
|
) -> Result<Value, crate::Error> {
|
||||||
let ciphers = Cipher::find_by_org(org_id, conn).await;
|
let ciphers = Cipher::find_by_org(org_id, conn).await;
|
||||||
let mut cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
|
let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
|
||||||
// Every cipher of the organization is listed, so list every collection of them too
|
|
||||||
cipher_sync_data.cipher_collections = HashMap::new();
|
|
||||||
for (cipher_id, collection_id) in Cipher::get_collections_with_cipher_by_organization(org_id, conn).await {
|
|
||||||
cipher_sync_data.cipher_collections.entry(cipher_id).or_default().push(collection_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
||||||
for c in ciphers {
|
for c in ciphers {
|
||||||
@@ -875,39 +868,6 @@ async fn get_org_details_impl(
|
|||||||
Ok(json!(ciphers_json))
|
Ok(json!(ciphers_json))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Admin Console lists these instead of every cipher of the organization for a member who may not edit every
|
|
||||||
// cipher, like a Custom member with Delete any collection only. As in Bitwarden, any confirmed member may list them.
|
|
||||||
#[get("/ciphers/organization-details/assigned?<data..>")]
|
|
||||||
async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult {
|
|
||||||
let Some(member) =
|
|
||||||
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await
|
|
||||||
else {
|
|
||||||
err_code!("Resource not found.", "User is not a confirmed member of the organization", Status::NotFound.code);
|
|
||||||
};
|
|
||||||
|
|
||||||
let (assigned, unassigned) = Cipher::find_assigned_to_member(&member, &conn).await;
|
|
||||||
let mut ciphers_json = Vec::with_capacity(assigned.len() + unassigned.len());
|
|
||||||
// The ciphers of the member's own vault, as they are there
|
|
||||||
let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await;
|
|
||||||
for cipher in assigned {
|
|
||||||
ciphers_json.push(
|
|
||||||
cipher
|
|
||||||
.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The ciphers in no collection, as the member may use them in the Admin Console
|
|
||||||
for cipher in unassigned {
|
|
||||||
ciphers_json.push(cipher.to_json_org_admin(&headers.host, &headers.user.uuid, &conn).await?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"data": ciphers_json,
|
|
||||||
"object": "list",
|
|
||||||
"continuationToken": null,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
||||||
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
|
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
|
||||||
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
||||||
@@ -1915,7 +1875,6 @@ async fn post_org_import(
|
|||||||
&conn,
|
&conn,
|
||||||
&nt,
|
&nt,
|
||||||
UpdateType::None,
|
UpdateType::None,
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
@@ -1949,37 +1908,29 @@ struct BulkCollectionsData {
|
|||||||
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let data: BulkCollectionsData = data.into_inner();
|
let data: BulkCollectionsData = data.into_inner();
|
||||||
|
|
||||||
let Some(member) =
|
if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() {
|
||||||
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await
|
|
||||||
else {
|
|
||||||
err!("You need to be a Member of the Organization to call this endpoint")
|
err!("You need to be a Member of the Organization to call this endpoint")
|
||||||
};
|
}
|
||||||
// A member who may edit any collection may use every collection of the organization
|
|
||||||
let can_edit_any_collection = member.can_edit_any_collection();
|
|
||||||
|
|
||||||
// Get all the collection available to the user in one query
|
// Get all the collection available to the user in one query
|
||||||
// Also filter based upon the provided collections
|
// Also filter based upon the provided collections
|
||||||
let collections = if can_edit_any_collection {
|
let user_collections: HashMap<CollectionId, Collection> =
|
||||||
Collection::find_by_organization(&data.organization_id, &conn).await
|
Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn)
|
||||||
} else {
|
.await
|
||||||
Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn).await
|
.into_iter()
|
||||||
};
|
.filter_map(|c| {
|
||||||
let user_collections: HashMap<CollectionId, Collection> = collections
|
if data.collection_ids.contains(&c.uuid) {
|
||||||
.into_iter()
|
Some((c.uuid.clone(), c))
|
||||||
.filter_map(|c| {
|
} else {
|
||||||
if data.collection_ids.contains(&c.uuid) {
|
None
|
||||||
Some((c.uuid.clone(), c))
|
}
|
||||||
} else {
|
})
|
||||||
None
|
.collect();
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Verify if all the collections requested exists and are writable for the user, else abort
|
// Verify if all the collections requested exists and are writable for the user, else abort
|
||||||
for collection_uuid in &data.collection_ids {
|
for collection_uuid in &data.collection_ids {
|
||||||
match user_collections.get(collection_uuid) {
|
match user_collections.get(collection_uuid) {
|
||||||
Some(collection)
|
Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (),
|
||||||
if can_edit_any_collection || collection.is_writable_by_user(&headers.user.uuid, &conn).await => {}
|
|
||||||
_ => err_code!("Resource not found", "User does not have access to a collection", 404),
|
_ => err_code!("Resource not found", "User does not have access to a collection", 404),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1988,7 +1939,7 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
|
|||||||
// Only act on existing cipher uuid's
|
// Only act on existing cipher uuid's
|
||||||
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example
|
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example
|
||||||
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await
|
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await
|
||||||
&& cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await
|
&& cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await
|
||||||
{
|
{
|
||||||
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
|
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
|
||||||
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
|
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-158
@@ -1,11 +1,7 @@
|
|||||||
mod query_logger;
|
mod query_logger;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
future::Future,
|
sync::{Arc, OnceLock},
|
||||||
sync::{
|
|
||||||
Arc, OnceLock,
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
},
|
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,7 +123,6 @@ pub static ACTIVE_DB_TYPE: OnceLock<DbConnType> = OnceLock::new();
|
|||||||
pub struct DbConn {
|
pub struct DbConn {
|
||||||
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
|
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
|
||||||
permit: Option<OwnedSemaphorePermit>,
|
permit: Option<OwnedSemaphorePermit>,
|
||||||
transaction_open: AtomicBool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -155,7 +150,6 @@ impl Drop for DbConn {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
let permit = self.permit.take();
|
let permit = self.permit.take();
|
||||||
let transaction_open = self.transaction_open.load(Ordering::Acquire);
|
|
||||||
|
|
||||||
// Since connection can't be on the stack in an async fn during an
|
// Since connection can't be on the stack in an async fn during an
|
||||||
// await, we have to spawn a new blocking-safe thread...
|
// await, we have to spawn a new blocking-safe thread...
|
||||||
@@ -163,13 +157,7 @@ impl Drop for DbConn {
|
|||||||
// And then re-enter the runtime to wait on the async mutex, but in a blocking fashion.
|
// And then re-enter the runtime to wait on the async mutex, but in a blocking fashion.
|
||||||
let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned());
|
let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned());
|
||||||
|
|
||||||
if let Some(mut conn) = conn.take() {
|
if let Some(conn) = conn.take() {
|
||||||
if transaction_open {
|
|
||||||
// A cancelled request must not return an open transaction to the pool.
|
|
||||||
if let Err(error) = conn.batch_execute("ROLLBACK") {
|
|
||||||
error!("Failed to roll back a cancelled database transaction: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +248,6 @@ impl DbPool {
|
|||||||
Ok(DbConn {
|
Ok(DbConn {
|
||||||
conn: Arc::new(Mutex::new(Some(c))),
|
conn: Arc::new(Mutex::new(Some(c))),
|
||||||
permit: Some(permit),
|
permit: Some(permit),
|
||||||
transaction_open: AtomicBool::new(false),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,29 +320,6 @@ impl DbConnType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DbConn {
|
impl DbConn {
|
||||||
/// Run asynchronous model calls on this request's pooled connection in one transaction.
|
|
||||||
/// Send external notifications only after this returns successfully.
|
|
||||||
pub async fn transaction<F, Fut, T>(&self, operation: F) -> Result<T, Error>
|
|
||||||
where
|
|
||||||
F: FnOnce() -> Fut,
|
|
||||||
Fut: Future<Output = Result<T, Error>>,
|
|
||||||
{
|
|
||||||
self.run(|conn| conn.batch_execute("BEGIN")).await.map_res("Error starting database transaction")?;
|
|
||||||
self.transaction_open.store(true, Ordering::Release);
|
|
||||||
let result = operation().await;
|
|
||||||
if result.is_ok() {
|
|
||||||
let committed = self.run(|conn| conn.batch_execute("COMMIT")).await;
|
|
||||||
let closed = committed.is_ok() || self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok();
|
|
||||||
if closed {
|
|
||||||
self.transaction_open.store(false, Ordering::Release);
|
|
||||||
}
|
|
||||||
committed.map_res("Error committing database transaction")?;
|
|
||||||
} else if self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok() {
|
|
||||||
self.transaction_open.store(false, Ordering::Release);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run<F, R>(&self, f: F) -> R
|
pub async fn run<F, R>(&self, f: F) -> R
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut DbConnInner) -> R + Send,
|
F: FnOnce(&mut DbConnInner) -> R + Send,
|
||||||
@@ -574,61 +538,9 @@ mod postgresql_migrations {
|
|||||||
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
|
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
|
||||||
#[cfg(all(test, sqlite))]
|
#[cfg(all(test, sqlite))]
|
||||||
pub mod test_db {
|
pub mod test_db {
|
||||||
use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS};
|
use super::sqlite_migrations::MIGRATIONS;
|
||||||
use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection};
|
use diesel::connection::SimpleConnection;
|
||||||
use diesel_migrations::MigrationHarness;
|
use diesel_migrations::MigrationHarness;
|
||||||
use std::sync::{Arc, atomic::AtomicBool};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
pub struct TestDb {
|
|
||||||
pool: Pool<DbConnManager>,
|
|
||||||
// Keep the named in-memory database alive across pooled connections.
|
|
||||||
_keeper: SqliteConnection,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestDb {
|
|
||||||
fn new() -> Self {
|
|
||||||
let url = format!("sqlite://{}?mode=memory&cache=shared", crate::util::get_uuid());
|
|
||||||
let mut keeper = SqliteConnection::establish(&url).unwrap();
|
|
||||||
keeper.run_pending_migrations(MIGRATIONS).unwrap();
|
|
||||||
let pool = Pool::builder().max_size(2).build(DbConnManager::new(&url)).unwrap();
|
|
||||||
Self {
|
|
||||||
pool,
|
|
||||||
_keeper: keeper,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn conn(&self) -> DbConn {
|
|
||||||
DbConn {
|
|
||||||
conn: Arc::new(Mutex::new(Some(self.pool.get().unwrap()))),
|
|
||||||
permit: None,
|
|
||||||
transaction_open: AtomicBool::new(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CONFIG must initialize in a child process: the DNS bootstrap test needs it uninitialized.
|
|
||||||
pub fn run(test: impl AsyncFnOnce(&TestDb)) {
|
|
||||||
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
|
|
||||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
|
||||||
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
|
|
||||||
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
|
|
||||||
.env("DATABASE_URL", "sqlite://:memory:")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
|
|
||||||
"{}\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// DbConn::run uses block_in_place, requiring a multi-threaded runtime.
|
|
||||||
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
|
|
||||||
test(&TestDb::new()).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
|
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
|
||||||
@@ -692,70 +604,4 @@ pub mod test_db {
|
|||||||
assert_eq!(grants(&mut conn), 0);
|
assert_eq!(grants(&mut conn), 0);
|
||||||
assert_eq!(rows(&mut conn), before);
|
assert_eq!(rows(&mut conn), before);
|
||||||
}
|
}
|
||||||
|
|
||||||
use super::models::{Collection, CustomPermission, Membership, MembershipType, Organization, OrganizationId, User};
|
|
||||||
|
|
||||||
pub async fn organization(conn: &DbConn) -> Organization {
|
|
||||||
let org = Organization::new("Org".into(), "billing@example.com", None, None);
|
|
||||||
org.save(conn).await.unwrap();
|
|
||||||
org
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn member(
|
|
||||||
conn: &DbConn,
|
|
||||||
org: &OrganizationId,
|
|
||||||
role: MembershipType,
|
|
||||||
permissions: &[CustomPermission],
|
|
||||||
) -> (User, Membership) {
|
|
||||||
let mut user = User::new(&format!("{}@example.com", crate::util::get_uuid()), None);
|
|
||||||
user.password_hash = b"existing-account".to_vec();
|
|
||||||
user.save(conn).await.unwrap();
|
|
||||||
let mut member = Membership::new(user.uuid.clone(), org.clone(), None);
|
|
||||||
member.set_type(role);
|
|
||||||
member.status = 2;
|
|
||||||
member.set_custom_permissions(&permissions.iter().copied().collect());
|
|
||||||
member.save(conn).await.unwrap();
|
|
||||||
(user, member)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn custom_member(conn: &DbConn, permission: CustomPermission) -> (Organization, User, Membership) {
|
|
||||||
let org = organization(conn).await;
|
|
||||||
let (user, member) = member(conn, &org.uuid, MembershipType::Custom, &[permission]).await;
|
|
||||||
(org, user, member)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn collection(conn: &DbConn, org: &OrganizationId) -> Collection {
|
|
||||||
let collection = Collection::new(org.clone(), "Collection".into(), None);
|
|
||||||
collection.save(conn).await.unwrap();
|
|
||||||
collection
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transactions_rollback_before_connection_reuse_and_commit_success() {
|
|
||||||
use crate::error::MapResult;
|
|
||||||
use diesel::RunQueryDsl;
|
|
||||||
run(async |db| {
|
|
||||||
let conn = db.conn();
|
|
||||||
conn.run(|c| c.batch_execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)")).await.unwrap();
|
|
||||||
for (sql, expected) in [
|
|
||||||
("INSERT INTO probe VALUES (1); INSERT INTO probe VALUES (1)", 0_i64),
|
|
||||||
("INSERT INTO probe VALUES (1)", 1),
|
|
||||||
] {
|
|
||||||
let result: Result<(), super::Error> = conn
|
|
||||||
.transaction(async || conn.run(move |c| c.batch_execute(sql)).await.map_res("Insert failed"))
|
|
||||||
.await;
|
|
||||||
assert_eq!(result.is_ok(), expected == 1);
|
|
||||||
let count: i64 = conn
|
|
||||||
.run(|c| {
|
|
||||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>("(SELECT COUNT(*) FROM probe)"))
|
|
||||||
.get_result(c)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, expected);
|
|
||||||
// The same live connection must accept another transaction, before DbConn::drop can help.
|
|
||||||
conn.run(|c| c.batch_execute("BEGIN; ROLLBACK")).await.unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+12
-132
@@ -1,4 +1,4 @@
|
|||||||
use std::{borrow::Cow, collections::HashSet};
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
@@ -80,47 +80,6 @@ pub enum RepromptType {
|
|||||||
Password = 1,
|
Password = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which access a cipher operation is authorized with.
|
|
||||||
///
|
|
||||||
/// The administrative cipher routes (`/ciphers/<id>/admin` and friends) are served by the same handlers as the
|
|
||||||
/// regular vault routes, so the handlers state which one applies. They only differ for a Custom member with Edit
|
|
||||||
/// any collection, who reaches every cipher of the organization on the administrative routes, but keeps their
|
|
||||||
/// own vault limited to the collections they are assigned to.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum CipherAccessScope {
|
|
||||||
User,
|
|
||||||
OrganizationAdmin,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CipherAccessScope {
|
|
||||||
/// Whether `member` reaches every cipher of their organization with this access.
|
|
||||||
fn reaches_all_ciphers(self, member: &Membership) -> bool {
|
|
||||||
match self {
|
|
||||||
CipherAccessScope::User => member.has_full_access(),
|
|
||||||
CipherAccessScope::OrganizationAdmin => member.can_edit_any_collection(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The access the v2 attachment upload asks for with `adminRequest`. It only picks which check runs, the
|
|
||||||
/// administrative one still needs a member who may edit any collection.
|
|
||||||
pub fn requested(admin_request: Option<bool>) -> Self {
|
|
||||||
if admin_request == Some(true) {
|
|
||||||
CipherAccessScope::OrganizationAdmin
|
|
||||||
} else {
|
|
||||||
CipherAccessScope::User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The access for a route that does not tell which one it is, from the member's own membership.
|
|
||||||
pub fn for_member(member: Option<&Membership>) -> Self {
|
|
||||||
if member.is_some_and(Membership::can_edit_any_collection) {
|
|
||||||
CipherAccessScope::OrganizationAdmin
|
|
||||||
} else {
|
|
||||||
CipherAccessScope::User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl Cipher {
|
impl Cipher {
|
||||||
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
|
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
|
||||||
@@ -215,29 +174,6 @@ impl Cipher {
|
|||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
sync_type: CipherSyncType,
|
sync_type: CipherSyncType,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Result<Value, crate::Error> {
|
|
||||||
self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`Cipher::to_json`] for an administrative route, telling what the member may do with the cipher there.
|
|
||||||
pub async fn to_json_org_admin(
|
|
||||||
&self,
|
|
||||||
host: &str,
|
|
||||||
user_uuid: &UserId,
|
|
||||||
conn: &DbConn,
|
|
||||||
) -> Result<Value, crate::Error> {
|
|
||||||
self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn to_json_scoped(
|
|
||||||
&self,
|
|
||||||
host: &str,
|
|
||||||
user_uuid: &UserId,
|
|
||||||
cipher_sync_data: Option<&CipherSyncData>,
|
|
||||||
sync_type: CipherSyncType,
|
|
||||||
scope: CipherAccessScope,
|
|
||||||
conn: &DbConn,
|
|
||||||
) -> 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};
|
||||||
|
|
||||||
@@ -270,7 +206,7 @@ impl Cipher {
|
|||||||
// We don't need these values at all for Organizational syncs
|
// We don't need these values at all for Organizational syncs
|
||||||
// Skip any other database calls if this is the case and just return false.
|
// Skip any other database calls if this is the case and just return false.
|
||||||
let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User {
|
let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User {
|
||||||
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await {
|
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await {
|
||||||
(ro, hp, mn)
|
(ro, hp, mn)
|
||||||
} else {
|
} else {
|
||||||
error!("Cipher ownership assertion failure");
|
error!("Cipher ownership assertion failure");
|
||||||
@@ -673,21 +609,20 @@ impl Cipher {
|
|||||||
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
|
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether this cipher is owned by an org in which the user reaches every cipher with `scope`.
|
/// Returns whether this cipher is owned by an org in which the user has full access.
|
||||||
async fn is_in_full_access_org(
|
async fn is_in_full_access_org(
|
||||||
&self,
|
&self,
|
||||||
user_uuid: &UserId,
|
user_uuid: &UserId,
|
||||||
scope: CipherAccessScope,
|
|
||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if let Some(ref org_uuid) = self.organization_uuid {
|
if let Some(ref org_uuid) = self.organization_uuid {
|
||||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
||||||
return scope.reaches_all_ciphers(cached_member);
|
return cached_member.has_full_access();
|
||||||
}
|
}
|
||||||
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
|
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
|
||||||
return scope.reaches_all_ciphers(&member);
|
return member.has_full_access();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
@@ -720,7 +655,6 @@ impl Cipher {
|
|||||||
pub async fn get_access_restrictions(
|
pub async fn get_access_restrictions(
|
||||||
&self,
|
&self,
|
||||||
user_uuid: &UserId,
|
user_uuid: &UserId,
|
||||||
scope: CipherAccessScope,
|
|
||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<(bool, bool, bool)> {
|
) -> Option<(bool, bool, bool)> {
|
||||||
@@ -728,7 +662,7 @@ impl Cipher {
|
|||||||
// a collection that the user has full access to. If so, there are no
|
// a collection that the user has full access to. If so, there are no
|
||||||
// access restrictions.
|
// access restrictions.
|
||||||
if self.is_owned_by_user(user_uuid)
|
if self.is_owned_by_user(user_uuid)
|
||||||
|| self.is_in_full_access_org(user_uuid, scope, cipher_sync_data, conn).await
|
|| self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await
|
||||||
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
|
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
|
||||||
{
|
{
|
||||||
return Some((false, false, true));
|
return Some((false, false, true));
|
||||||
@@ -851,13 +785,8 @@ impl Cipher {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_write_accessible_to_user(
|
pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||||
&self,
|
match self.get_access_restrictions(user_uuid, None, conn).await {
|
||||||
user_uuid: &UserId,
|
|
||||||
scope: CipherAccessScope,
|
|
||||||
conn: &DbConn,
|
|
||||||
) -> bool {
|
|
||||||
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
|
|
||||||
Some((read_only, _hide_passwords, manage)) => !read_only || manage,
|
Some((read_only, _hide_passwords, manage)) => !read_only || manage,
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
@@ -865,20 +794,15 @@ impl Cipher {
|
|||||||
|
|
||||||
// used for checking if collection can be edited (only if user has access to a collection they
|
// used for checking if collection can be edited (only if user has access to a collection they
|
||||||
// can write to and also passwords are not hidden to prevent privilege escalation)
|
// can write to and also passwords are not hidden to prevent privilege escalation)
|
||||||
pub async fn is_in_editable_collection_by_user(
|
pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||||
&self,
|
match self.get_access_restrictions(user_uuid, None, conn).await {
|
||||||
user_uuid: &UserId,
|
|
||||||
scope: CipherAccessScope,
|
|
||||||
conn: &DbConn,
|
|
||||||
) -> bool {
|
|
||||||
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
|
|
||||||
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
|
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool {
|
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||||
self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some()
|
self.get_access_restrictions(user_uuid, None, conn).await.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns whether this cipher is a favorite of the specified user.
|
// Returns whether this cipher is a favorite of the specified user.
|
||||||
@@ -1316,50 +1240,6 @@ impl Cipher {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every collection of every cipher of the organization, for the members who may see all of them.
|
|
||||||
pub async fn get_collections_with_cipher_by_organization(
|
|
||||||
org_uuid: &OrganizationId,
|
|
||||||
conn: &DbConn,
|
|
||||||
) -> Vec<(CipherId, CollectionId)> {
|
|
||||||
conn.run(move |conn| {
|
|
||||||
ciphers_collections::table
|
|
||||||
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
|
|
||||||
.filter(collections::org_uuid.eq(org_uuid))
|
|
||||||
.select(ciphers_collections::all_columns)
|
|
||||||
.load::<(CipherId, CollectionId)>(conn)
|
|
||||||
.unwrap_or_default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The ciphers of the organization of `member` the Admin Console lists when they may not edit every cipher, like
|
|
||||||
/// Bitwarden does: the ones in the member's own vault, then, for a member who may edit any collection, the ones in
|
|
||||||
/// no collection at all.
|
|
||||||
pub async fn find_assigned_to_member(member: &Membership, conn: &DbConn) -> (Vec<Self>, Vec<Self>) {
|
|
||||||
let assigned: Vec<Self> = Self::find_by_user_visible(&member.user_uuid, conn)
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.filter(|cipher| cipher.organization_uuid.as_ref() == Some(&member.org_uuid))
|
|
||||||
.collect();
|
|
||||||
if !member.can_edit_any_collection() {
|
|
||||||
return (assigned, Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let listed: HashSet<CipherId> = assigned.iter().map(|cipher| cipher.uuid.clone()).collect();
|
|
||||||
let in_collections: HashSet<CipherId> =
|
|
||||||
Self::get_collections_with_cipher_by_organization(&member.org_uuid, conn)
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.map(|(cipher, _)| cipher)
|
|
||||||
.collect();
|
|
||||||
let unassigned = Self::find_by_org(&member.org_uuid, conn)
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.filter(|cipher| !in_collections.contains(&cipher.uuid) && !listed.contains(&cipher.uuid))
|
|
||||||
.collect();
|
|
||||||
(assigned, unassigned)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ 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, CipherAccessScope, CipherId, RepromptType, is_data_blob_encrypted};
|
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::{
|
||||||
|
|||||||
@@ -488,35 +488,6 @@ impl Membership {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn custom_permission(&self, permission: CustomPermission) -> bool {
|
|
||||||
match permission {
|
|
||||||
CustomPermission::ManageUsers => self.manage_users,
|
|
||||||
CustomPermission::ManageGroups => self.manage_groups,
|
|
||||||
CustomPermission::ManagePolicies => self.manage_policies,
|
|
||||||
CustomPermission::CreateNewCollections => self.create_new_collections,
|
|
||||||
CustomPermission::EditAnyCollection => self.edit_any_collection,
|
|
||||||
CustomPermission::DeleteAnyCollection => self.delete_any_collection,
|
|
||||||
CustomPermission::AccessEventLogs => self.access_event_logs,
|
|
||||||
CustomPermission::AccessImportExport => self.access_import_export,
|
|
||||||
CustomPermission::AccessReports => self.access_reports,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the role of this member grants `permission`. Only a Custom role grants any, only while Custom roles
|
|
||||||
/// are enabled, and only the permissions this version enforces.
|
|
||||||
fn grants_custom_permission(&self, permission: CustomPermission) -> bool {
|
|
||||||
custom_roles_enabled()
|
|
||||||
&& permission.is_supported()
|
|
||||||
&& self.has_type(MembershipType::Custom)
|
|
||||||
&& self.custom_permission(permission)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this member may act with `permission`, granted by their Custom role. Like the authority of any
|
|
||||||
/// other role, it needs a confirmed membership.
|
|
||||||
pub fn has_custom_permission(&self, permission: CustomPermission) -> bool {
|
|
||||||
self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn restore(&mut self) -> bool {
|
pub fn restore(&mut self) -> bool {
|
||||||
if self.status < MembershipStatus::Invited as i32 {
|
if self.status < MembershipStatus::Invited as i32 {
|
||||||
self.status += ACTIVATE_REVOKE_DIFF;
|
self.status += ACTIVATE_REVOKE_DIFF;
|
||||||
@@ -1035,13 +1006,6 @@ impl Membership {
|
|||||||
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
|
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this member may edit every collection and the items in them in the Admin Console: every member with
|
|
||||||
/// full access, and a Custom member with Edit any collection. Unlike full access, the permission does not add
|
|
||||||
/// anything to the member's own vault.
|
|
||||||
pub fn can_edit_any_collection(&self) -> bool {
|
|
||||||
self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
|
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
|
||||||
|
|||||||
@@ -767,23 +767,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_has_v2_encryption() {
|
fn test_has_v2_encryption() {
|
||||||
// User::new loads CONFIG; isolate it from the DNS bootstrap test and filesystem configuration.
|
|
||||||
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
|
|
||||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
|
||||||
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
|
|
||||||
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
|
|
||||||
.env("DATABASE_URL", "sqlite://:memory:")
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
|
|
||||||
"{}\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = |private_key: Option<&str>, security_version: Option<i32>| {
|
let user = |private_key: Option<&str>, security_version: Option<i32>| {
|
||||||
let mut user = User::new("user@example.com", None);
|
let mut user = User::new("user@example.com", None);
|
||||||
user.private_key = private_key.map(String::from);
|
user.private_key = private_key.map(String::from);
|
||||||
|
|||||||
+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())
|
||||||
|
|||||||
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