From d2d1294098e8eb371992327fb00f7b6edf3db63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Fri, 13 Nov 2026 19:48:10 +0100 Subject: [PATCH] Run the Bitwarden SDK's live end-to-end tests against Vaultwarden (#7953) * Add a test seeder behind the unsafe_test_mode feature For the end-to-end tests: with the feature, /test/seeder answers the calls the SDK's live tests make to a seeder API (POST /seed with SingleUserScene or TestVectorUserScene, DELETE /seed/), behind HTTP basic auth from TEST_SEEDER_USERNAME and TEST_SEEDER_PASSWORD, and 404 without them. It creates accounts without any checks, so no release build has the feature. CI builds and tests it with sqlite. * Run the SDK live tests against the test seeder tools/sdk-live-tests/run.sh builds the server with unsafe_test_mode and runs the live tests of the SDK's e2e/wasm suite. For SDK versions whose tests log in to one given account, each user test vector with a master password is seeded with its vault on a fresh server; for versions that seed their own accounts, the tests get the seeder's address. Every vector runs, then a summary. The workflow runs it by hand, and on changes to the seeder or the runner. * Run the SDK live tests daily * Test the published SDK package by default in the SDK live tests --- .github/workflows/build.yml | 17 + .github/workflows/sdk-live-tests.yml | 163 +++++++++ Cargo.lock | 1 + Cargo.toml | 4 + src/api/core/ciphers.rs | 62 ++++ src/api/core/mod.rs | 2 + src/api/core/sends.rs | 17 + src/api/mod.rs | 5 + src/api/test_seeder.rs | 511 +++++++++++++++++++++++++++ src/main.rs | 12 +- tools/sdk-live-tests/run.sh | 145 ++++++++ tools/sdk-live-tests/seed_vector.py | 59 ++++ 12 files changed, 997 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sdk-live-tests.yml create mode 100644 src/api/test_seeder.rs create mode 100755 tools/sdk-live-tests/run.sh create mode 100644 tools/sdk-live-tests/seed_vector.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 673137cf..71ec8625 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -158,6 +158,13 @@ jobs: if: ${{ !cancelled() }} run: | 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 @@ -167,6 +174,12 @@ jobs: if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }} run: | 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 @@ -190,7 +203,9 @@ jobs: TEST_SQLITE: ${{ steps.test_sqlite.outcome }} TEST_MYSQL: ${{ steps.test_mysql.outcome }} TEST_POSTGRESQL: ${{ steps.test_postgresql.outcome }} + TEST_UNSAFE_TEST_MODE: ${{ steps.test_unsafe_test_mode.outcome }} CLIPPY: ${{ steps.clippy.outcome }} + CLIPPY_UNSAFE_TEST_MODE: ${{ steps.clippy_unsafe_test_mode.outcome }} FMT: ${{ steps.formatting.outcome }} run: | echo "### :x: Checks Failed!" >> "${GITHUB_STEP_SUMMARY}" @@ -203,7 +218,9 @@ jobs: echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (mysql)|${TEST_MYSQL}|" >> "${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,unsafe_test_mode)|${CLIPPY_UNSAFE_TEST_MODE}|" >> "${GITHUB_STEP_SUMMARY}" echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}" echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/sdk-live-tests.yml b/.github/workflows/sdk-live-tests.yml new file mode 100644 index 00000000..0ddabc31 --- /dev/null +++ b/.github/workflows/sdk-live-tests.yml @@ -0,0 +1,163 @@ +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. +# By default it tests the latest @bitwarden/sdk-internal on npm, which the SDK publishes for every commit to its main +# branch, with the e2e tests from that commit. A manual run can name an npm version instead, or a branch, tag or commit +# to build the SDK from. It runs daily, by hand, and on changes to the test seeder or the runner. +on: + schedule: + # 04:15 UTC, off the top of the hour, when GitHub may delay or drop scheduled runs + - cron: "15 4 * * *" + workflow_dispatch: + inputs: + sdk_npm_version: + description: "Version of @bitwarden/sdk-internal on npm to test (empty for the latest)" + required: false + default: "" + sdk_ref: + description: "Or a branch, tag or commit of bitwarden/sdk-internal to build and test (slower)" + required: false + default: "" + 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 + env: + # Less to compile and link, and a smaller cache. Panics still show file and line. + CARGO_PROFILE_DEV_DEBUG: "0" + 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 + + # A published package (npm) or a build from a git ref (git), and the sdk-internal commit to check out + - name: "Pick the SDK" + id: sdk + env: + NPM_VERSION: ${{ inputs.sdk_npm_version }} + GIT_REF: ${{ inputs.sdk_ref }} + run: | + if [[ -n "${NPM_VERSION}" && -n "${GIT_REF}" ]]; then + echo "::error::Give an npm version or a git ref, not both" + exit 1 + fi + if [[ -n "${GIT_REF}" ]]; then + { + echo "source=git" + echo "ref=${GIT_REF}" + } >> "${GITHUB_OUTPUT}" + echo "SDK: bitwarden/sdk-internal \`${GIT_REF}\`, built from source" >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + spec="${NPM_VERSION:-latest}" + if [[ ! "${spec}" =~ ^[0-9A-Za-z.+-]+$ ]]; then + echo "::error::That is not an npm version or tag" + exit 1 + fi + manifest="$(curl -fsS --retry 3 "https://registry.npmjs.org/@bitwarden%2Fsdk-internal/${spec}")" + version="$(jq -r .version <<< "${manifest}")" + commit="$(jq -r .gitHead <<< "${manifest}")" + if [[ ! "${commit}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::@bitwarden/sdk-internal@${version} doesn't name the commit it was built from" + exit 1 + fi + { + echo "source=npm" + echo "version=${version}" + echo "ref=${commit}" + } >> "${GITHUB_OUTPUT}" + echo "SDK: @bitwarden/sdk-internal ${version} from npm, e2e tests at ${commit}" >> "${GITHUB_STEP_SUMMARY}" + + - name: "Checkout sdk-internal" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: bitwarden/sdk-internal + ref: ${{ steps.sdk.outputs.ref }} + path: sdk-internal + persist-credentials: false + + # Each checkout pins its own toolchain in rust-toolchain.toml. The SDK's is only needed to build it. + - name: "Install toolchains" + env: + SDK_SOURCE: ${{ steps.sdk.outputs.source }} + run: | + rustup toolchain install + if [[ "${SDK_SOURCE}" == git ]]; then + cd sdk-internal + rustup toolchain install + rustup target add wasm32-unknown-unknown + rustup component add rust-src + fi + + # One cache per SDK source, so that neither restores the other's + - name: "Cache Rust builds" + if: ${{ steps.sdk.outputs.source == 'npm' }} + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: sdk-live-tests-npm + add-job-id-key: false + workspaces: ". -> target" + + - name: "Cache Rust builds (with the SDK)" + if: ${{ steps.sdk.outputs.source == 'git' }} + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: sdk-live-tests-sdk-build + add-job-id-key: false + workspaces: | + . -> target + sdk-internal -> target + + - name: "Setup Node" + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: sdk-internal/.nvmrc + + # Where the SDK's build would put it + - name: "Download the SDK package" + if: ${{ steps.sdk.outputs.source == 'npm' }} + env: + SDK_VERSION: ${{ steps.sdk.outputs.version }} + SDK_COMMIT: ${{ steps.sdk.outputs.ref }} + run: | + # npm checks the tarball against the registry's checksum + tarball="$(npm pack "@bitwarden/sdk-internal@${SDK_VERSION}" --pack-destination "${RUNNER_TEMP}" --json | jq -r '.[0].filename')" + package_dir=sdk-internal/crates/bitwarden-wasm-internal/npm + tar -xzf "${RUNNER_TEMP}/${tarball}" --strip-components=1 -C "${package_dir}" + if [[ "$(cat "${package_dir}/VERSION")" != "${SDK_COMMIT}" ]]; then + echo "::error::The package's VERSION doesn't match its commit ${SDK_COMMIT}" + exit 1 + fi + + - name: "Run the SDK live tests" + env: + SDK_SOURCE: ${{ steps.sdk.outputs.source }} + run: | + args=(--sdk-dir sdk-internal) + if [[ "${SDK_SOURCE}" == npm ]]; then + args+=(--skip-sdk-build) + fi + tools/sdk-live-tests/run.sh "${args[@]}" + + - 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 diff --git a/Cargo.lock b/Cargo.lock index 587ac6b6..91299665 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6270,6 +6270,7 @@ dependencies = [ "rustls", "semver", "serde", + "serde_cbor_2", "serde_json", "subtle", "svg-hush", diff --git a/Cargo.toml b/Cargo.toml index 1717ec63..e677b162 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,9 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"] # Currently only used to enable rusts official ip support unstable = [] +# Enables special test mode features. This should NEVER be enabled in production +unsafe_test_mode = ["dep:serde_cbor_2"] + [target."cfg(unix)".dependencies] nix = { version = "0.31.3", features = ["fs"] } # Logging @@ -157,6 +160,7 @@ 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-proto = "0.5.5" webauthn-rs-core = "0.5.5" +serde_cbor_2 = { version = "0.13.0", optional = true } # Handling of URL's for WebAuthn and favicons url = "2.5.8" diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 817fca8e..7bbabcc0 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -822,6 +822,68 @@ fn set_cipher_content(cipher: &mut Cipher, data: &mut CipherData) -> EmptyResult 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, + conn: &DbConn, +) -> ApiResult { + // 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 mut 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; + set_cipher_content(&mut cipher, &mut data)?; + 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 { + 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)] #[serde(rename_all = "camelCase")] struct ImportData { diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index b7923982..121f20b1 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -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 events::{event_cleanup_job, log_event, log_user_event}; pub use sends::purge_sends; +#[cfg(feature = "unsafe_test_mode")] +pub use {ciphers::seed_cipher, sends::seed_send}; use reqwest::Method; use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index f8cc92bc..97d68731 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -126,6 +126,23 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c 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 { + 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 { let data_val = if data.r#type == SendType::Text as i32 { data.text diff --git a/src/api/mod.rs b/src/api/mod.rs index 42c6d53d..4c10f10b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,8 +4,13 @@ mod icons; mod identity; mod notifications; mod push; +#[cfg(feature = "unsafe_test_mode")] +mod test_seeder; mod web; +#[cfg(feature = "unsafe_test_mode")] +pub use test_seeder::routes as test_seeder_routes; + use rocket::serde::json::Json; use serde_json::Value; diff --git a/src/api/test_seeder.rs b/src/api/test_seeder.rs new file mode 100644 index 00000000..f02298db --- /dev/null +++ b/src/api/test_seeder.rs @@ -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: +//! Ref: + +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 { + routes![seed, destroy_play] +} + +/// The accounts seeded under each play id. +static PLAYS: LazyLock>>> = 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 { + 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 { + 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, +} + +#[post("/seed", data = "")] +async fn seed(data: Json, _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/")] +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, + #[serde(default)] + enroll_in_email2_fa: bool, + #[serde(default)] + corrupt_private_key: bool, +} + +/// Ref: +const DEFAULT_KDF_ITERATIONS: u32 = 5_000; + +async fn single_user_scene(request: SingleUserRequest, play: &str, conn: &DbConn) -> ApiResult { + 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 { + 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 { + 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: +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TestVector { + name: String, + account: VectorAccount, + unlock_methods: Vec, + master_password_authentication_hash: Option, + raw_cryptographic_state: VectorRawCryptographicState, + #[serde(default)] + vault: VectorVault, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct VectorAccount { + email: String, + security_version: Option, + 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, + user_key_id: Option, +} + +#[derive(Default, Deserialize)] +struct VectorVault { + #[serde(default)] + ciphers: Vec, + #[serde(default)] + folders: Vec, + #[serde(default)] + sends: Vec, +} + +#[derive(Deserialize)] +struct VectorItem { + id: String, + encrypted: Value, +} + +/// Upstream's scene, not merged there yet: +/// +async fn test_vector_user_scene(vector: TestVector, play: &str, conn: &DbConn) -> ApiResult { + 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::(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::(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 { + 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::(&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 { + 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 { + let parts: Vec> = 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); + } +} diff --git a/src/main.rs b/src/main.rs index 76c561f2..6d34c850 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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("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 // 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, "/api"].concat(), api::core_routes()) .mount([basepath, "/admin"].concat(), api::admin_routes()) diff --git a/tools/sdk-live-tests/run.sh b/tools/sdk-live-tests/run.sh new file mode 100755 index 00000000..d57183e0 --- /dev/null +++ b/tools/sdk-live-tests/run.sh @@ -0,0 +1,145 @@ +#!/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 [--skip-sdk-build] [--mode vectors|seeder] [-- ] +# +# --skip-sdk-build uses the package already in the SDK's crates/bitwarden-wasm-internal/npm: an earlier build, or a +# published @bitwarden/sdk-internal unpacked there. +# +# 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 /sdk-live-tests/. BW_LIVE_CLIENT_VERSION defaults to 2026.10.1, the minimum for V2 accounts. +# +# Needs: Node.js >= 22.12, curl, python3, and to build the SDK its Rust toolchain with the wasm32-unknown-unknown target +# and rust-src. +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" +fi +(cd "${TESTS_DIR}" && npm ci) + +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}" diff --git a/tools/sdk-live-tests/seed_vector.py b/tools/sdk-live-tests/seed_vector.py new file mode 100644 index 00000000..85db6f07 --- /dev/null +++ b/tools/sdk-live-tests/seed_vector.py @@ -0,0 +1,59 @@ +"""Seeds one of the SDK's user test vectors, vault included, through vaultwarden's test seeder. + +Usage: seed_vector.py + +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())