From 76b2905aa2296e69bbaad1bfed7a8bcd9abcc21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Thu, 24 Sep 2026 01:16:45 +0200 Subject: [PATCH] Add a manual workflow that runs the SDK live integration tests --- .github/workflows/sdk-live-tests.yml | 65 +++++++++++++ tools/sdk-live-tests/register_vector.py | 120 ++++++++++++++++++++++++ tools/sdk-live-tests/run.sh | 61 ++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 .github/workflows/sdk-live-tests.yml create mode 100755 tools/sdk-live-tests/register_vector.py create mode 100755 tools/sdk-live-tests/run.sh diff --git a/.github/workflows/sdk-live-tests.yml b/.github/workflows/sdk-live-tests.yml new file mode 100644 index 00000000..f94b3991 --- /dev/null +++ b/.github/workflows/sdk-live-tests.yml @@ -0,0 +1,65 @@ +name: SDK live tests +permissions: {} + +# Runs the live-server integration tests of bitwarden/sdk-internal against this branch. +# Only started by hand: it builds the SDK too, and the tests aren't in a released SDK yet. +on: + workflow_dispatch: + inputs: + sdk_ref: + description: "Branch, tag or commit of bitwarden/sdk-internal to test with" + required: true + default: "km/live-server-integration-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 }} + 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: "Setup Node" + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + + - name: "Install binaryen" + run: npm i -g binaryen + + - name: "Run the SDK live tests" + run: tools/sdk-live-tests/run.sh --sdk-dir sdk-internal + + - name: "Upload logs" + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sdk-live-tests-logs + path: target/sdk-live-tests/**/*.log diff --git a/tools/sdk-live-tests/register_vector.py b/tools/sdk-live-tests/register_vector.py new file mode 100755 index 00000000..ee9d89ee --- /dev/null +++ b/tools/sdk-live-tests/register_vector.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Registers the account of an sdk-internal test vector on a vaultwarden server, as a client would. + +Usage: register_vector.py + +A V1 vector is registered with the flat `keys` object, a V2 one with `accountKeys` and the user key +id. Prints the account's email and password on two lines, for the caller to log in with. + +Exits with SKIP, and the reason on stderr, for a vector the live tests can't use. +""" + +import json +import sys +import urllib.error +import urllib.request + +SKIP = 3 +MIN_PBKDF2_ITERATIONS = 100_000 + + +def skip_reason(vector): + if not any("masterPasswordUnlock" in m for m in vector["unlockMethods"]): + return "it has no master password, which the live tests log in with" + pbkdf2 = vector["account"]["kdf"].get("pBKDF2") + if pbkdf2 is not None and pbkdf2["iterations"] < MIN_PBKDF2_ITERATIONS: + return f"registration requires at least {MIN_PBKDF2_ITERATIONS} PBKDF2 iterations (upstream 600000)" + return None + + +def kdf_of(account): + kind, params = next(iter(account["kdf"].items())) + if kind == "pBKDF2": + return {"kdfType": 0, "iterations": params["iterations"]} + return { + "kdfType": 1, + "iterations": params["iterations"], + "memory": params["memory"], + "parallelism": params["parallelism"], + } + + +def register_body(vector): + account = vector["account"] + raw = vector["rawCryptographicState"] + version, state = next(iter(account["accountCryptographicState"].items())) + kdf = kdf_of(account) + unlock = next(m["masterPasswordUnlock"] for m in vector["unlockMethods"] if "masterPasswordUnlock" in m) + mp_unlock = unlock["master_password_unlock"] + + body = { + "email": account["email"], + "name": vector["name"], + "masterPasswordHint": None, + "masterPasswordAuthentication": { + "kdf": kdf, + "salt": mp_unlock["salt"], + "masterPasswordAuthenticationHash": vector["masterPasswordAuthenticationHash"], + }, + "masterPasswordUnlock": { + "kdf": kdf, + "salt": mp_unlock["salt"], + "masterKeyWrappedUserKey": mp_unlock["masterKeyWrappedUserKey"], + }, + } + + if version == "V2": + body["masterPasswordUnlock"]["containedKeyId"] = raw["userKeyId"] + body["accountKeys"] = { + "userKeyEncryptedAccountPrivateKey": state["private_key"], + "accountPublicKey": raw["publicKey"], + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": state["private_key"], + "publicKey": raw["publicKey"], + "signedPublicKey": state["signed_public_key"], + }, + "signatureKeyPair": { + "signatureAlgorithm": "ed25519", + "wrappedSigningKey": state["signing_key"], + "verifyingKey": raw["verifyingKey"], + }, + "securityState": { + "securityState": state["security_state"], + "securityVersion": account["securityVersion"], + }, + } + else: + body["keys"] = {"encryptedPrivateKey": state["private_key"], "publicKey": raw["publicKey"]} + + return body, unlock["password"] + + +def main(): + with open(sys.argv[1]) as f: + vector = json.load(f) + server = sys.argv[2].rstrip("/") + + reason = skip_reason(vector) + if reason is not None: + print(f"Skipped: {reason}", file=sys.stderr) + sys.exit(SKIP) + + body, password = register_body(vector) + request = urllib.request.Request( + f"{server}/identity/accounts/register", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request): + pass + except urllib.error.HTTPError as e: + sys.exit(f"Registering {body['email']} failed: {e.code} {e.read().decode()}") + + print(body["email"]) + print(password) + + +if __name__ == "__main__": + main() diff --git a/tools/sdk-live-tests/run.sh b/tools/sdk-live-tests/run.sh new file mode 100755 index 00000000..98660265 --- /dev/null +++ b/tools/sdk-live-tests/run.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Runs the live-server integration tests of bitwarden/sdk-internal against vaultwarden. +# +# Usage: tools/sdk-live-tests/run.sh --sdk-dir [--skip-sdk-build] +# +# Every test vector of the SDK (test-vectors/users/) that the tests can log in with gets a fresh +# server and database, since the tests rotate the account's keys. Stops at the first failure; the +# server logs are in target/sdk-live-tests/. Building the SDK needs its Rust toolchain with the +# wasm32-unknown-unknown target and rust-src, Node.js, and binaryen (`npm i -g binaryen`). +set -euo pipefail + +VW_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +OUT_DIR="${VW_DIR}/target/sdk-live-tests" +URL="http://127.0.0.1:8099" +SKIP=3 # register_vector.py's exit code for a vector the tests can't use + +SDK_DIR="" +BUILD_SDK=1 +while [[ $# -gt 0 ]]; do + case "$1" in + --sdk-dir) SDK_DIR="$(cd "$2" && pwd)"; shift 2 ;; + --skip-sdk-build) BUILD_SDK=0; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done +[[ -n "${SDK_DIR}" ]] || { echo "--sdk-dir is required" >&2; exit 2; } +TESTS_DIR="${SDK_DIR}/crates/bitwarden-wasm-internal/integration-tests" + +(cd "${VW_DIR}" && cargo build --features sqlite) +if [[ ${BUILD_SDK} -eq 1 ]]; then + bash "${SDK_DIR}/crates/bitwarden-wasm-internal/build.sh" + (cd "${TESTS_DIR}" && npm ci) +fi + +trap 'kill $(jobs -p) 2>/dev/null || true' EXIT + +for vector_file in "${SDK_DIR}"/test-vectors/users/*.json; do + vector="$(basename "${vector_file}" .json)" + echo "=== ${vector}" + data_dir="${OUT_DIR}/${vector}" + rm -rf "${data_dir}" && mkdir -p "${data_dir}" + + DATA_FOLDER="${data_dir}" DATABASE_URL="sqlite://${data_dir}/db.sqlite3" \ + ROCKET_ADDRESS=127.0.0.1 ROCKET_PORT=8099 DOMAIN="${URL}" WEB_VAULT_ENABLED=false \ + SIGNUPS_ALLOWED=true SIGNUPS_VERIFY=false LOGIN_RATELIMIT_MAX_BURST=1000 \ + "${VW_DIR}/target/debug/vaultwarden" > "${data_dir}/vaultwarden.log" 2>&1 & + server=$! + curl -sf --retry 30 --retry-connrefused --retry-delay 1 "${URL}/alive" > /dev/null + + status=0 + credentials="$(python3 "${VW_DIR}/tools/sdk-live-tests/register_vector.py" "${vector_file}" "${URL}")" || status=$? + if [[ ${status} -eq 0 ]]; then + (cd "${TESTS_DIR}" && BW_LIVE_SERVER_URL="${URL}" BW_LIVE_EMAIL="$(sed -n 1p <<< "${credentials}")" \ + BW_LIVE_PASSWORD="$(sed -n 2p <<< "${credentials}")" npm run test:live) + elif [[ ${status} -ne ${SKIP} ]]; then + exit "${status}" + fi + + kill "${server}" && wait "${server}" || true +done +echo "All test vectors passed"