diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/crypto/pure-crypto.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/crypto/happy-path.test.ts similarity index 100% rename from crates/bitwarden-wasm-internal/integration-tests/tests/crypto/pure-crypto.test.ts rename to crates/bitwarden-wasm-internal/integration-tests/tests/crypto/happy-path.test.ts diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/conformance.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/conformance.test.ts new file mode 100644 index 000000000..1bae517c0 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/conformance.test.ts @@ -0,0 +1,69 @@ +// Rotating the keys of a key-connector account, for each account version the vectors carry. +// +// A key-connector rotation authorizes itself with the key fetched from the connector rather than a +// derived master key, which is the one thing that differs from the other rotations. The shared +// assertion body lives in `tests/rotation-cases.ts`. + +import type { InitUserCryptoMethod } from "@bitwarden/sdk-internal"; + +import { KEY_CONNECTOR_URL } from "../model-server/install"; +import { + assertRotationHarnessClean, + expectRotationSucceeds, + ROTATION_TIMEOUT, + setupRotation, + UNLOCK_METHOD, + type RotationCase, + type RotationHarness, +} from "../rotation-cases"; +import { loadUserVectors, userVector, type UserVector } from "../test-vectors/load"; + +const users = loadUserVectors(); + +/** Pulls the key-connector key out of the vector's own key-connector unlock method. */ +function keyConnectorKeyOf(vector: UserVector): string { + const method = vector.unlockMethods.find((m) => "keyConnector" in m) as + Extract | undefined; + if (method === undefined) { + throw new Error(`${vector.name} has no key-connector unlock method`); + } + return method.keyConnector.master_key.toString(); +} + +const cases: [string, RotationCase][] = [ + [ + "V1 key connector", + { + vector: userVector(users, "v1-pbkdf2-key-connector"), + method: () => ({ KeyConnector: { key_connector_url: KEY_CONNECTOR_URL } }), + expectedUnlockMethod: UNLOCK_METHOD.keyConnector, + keyConnectorKey: keyConnectorKeyOf(userVector(users, "v1-pbkdf2-key-connector")), + }, + ], + [ + "V2 key connector", + { + vector: userVector(users, "v2-pbkdf2-key-connector"), + method: () => ({ KeyConnector: { key_connector_url: KEY_CONNECTOR_URL } }), + expectedUnlockMethod: UNLOCK_METHOD.keyConnector, + keyConnectorKey: keyConnectorKeyOf(userVector(users, "v2-pbkdf2-key-connector")), + }, + ], +]; + +describe("key connector key rotation", () => { + let harness: RotationHarness; + + afterEach(() => assertRotationHarnessClean(harness)); + + describe.each(cases)("%s", (_label, rotationCase) => { + it( + "posts a V2 cryptographic state, the re-encrypted vault and the right unlock method", + async () => { + harness = setupRotation(rotationCase.vector, rotationCase.keyConnectorKey); + await expectRotationSucceeds(harness, rotationCase); + }, + ROTATION_TIMEOUT, + ); + }); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/edge-cases.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/edge-cases.test.ts new file mode 100644 index 000000000..ef9f85abf --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/edge-cases.test.ts @@ -0,0 +1,126 @@ +// The four ways a key connector migration can go sideways, kept so none of them regresses. +// +// Ordering matters more here than in most operations. If the account were enrolled server-side while the +// key connector had not stored the key, the user would be permanently locked out — so a failure at the +// connector must abort before enrollment. The reverse order is the safe one, and the last test documents +// that the SDK does leave an orphaned key behind rather than risk the other way round. +// +// The connector model enforces the real verb constraint (`POST` with a key present is a 409, `PUT` with +// none a 404), so the first test only has to check that the migration completed at all. + +import { LocalState, SETTINGS } from "../model-server/local-state"; +import { syncToLocalState, unlockMethodFor } from "../model-server/sync"; +import { KEY_CONNECTOR_URL } from "../model-server/install"; +import { + assertMigrationHarnessClean, + KEY_CONNECTOR_KEY_BYTES, + MIGRATION_CASES, + MIGRATION_TIMEOUT, + setupMigration, + type MigrationHarness, +} from "./migration-support"; +import { makePasswordManagerClient, makeStateBridge } from "../utils"; + +/** The cheapest vector in the set to unlock; none of these cases turns on the account version. */ +const vector = MIGRATION_CASES[0][1]; + +describe("key connector migration", () => { + let harness: MigrationHarness; + + afterEach(() => assertMigrationHarnessClean(harness)); + + it( + "updates the existing key with PUT when the connector already holds one", + async () => { + // The connector rejects a PUT for a key that does not exist and a POST for one that does, so + // picking the wrong verb here fails the migration outright. + harness = setupMigration(vector); + const { api, keyConnector } = harness; + const preexisting = Buffer.alloc(KEY_CONNECTOR_KEY_BYTES, 7).toString("base64"); + keyConnector.seedKey(preexisting); + + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, vector.account.email)); + await client.user_crypto_management().migrate_to_key_connector(KEY_CONNECTOR_URL); + + // The connector rejects a POST when a key already exists, so the migration completing at all is + // what shows the client read first and chose PUT. And the key it stores is its own new one, not + // the key the connector already had. + expect(keyConnector.key()).not.toBe(preexisting); + expect(keyConnector.key()).toBeTruthy(); + }, + MIGRATION_TIMEOUT, + ); + + it( + "sends nothing at all when the client is locked", + async () => { + // No `initialize_user_crypto`, so there is no user key to wrap. The migration must fail at + // step 2, before the freshly minted key connector key has been shown to anyone — otherwise a + // locked client would leave a usable key sitting at the connector. + harness = setupMigration(); + const client = makePasswordManagerClient(makeStateBridge(), SETTINGS); + + await expect( + client.user_crypto_management().migrate_to_key_connector(KEY_CONNECTOR_URL), + ).rejects.toBeDefined(); + + expect(harness.servers.requests).toEqual([]); + expect(harness.keyConnector.key()).toBeUndefined(); + }, + MIGRATION_TIMEOUT, + ); + + it( + "does not enroll the account when the key connector rejects the key", + async () => { + // The invariant that protects against permanent lockout: if the server recorded the account as + // key-connector-unlocked while the connector had no key, nothing could ever unlock it again. + harness = setupMigration(vector); + const { api, keyConnector } = harness; + keyConnector.failWrites(500); + + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, vector.account.email)); + await expect( + client.user_crypto_management().migrate_to_key_connector(KEY_CONNECTOR_URL), + ).rejects.toBeDefined(); + + // Neither side changed: no key stored, and — the invariant that matters — no enrolment recorded. + // If the account were enrolled while the connector held no key, nothing could unlock it again. + expect(keyConnector.key()).toBeUndefined(); + expect(api.db.user(api.soleUserId()).keyConnectorKeyWrappedUserKey).toBeUndefined(); + }, + MIGRATION_TIMEOUT, + ); + + it( + "surfaces a failure to enroll, after the key connector has already stored the key", + async () => { + // Override just the enrolment endpoint to fail, leaving the rest of the model intact. + harness = setupMigration(vector, { + "POST /accounts/key-connector/enroll": () => ({ + status: 500, + json: { message: "enrolment unavailable" }, + }), + }); + const { api, keyConnector } = harness; + + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, vector.account.email)); + await expect( + client.user_crypto_management().migrate_to_key_connector(KEY_CONNECTOR_URL), + ).rejects.toBeDefined(); + + // Documenting real behaviour rather than endorsing it: the key is already at the connector at + // this point and is not rolled back. That is the safe way round — the account still unlocks by + // master password, and a retry overwrites the orphaned key via PUT. + expect(keyConnector.key()).toBeDefined(); + expect(api.db.user(api.soleUserId()).keyConnectorKeyWrappedUserKey).toBeUndefined(); + }, + MIGRATION_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/migration-support.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/migration-support.ts new file mode 100644 index 000000000..00839b114 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/migration-support.ts @@ -0,0 +1,58 @@ +// The shared harness for `migrate_to_key_connector`, so the flow and the edge cases can each stand up +// the two servers without re-deriving the setup. +// +// A migration spans two independent origins — the API and the key connector — so every test needs both +// models installed together, and both are inspected afterwards. + +import type { Routes } from "../http-mock"; +import { ApiServer } from "../model-server/api-server"; +import { installServers, type InstalledServers } from "../model-server/install"; +import { KeyConnectorServer } from "../model-server/key-connector-server"; +import { loadUserVectors, userVector, type UserVector } from "../test-vectors/load"; + +/** Unlocking to get a user key in memory pays the account's real KDF cost. */ +export const MIGRATION_TIMEOUT = 120_000; + +/** A key connector key is 32 raw bytes, sent base64. */ +export const KEY_CONNECTOR_KEY_BYTES = 32; + +const users = loadUserVectors(); + +/** + * A V1 and a V2 account, because the user key being wrapped differs in kind: V1 is an + * `Aes256CbcHmac` key, V2 an `XChaCha20Poly1305` COSE key, and `encrypt_user_key` encodes them + * differently. `v1-pbkdf2-min-iterations` is also the cheapest vector in the set to unlock. + */ +export const MIGRATION_CASES: [string, UserVector][] = [ + ["a V1 account", userVector(users, "v1-pbkdf2-min-iterations")], + ["a V2 account", userVector(users, "v2-argon2id-blob")], +]; + +export interface MigrationHarness { + api: ApiServer; + keyConnector: KeyConnectorServer; + servers: InstalledServers; +} + +/** + * Stands up an API model and a key connector model on their own origins. + * + * Passing no vector leaves the API with no account at all, which is what the locked-client case needs. + */ +export function setupMigration(vector?: UserVector, extraRoutes?: Routes): MigrationHarness { + const api = new ApiServer(); + if (vector !== undefined) { + api.seedUser(vector); + } + const keyConnector = new KeyConnectorServer(); + return { api, keyConnector, servers: installServers({ api, keyConnector, extraRoutes }) }; +} + +/** The assertions every migration suite makes in `afterEach`. */ +export function assertMigrationHarnessClean(harness: MigrationHarness): void { + expect(harness.servers.unmatched.map((request) => request.route)).toEqual([]); + // No seeded account's password, user key, private key or master key may ever appear in a request + // body. Policed by the server on every request, so no individual test has to remember to look. + expect(harness.api.secretLeaks()).toEqual([]); + harness.servers.restore(); +} diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/user-facing-flow.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/user-facing-flow.test.ts new file mode 100644 index 000000000..2100f2476 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/key-connector/user-facing-flow.test.ts @@ -0,0 +1,161 @@ +// Key connector as a user meets it: migrating an existing master-password account onto it, and +// registering a new account that unlocks by it. Both end the same way — unlock the account again from +// nothing but what the two servers now hold. +// +// `migrate_to_key_connector` is a four-step operation that spans two different servers: it mints a +// random key connector key, wraps the current user key with it, posts the key to the *key connector*, +// then posts the wrapped user key to the *API*. The key connector is a separate model on its own origin, +// so "did the key reach the connector" and "did the account get enrolled at the API" are two independent +// questions about two independent pieces of state. +// +// The migration test does not stop at the stored payloads. It takes the two values the SDK produced — +// the key the connector now stores and the wrapped user key the API now holds — feeds them back in as a +// `keyConnector` unlock method on a fresh client, and asserts that client arrives at the *original* user +// key. Then it unlocks the way a real client does, by URL, so the SDK fetches the key from the connector +// over HTTP rather than being handed a value the test already had. +// +// Registration is the one operation with no second chance: it *chooses* the account's keys, and if it +// emits a state the SDK cannot later load, the account is unrecoverable. Its shared scaffolding lives in +// `tests/registration-support.ts`. + +import type { B64, EncString } from "@bitwarden/sdk-internal"; + +import { LocalState } from "../model-server/local-state"; +import { syncToLocalState, unlockMethodFor } from "../model-server/sync"; +import { KEY_CONNECTOR_URL } from "../model-server/install"; +import { + assertMigrationHarnessClean, + KEY_CONNECTOR_KEY_BYTES, + MIGRATION_CASES, + MIGRATION_TIMEOUT, + setupMigration, + type MigrationHarness, +} from "./migration-support"; +import { + assertRegistrationHarnessClean, + newClient, + REGISTRATION_TIMEOUT, + setupRegistration, + unlockFreshAndValidate, + type RegistrationHarness, +} from "../registration-support"; +import { unlockVector } from "../test-vectors/unlock"; +import { asEncString } from "../type-assertion-helpers"; + +describe("key connector migration", () => { + let harness: MigrationHarness; + + afterEach(() => assertMigrationHarnessClean(harness)); + + describe.each(MIGRATION_CASES)("%s", (_label, vector) => { + it( + "posts the key connector key, enrolls with the wrapped user key, and the pair unlocks the account", + async () => { + harness = setupMigration(vector); + const { api, keyConnector } = harness; + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, vector.account.email)); + await client.user_crypto_management().migrate_to_key_connector(KEY_CONNECTOR_URL); + + // The two halves landed in two different places, which is the point of separate models. + const postedKey = keyConnector.key(); + const wrapped = api.db.user(api.soleUserId()).keyConnectorKeyWrappedUserKey; + + // A freshly minted 32-byte key, not anything derived from the account's existing material. + if (postedKey === undefined) { + throw new Error("the key connector stored no key"); + } + expect(Buffer.from(postedKey, "base64")).toHaveLength(KEY_CONNECTOR_KEY_BYTES); + + // The wrapped user key is an `Aes256CbcHmac` EncString, because the key connector key is + // stretched into one before wrapping regardless of the user key's own algorithm. + if (wrapped === undefined) { + throw new Error("the account was not enrolled in key connector unlock"); + } + expect(wrapped).toMatch(/^2\./); + + // The new wrapping replaces the master-key one rather than reusing it. + const previous = vector.unlockMethods.find((m) => "masterPasswordUnlock" in m) as { + masterPasswordUnlock: { master_password_unlock: { masterKeyWrappedUserKey: unknown } }; + }; + expect(wrapped).not.toBe( + String(previous.masterPasswordUnlock.master_password_unlock.masterKeyWrappedUserKey), + ); + + // The actual point: the two stored values must unlock the account to the same user key. + const migrated = await unlockVector(vector, { + keyConnector: { + master_key: postedKey as B64, + user_key: wrapped as EncString, + }, + }); + expect((await migrated.crypto().get_user_encryption_key()).toString()).toBe( + vector.rawCryptographicState.userKey, + ); + + // The unlock a real client actually performs after migrating: it is handed the *URL*, not the + // key, and fetches the key from the connector itself. This is the assertion the separate key + // connector model exists for — it exercises the connector over HTTP rather than trusting a + // value the test already had in hand. + const viaUrl = await local.unlock({ + keyConnectorUrl: { + url: KEY_CONNECTOR_URL, + key_connector_key_wrapped_user_key: asEncString(wrapped), + }, + }); + expect((await viaUrl.crypto().get_user_encryption_key()).toString()).toBe( + vector.rawCryptographicState.userKey, + ); + + // Proof that the check above has teeth: the same wrapped user key against a *different* key + // connector key must not unlock anything. Without this, an unlock that ignored `master_key` + // entirely would satisfy the assertion. + const wrongKey = Buffer.alloc(KEY_CONNECTOR_KEY_BYTES, 9).toString("base64"); + expect(wrongKey).not.toBe(postedKey); + await expect( + unlockVector(vector, { + keyConnector: { master_key: wrongKey as B64, user_key: wrapped as EncString }, + }), + ).rejects.toBeDefined(); + }, + MIGRATION_TIMEOUT, + ); + }); +}); + +describe("registering an account that unlocks by key connector", () => { + let harness: RegistrationHarness; + + beforeEach(() => { + harness = setupRegistration(); + }); + + afterEach(() => assertRegistrationHarnessClean(harness)); + + it( + "registers a key-connector account, and the account unlocks by key connector", + async () => { + const result = await newClient() + .auth() + .registration() + .post_keys_for_key_connector_registration(KEY_CONNECTOR_URL, "sso-identifier"); + + // The key must reach the connector, or the account could never be unlocked again. + expect(harness.keyConnector.key()).toBe(result.key_connector_key.toString()); + + await unlockFreshAndValidate( + result.account_cryptographic_state, + { + keyConnector: { + master_key: result.key_connector_key, + user_key: result.key_connector_key_wrapped_user_key, + }, + }, + result.user_key.toString(), + { pBKDF2: { iterations: 600_000 } }, + ); + }, + REGISTRATION_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/key-management/crypto-sync-handler.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/key-management/conformance.test.ts similarity index 100% rename from crates/bitwarden-wasm-internal/integration-tests/tests/key-management/crypto-sync-handler.test.ts rename to crates/bitwarden-wasm-internal/integration-tests/tests/key-management/conformance.test.ts diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/key-rotation/edge-cases.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/key-rotation/edge-cases.test.ts new file mode 100644 index 000000000..a9864da74 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/key-rotation/edge-cases.test.ts @@ -0,0 +1,114 @@ +// Two rotation cases worth pinning down so they cannot regress, neither tied to an unlock method. +// +// The first is a deliberate non-write: with `upgrade_token_action: "Skip"` a rotation leaves local state +// alone, so a client that trusted local state instead of re-syncing is left holding the old key. The +// second is a refusal: an account whose vault still carries pre-v2 attachments cannot be rotated at all. + +import { ApiServer } from "../model-server/api-server"; +import { LocalState, SETTINGS } from "../model-server/local-state"; +import { syncToLocalState, unlockMethodFor } from "../model-server/sync"; +import { installServers, type InstalledServers } from "../model-server/install"; +import { + assertRotationHarnessClean, + ROTATION_ROUTES, + ROTATION_TIMEOUT, + setupRotation, + type RotationHarness, +} from "../rotation-cases"; +import { loadUserVectors, userVector } from "../test-vectors/load"; +import { makePasswordManagerClient, makeStateBridge } from "../utils"; + +const users = loadUserVectors(); + +describe("rotation and local state", () => { + // Same account as the rotation chain: the richest vault a rotation will actually accept. + const vector = userVector(users, "v1-argon2id-password"); + + let api: ApiServer; + let servers: InstalledServers; + + beforeEach(() => { + api = new ApiServer(); + api.seedUser(vector); + servers = installServers({ api }); + }); + + afterEach(() => { + expect(servers.unmatched.map((request) => request.route)).toEqual([]); + // No seeded account's password, user key, private key or master key may ever appear in a request + // body. Policed by the server on every request, so no individual test has to remember to look. + expect(api.secretLeaks()).toEqual([]); + servers.restore(); + }); + + it( + "leaves local state untouched when the upgrade token is skipped", + async () => { + // `rotate_user_keys` only writes the account state, user key and token to the state bridge when it + // created an upgrade token. With `Skip` it writes nothing, so a client that relied on local state + // rather than a follow-up sync would still be holding the pre-rotation key. + const bridge = makeStateBridge(); + const client = makePasswordManagerClient(bridge, SETTINGS); + await client.crypto().initialize_user_crypto({ + userId: vector.account.userId, + kdfParams: vector.account.kdf, + email: vector.account.email, + accountCryptographicState: vector.account.accountCryptographicState, + method: vector.unlockMethods[0], + upgradeToken: vector.account.upgradeToken, + }); + + await client.user_crypto_management().rotate_user_keys({ + key_rotation_method: { Password: { password: vector.account.password } }, + trusted_emergency_access_public_keys: [], + trusted_organization_public_keys: [], + upgrade_token_action: "Skip", + }); + + expect(await bridge.get_v2_upgrade_token()).toBeFalsy(); + expect(await bridge.get_account_cryptographic_state()).toBeFalsy(); + }, + ROTATION_TIMEOUT, + ); +}); + +describe("key rotation with pre-v2 attachments", () => { + let harness: RotationHarness; + + afterEach(() => assertRotationHarnessClean(harness)); + + it( + "refuses to rotate an account whose vault still has pre-v2 attachments", + async () => { + // An attachment with no key has its contents encrypted under the user key, so rotating would make + // the file unreadable. `check_for_old_attachments` fails the rotation before anything is posted. + const vector = userVector(users, "v1-pbkdf2-password"); + expect( + vector.vault.ciphers.some((cipher) => + Object.values(cipher.keys.attachments).some((attachment) => attachment.version !== "V2"), + ), + ).toBe(true); + + harness = setupRotation(vector); + const { api, servers } = harness; + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, vector.account.email)); + + await expect( + client.user_crypto_management().rotate_user_keys({ + key_rotation_method: { Password: { password: vector.account.password } }, + trusted_emergency_access_public_keys: [], + trusted_organization_public_keys: [], + upgrade_token_action: "Skip", + }), + ).rejects.toBeDefined(); + + // Nothing was posted: the check runs before any re-encryption. + expect(servers.routes()).not.toContain(ROTATION_ROUTES.rotate); + // And the account on the server is untouched — still V1. + expect("V1" in api.db.user(api.soleUserId()).accountCryptographicState).toBe(true); + }, + ROTATION_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/key-rotation/user-facing-flow.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/key-rotation/user-facing-flow.test.ts new file mode 100644 index 000000000..e8db46111 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/key-rotation/user-facing-flow.test.ts @@ -0,0 +1,211 @@ +// Rotation as a user performs it: rotate, then carry on using the account. +// +// Two flows that are not tied to any unlock method. The first rotates the same account five times in a +// row, rebuilding the SDK from the server between each; the second rotates an account that shares its +// key with an organization and an emergency-access contact, and checks the shares were re-sealed. +// +// Each feature's own `conformance.test.ts` covers a single rotation per account type and asserts what +// gets posted. Neither of those consumes the result, so nothing there proves a rotation produces state +// the SDK can *load* — only that it produces state of the right shape. Anything that survives one round +// trip but degrades over successive generations is invisible to them: a key id that stops being +// regenerated, a security version that fails to advance, an unlock payload that happens to still work +// only because the original one did. +// +// The chain closes that loop. The model server plays the role the real backend plays: it serves the +// account's current state to `GET /sync`, absorbs `POST /rotate-user-keys`, and serves *that* on the next +// rotation. Between generations the client is thrown away and rebuilt from the served state and the +// master password alone, which is the only way to show the previous rotation's output is genuinely usable. +// +// Every generation is validated: the vault must decrypt to the plaintext the vector recorded, from the +// very first generation to the sixth. Because the expected plaintext comes from the committed vector and +// never changes, a mistake in the server's write handling shows up as a decryption failure rather than as +// a test that agrees with itself. + +import type { PublicKey } from "@bitwarden/sdk-internal"; + +import { ApiServer } from "../model-server/api-server"; +import { LocalState } from "../model-server/local-state"; +import { + syncToLocalState, + unlockMethodFor, + validateAfterLogoutLogin, + validateVector, +} from "../model-server/sync"; +import { installServers, type InstalledServers } from "../model-server/install"; +import { + assertRotationHarnessClean, + ROTATION_TIMEOUT, + setupRotation, + type RotationHarness, +} from "../rotation-cases"; +import { + loadEmergencyAccessVectors, + loadOrganizationVectors, + loadUserVectors, + userVector, +} from "../test-vectors/load"; + +// Five rotations, each re-deriving the master key, plus a validating decryption pass per generation. +const TIMEOUT = 300_000; + +const ROTATIONS = 5; + +const users = loadUserVectors(); +const organizations = loadOrganizationVectors(); +const emergencyAccess = loadEmergencyAccessVectors(); + +/** + * `v1-argon2id-password` is the richest vault in the set that a rotation will actually accept: two + * ciphers, a folder and a send, and — critically — no pre-v2 attachments, which + * `check_for_old_attachments` refuses to rotate. Argon2 at 6 iterations also keeps five rotations quick. + */ +const vector = userVector(users, "v1-argon2id-password"); + +describe("successive key rotations", () => { + let api: ApiServer; + let servers: InstalledServers; + + beforeEach(() => { + api = new ApiServer(); + api.seedUser(vector); + // No organizations or emergency-access grants are seeded: re-sharing needs trusted public keys + // supplied per call and is covered by its own suite below, so leaving them out keeps the chain + // about the account's own keys. + servers = installServers({ api }); + }); + + afterEach(() => { + expect(servers.unmatched.map((request) => request.route)).toEqual([]); + // No seeded account's password, user key, private key or master key may ever appear in a request + // body. Policed by the server on every request, so no individual test has to remember to look. + expect(api.secretLeaks()).toEqual([]); + servers.restore(); + }); + + it( + `survives ${ROTATIONS} rotations, decrypting to the recorded plaintext at every generation`, + async () => { + const user = () => api.db.user(api.soleUserId()); + const wrappedPrivateKeyOf = () => { + const state = user().accountCryptographicState; + return ("V1" in state ? state.V1.private_key : state.V2.private_key).toString(); + }; + + // Generation 0: the account exactly as committed. If this fails, nothing below means anything. + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + await validateVector(local, unlockMethodFor(api, vector.account.email), vector); + + const initial = await local.unlock(unlockMethodFor(api, vector.account.email)); + const userKeys: string[] = [(await initial.crypto().get_user_encryption_key()).toString()]; + expect(userKeys[0]).toBe(vector.rawCryptographicState.userKey); + + const securityVersions: number[] = [user().securityVersion]; + const wrappedUserKeys: string[] = [user().masterPasswordUnlock!.masterKeyWrappedUserKey]; + const publicKeys: string[] = [user().publicKey]; + const wrappedPrivateKeys: string[] = [wrappedPrivateKeyOf()]; + + for (let generation = 1; generation <= ROTATIONS; generation++) { + // Act: rotate from a client rebuilt on the previous generation's state. + const rotating = await local.unlock(unlockMethodFor(api, vector.account.email)); + await rotating.user_crypto_management().rotate_user_keys({ + key_rotation_method: { Password: { password: vector.account.password } }, + trusted_emergency_access_public_keys: [], + trusted_organization_public_keys: [], + upgrade_token_action: "Skip", + }); + + // Assert: log out and back in. Local state is discarded, the rotation is synced down, and the + // account is unlocked from nothing but what the server holds — so unlocking at all proves the + // rotation's output is loadable. + const next = await validateAfterLogoutLogin(api, vector.account.email, vector, { + // A rotation mints a new user key by definition, so the vector's recorded one no longer + // applies from here on. + expectedUserKey: undefined, + }); + + const rebuilt = await next.unlock(unlockMethodFor(api, vector.account.email)); + userKeys.push((await rebuilt.crypto().get_user_encryption_key()).toString()); + securityVersions.push(user().securityVersion); + wrappedUserKeys.push(user().masterPasswordUnlock!.masterKeyWrappedUserKey); + publicKeys.push(user().publicKey); + wrappedPrivateKeys.push(wrappedPrivateKeyOf()); + await syncToLocalState(api, vector.account.email, local); + } + + // Every generation must be genuinely new material, not a re-post of the last one. + expect(new Set(userKeys).size).toBe(ROTATIONS + 1); + expect(new Set(wrappedUserKeys).size).toBe(ROTATIONS + 1); + + // The identity key pair, on the other hand, must survive rotation unchanged. Regenerating it + // would invalidate every organization and emergency-access share the account is party to, and + // force every peer to re-trust the new key. Rotation re-wraps the *same* private key under the + // new user key instead, so the public half is stable while every wrapping differs. + expect(new Set(publicKeys).size).toBe(1); + expect(publicKeys[0]).toBe(vector.rawCryptographicState.publicKey); + expect(new Set(wrappedPrivateKeys).size).toBe(ROTATIONS + 1); + + // The account starts V1 and is V2 from the first rotation onwards, never regressing. + expect(securityVersions[0]).toBe(1); + for (const version of securityVersions.slice(1)) { + expect(version).toBeGreaterThanOrEqual(2); + } + + // The original user key is dead: it must not be reachable again at any later generation. + expect(userKeys.slice(1)).not.toContain(vector.rawCryptographicState.userKey); + }, + TIMEOUT, + ); +}); + +describe("key rotation re-sharing", () => { + let harness: RotationHarness; + + afterEach(() => assertRotationHarnessClean(harness)); + + it( + "re-shares the user key to trusted organizations and emergency access contacts", + async () => { + // The only account that is both enrolled in account recovery and an emergency-access grantor + // while carrying no pre-v2 attachments, so it can actually complete a rotation. + const sharingVector = userVector(users, "v2-argon2id-blob"); + harness = setupRotation(sharingVector); + const { api } = harness; + const local = new LocalState(); + await syncToLocalState(api, sharingVector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, sharingVector.account.email)); + + const organization = organizations.find((o) => + o.members.some( + (member) => + member.userVector === sharingVector.name && member.accountRecoveryKey !== undefined, + ), + )!; + const grant = emergencyAccess.find((g) => g.grantorVector === sharingVector.name)!; + + await client.user_crypto_management().rotate_user_keys({ + key_rotation_method: { Password: { password: sharingVector.account.password } }, + trusted_emergency_access_public_keys: [grant.granteePublicKey as unknown as PublicKey], + trusted_organization_public_keys: [organization.publicKey as unknown as PublicKey], + upgrade_token_action: "Skip", + }); + + // Read back off the account the server stored, not the request body. + const stored = api.db.user(api.soleUserId()); + + // The new user key is re-sealed to each trusted party. The ciphertexts must differ from the + // committed ones, which were sealed against the *old* user key. + expect(stored.accountRecoveryUnlockData).toHaveLength(1); + expect(stored.emergencyAccessUnlockData).toHaveLength(1); + + const member = organization.members.find((m) => m.userVector === sharingVector.name)!; + expect(stored.accountRecoveryUnlockData[0].resetPasswordKey).not.toBe( + member.accountRecoveryKey!.toString(), + ); + expect(stored.emergencyAccessUnlockData[0].keyEncrypted).not.toBe( + grant.grantorUserKeySealedToGrantee.toString(), + ); + }, + ROTATION_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/change-kdf-support.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/change-kdf-support.ts new file mode 100644 index 000000000..1543ca384 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/change-kdf-support.ts @@ -0,0 +1,79 @@ +// The shared harness for `change_kdf`. +// +// A KDF change needs an unlocked account whose master-password unlock data is real, so the harness seeds +// a committed vector into the model server, syncs it into local state and unlocks it — the same route a +// client takes. Tests get the server, the local state and the unlocked client together. + +import type { Kdf, PasswordManagerClient } from "@bitwarden/sdk-internal"; + +import type { Routes } from "../http-mock"; +import { ApiServer } from "../model-server/api-server"; +import { installServers, type InstalledServers } from "../model-server/install"; +import { LocalState } from "../model-server/local-state"; +import { syncToLocalState, unlockMethodFor } from "../model-server/sync"; +import { loadUserVectors, userVector, type UserVector } from "../test-vectors/load"; + +/** Two KDF derivations per change — the old one to prove possession, the new one to re-wrap. */ +export const CHANGE_KDF_TIMEOUT = 120_000; + +export const CHANGE_KDF_ROUTE = "POST /accounts/kdf"; + +/** The server's numeric `KdfType`. */ +export const KDF_TYPE = { pbkdf2Sha256: 0, argon2id: 1 } as const; + +export const NEW_PBKDF2: Kdf = { pBKDF2: { iterations: 700_000 } }; +export const NEW_ARGON2: Kdf = { argon2id: { iterations: 3, memory: 16, parallelism: 4 } }; + +const users = loadUserVectors(); + +/** + * The cheapest master-password account in the set to unlock, so the cost of a test is dominated by the + * KDF being changed *to* rather than the one being changed from. + */ +export const CHANGE_KDF_VECTOR = userVector(users, "v1-pbkdf2-min-iterations"); + +/** An account with no master password at all, for the case where there is no unlock data to re-derive. */ +export const NO_MASTER_PASSWORD_VECTOR = userVector(users, "v1-argon2id-tde"); + +export interface ChangeKdfHarness { + api: ApiServer; + servers: InstalledServers; + local: LocalState; + client: PasswordManagerClient; + vector: UserVector; + assertClean(): void; +} + +/** + * Seeds `vector`, installs the API model, syncs the account into local state and unlocks it. + * + * `extraRoutes` overrides endpoints on the API origin, which is how the failure cases make the KDF + * change be rejected without disturbing the rest of the model. + */ +export async function setupChangeKdf( + options: { vector?: UserVector; extraRoutes?: Routes } = {}, +): Promise { + const vector = options.vector ?? CHANGE_KDF_VECTOR; + const api = new ApiServer(); + api.seedUser(vector); + const servers = installServers({ api, extraRoutes: options.extraRoutes }); + + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, vector.account.email)); + + return { + api, + servers, + local, + client, + vector, + assertClean() { + expect(servers.unmatched.map((request) => request.route)).toEqual([]); + // No seeded account's password, user key, private key or master key may ever appear in a request + // body. Policed by the server on every request, so no individual test has to remember to look. + expect(api.secretLeaks()).toEqual([]); + servers.restore(); + }, + }; +} diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/conformance.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/conformance.test.ts new file mode 100644 index 000000000..31fb7fcd8 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/conformance.test.ts @@ -0,0 +1,290 @@ +// Low-level assertions on the three master-password operations that rewrite the account's key material: +// changing the KDF, rotating the user key, and minting keys at registration. +// +// These read the request bodies and the stored account on purpose. A KDF change is only correct if the +// two halves it posts agree with each other and with what it persists, and that agreement is not visible +// from the outside — an account that unlocks can still have been left with a salt or a KDF variant the +// server and the client disagree about. Expect these to break whenever the wire shape changes; that is +// what they are for. + +import { + unlockMethodFor, + validateAfterLockUnlock, + validateAfterLogoutLogin, +} from "../model-server/sync"; +import { + assertRotationHarnessClean, + expectRotationSucceeds, + ROTATION_TIMEOUT, + setupRotation, + UNLOCK_METHOD, + type RotationCase, + type RotationHarness, +} from "../rotation-cases"; +import { + assertRegistrationHarnessClean, + newClient, + organization, + passwordRegistrationRequest, + REGISTRATION_TIMEOUT, + setupRegistration, + type RegistrationHarness, +} from "../registration-support"; +import { loadUserVectors, userVector } from "../test-vectors/load"; +import { + CHANGE_KDF_ROUTE, + CHANGE_KDF_TIMEOUT, + KDF_TYPE, + NEW_ARGON2, + NEW_PBKDF2, + setupChangeKdf, + type ChangeKdfHarness, +} from "./change-kdf-support"; + +const users = loadUserVectors(); + +describe("change kdf", () => { + let harness: ChangeKdfHarness; + + afterEach(() => harness.assertClean()); + + describe("request", () => { + it( + "posts the re-derived authentication and unlock data under the new KDF", + async () => { + harness = await setupChangeKdf(); + const { api, client, vector, servers } = harness; + const before = api.db.user(api.soleUserId()).masterPasswordUnlock!; + + await client.user_crypto_management().change_kdf(vector.account.password, NEW_PBKDF2); + + const posted = servers.bodyFor(CHANGE_KDF_ROUTE); + expect(Object.keys(posted).sort()).toEqual([ + "authenticationData", + "masterPasswordHash", + "unlockData", + ]); + expect(Object.keys(posted.authenticationData).sort()).toEqual([ + "kdf", + "masterPasswordAuthenticationHash", + "salt", + ]); + expect(Object.keys(posted.unlockData).sort()).toEqual([ + "kdf", + "masterKeyWrappedUserKey", + "salt", + ]); + + // Both halves carry the new KDF; memory and parallelism are omitted for PBKDF2. + const kdf = { kdfType: KDF_TYPE.pbkdf2Sha256, iterations: 700_000 }; + expect(posted.authenticationData.kdf).toEqual(kdf); + expect(posted.unlockData.kdf).toEqual(kdf); + + // The salt is carried over from the current unlock data, not re-derived. + expect(posted.authenticationData.salt).toBe(before.salt); + expect(posted.unlockData.salt).toBe(before.salt); + + // The user key is re-wrapped under the master key derived with the new KDF. + expect(posted.unlockData.masterKeyWrappedUserKey).toMatch(/^2\./); + expect(posted.unlockData.masterKeyWrappedUserKey).not.toBe(before.masterKeyWrappedUserKey); + }, + CHANGE_KDF_TIMEOUT, + ); + + it( + "proves possession with a hash derived under the old KDF", + async () => { + harness = await setupChangeKdf(); + const { client, vector, servers } = harness; + + await client.user_crypto_management().change_kdf(vector.account.password, NEW_PBKDF2); + + const posted = servers.bodyFor(CHANGE_KDF_ROUTE); + // `masterPasswordHash` is the old-KDF hash the server authenticates the change with; + // `authenticationData` is what replaces it. Same password, different KDF, so they differ. + // The model rejects the change outright when the hash is absent, so reaching here at all + // shows one was sent. + expect(posted.masterPasswordHash).not.toBe( + posted.authenticationData.masterPasswordAuthenticationHash, + ); + expect(posted.masterPasswordHash).not.toBe(""); + }, + CHANGE_KDF_TIMEOUT, + ); + + it( + "converts the argon2id variant across the boundary", + async () => { + harness = await setupChangeKdf(); + const { api, client, local, vector, servers } = harness; + + await client.user_crypto_management().change_kdf(vector.account.password, NEW_ARGON2); + + const posted = servers.bodyFor(CHANGE_KDF_ROUTE); + const kdf = { kdfType: KDF_TYPE.argon2id, iterations: 3, memory: 16, parallelism: 4 }; + expect(posted.authenticationData.kdf).toEqual(kdf); + expect(posted.unlockData.kdf).toEqual(kdf); + + // Round trip: the argon2id variant survives out to the state bridge and back into the + // account the server now holds, so neither side has silently dropped a parameter. + expect(await local.bridge.get_kdf_config()).toEqual(NEW_ARGON2); + expect(api.db.user(api.soleUserId()).masterPasswordUnlock!.kdf).toEqual(NEW_ARGON2); + }, + CHANGE_KDF_TIMEOUT, + ); + }); + + describe("persisted state", () => { + it( + "writes the new KDF config and unlock data to the state bridge", + async () => { + harness = await setupChangeKdf(); + const { api, client, local, vector, servers } = harness; + + await client.user_crypto_management().change_kdf(vector.account.password, NEW_PBKDF2); + + expect(await local.bridge.get_kdf_config()).toEqual(NEW_PBKDF2); + + const persisted = await local.bridge.get_masterpassword_unlock_data(); + // Exactly what was posted, so client and server cannot disagree about the wrapped key. The + // bridge holds the SDK's own `Kdf` while the wire carries the server's numeric form, so the + // KDF is compared after conversion and the rest byte for byte. + const posted = servers.bodyFor(CHANGE_KDF_ROUTE).unlockData; + expect(persisted).toEqual({ + masterKeyWrappedUserKey: posted.masterKeyWrappedUserKey, + salt: posted.salt, + kdf: NEW_PBKDF2, + }); + // And the account the server stored agrees with both. + expect(api.db.user(api.soleUserId()).masterPasswordUnlock).toEqual({ + masterKeyWrappedUserKey: posted.masterKeyWrappedUserKey, + salt: posted.salt, + kdf: NEW_PBKDF2, + }); + }, + CHANGE_KDF_TIMEOUT, + ); + + it( + "leaves the persisted unlock data usable: a fresh client recovers the same user key", + async () => { + harness = await setupChangeKdf(); + const { api, client, local, vector } = harness; + + await client.user_crypto_management().change_kdf(vector.account.password, NEW_PBKDF2); + + // A KDF change re-wraps the user key without changing it, so the vector's recorded key and + // its whole plaintext vault must still be reachable — from this client's own local state, and + // from a client that has nothing but what the server holds. + await validateAfterLockUnlock(local, unlockMethodFor(api, vector.account.email), vector); + await validateAfterLogoutLogin(api, vector.account.email, vector); + }, + CHANGE_KDF_TIMEOUT, + ); + }); + + it( + "never sends the password or the user key", + async () => { + harness = await setupChangeKdf(); + const { api, client, vector, servers } = harness; + + await client.user_crypto_management().change_kdf(vector.account.password, NEW_PBKDF2); + + // The account's password, user key, private key and master key are all watched by the server's + // inspector on every request, so this covers the whole exchange rather than one body. + expect(servers.requests).not.toEqual([]); + expect(api.secretLeaks()).toEqual([]); + }, + CHANGE_KDF_TIMEOUT, + ); +}); + +const rotationCases: [string, RotationCase][] = [ + [ + "V1 master password", + { + vector: userVector(users, "v1-argon2id-password"), + method: (vector) => ({ Password: { password: vector.account.password } }), + expectedUnlockMethod: UNLOCK_METHOD.masterPassword, + }, + ], + [ + "V1 master password at minimum KDF iterations", + { + vector: userVector(users, "v1-pbkdf2-min-iterations"), + method: (vector) => ({ Password: { password: vector.account.password } }), + expectedUnlockMethod: UNLOCK_METHOD.masterPassword, + }, + ], + [ + "V2 master password", + { + vector: userVector(users, "v2-argon2id-blob"), + method: (vector) => ({ Password: { password: vector.account.password } }), + expectedUnlockMethod: UNLOCK_METHOD.masterPassword, + }, + ], + [ + "the mid-upgrade account", + { + vector: userVector(users, "v2-argon2id-upgrade-token"), + method: (vector) => ({ Password: { password: vector.account.password } }), + expectedUnlockMethod: UNLOCK_METHOD.masterPassword, + }, + ], +]; + +describe("master password key rotation", () => { + let harness: RotationHarness; + + afterEach(() => assertRotationHarnessClean(harness)); + + describe.each(rotationCases)("%s", (_label, rotationCase) => { + it( + "posts a V2 cryptographic state, the re-encrypted vault and the right unlock method", + async () => { + harness = setupRotation(rotationCase.vector); + await expectRotationSucceeds(harness, rotationCase); + }, + ROTATION_TIMEOUT, + ); + }); +}); + +describe("registering an account that unlocks by master password", () => { + let harness: RegistrationHarness; + + beforeEach(() => { + harness = setupRegistration(); + }); + + afterEach(() => assertRegistrationHarnessClean(harness)); + + it( + "mints distinct key material for every registration", + async () => { + // Two registrations must never collide, which is the one property a seeded or defaulted RNG + // would silently break. + const registration = newClient().auth().registration(); + + const first = await registration.post_keys_for_user_password_registration( + passwordRegistrationRequest, + ); + const second = await registration.post_keys_for_user_password_registration( + passwordRegistrationRequest, + ); + + expect(first.user_key.toString()).not.toBe(second.user_key.toString()); + expect(JSON.stringify(first.account_cryptographic_state)).not.toBe( + JSON.stringify(second.account_cryptographic_state), + ); + }, + REGISTRATION_TIMEOUT, + ); +}); + +// A sanity check on the fixture the registration suites lean on. +it("has an organization vector to register into", () => { + expect(organization.publicKey).toBeTruthy(); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/edge-cases.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/edge-cases.test.ts new file mode 100644 index 000000000..178bb1760 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/edge-cases.test.ts @@ -0,0 +1,103 @@ +// The three ways a KDF change refuses, kept so none of them starts half-applying. +// +// A partially applied change is the failure that matters: an account whose stored KDF no longer matches +// the unlock data it was derived with cannot be opened by anyone. So each case asserts not just that the +// call rejected but that both sides are exactly as they were, and that the account still unlocks under +// its original password. + +import type { Kdf } from "@bitwarden/sdk-internal"; +import { ChangeKdfError, isChangeKdfError } from "@bitwarden/sdk-internal"; + +import { unlockMethodFor, validateAfterLockUnlock } from "../model-server/sync"; +import { + CHANGE_KDF_ROUTE, + CHANGE_KDF_TIMEOUT, + NEW_PBKDF2, + NO_MASTER_PASSWORD_VECTOR, + setupChangeKdf, + type ChangeKdfHarness, +} from "./change-kdf-support"; + +/** Awaits a rejection and narrows it to a {@link ChangeKdfError}. */ +async function rejection(promise: Promise): Promise { + const thrown = await promise.then( + () => undefined, + (error) => error, + ); + if (!isChangeKdfError(thrown)) { + throw new Error(`expected a ChangeKdfError, got ${thrown}`); + } + return thrown; +} + +describe("change kdf", () => { + let harness: ChangeKdfHarness; + + afterEach(() => harness.assertClean()); + + describe("failures", () => { + it( + "leaves state untouched when the server rejects the change", + async () => { + harness = await setupChangeKdf({ + extraRoutes: { + [CHANGE_KDF_ROUTE]: () => ({ status: 400, json: { message: "kdf change rejected" } }), + }, + }); + const { api, client, local, vector } = harness; + const before = { ...api.db.user(api.soleUserId()).masterPasswordUnlock! }; + + const error = await rejection( + client.user_crypto_management().change_kdf(vector.account.password, NEW_PBKDF2), + ); + + expect(error.variant).toBe("Api"); + // Nothing is persisted on either side, so the account is not left half-migrated. + expect(await local.bridge.get_kdf_config()).toEqual(vector.account.kdf); + expect(api.db.user(api.soleUserId()).masterPasswordUnlock).toEqual(before); + // And it still opens under the original password. + await validateAfterLockUnlock(local, unlockMethodFor(api, vector.account.email), vector); + }, + CHANGE_KDF_TIMEOUT, + ); + + it( + "errors before making a request when the unlock data is missing", + async () => { + // A trusted-device account has no master password at all, so there is no unlock data to + // re-derive and nothing the change could be based on. + harness = await setupChangeKdf({ vector: NO_MASTER_PASSWORD_VECTOR }); + const { client, servers } = harness; + + const error = await rejection( + client.user_crypto_management().change_kdf("irrelevant-password", NEW_PBKDF2), + ); + + expect(error.variant).toBe("MissingMasterPasswordUnlockData"); + expect(servers.requests).toEqual([]); + }, + CHANGE_KDF_TIMEOUT, + ); + + it( + "errors before making a request when the new KDF is below the allowed minimum", + async () => { + harness = await setupChangeKdf(); + const { api, client, local, vector, servers } = harness; + const before = { ...api.db.user(api.soleUserId()).masterPasswordUnlock! }; + const belowMinimum: Kdf = { argon2id: { iterations: 1, memory: 16, parallelism: 1 } }; + + const error = await rejection( + client.user_crypto_management().change_kdf(vector.account.password, belowMinimum), + ); + + expect(error.variant).toBe("MasterPassword"); + // The floor is enforced before anything is derived or sent. + expect(servers.requests).toEqual([]); + expect(await local.bridge.get_kdf_config()).toEqual(vector.account.kdf); + expect(api.db.user(api.soleUserId()).masterPasswordUnlock).toEqual(before); + }, + CHANGE_KDF_TIMEOUT, + ); + }); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/user-facing-flow.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/user-facing-flow.test.ts new file mode 100644 index 000000000..323c32303 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/master-password/user-facing-flow.test.ts @@ -0,0 +1,107 @@ +// Creating an account that unlocks by master password, the two ways a user arrives at one: signing up +// directly, and being provisioned into an organization at first sign-in. +// +// Registration is the one operation with no second chance: it *chooses* the account's keys, and if it +// emits a state the SDK cannot later load, the account is unrecoverable. So each test registers against +// the model server and then proves the emitted material works by unlocking a brand new client with it. +// +// The shared scaffolding — the harness, the constants and `unlockFreshAndValidate` — lives in +// `tests/registration-support.ts`. + +import type { B64, JitMasterPasswordRegistrationRequest } from "@bitwarden/sdk-internal"; + +import { + assertRegistrationHarnessClean, + EMAIL, + newClient, + organization, + PASSWORD, + passwordRegistrationRequest, + REGISTRATION_TIMEOUT, + setupRegistration, + unlockFreshAndValidate, + USER_ID, + type RegistrationHarness, +} from "../registration-support"; + +describe("registering an account that unlocks by master password", () => { + let harness: RegistrationHarness; + + beforeEach(() => { + harness = setupRegistration(); + }); + + afterEach(() => assertRegistrationHarnessClean(harness)); + + it( + "registers a master-password account, and the account unlocks by master password", + async () => { + const result = await newClient() + .auth() + .registration() + .post_keys_for_user_password_registration(passwordRegistrationRequest); + + const kdf = result.master_password_unlock.kdf; + + // Unlock a fresh client through the method registration enrolled. + await unlockFreshAndValidate( + result.account_cryptographic_state, + { + masterPasswordUnlock: { + password: PASSWORD, + master_password_unlock: result.master_password_unlock, + }, + }, + result.user_key.toString(), + kdf, + ); + + // And the same account is reachable from the returned decrypted key, which is what a client that + // stays unlocked straight after registering uses. + await unlockFreshAndValidate( + result.account_cryptographic_state, + { decryptedKey: { decrypted_user_key: result.user_key } }, + result.user_key.toString(), + kdf, + ); + }, + REGISTRATION_TIMEOUT, + ); + + it( + "registers a just-in-time master-password account into an organization", + async () => { + const result = await newClient() + .auth() + .registration() + .post_keys_for_jit_password_registration({ + org_id: organization.organizationId, + org_public_key: organization.publicKey as B64, + organization_sso_identifier: "sso-identifier", + user_id: USER_ID, + salt: EMAIL, + master_password: PASSWORD, + master_password_hint: undefined, + reset_password_enroll: true, + } satisfies JitMasterPasswordRegistrationRequest); + + // `reset_password_enroll: true` must actually enroll the user for admin recovery. + expect(harness.api.resetPasswordEnrollments()).toEqual([ + `${organization.organizationId}/${USER_ID}`, + ]); + + await unlockFreshAndValidate( + result.account_cryptographic_state, + { + masterPasswordUnlock: { + password: PASSWORD, + master_password_unlock: result.master_password_unlock, + }, + }, + result.user_key.toString(), + result.master_password_unlock.kdf, + ); + }, + REGISTRATION_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/invite-link.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/conformance.test.ts similarity index 60% rename from crates/bitwarden-wasm-internal/integration-tests/tests/organizations/invite-link.test.ts rename to crates/bitwarden-wasm-internal/integration-tests/tests/organizations/conformance.test.ts index ef932670f..6942d8fbd 100644 --- a/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/invite-link.test.ts +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/conformance.test.ts @@ -1,4 +1,14 @@ -import { ClientSettings, PasswordManagerClient } from "@bitwarden/sdk-internal"; +// Low-level assertions on the three organization subjects: the shape of the committed vectors, the +// invite-link wire protocol, and the sealed open-org invite context. +// +// The invite-link suite is the reason most of this file reads request bodies. An invite is a bundle of +// five sealed envelopes that the admin posts and the invitee later redeems, and almost every property +// worth asserting — which envelopes are present, that the secret never leaves the client, that +// confirming and merely accepting post different fields — is only visible on the wire. It runs against +// its own route table (`invite-link-server.ts`) rather than the model server, so route sequences are +// asserted directly here. + +import { ClientSettings, CryptoClient, PasswordManagerClient } from "@bitwarden/sdk-internal"; import { HttpMock, installHttpMock } from "../http-mock"; import { @@ -7,8 +17,85 @@ import { TEST_INVITE_SECRET, TEST_ORGANIZATION_ID, } from "../org-fixtures"; -import { makeOrgAccountClient, makeOrgInitializedClient, makeStateBridge } from "../utils"; +import { + makeOrgAccountClient, + makeOrgInitializedClient, + makePasswordManagerClient, + makeStateBridge, +} from "../utils"; import { CREATION_DATE, LINK_CODE, LINK_ID, ROUTES, inviteLinkRoutes } from "./invite-link-server"; +import { memberVector, organizationCases } from "./vault-support"; + +describe("organization test vectors", () => { + describe.each(organizationCases)("%s", (_name, vector) => { + it("covers both the keyed and keyless organization cipher shapes", () => { + // The two are decrypted along different paths: a keyed item's fields sit under a per-item key + // that the organization key unwraps, a keyless item's sit directly under the organization key. + // Both must work, so the vault is expected to carry one of each. + const keyed = vector.vault.ciphers.filter((cipher) => cipher.keys.cipherKey !== null); + const keyless = vector.vault.ciphers.filter((cipher) => cipher.keys.cipherKey === null); + + expect(keyed.length).toBeGreaterThan(0); + expect(keyless.length).toBeGreaterThan(0); + }); + + it("records an organization key whose id matches the key material", () => { + const keyId = CryptoClient.get_key_id_for_symmetric_key( + Buffer.from(vector.organizationKey, "base64"), + ); + + if (vector.organizationKeyId === null) { + // A V1 `Aes256CbcHmac` organization key carries no key id at all. + expect(keyId).toBeUndefined(); + } else { + expect(keyId === undefined ? undefined : Buffer.from(keyId).toString("hex")).toBe( + vector.organizationKeyId, + ); + } + }); + + it("seals the organization key to every member, agreeing with that member's own vector", () => { + expect(vector.members.length).toBeGreaterThan(0); + + for (const [index, member] of vector.members.entries()) { + const user = memberVector(vector, index); + // `organizationKeys` is keyed by the plain id string; `OrganizationId` is branded, so it + // needs widening before it can index the record. + const fromUserVector = (user.account.organizationKeys ?? {})[String(vector.organizationId)]; + + // The two files are generated independently; if they ever disagree about the sealed key, one + // of them is stale and the member below would fail to unseal it. + expect(fromUserVector?.toString()).toBe(member.organizationKeySealedToMember.toString()); + } + }); + + it("never blob-encrypts organization ciphers, even when a member is a V2 account", () => { + // Blob encryption is individual-vault only until PM-32430; `should_use_blob_encryption` + // returns false whenever `organization_id` is set, regardless of the member's security version. + for (const cipher of vector.vault.ciphers) { + expect(cipher.blobEncrypted).toBe(false); + } + + // Without a V2 member the assertion above would hold trivially. + const securityVersions = vector.members.map( + (_member, index) => memberVector(vector, index).account.securityVersion, + ); + expect(Math.max(...securityVersions)).toBeGreaterThanOrEqual(2); + }); + + it("enrolls at least one member in account recovery", () => { + const enrolled = vector.members.filter((member) => member.accountRecoveryKey != null); + expect(enrolled.length).toBeGreaterThan(0); + + // The enrolled member's user key sealed to the organization's public key, which is what lets an + // admin recover them. Unsealing it needs the organization private key, so it is asserted for + // shape here and exercised on the Rust side. + for (const member of enrolled) { + expect(member.accountRecoveryKey!.toString()).toMatch(/^\d+\./); + } + }); + }); +}); // Nothing listens here; every request is served by the fetch mock. A concrete host keeps the // SDK's request URLs parseable and makes an unmocked route fail loudly rather than escape to @@ -272,15 +359,14 @@ describe("invite link client", () => { expect(persisted).toBe(link.invite); // The invitee holds no organization key; everything they send is derived from the secret. - await invitee - .invite_link() - .accept_and_optionally_confirm( - TEST_ORGANIZATION_ID, - link.code, - secret, - COLLECTION_NAME, - true, - ); + await invitee.invite_link().accept_and_optionally_confirm( + TEST_ORGANIZATION_ID, + // The response model types `code` loosely; this parameter takes a plain string. + String(link.code), + secret, + COLLECTION_NAME, + true, + ); expect(mock.routes()).toEqual([ ROUTES.privateKey, @@ -302,3 +388,56 @@ describe("invite link client", () => { } }); }); + +const SAMPLE_INPUT = { + organizationId: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8", + inviteLinkCode: "abcd1234efgh5678", + inviteSecret: "raw-invite-secret-material-base64url", +}; + +describe("open org invite registration seal/unseal", () => { + it("seal_open_org_invite_data returns a non-empty sealedData and paired highEntropySecret", async () => { + const client = makePasswordManagerClient(makeStateBridge()); + + const sealed = client.auth().registration().seal_open_org_invite_data(SAMPLE_INPUT); + + expect(sealed.sealedData).not.toEqual(""); + expect(sealed.highEntropySecret).not.toEqual(""); + }); + + it("unseal_open_org_invite_data recovers the plaintext invite context with fields intact", () => { + const client = makePasswordManagerClient(makeStateBridge()); + const registration = client.auth().registration(); + + const sealed = registration.seal_open_org_invite_data(SAMPLE_INPUT); + const unsealed = registration.unseal_open_org_invite_data(sealed); + + expect(unsealed.organizationId).toEqual(SAMPLE_INPUT.organizationId); + expect(unsealed.inviteLinkCode).toEqual(SAMPLE_INPUT.inviteLinkCode); + expect(unsealed.inviteSecret).toEqual(SAMPLE_INPUT.inviteSecret); + }); + + it("two independent seals produce different highEntropySecret values (per-registration randomness)", () => { + const client = makePasswordManagerClient(makeStateBridge()); + const registration = client.auth().registration(); + + const first = registration.seal_open_org_invite_data(SAMPLE_INPUT); + const second = registration.seal_open_org_invite_data(SAMPLE_INPUT); + + expect(first.highEntropySecret).not.toEqual(second.highEntropySecret); + expect(first.sealedData).not.toEqual(second.sealedData); + }); + + it("the sealedData serializes as base64url that crosses the FFI boundary intact", async () => { + const client = makePasswordManagerClient(makeStateBridge()); + + const sealed = client.auth().registration().seal_open_org_invite_data(SAMPLE_INPUT); + + // Wire-format sanity: sealedData must round-trip through Node's native "base64url" + // encoding (available since Node 16) without drift. + const sealedStr = sealed.sealedData as unknown as string; + expect(sealedStr).not.toEqual(""); + const reencoded = Buffer.from(sealedStr, "base64url").toString("base64url"); + expect(reencoded).toEqual(sealedStr); + }); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/edge-cases.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/edge-cases.test.ts new file mode 100644 index 000000000..8df2a2186 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/edge-cases.test.ts @@ -0,0 +1,26 @@ +// The state a member is in before their client has loaded the organization key. +// +// A client that belongs to an organization but has not yet called `initialize_org_crypto` holds no +// organization key at all, so an organization cipher must refuse to decrypt rather than fall back to the +// user key and produce something. + +import { unlockVector } from "../test-vectors/unlock"; +import { memberVector, organizationCases, ORGANIZATION_TIMEOUT } from "./vault-support"; + +describe("organization test vectors", () => { + describe.each(organizationCases)("%s", (_name, vector) => { + it( + "cannot reach organization ciphers without the organization key", + async () => { + const user = memberVector(vector, 0); + // The state a user who belongs to an organization is in before their client loads its key. + const client = await unlockVector(user, user.unlockMethods[0], { organizations: false }); + + await expect( + client.vault().ciphers().decrypt(vector.vault.ciphers[0].encrypted), + ).rejects.toBeDefined(); + }, + ORGANIZATION_TIMEOUT, + ); + }); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/happy-path.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/happy-path.test.ts new file mode 100644 index 000000000..2b4ff3791 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/happy-path.test.ts @@ -0,0 +1,41 @@ +// Decrypts every committed organization vector's vault, as each of its members. +// +// This exercises a chain the user-vector suites never touch — account private key -> organization key -> +// per-item cipher key -> plaintext — and it does so once per member, because every member must reach +// byte-identical plaintext from their own differently-sealed copy of the same organization key. +// +// It matters in TypeScript rather than only in Rust for the same reason the vault suite does: the models +// cross the FFI boundary, and `organizationKeys` in particular crosses as a `Map`, which is a shape only +// the bindings impose. + +import { + memberVector, + organizationCases, + organizations, + ORGANIZATION_TIMEOUT, + validateOrganizationVaultFor, +} from "./vault-support"; + +describe("organization test vectors", () => { + it("loads the expected set of vectors", () => { + expect(organizations.map((vector) => vector.name).sort()).toEqual(["example-org"]); + }); + + describe.each(organizationCases)("%s", (_name, vector) => { + it("holds a non-empty vault of organization-owned ciphers", () => { + // Guards every decryption test below against being vacuous. + expect(vector.vault.ciphers.length).toBeGreaterThan(0); + for (const cipher of vector.vault.ciphers) { + expect(cipher.encrypted.organizationId).toBe(vector.organizationId); + } + }); + + it.each(vector.members.map((member, index) => [member.userVector, index] as const))( + "decrypts the organization vault as %s", + async (_userVectorName, index) => { + await validateOrganizationVaultFor(vector, memberVector(vector, index)); + }, + ORGANIZATION_TIMEOUT, + ); + }); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/vault-support.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/vault-support.ts new file mode 100644 index 000000000..f578951c4 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/organizations/vault-support.ts @@ -0,0 +1,70 @@ +// Shared scaffolding for the organization vector suites. +// +// An organization cipher is reached through a different key path than a personal one: the item key is +// wrapped by the *organization* key, which itself arrives sealed to the member's public key and is +// unsealed by `initialize_org_crypto`. Every suite below needs the same two things — the loaded vectors, +// and a way to resolve a member back to the user vector it names. + +import { + loadOrganizationVectors, + loadUserVectors, + userVector, + type OrganizationVector, + type UserVector, +} from "../test-vectors/load"; +import { LocalState } from "../model-server/local-state"; +import { validateLocalState } from "../model-server/sync"; + +/** Unlocking a member runs real KDF iterations — up to 600k rounds of PBKDF2 — once per member. */ +export const ORGANIZATION_TIMEOUT = 120_000; + +export const organizations = loadOrganizationVectors(); +export const users = loadUserVectors(); + +/** Every organization vector, shaped for `describe.each`. */ +export const organizationCases = organizations.map((vector) => [vector.name, vector] as const); + +/** Resolves a member back to the user vector it names, failing loudly if the reference is dangling. */ +export function memberVector(vector: OrganizationVector, index: number): UserVector { + return userVector(users, vector.members[index].userVector); +} + +/** + * Seeds a `LocalState` with the member's account and the *organization's* vault, then validates through + * the shared helper. + * + * The organization's items are what local state holds, so the same validator that checks a personal + * vault checks a shared one — the only difference is whose keys unseal it. + */ +export async function validateOrganizationVaultFor( + vector: OrganizationVector, + user: UserVector, +): Promise<{ ciphers: number }> { + const local = new LocalState(); + await local.seedAccount({ + userId: user.account.userId as unknown as string, + email: user.account.email, + accountCryptographicState: user.account.accountCryptographicState, + kdf: user.account.kdf, + upgradeToken: user.account.upgradeToken, + organizationKeys: user.account.organizationKeys ?? {}, + }); + await local.seedVault({ + ciphers: vector.vault.ciphers.map((item) => item.encrypted), + folders: vector.vault.folders.map((item) => item.encrypted), + }); + + const result = await validateLocalState( + local, + user.unlockMethods[0], + { ciphers: vector.vault.ciphers, folders: vector.vault.folders }, + { + expectedUserKey: user.rawCryptographicState.userKey, + expectedUserKeyId: user.rawCryptographicState.userKeyId, + // Nothing has been written, so nothing is volatile: compare the views exactly. + ignore: [], + }, + ); + expect(result.ciphers).toBe(vector.vault.ciphers.length); + return result; +} diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/pin-lock.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/pin/conformance.test.ts similarity index 100% rename from crates/bitwarden-wasm-internal/integration-tests/tests/unlock/pin-lock.test.ts rename to crates/bitwarden-wasm-internal/integration-tests/tests/pin/conformance.test.ts diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/pin/user-facing-flow.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/pin/user-facing-flow.test.ts new file mode 100644 index 000000000..e39ae6cf3 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/pin/user-facing-flow.test.ts @@ -0,0 +1,195 @@ +// Using a PIN the way a user does: unlock with the master password once, enrol a PIN, then reopen the +// vault with nothing but the PIN. +// +// The PIN never leaves the device — it protects a locally stored envelope holding the user key, and +// nothing about it is ever sent to the server. That is asserted the way a user would notice it rather +// than by reading request bodies: after logging out and back in, the PIN no longer opens anything, +// because the envelope it unwrapped was only ever in local state. +// +// Every PIN unlock is preceded by `local.clearEphemeral()`, which drops the state an app loses when it +// is closed rather than merely locked. That is what makes the two lock types distinguishable: +// `BeforeFirstUnlock` writes a persistent envelope that survives a restart, `AfterFirstUnlock` only an +// ephemeral one that does not. Without the clear, a PIN enrolled either way still opens the vault and +// the difference between them is invisible — enrolling with `BeforeFirstUnlock` populates *both* +// envelopes, so the persistent path is only actually exercised once the ephemeral one is gone. +// +// Every unlock goes through `validateAfterLockUnlock`, so "the PIN worked" means the entire vault +// decrypted to the plaintext the committed vector records — not merely that a call returned. + +import { ApiServer } from "../model-server/api-server"; +import { installServers, type InstalledServers } from "../model-server/install"; +import { LocalState } from "../model-server/local-state"; +import { + syncToLocalState, + unlockMethodFor, + validateAfterLockUnlock, + validateAfterLogoutLogin, +} from "../model-server/sync"; +import { loadUserVectors, userVector } from "../test-vectors/load"; +import { TEST_PIN } from "../utils"; + +/** Unlocking pays the account's real KDF cost, and these tests unlock several times over. */ +const TIMEOUT = 120_000; + +const WRONG_PIN = "9999"; + +const users = loadUserVectors(); + +/** The cheapest master-password account in the set to unlock. */ +const vector = userVector(users, "v1-pbkdf2-min-iterations"); + +describe("unlocking with a PIN", () => { + let api: ApiServer; + let servers: InstalledServers; + let local: LocalState; + + /** An account synced down from the server and unlocked by master password, as a user starts. */ + async function arrange() { + api = new ApiServer(); + api.seedUser(vector); + servers = installServers({ api }); + local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + return local.unlock(unlockMethodFor(api, vector.account.email)); + } + + /** Enrols a PIN on an unlocked client, then closes the app: nothing in-memory carries over. */ + async function enrolPinAndClose( + client: Awaited>, + lockType: "BeforeFirstUnlock" | "AfterFirstUnlock" = "BeforeFirstUnlock", + ) { + await client.user_crypto_management().pin_settings().set_pin(TEST_PIN, lockType); + await local.clearEphemeral(); + } + + afterEach(() => { + expect(servers.unmatched.map((request) => request.route)).toEqual([]); + // No seeded account's password, user key, private key or master key may ever appear in a request + // body. Policed by the server on every request, so no individual test has to remember to look. + expect(api.secretLeaks()).toEqual([]); + servers.restore(); + }); + + it( + "enrols a PIN, then reopens the vault with the PIN alone", + async () => { + // Arrange + const client = await arrange(); + + // Act + await enrolPinAndClose(client); + + // Assert: reopen with the PIN, reading the persisted envelope out of local state exactly as a + // restarted app would. + await validateAfterLockUnlock(local, { pinState: { pin: TEST_PIN } }, vector); + }, + TIMEOUT, + ); + + it( + "keeps a BeforeFirstUnlock PIN working after the app is closed", + async () => { + // Arrange + const client = await arrange(); + + // Act + await enrolPinAndClose(client, "BeforeFirstUnlock"); + + // Assert: the persistent envelope is the whole point of this lock type — the PIN still opens the + // vault with nothing in memory. + expect(await local.bridge.get_ephemeral_pin_envelope()).toBeNull(); + await validateAfterLockUnlock(local, { pinState: { pin: TEST_PIN } }, vector); + }, + TIMEOUT, + ); + + it( + "stops an AfterFirstUnlock PIN working once the app is closed", + async () => { + // Arrange + const client = await arrange(); + await client.user_crypto_management().pin_settings().set_pin(TEST_PIN, "AfterFirstUnlock"); + + // The PIN opens the vault while the app is still running. + await validateAfterLockUnlock(local, { pinState: { pin: TEST_PIN } }, vector); + + // Act: close the app. + await local.clearEphemeral(); + + // Assert: this lock type deliberately does not survive it, and the master password still does. + await expect(local.unlock({ pinState: { pin: TEST_PIN } })).rejects.toBeDefined(); + await validateAfterLockUnlock(local, unlockMethodFor(api, vector.account.email), vector); + }, + TIMEOUT, + ); + + it( + "leaves the master password working after a PIN is enrolled", + async () => { + // Arrange + const client = await arrange(); + + // Act + await enrolPinAndClose(client); + + // Assert: a PIN is an additional way in, not a replacement, so both routes still open the vault + // — from this client's own local state and from a client that has only what the server holds. + await validateAfterLockUnlock(local, unlockMethodFor(api, vector.account.email), vector); + await validateAfterLogoutLogin(api, vector.account.email, vector); + }, + TIMEOUT, + ); + + it( + "stops opening the vault once the PIN is removed", + async () => { + // Arrange + const client = await arrange(); + const pinSettings = client.user_crypto_management().pin_settings(); + await pinSettings.set_pin(TEST_PIN, "BeforeFirstUnlock"); + + // Act + await pinSettings.unset_pin(); + await local.clearEphemeral(); + + // Assert: the PIN is dead, and the account is still reachable the way it was before. + await expect(local.unlock({ pinState: { pin: TEST_PIN } })).rejects.toBeDefined(); + await validateAfterLockUnlock(local, unlockMethodFor(api, vector.account.email), vector); + }, + TIMEOUT, + ); + + it( + "refuses a PIN that is not the one enrolled", + async () => { + // Arrange + const client = await arrange(); + + // Act + await enrolPinAndClose(client); + + // Assert: the wrong PIN opens nothing, while the enrolled one still does — so the rejection is + // about the PIN and not about a PIN unlock being broken outright. + await expect(local.unlock({ pinState: { pin: WRONG_PIN } })).rejects.toBeDefined(); + await validateAfterLockUnlock(local, { pinState: { pin: TEST_PIN } }, vector); + }, + TIMEOUT, + ); + + it( + "does not carry the PIN across a logout, because the envelope is local only", + async () => { + // Arrange + const client = await arrange(); + await enrolPinAndClose(client); + + // Act: log out and back in — local state is discarded and everything comes from the server. + const returning = await validateAfterLogoutLogin(api, vector.account.email, vector); + + // Assert: the PIN cannot open the returning client, which is only true if the envelope was never + // sent to the server in the first place. + await expect(returning.unlock({ pinState: { pin: TEST_PIN } })).rejects.toBeDefined(); + }, + TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/registration-support.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/registration-support.ts new file mode 100644 index 000000000..e255fadcd --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/registration-support.ts @@ -0,0 +1,97 @@ +// Shared scaffolding for the registration suites. + +import type { + InitUserCryptoMethod, + Kdf, + PasswordManagerClient, + UserId, + UserMasterPasswordRegistrationRequest, + WrappedAccountCryptographicState, +} from "@bitwarden/sdk-internal"; + +import { ApiServer } from "./model-server/api-server"; +import { installServers, type InstalledServers } from "./model-server/install"; +import { KeyConnectorServer } from "./model-server/key-connector-server"; +import { SETTINGS } from "./model-server/local-state"; +import { loadOrganizationVectors } from "./test-vectors/load"; +import { validateUserKey } from "./test-vectors/validate"; +import { makePasswordManagerClient, makeStateBridge } from "./utils"; + +export const REGISTRATION_TIMEOUT = 180_000; + +export const EMAIL = "registration@test.bitwarden.com"; +export const PASSWORD = "correct horse battery staple"; +/** Branded at the boundary, once, rather than cast at every use. */ +export const USER_ID = "bc0f0000-0000-4000-8000-000000000000" as unknown as UserId; +export const DEVICE_IDENTIFIER = "device-1"; + +/** An organization to enroll into, reusing a committed org's real public key. */ +export const organization = loadOrganizationVectors()[0]; + +export const passwordRegistrationRequest: UserMasterPasswordRegistrationRequest = { + email: EMAIL, + salt: EMAIL, + master_password: PASSWORD, + master_password_hint: undefined, + email_verification_token: undefined, + sales_assisted_token: undefined, + organization_user_id: undefined, + org_invite_token: undefined, + org_sponsored_free_family_plan_token: undefined, + accept_emergency_access_invite_token: undefined, + accept_emergency_access_id: undefined, + provider_invite_token: undefined, + provider_user_id: undefined, +}; + +export function newClient(): PasswordManagerClient { + return makePasswordManagerClient(makeStateBridge(), SETTINGS); +} + +export interface RegistrationHarness { + api: ApiServer; + keyConnector: KeyConnectorServer; + servers: InstalledServers; +} + +/** Installs the servers with the organization seeded, but no account. */ +export function setupRegistration(): RegistrationHarness { + const api = new ApiServer(); + api.seedOrganization(organization); + const keyConnector = new KeyConnectorServer(); + return { api, keyConnector, servers: installServers({ api, keyConnector }) }; +} + +/** The assertions every registration suite makes in `afterEach`. */ +export function assertRegistrationHarnessClean(harness: RegistrationHarness): void { + expect(harness.servers.unmatched.map((request) => request.route)).toEqual([]); + // No seeded account's password, user key, private key or master key may ever appear in a request + // body. Policed by the server on every request, so no individual test has to remember to look. + expect(harness.api.secretLeaks()).toEqual([]); + harness.servers.restore(); +} + +/** + * Unlocks a fresh client with `method` and asserts it reaches `expectedUserKey`. + * + * Also checks the state is a V2 one: registration always mints a V2 account, so a V1 state here would + * mean the wrong branch ran. + */ +export async function unlockFreshAndValidate( + accountCryptographicState: WrappedAccountCryptographicState, + method: InitUserCryptoMethod, + expectedUserKey: string, + kdf: Kdf, +): Promise { + const client = newClient(); + await client.crypto().initialize_user_crypto({ + userId: USER_ID, + kdfParams: kdf, + email: EMAIL, + accountCryptographicState, + method, + }); + + await validateUserKey(client, expectedUserKey); + expect("V2" in accountCryptographicState).toBe(true); +} diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/registration/open-org-invite-registration.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/registration/open-org-invite-registration.test.ts deleted file mode 100644 index 0192133ae..000000000 --- a/crates/bitwarden-wasm-internal/integration-tests/tests/registration/open-org-invite-registration.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { makePasswordManagerClient, makeStateBridge } from "../utils"; - -const SAMPLE_INPUT = { - organizationId: "1bc9ac1e-f5aa-45f2-94bf-b181009709b8", - inviteLinkCode: "abcd1234efgh5678", - inviteSecret: "raw-invite-secret-material-base64url", -}; - -describe("open org invite registration seal/unseal", () => { - it("seal_open_org_invite_data returns a non-empty sealedData and paired highEntropySecret", async () => { - const client = makePasswordManagerClient(makeStateBridge()); - - const sealed = client.auth().registration().seal_open_org_invite_data(SAMPLE_INPUT); - - expect(sealed.sealedData).not.toEqual(""); - expect(sealed.highEntropySecret).not.toEqual(""); - }); - - it("unseal_open_org_invite_data recovers the plaintext invite context with fields intact", () => { - const client = makePasswordManagerClient(makeStateBridge()); - const registration = client.auth().registration(); - - const sealed = registration.seal_open_org_invite_data(SAMPLE_INPUT); - const unsealed = registration.unseal_open_org_invite_data(sealed); - - expect(unsealed.organizationId).toEqual(SAMPLE_INPUT.organizationId); - expect(unsealed.inviteLinkCode).toEqual(SAMPLE_INPUT.inviteLinkCode); - expect(unsealed.inviteSecret).toEqual(SAMPLE_INPUT.inviteSecret); - }); - - it("two independent seals produce different highEntropySecret values (per-registration randomness)", () => { - const client = makePasswordManagerClient(makeStateBridge()); - const registration = client.auth().registration(); - - const first = registration.seal_open_org_invite_data(SAMPLE_INPUT); - const second = registration.seal_open_org_invite_data(SAMPLE_INPUT); - - expect(first.highEntropySecret).not.toEqual(second.highEntropySecret); - expect(first.sealedData).not.toEqual(second.sealedData); - }); - - it("the sealedData serializes as base64url that crosses the FFI boundary intact", async () => { - const client = makePasswordManagerClient(makeStateBridge()); - - const sealed = client.auth().registration().seal_open_org_invite_data(SAMPLE_INPUT); - - // Wire-format sanity: sealedData must round-trip through Node's native "base64url" - // encoding (available since Node 16) without drift. - const sealedStr = sealed.sealedData as unknown as string; - expect(sealedStr).not.toEqual(""); - const reencoded = Buffer.from(sealedStr, "base64url").toString("base64url"); - expect(reencoded).toEqual(sealedStr); - }); -}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/rotation-cases.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/rotation-cases.ts new file mode 100644 index 000000000..8220ee06f --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/rotation-cases.ts @@ -0,0 +1,177 @@ +// The shared body of a key-rotation case, so each feature can assert its own accounts without +// re-deriving the setup. +// +// A rotation touches everything at once — the account cryptographic state, every vault item, the +// primary unlock method and every shared key — so what has to be asserted is the same regardless of how +// the rotation was authorized. Only the accounts and the expected unlock method differ, which is what +// each feature's own suite supplies. + +import type { KeyRotationMethod, PublicKey } from "@bitwarden/sdk-internal"; + +import { ApiServer } from "./model-server/api-server"; +import { installServers, type InstalledServers } from "./model-server/install"; +import { KeyConnectorServer } from "./model-server/key-connector-server"; +import { LocalState } from "./model-server/local-state"; +import { syncToLocalState, unlockMethodFor, validateAfterLogoutLogin } from "./model-server/sync"; +import { + loadEmergencyAccessVectors, + loadOrganizationVectors, + type UserVector, +} from "./test-vectors/load"; + +/** A rotation re-derives the master key, so it pays the KDF cost on top of unlocking. */ +export const ROTATION_TIMEOUT = 120_000; + +/** `UnlockMethod` as the server model numbers it. */ +export const UNLOCK_METHOD = { tde: 0, masterPassword: 1, keyConnector: 2 } as const; + +export const ROTATION_ROUTES = { + accountKeys: "GET /accounts/keys", + sync: "GET /sync", + keyRotationData: "GET /accounts/key-management/key-rotation-data", + rotate: "POST /accounts/key-management/rotate-user-keys", + keyConnectorUserKeys: "GET /user-keys", +} as const; + +export interface RotationCase { + /** The user vector to rotate. */ + vector: UserVector; + /** How to authorize the rotation. */ + method: (vector: UserVector) => KeyRotationMethod; + /** The `unlockMethod` the rotated account should declare. */ + expectedUnlockMethod: number; + /** The key connector key to serve, for a key-connector rotation. */ + keyConnectorKey?: string; +} + +export interface RotationHarness { + api: ApiServer; + keyConnector: KeyConnectorServer; + servers: InstalledServers; +} + +const organizations = loadOrganizationVectors(); +const emergencyAccess = loadEmergencyAccessVectors(); + +/** Seeds the account plus every organization and emergency-access grant, and installs the servers. */ +export function setupRotation(vector: UserVector, keyConnectorKey?: string): RotationHarness { + const api = new ApiServer(); + api.seedUser(vector); + for (const organization of organizations) { + api.seedOrganization(organization); + } + api.seedEmergencyAccess(emergencyAccess); + + const keyConnector = new KeyConnectorServer(); + if (keyConnectorKey !== undefined) { + keyConnector.seedKey(keyConnectorKey); + } + + return { api, keyConnector, servers: installServers({ api, keyConnector }) }; +} + +/** The assertions every rotation suite makes in `afterEach`. */ +export function assertRotationHarnessClean(harness: RotationHarness): void { + expect(harness.servers.unmatched.map((request) => request.route)).toEqual([]); + // No seeded account's password, user key, private key or master key may ever appear in a request + // body. Policed by the server on every request, so no individual test has to remember to look. + expect(harness.api.secretLeaks()).toEqual([]); + harness.servers.restore(); +} + +/** + * Rotates `rotationCase`'s account and asserts the result, from the account the server now holds. + * + * Shared by every feature's rotation suite. If this stops asserting, every rotation case silently + * passes — so a change here should be checked by breaking one assertion and confirming all of them + * fail. + */ +export async function expectRotationSucceeds( + harness: RotationHarness, + rotationCase: RotationCase, +): Promise { + const { api, servers } = harness; + const { vector } = rotationCase; + + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, vector.account.email)); + + // The organization and emergency-access public keys the user would have confirmed in the UI. + const trustedOrganizationKeys = organizations + .filter((organization) => + organization.members.some( + (member) => member.userVector === vector.name && member.accountRecoveryKey !== undefined, + ), + ) + .map((organization) => organization.publicKey as unknown as PublicKey); + const trustedEmergencyAccessKeys = emergencyAccess + .filter((grant) => grant.grantorVector === vector.name) + .map((grant) => grant.granteePublicKey as unknown as PublicKey); + + await client.user_crypto_management().rotate_user_keys({ + key_rotation_method: rotationCase.method(vector), + trusted_emergency_access_public_keys: trustedEmergencyAccessKeys, + trusted_organization_public_keys: trustedOrganizationKeys, + upgrade_token_action: "Skip", + }); + + // A rotation reads the account, reads its rotation data, and posts the result. + expect(servers.routes()).toContain(ROTATION_ROUTES.sync); + expect(servers.routes()).toContain(ROTATION_ROUTES.keyRotationData); + expect(servers.routes()).toContain(ROTATION_ROUTES.rotate); + if (rotationCase.keyConnectorKey !== undefined) { + // A key-connector rotation fetches the key from the connector rather than deriving it. + expect(servers.routes()).toContain(ROTATION_ROUTES.keyConnectorUserKeys); + } + + // Everything below reads the account the server now holds, never the request that produced it. + // A request body only shows what the client intended; the stored account is what a client has + // to live with afterwards. + const stored = api.db.user(api.soleUserId()); + + // Rotation always lands on a V2 state: a COSE-wrapped private key (`7.`), a signature key pair + // and a signed security state. + expect("V2" in stored.accountCryptographicState).toBe(true); + expect(stored.accountCryptographicState.V2.private_key).toMatch(/^7\./); + expect(stored.accountCryptographicState.V2.signing_key).toMatch(/^7\./); + expect(stored.securityVersion).toBeGreaterThanOrEqual(2); + expect(stored.unlockMethod).toBe(rotationCase.expectedUnlockMethod); + + // Every vault item survives, and every cipher is blob-encrypted, because rotation always lands + // the account on the V2 security state. + expect(api.db.ciphersFor(stored.userId)).toHaveLength(vector.vault.ciphers.length); + expect(api.db.foldersFor(stored.userId)).toHaveLength(vector.vault.folders.length); + expect(api.db.sendsFor(stored.userId)).toHaveLength(vector.vault.sends.length); + for (const cipher of api.db.ciphersFor(stored.userId)) { + expect((cipher as any).data).toBeTruthy(); + } + + // The real proof that the rotation is usable: throw the client away and open the account again + // from nothing but what the server holds, then decrypt the whole vault. + // + // Master-password and key-connector accounts are both re-opened here: the server records the + // re-derived master-password unlock data and the re-wrapped key-connector user key, so + // `unlockMethodFor` rebuilds the post-rotation method in each case. + // + // TDE is the exception. Its rotated unlock data rides in `unlockData.deviceKeyUnlockData`, which + // the SDK derives from the `trustedDeviceKeyData` the rotation-data endpoint serves. Seeding a + // trusted device there needs the device's public key encrypted under the *user* key, and the + // committed vectors record only `protected_device_private_key` and `device_protected_user_key` — + // so there is nothing to seed it from until the generator emits that field. Until then the model + // serves an empty device list, the rotation re-encrypts nothing, and a TDE account's stored unlock + // data stays the pre-rotation copy. + // + // Logout/login only, deliberately: a rotation with `upgrade_token_action: "Skip"` writes nothing + // to local state, so the writing client is *expected* to be left holding the old keys. That + // staleness is asserted on its own in `key-rotation/edge-cases.test.ts`; asserting lock/unlock here + // would be asserting the pre-rotation account. + const reopenable = + stored.masterPasswordUnlock !== null || stored.keyConnectorKeyWrappedUserKey !== undefined; + if (reopenable) { + await validateAfterLogoutLogin(api, vector.account.email, vector, { + // A rotation mints a new user key, so the vector's recorded one no longer applies. + expectedUserKey: undefined, + }); + } +} diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/shared-unlock.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/shared-unlock/user-facing-flow.test.ts similarity index 55% rename from crates/bitwarden-wasm-internal/integration-tests/tests/unlock/shared-unlock.test.ts rename to crates/bitwarden-wasm-internal/integration-tests/tests/shared-unlock/user-facing-flow.test.ts index 0b03b6963..ab4dbb8e8 100644 --- a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/shared-unlock.test.ts +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/shared-unlock/user-facing-flow.test.ts @@ -1,13 +1,114 @@ -import { UserId } from "@bitwarden/sdk-internal"; +// Unlock state shared between two clients on one device, over IPC. +// +// Two subjects, both driven end to end through a real transport pair rather than by calling the handlers +// directly. Biometrics: a requester asks the responder for its status, for an unlock, and for a user +// verification check, and gets back whatever the responder's driver decided. Shared unlock: a leader and +// a follower mirror each other's lock and unlock events, and keep doing so after either side is +// process-reloaded. + import { - sleep, - setupSharedUnlockPair, + BiometricsStatus, + IpcClient, + ipcRegisterBiometricsHandlers, + ipcRequestAuthenticateBiometrics, + ipcRequestGetBiometricsStatus, + ipcRequestUnlockBiometrics, + init_sdk, +} from "@bitwarden/sdk-internal"; + +import { asUserId } from "../type-assertion-helpers"; +import { + makeMockBiometricsDriver, + makeMockTransportPair, reloadFollower, reloadLeader, + setupSharedUnlockPair, + sleep, + TEST_USER_ID, testSymmetricKey, } from "../utils"; -const USER_A = "00000000-0000-0000-0000-000000000001" as unknown as UserId; +async function setupClientPair(driver = makeMockBiometricsDriver()) { + init_sdk(); + + const [requesterBackend, responderBackend] = makeMockTransportPair(); + const requester = IpcClient.newWithSdkInMemorySessions(requesterBackend); + const responder = IpcClient.newWithSdkInMemorySessions(responderBackend); + + await requester.start(); + await responder.start(); + + await ipcRegisterBiometricsHandlers(responder, driver); + + return { requester, responder }; +} + +describe("biometrics ipc", () => { + it("returns the responder's biometrics status", async () => { + const { requester } = await setupClientPair( + makeMockBiometricsDriver({ + userKey: testSymmetricKey(), + uvResult: true, + status: BiometricsStatus.UnlockNeeded, + }), + ); + + const status = await ipcRequestGetBiometricsStatus(requester, TEST_USER_ID); + + expect(status).toBe(BiometricsStatus.UnlockNeeded); + }); + + it("returns the user key on successful biometric unlock", async () => { + const userKey = testSymmetricKey(0x37); + const { requester } = await setupClientPair( + makeMockBiometricsDriver({ userKey, uvResult: true, status: BiometricsStatus.Available }), + ); + + const response = await ipcRequestUnlockBiometrics(requester, TEST_USER_ID); + + expect(response.user_key).toBe(userKey); + }); + + it("returns undefined when biometric unlock is canceled or fails", async () => { + const { requester } = await setupClientPair( + makeMockBiometricsDriver({ + userKey: undefined, + uvResult: false, + status: BiometricsStatus.UnlockNeeded, + }), + ); + + const response = await ipcRequestUnlockBiometrics(requester, TEST_USER_ID); + + expect(response.user_key).toBeUndefined(); + }); + + it("forwards a successful biometrics UV check", async () => { + const { requester } = await setupClientPair( + makeMockBiometricsDriver({ + userKey: undefined, + uvResult: true, + status: BiometricsStatus.Available, + }), + ); + + expect(await ipcRequestAuthenticateBiometrics(requester)).toBe(true); + }); + + it("forwards a failed biometrics UV check", async () => { + const { requester } = await setupClientPair( + makeMockBiometricsDriver({ + userKey: undefined, + uvResult: false, + status: BiometricsStatus.Available, + }), + ); + + expect(await ipcRequestAuthenticateBiometrics(requester)).toBe(false); + }); +}); + +const USER_A = asUserId("00000000-0000-0000-0000-000000000001"); const USER_KEY = testSymmetricKey(0x11); const USER_A_LOCKED_STATE = new Map([[USER_A, undefined]]); const USER_A_UNLOCKED_STATE = new Map([[USER_A, USER_KEY]]); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/trusted-devices/conformance.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/trusted-devices/conformance.test.ts new file mode 100644 index 000000000..817f01886 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/trusted-devices/conformance.test.ts @@ -0,0 +1,53 @@ +// Rotating the keys of a trusted-device (TDE) account, for each account version the vectors carry. +// +// A TDE rotation authorizes itself with no user-supplied secret at all — the device is the credential. +// The shared assertion body lives in `tests/rotation-cases.ts`. + +import { + assertRotationHarnessClean, + expectRotationSucceeds, + ROTATION_TIMEOUT, + setupRotation, + UNLOCK_METHOD, + type RotationCase, + type RotationHarness, +} from "../rotation-cases"; +import { loadUserVectors, userVector } from "../test-vectors/load"; + +const users = loadUserVectors(); + +const cases: [string, RotationCase][] = [ + [ + "V1 TDE", + { + vector: userVector(users, "v1-argon2id-tde"), + method: () => "Tde", + expectedUnlockMethod: UNLOCK_METHOD.tde, + }, + ], + [ + "V2 TDE", + { + vector: userVector(users, "v2-argon2id-tde"), + method: () => "Tde", + expectedUnlockMethod: UNLOCK_METHOD.tde, + }, + ], +]; + +describe("trusted device key rotation", () => { + let harness: RotationHarness; + + afterEach(() => assertRotationHarnessClean(harness)); + + describe.each(cases)("%s", (_label, rotationCase) => { + it( + "posts a V2 cryptographic state, the re-encrypted vault and the right unlock method", + async () => { + harness = setupRotation(rotationCase.vector); + await expectRotationSucceeds(harness, rotationCase); + }, + ROTATION_TIMEOUT, + ); + }); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/trusted-devices/user-facing-flow.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/trusted-devices/user-facing-flow.test.ts new file mode 100644 index 000000000..c0085ba98 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/trusted-devices/user-facing-flow.test.ts @@ -0,0 +1,70 @@ +// Registering an account that unlocks by trusted device (TDE). +// +// Registration is the one operation with no second chance: it *chooses* the account's keys, and if it +// emits a state the SDK cannot later load, the account is unrecoverable. So each test registers against +// the model server and then proves the emitted material works by unlocking a brand new client with it. +// +// The shared scaffolding — the harness, the constants and `unlockFreshAndValidate` — lives in +// `tests/registration-support.ts`. + +import type { UnsignedSharedKey } from "@bitwarden/sdk-internal"; + +import { + assertRegistrationHarnessClean, + DEVICE_IDENTIFIER, + newClient, + organization, + REGISTRATION_TIMEOUT, + setupRegistration, + unlockFreshAndValidate, + USER_ID, + type RegistrationHarness, +} from "../registration-support"; +import { encstring } from "../utils"; + +describe("registering an account that unlocks by trusted device (tde)", () => { + let harness: RegistrationHarness; + + beforeEach(() => { + harness = setupRegistration(); + }); + + afterEach(() => assertRegistrationHarnessClean(harness)); + + it( + "registers a TDE account, and the account unlocks by device key", + async () => { + const result = await newClient() + .auth() + .registration() + .post_keys_for_tde_registration({ + org_id: organization.organizationId, + org_public_key: organization.publicKey, + user_id: USER_ID, + device_identifier: DEVICE_IDENTIFIER, + trust_device: true, + } as never); + + // The device keys the client posted are the other half of a device-key unlock; the response only + // carries the device key itself, so the wrapped halves come off the server. + const deviceKeys = harness.api.deviceKeys(DEVICE_IDENTIFIER); + if (deviceKeys === undefined) { + throw new Error(`no device keys recorded for ${DEVICE_IDENTIFIER}`); + } + + await unlockFreshAndValidate( + result.account_cryptographic_state, + { + deviceKey: { + device_key: result.device_key, + protected_device_private_key: encstring(deviceKeys.encryptedPrivateKey), + device_protected_user_key: deviceKeys.encryptedUserKey as unknown as UnsignedSharedKey, + }, + }, + result.user_key.toString(), + { pBKDF2: { iterations: 600_000 } }, + ); + }, + REGISTRATION_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/biometrics-ipc.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/biometrics-ipc.test.ts deleted file mode 100644 index 108a859c5..000000000 --- a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/biometrics-ipc.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - BiometricsStatus, - IpcClient, - ipcRegisterBiometricsHandlers, - ipcRequestAuthenticateBiometrics, - ipcRequestGetBiometricsStatus, - ipcRequestUnlockBiometrics, - init_sdk, -} from "@bitwarden/sdk-internal"; -import { - makeMockBiometricsDriver, - makeMockTransportPair, - TEST_USER_ID, - testSymmetricKey, -} from "../utils"; - -async function setupClientPair(driver = makeMockBiometricsDriver()) { - init_sdk(); - - const [requesterBackend, responderBackend] = makeMockTransportPair(); - const requester = IpcClient.newWithSdkInMemorySessions(requesterBackend); - const responder = IpcClient.newWithSdkInMemorySessions(responderBackend); - - await requester.start(); - await responder.start(); - - await ipcRegisterBiometricsHandlers(responder, driver); - - return { requester, responder }; -} - -describe("biometrics ipc", () => { - it("returns the responder's biometrics status", async () => { - const { requester } = await setupClientPair( - makeMockBiometricsDriver({ - userKey: testSymmetricKey(), - uvResult: true, - status: BiometricsStatus.UnlockNeeded, - }), - ); - - const status = await ipcRequestGetBiometricsStatus(requester, TEST_USER_ID); - - expect(status).toBe(BiometricsStatus.UnlockNeeded); - }); - - it("returns the user key on successful biometric unlock", async () => { - const userKey = testSymmetricKey(0x37); - const { requester } = await setupClientPair( - makeMockBiometricsDriver({ userKey, uvResult: true, status: BiometricsStatus.Available }), - ); - - const response = await ipcRequestUnlockBiometrics(requester, TEST_USER_ID); - - expect(response.user_key).toBe(userKey); - }); - - it("returns undefined when biometric unlock is canceled or fails", async () => { - const { requester } = await setupClientPair( - makeMockBiometricsDriver({ - userKey: undefined, - uvResult: false, - status: BiometricsStatus.UnlockNeeded, - }), - ); - - const response = await ipcRequestUnlockBiometrics(requester, TEST_USER_ID); - - expect(response.user_key).toBeUndefined(); - }); - - it("forwards a successful biometrics UV check", async () => { - const { requester } = await setupClientPair( - makeMockBiometricsDriver({ - userKey: undefined, - uvResult: true, - status: BiometricsStatus.Available, - }), - ); - - expect(await ipcRequestAuthenticateBiometrics(requester)).toBe(true); - }); - - it("forwards a failed biometrics UV check", async () => { - const { requester } = await setupClientPair( - makeMockBiometricsDriver({ - userKey: undefined, - uvResult: false, - status: BiometricsStatus.Available, - }), - ); - - expect(await ipcRequestAuthenticateBiometrics(requester)).toBe(false); - }); -}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/conformance.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/conformance.test.ts new file mode 100644 index 000000000..3b890e4a4 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/conformance.test.ts @@ -0,0 +1,232 @@ +// The unlock step itself, and the shape of the matrix that exercises it. +// +// `happy-path.test.ts` unlocks through every method on its way to decrypting a vault, so this file +// deliberately does not repeat that. What it covers instead is what a decryption pass can only assert +// indirectly: +// +// - that the matrix of methods is complete, so a regenerated vector set that silently drops a method +// (say `authRequest`, which only one account carries) fails here rather than quietly losing coverage; +// - that every method for one account is *interchangeable* — all of them must land on identical key +// material, since they are different wrappings of the same user key. A method that unlocked to a +// subtly wrong key would still decrypt nothing, so this is the assertion that pins it down; +// - the two invariants the vector set encodes about how vaults are encrypted, asserted from the +// TypeScript side so a regenerated set that drops one fails here too; +// - that `initialize_user_crypto` accepts each of the state-backed methods. +// +// Being an inventory of the committed data, this is expected to change whenever the vector set does. + +import { unlockMethodName, type UserVector } from "../test-vectors/load"; +import { unlockVector } from "../test-vectors/unlock"; +import { validateKeys } from "../test-vectors/validate"; +import { + initializeCryptoDefault, + initializeUserCrypto, + makePasswordManagerClient, + makeStateBridge, + MASTER_KEY_WRAPPED_USER_KEY, + TEST_EMAIL, + TEST_KDF_PARAMS, + TEST_PASSWORD, + TEST_PIN, +} from "../utils"; +import { allPairs, UNLOCK_TIMEOUT, vectors } from "./unlock-support"; + +/** + * Every unlock method the vector set is meant to exercise. + * + * Hard-coded rather than derived from the vectors, because deriving it from the thing under test would + * make the completeness check vacuous. + */ +const EXPECTED_METHODS = [ + "authRequest", + "decryptedKey", + "deviceKey", + "keyConnector", + "masterPasswordUnlock", + "pinEnvelope", +] as const; + +const encstring = (s: string) => s as unknown as never; + +describe("unlock methods", () => { + it("covers every unlock method the vector set is meant to exercise", () => { + const covered = new Set(allPairs.map(([, methodName]) => methodName)); + expect([...covered].sort()).toEqual([...EXPECTED_METHODS]); + }); + + it("declares each method as a single-variant tagged union", () => { + // `InitUserCryptoMethod` is an externally tagged enum, so anything other than exactly one key + // means the vector was written against a different shape than the bindings expose. + for (const vector of vectors) { + expect(vector.unlockMethods.length).toBeGreaterThan(0); + for (const method of vector.unlockMethods) { + expect(Object.keys(method)).toHaveLength(1); + } + } + }); + + it("records which vector covers which method", () => { + // Not an assertion so much as a readable inventory: if the matrix shifts, the diff here says how. + const byMethod = new Map(); + for (const [name, methodName] of allPairs) { + byMethod.set(methodName, [...(byMethod.get(methodName) ?? []), name]); + } + + expect(Object.fromEntries([...byMethod].sort(([a], [b]) => a.localeCompare(b)))).toEqual({ + authRequest: ["v1-argon2id-tde"], + decryptedKey: ["v1-argon2id-password", "v2-pbkdf2-blob"], + deviceKey: ["v1-argon2id-tde", "v2-argon2id-tde"], + keyConnector: ["v1-pbkdf2-key-connector", "v2-pbkdf2-key-connector"], + masterPasswordUnlock: [ + "v1-argon2id-password", + "v1-pbkdf2-min-iterations", + "v1-pbkdf2-password", + "v2-argon2id-blob", + "v2-argon2id-upgrade-token", + "v2-pbkdf2-blob", + ], + pinEnvelope: ["v1-pbkdf2-password", "v2-argon2id-blob", "v2-argon2id-upgrade-token"], + }); + }); + + it.each( + allPairs.map( + ([name, methodName, vector, method]) => + [`${name} via ${methodName}`, vector, method] as const, + ), + )( + "unlocks %s to the recorded key material", + async (_label, vector, method) => { + const client = await unlockVector(vector, method); + await validateKeys(client, vector); + }, + UNLOCK_TIMEOUT, + ); + + // The two invariants the matrix encodes, asserted from the TypeScript side so a regenerated set that + // silently drops one of them fails here too. + it("keeps V2 vaults uniformly blob-encrypted and V1 vaults legacy", () => { + for (const vector of vectors) { + const isV2 = vector.account.securityVersion >= 2; + for (const cipher of vector.vault.ciphers) { + expect(cipher.blobEncrypted).toBe(isV2); + if (isV2) { + expect(cipher.keys.cipherKey).not.toBeNull(); + } + } + } + }); + + it("covers all three attachment layouts, and only on accounts that can hold them", () => { + const seen = new Set(); + for (const vector of vectors) { + const isV2 = vector.account.securityVersion >= 2; + for (const cipher of vector.vault.ciphers) { + for (const attachment of Object.values(cipher.keys.attachments)) { + seen.add(attachment.version); + if (isV2) { + // `check_for_old_attachments` rejects a keyless attachment during rotation, so a V2 + // account cannot legitimately hold a pre-v2 one. + expect(attachment.version).toBe("V2"); + } + } + } + } + expect([...seen].sort()).toEqual(["V0", "V1", "V2"]); + }); +}); + +describe("interchangeability of unlock methods", () => { + const multiMethod = vectors.filter((vector: UserVector) => vector.unlockMethods.length > 1); + + it("has accounts carrying more than one method, so the checks below are not vacuous", () => { + expect(multiMethod.length).toBeGreaterThan(1); + }); + + it.each(multiMethod.map((vector: UserVector) => [vector.name, vector] as const))( + "%s reaches the same user key through every one of its methods", + async (_name, vector) => { + const keys = new Map(); + for (const method of vector.unlockMethods) { + const client = await unlockVector(vector, method); + keys.set( + unlockMethodName(method), + (await client.crypto().get_user_encryption_key()).toString(), + ); + } + + // Compared as a map so a failure names the method that diverged rather than just "not equal". + expect(Object.fromEntries(keys)).toEqual( + Object.fromEntries( + [...keys.keys()].map((name) => [name, vector.rawCryptographicState.userKey]), + ), + ); + expect(keys.size).toBe(vector.unlockMethods.length); + }, + UNLOCK_TIMEOUT, + ); +}); + +describe("user crypto initialization tests", () => { + it("initializes the user account via master password", async () => { + const stateBridge = makeStateBridge(); + const client = makePasswordManagerClient(stateBridge); + + initializeUserCrypto(client, { + masterPasswordUnlock: { + password: TEST_PASSWORD, + master_password_unlock: { + masterKeyWrappedUserKey: encstring(MASTER_KEY_WRAPPED_USER_KEY), + salt: TEST_EMAIL, + kdf: TEST_KDF_PARAMS, + }, + }, + }); + + expect(await client.crypto().get_user_encryption_key()).toBeDefined(); + }); + + it("initializes the user account via PIN Envelope", async () => { + // Set up a PIN with BeforeFirstUnlock so the persistent envelope is written to the bridge. + const stateBridge = makeStateBridge(); + const setupClient = makePasswordManagerClient(stateBridge); + await initializeCryptoDefault(setupClient); + + await setupClient + .user_crypto_management() + .pin_settings() + .set_pin(TEST_PIN, "BeforeFirstUnlock"); + + const pinEnvelope = await stateBridge.get_persistent_pin_envelope(); + expect(pinEnvelope).toBeDefined(); + + // Now make a new client and initialize with the PIN envelope. + const client = makePasswordManagerClient(stateBridge); + await initializeUserCrypto(client, { + pinEnvelope: { pin: TEST_PIN, pin_protected_user_key_envelope: pinEnvelope! }, + }); + + expect(await client.crypto().get_user_encryption_key()).toBeDefined(); + }); + + it("initializes the user account via PIN State", async () => { + // Set up a PIN with BeforeFirstUnlock so the persistent envelope is written to the bridge. + const stateBridge = makeStateBridge(); + const setupClient = makePasswordManagerClient(stateBridge); + initializeCryptoDefault(setupClient); + + await setupClient + .user_crypto_management() + .pin_settings() + .set_pin(TEST_PIN, "BeforeFirstUnlock"); + + const pinState = await stateBridge.get_encrypted_pin(); + expect(pinState).toBeDefined(); + + // Now make a new client and initialize with the PIN state. + const client = makePasswordManagerClient(stateBridge); + await initializeUserCrypto(client, { pinState: { pin: TEST_PIN } }); + + expect(await client.crypto().get_user_encryption_key()).toBeDefined(); + }); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/edge-cases.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/edge-cases.test.ts new file mode 100644 index 000000000..efb521329 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/edge-cases.test.ts @@ -0,0 +1,130 @@ +// Two unlock situations worth pinning down: a credential that must be refused, and the one account in +// the set that is caught mid-upgrade between security versions. +// +// The mid-upgrade account is the interesting one. Its master-password unlock data still wraps the *V1* +// user key, while its vault is already blob-encrypted under the V2 key, so opening it requires the +// upgrade token to be consumed during unlock. An account in this state is transient in production and +// easy to break without noticing, which is why the set carries one permanently. + +import type { InitUserCryptoMethod } from "@bitwarden/sdk-internal"; + +import { userVector, type UserVector } from "../test-vectors/load"; +import { unlockVector } from "../test-vectors/unlock"; +import { + CHEAPEST_VECTOR, + UNLOCK_TIMEOUT, + unlockWithOrganizations, + validateVectorDirectly, + vectors, +} from "./unlock-support"; + +type MasterPasswordMethod = Extract; + +/** Picks a vector's master-password method, failing loudly if it has none. */ +function masterPasswordMethod(vector: UserVector): MasterPasswordMethod { + const method = vector.unlockMethods.find((m) => "masterPasswordUnlock" in m); + if (method === undefined) { + throw new Error(`${vector.name} declares no masterPasswordUnlock method`); + } + return method as MasterPasswordMethod; +} + +/** + * Corrupts one byte of an `EncString`'s ciphertext, leaving its structure untouched. + * + * An `Aes256CbcHmac` string is `2.||`. Flipping a character at the *start* of the + * ciphertext segment matters: base64 padding only ever appears at the end of a segment, so editing the + * front cannot change any decoded length, and the string still parses. The MAC then fails, which is the + * behaviour under test. + */ +function tamperCiphertext(text: string): string { + const dot = text.indexOf("."); + expect(dot).toBeGreaterThan(0); + + const parts = text.slice(dot + 1).split("|"); + expect(parts).toHaveLength(3); + + const ciphertext = parts[1]; + parts[1] = (ciphertext[0] === "A" ? "B" : "A") + ciphertext.slice(1); + + return text.slice(0, dot + 1) + parts.join("|"); +} + +describe("rejecting bad credentials", () => { + const vector = userVector(vectors, CHEAPEST_VECTOR); + + it( + "refuses a master password that is not the account's", + async () => { + const method = structuredClone(masterPasswordMethod(vector)); + method.masterPasswordUnlock.password = "not-the-password"; + + await expect(unlockVector(vector, method)).rejects.toBeDefined(); + }, + UNLOCK_TIMEOUT, + ); + + it( + "refuses a master password unlock whose wrapped user key has been tampered with", + async () => { + const method = structuredClone(masterPasswordMethod(vector)); + const wrapped = method.masterPasswordUnlock.master_password_unlock.masterKeyWrappedUserKey; + + const text = wrapped.toString(); + const tampered = tamperCiphertext(text); + method.masterPasswordUnlock.master_password_unlock.masterKeyWrappedUserKey = + tampered as unknown as typeof wrapped; + + // The failure must come from the MAC check, not from a malformed EncString — otherwise this + // would pass just as happily against a build that skipped authentication entirely. + expect(tampered).not.toBe(text); + expect(tampered).toHaveLength(text.length); + + await expect(unlockVector(vector, method)).rejects.toBeDefined(); + }, + UNLOCK_TIMEOUT, + ); +}); + +describe("the mid-upgrade account", () => { + const vector = vectors.find((v) => v.name === "v2-argon2id-upgrade-token")!; + + it("exists and carries an upgrade token", () => { + expect(vector.account.upgradeToken).toBeDefined(); + expect(vector.account.securityVersion).toBe(2); + }); + + it("still wraps the V1 user key in its master-password unlock data", () => { + const method = vector.unlockMethods.find((m) => "masterPasswordUnlock" in m) as Extract< + InitUserCryptoMethod, + { masterPasswordUnlock: unknown } + >; + + // `2.` is AES-256-CBC-HMAC, the V1 user key. A plain V2 account would carry `7.` here. + expect( + method.masterPasswordUnlock.master_password_unlock.masterKeyWrappedUserKey.toString(), + ).toMatch(/^2\./); + // The token carries the V2 key wrapped by the V1 key, and vice versa. + expect(vector.account.upgradeToken!.wrapped_user_key_2.toString()).toMatch(/^2\./); + expect(vector.account.upgradeToken!.wrapped_user_key_1.toString()).toMatch(/^7\./); + }); + + it( + "reaches its V2 blob vault from a V1 master-password unlock", + async () => { + const method = vector.unlockMethods.find((m) => "masterPasswordUnlock" in m)!; + const client = await unlockWithOrganizations(vector, method); + + // The user key the client ends up holding is the upgraded V2 key, which is the only key that + // opens the blob vault below. + expect((await client.crypto().get_user_encryption_key()).toString()).toBe( + vector.rawCryptographicState.userKey, + ); + expect(vector.rawCryptographicState.userKeyId).not.toBeNull(); + + await validateVectorDirectly(vector, method); + expect(vector.vault.ciphers.every((cipher) => cipher.blobEncrypted)).toBe(true); + }, + UNLOCK_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/happy-path.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/happy-path.test.ts new file mode 100644 index 000000000..89c99e4e9 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/happy-path.test.ts @@ -0,0 +1,37 @@ +// Decrypts every committed test vector's vault, through every unlock method the vector declares. +// +// This is the baseline the rest of the suite rests on: if an account in the set cannot be opened and read +// end to end, nothing else that account appears in means anything. It matters in TypeScript rather than +// only in Rust because the models cross the FFI boundary on the way in and out — a `Cipher` is +// deserialized from JS, decrypted in Rust, and a `CipherView` is serialized back, so a naming or shape +// regression in the bindings shows up as a decryption mismatch the Rust tests would never see. + +import { unlockMethodName } from "../test-vectors/load"; +import { UNLOCK_TIMEOUT, validateVectorDirectly, vectorCases, vectors } from "./unlock-support"; + +describe("test vectors", () => { + it("loads the expected set of vectors", () => { + expect(vectors.map((vector) => vector.name).sort()).toEqual([ + "v1-argon2id-password", + "v1-argon2id-tde", + "v1-pbkdf2-key-connector", + "v1-pbkdf2-min-iterations", + "v1-pbkdf2-password", + "v2-argon2id-blob", + "v2-argon2id-tde", + "v2-argon2id-upgrade-token", + "v2-pbkdf2-blob", + "v2-pbkdf2-key-connector", + ]); + }); + + describe.each(vectorCases)("%s", (_name, vector) => { + it.each(vector.unlockMethods.map((method) => [unlockMethodName(method), method] as const))( + "decrypts its vault after unlocking via %s", + async (_methodName, method) => { + await validateVectorDirectly(vector, method); + }, + UNLOCK_TIMEOUT, + ); + }); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/init-user-crypto.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/init-user-crypto.test.ts deleted file mode 100644 index d2e5a0e76..000000000 --- a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/init-user-crypto.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - initializeCryptoDefault, - initializeUserCrypto, - makePasswordManagerClient, - makeStateBridge, - MASTER_KEY_WRAPPED_USER_KEY, - TEST_EMAIL, - TEST_KDF_PARAMS, - TEST_PASSWORD, - TEST_PIN, -} from "../utils"; - -const encstring = (s: string) => s as unknown as never; - -describe("user crypto initialization tests", () => { - it("initializes the user account via master password", async () => { - const stateBridge = makeStateBridge(); - const client = makePasswordManagerClient(stateBridge); - - initializeUserCrypto(client, { - masterPasswordUnlock: { - password: TEST_PASSWORD, - master_password_unlock: { - masterKeyWrappedUserKey: encstring(MASTER_KEY_WRAPPED_USER_KEY), - salt: TEST_EMAIL, - kdf: TEST_KDF_PARAMS, - }, - }, - }); - - expect(await client.crypto().get_user_encryption_key()).toBeDefined(); - }); - - it("initializes the user account via PIN Envelope", async () => { - // Set up a PIN with BeforeFirstUnlock so the persistent envelope is written to the bridge. - const stateBridge = makeStateBridge(); - const setupClient = makePasswordManagerClient(stateBridge); - await initializeCryptoDefault(setupClient); - - await setupClient - .user_crypto_management() - .pin_settings() - .set_pin(TEST_PIN, "BeforeFirstUnlock"); - - const pinEnvelope = await stateBridge.get_persistent_pin_envelope(); - expect(pinEnvelope).toBeDefined(); - - // Now make a new client and initialize with the PIN envelope. - const client = makePasswordManagerClient(stateBridge); - await initializeUserCrypto(client, { - pinEnvelope: { pin: TEST_PIN, pin_protected_user_key_envelope: pinEnvelope! }, - }); - - expect(await client.crypto().get_user_encryption_key()).toBeDefined(); - }); - - it("initializes the user account via PIN State", async () => { - // Set up a PIN with BeforeFirstUnlock so the persistent envelope is written to the bridge. - const stateBridge = makeStateBridge(); - const setupClient = makePasswordManagerClient(stateBridge); - initializeCryptoDefault(setupClient); - - await setupClient - .user_crypto_management() - .pin_settings() - .set_pin(TEST_PIN, "BeforeFirstUnlock"); - - const pinState = await stateBridge.get_encrypted_pin(); - expect(pinState).toBeDefined(); - - // Now make a new client and initialize with the PIN state. - const client = makePasswordManagerClient(stateBridge); - await initializeUserCrypto(client, { pinState: { pin: TEST_PIN } }); - - expect(await client.crypto().get_user_encryption_key()).toBeDefined(); - }); -}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/unlock-support.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/unlock-support.ts new file mode 100644 index 000000000..c1b34eb45 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/unlock/unlock-support.ts @@ -0,0 +1,95 @@ +// Shared scaffolding for the unlock suites. +// +// Every suite here works off the committed user vectors and the (vector, method) matrix they imply, so +// the loading, the matrix and the "seed local state and validate" helper live in one place. + +import type { InitUserCryptoMethod, PasswordManagerClient } from "@bitwarden/sdk-internal"; + +import { LocalState } from "../model-server/local-state"; +import { validateLocalState } from "../model-server/sync"; +import { loadUserVectors, unlockMethodName, type UserVector } from "../test-vectors/load"; +import { makePasswordManagerClient, makeStateBridge } from "../utils"; + +/** + * Unlocking runs real KDF iterations — up to 600k rounds of PBKDF2 — for every vector and every method, + * so every suite here needs a generous budget. + */ +export const UNLOCK_TIMEOUT = 120_000; + +/** The cheapest vector to unlock: PBKDF2 at 5,000 rounds rather than 600,000. */ +export const CHEAPEST_VECTOR = "v1-pbkdf2-min-iterations"; + +export const vectors = loadUserVectors(); + +/** Every vector, shaped for `describe.each`. */ +export const vectorCases = vectors.map((vector) => [vector.name, vector] as const); + +/** Every (vector, method) pair in the set, as `it.each` table rows. */ +export const allPairs = vectors.flatMap((vector) => + vector.unlockMethods.map( + (method) => [vector.name, unlockMethodName(method), vector, method] as const, + ), +); + +/** Builds a client and unlocks it with `method`, loading organization keys if the account has any. */ +export async function unlockWithOrganizations( + vector: UserVector, + method: InitUserCryptoMethod, +): Promise { + const client = makePasswordManagerClient(makeStateBridge()); + + await client.crypto().initialize_user_crypto({ + userId: vector.account.userId, + kdfParams: vector.account.kdf, + email: vector.account.email, + accountCryptographicState: vector.account.accountCryptographicState, + method, + upgradeToken: vector.account.upgradeToken, + }); + + const organizationKeys = vector.account.organizationKeys ?? {}; + if (Object.keys(organizationKeys).length > 0) { + await client.crypto().initialize_org_crypto({ + organizationKeys: new Map(Object.entries(organizationKeys)) as never, + }); + } + + return client; +} + +/** + * Seeds a `LocalState` straight from the vector and validates through the shared helper. + * + * No server is involved — these suites only decrypt committed data — so local state is seeded from the + * vector rather than synced. It routes through the same validator as every other suite so there is one + * definition of "the vault decrypts correctly". + */ +export async function validateVectorDirectly(vector: UserVector, method: InitUserCryptoMethod) { + const local = new LocalState(); + await local.seedAccount({ + userId: vector.account.userId as unknown as string, + email: vector.account.email, + accountCryptographicState: vector.account.accountCryptographicState, + kdf: vector.account.kdf, + upgradeToken: vector.account.upgradeToken, + organizationKeys: vector.account.organizationKeys ?? {}, + }); + await local.seedVault({ + ciphers: vector.vault.ciphers.map((item) => item.encrypted), + folders: vector.vault.folders.map((item) => item.encrypted), + }); + + const result = await validateLocalState( + local, + method, + { ciphers: vector.vault.ciphers, folders: vector.vault.folders }, + { + expectedUserKey: vector.rawCryptographicState.userKey, + expectedUserKeyId: vector.rawCryptographicState.userKeyId, + // Nothing has been written, so nothing is volatile: compare the views exactly. + ignore: [], + }, + ); + expect(result.ciphers).toBeGreaterThan(0); + return result; +} diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/user-crypto-management/change-kdf.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/user-crypto-management/change-kdf.test.ts deleted file mode 100644 index f48807d64..000000000 --- a/crates/bitwarden-wasm-internal/integration-tests/tests/user-crypto-management/change-kdf.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - ChangeKdfError, - ClientSettings, - Kdf, - MasterPasswordUnlockData, - PasswordManagerClient, - WasmStateBridge, - isChangeKdfError, -} from "@bitwarden/sdk-internal"; - -import { HttpMock, installHttpMock } from "../http-mock"; -import { - MASTER_KEY_WRAPPED_USER_KEY, - TEST_EMAIL, - TEST_KDF_PARAMS, - TEST_PASSWORD, - initializeUserCrypto, - makeInitializedPasswordmanagerClient, - makePasswordManagerClient, - makeStateBridge, - seedMasterPasswordUnlockData, -} from "../utils"; - -// Nothing listens here; every request is served by the fetch mock. A concrete host keeps the -// SDK's request URLs parseable and makes an unmocked route fail loudly rather than escape to -// the network. -const SETTINGS: ClientSettings = { - apiUrl: "http://localhost:4000", - identityUrl: "http://localhost:4000/identity", -}; - -const ROUTE = "POST /accounts/kdf"; - -const NEW_PBKDF2: Kdf = { pBKDF2: { iterations: 700_000 } }; -const NEW_ARGON2: Kdf = { argon2id: { iterations: 3, memory: 16, parallelism: 4 } }; - -const PBKDF2_SHA256 = 0; -const ARGON2ID = 1; - -const TIMEOUT = 60_000; - -/** Guards the user-key comparisons below against passing on two `undefined`s. */ -const BASE64_PATTERN = /^[A-Za-z0-9+/]{40,}={0,2}$/; - -/** A client unlocked under {@link TEST_KDF_PARAMS} with its master-password state seeded. */ -async function setup(): Promise<{ stateBridge: WasmStateBridge; client: PasswordManagerClient }> { - const stateBridge = makeStateBridge(); - const client = await makeInitializedPasswordmanagerClient(stateBridge, SETTINGS); - await seedMasterPasswordUnlockData(stateBridge); - return { stateBridge, client }; -} - -/** The happy-path stand-in for `POST /accounts/kdf`, which answers with an empty 200. */ -const okRoutes = () => ({ [ROUTE]: () => ({}) }); - -/** Awaits a rejection and narrows it to a {@link ChangeKdfError}. */ -async function rejection(promise: Promise): Promise { - const thrown = await promise.then( - () => undefined, - (error) => error, - ); - if (!isChangeKdfError(thrown)) { - throw new Error(`expected a ChangeKdfError, got ${thrown}`); - } - return thrown; -} - -describe("change kdf", () => { - let mock: HttpMock; - - afterEach(() => { - expect(mock.unmatched.map((request) => request.route)).toEqual([]); - mock.restore(); - }); - - describe("request", () => { - it( - "posts the re-derived authentication and unlock data under the new KDF", - async () => { - mock = installHttpMock(okRoutes()); - const { client } = await setup(); - - await client.user_crypto_management().change_kdf(TEST_PASSWORD, NEW_PBKDF2); - - expect(mock.routes()).toEqual([ROUTE]); - - const posted = mock.bodyFor(ROUTE); - expect(Object.keys(posted).sort()).toEqual([ - "authenticationData", - "masterPasswordHash", - "unlockData", - ]); - expect(Object.keys(posted.authenticationData).sort()).toEqual([ - "kdf", - "masterPasswordAuthenticationHash", - "salt", - ]); - expect(Object.keys(posted.unlockData).sort()).toEqual([ - "kdf", - "masterKeyWrappedUserKey", - "salt", - ]); - - // Both halves carry the new KDF; memory and parallelism are omitted for PBKDF2. - const kdf = { kdfType: PBKDF2_SHA256, iterations: 700_000 }; - expect(posted.authenticationData.kdf).toEqual(kdf); - expect(posted.unlockData.kdf).toEqual(kdf); - - // The salt is carried over from the current unlock data, not re-derived. - expect(posted.authenticationData.salt).toBe(TEST_EMAIL); - expect(posted.unlockData.salt).toBe(TEST_EMAIL); - - // The user key is re-wrapped under the master key derived with the new KDF. - expect(posted.unlockData.masterKeyWrappedUserKey).toMatch(/^2\./); - expect(posted.unlockData.masterKeyWrappedUserKey).not.toBe(MASTER_KEY_WRAPPED_USER_KEY); - }, - TIMEOUT, - ); - - it( - "proves possession with a hash derived under the old KDF", - async () => { - mock = installHttpMock(okRoutes()); - const { client } = await setup(); - - await client.user_crypto_management().change_kdf(TEST_PASSWORD, NEW_PBKDF2); - - const posted = mock.bodyFor(ROUTE); - // `masterPasswordHash` is the old-KDF hash the server authenticates the change with; - // `authenticationData` is what replaces it. Same password, different KDF, so they differ. - expect(posted.masterPasswordHash).not.toBe( - posted.authenticationData.masterPasswordAuthenticationHash, - ); - expect(posted.masterPasswordHash).not.toBe(""); - }, - TIMEOUT, - ); - - it( - "converts the argon2id variant across the boundary", - async () => { - mock = installHttpMock(okRoutes()); - const { stateBridge, client } = await setup(); - - await client.user_crypto_management().change_kdf(TEST_PASSWORD, NEW_ARGON2); - - const posted = mock.bodyFor(ROUTE); - const kdf = { kdfType: ARGON2ID, iterations: 3, memory: 16, parallelism: 4 }; - expect(posted.authenticationData.kdf).toEqual(kdf); - expect(posted.unlockData.kdf).toEqual(kdf); - - // Round trip: the argon2id variant survives the trip out to the bridge as well. - expect(await stateBridge.get_kdf_config()).toEqual(NEW_ARGON2); - }, - TIMEOUT, - ); - }); - - describe("persisted state", () => { - it( - "writes the new KDF config and unlock data to the state bridge", - async () => { - mock = installHttpMock(okRoutes()); - const { stateBridge, client } = await setup(); - - await client.user_crypto_management().change_kdf(TEST_PASSWORD, NEW_PBKDF2); - - expect(await stateBridge.get_kdf_config()).toEqual(NEW_PBKDF2); - - const persisted = await stateBridge.get_masterpassword_unlock_data(); - expect(persisted).toBeDefined(); - expect(persisted!.kdf).toEqual(NEW_PBKDF2); - expect(persisted!.salt).toBe(TEST_EMAIL); - // Exactly what was posted, so client and server cannot disagree about the wrapped key. - expect(persisted!.masterKeyWrappedUserKey).toBe( - mock.bodyFor(ROUTE).unlockData.masterKeyWrappedUserKey, - ); - }, - TIMEOUT, - ); - - it( - "leaves the persisted unlock data usable: a fresh client recovers the same user key", - async () => { - mock = installHttpMock(okRoutes()); - const { stateBridge, client } = await setup(); - const userKey = await client.crypto().get_user_encryption_key(); - expect(userKey).toMatch(BASE64_PATTERN); - - await client.user_crypto_management().change_kdf(TEST_PASSWORD, NEW_PBKDF2); - const persisted = await stateBridge.get_masterpassword_unlock_data(); - - // A new client over a fresh bridge, unlocked from nothing but the persisted data. - const next = makePasswordManagerClient(makeStateBridge(), SETTINGS); - await initializeUserCrypto( - next, - { - masterPasswordUnlock: { - password: TEST_PASSWORD, - master_password_unlock: persisted as MasterPasswordUnlockData, - }, - }, - NEW_PBKDF2, - ); - - // Changing the KDF re-wraps the user key; it must not change it. - expect(await next.crypto().get_user_encryption_key()).toBe(userKey); - }, - TIMEOUT, - ); - }); - - describe("failures", () => { - it( - "leaves state untouched when the server rejects the change", - async () => { - mock = installHttpMock({ - [ROUTE]: () => ({ status: 400, json: { message: "kdf change rejected" } }), - }); - const { stateBridge, client } = await setup(); - - const error = await rejection( - client.user_crypto_management().change_kdf(TEST_PASSWORD, NEW_PBKDF2), - ); - - expect(error.variant).toBe("Api"); - expect(mock.routes()).toEqual([ROUTE]); - // Nothing is persisted, so the account is not left half-migrated. - expect(await stateBridge.get_kdf_config()).toBeNull(); - const unlockData = await stateBridge.get_masterpassword_unlock_data(); - expect(unlockData!.kdf).toEqual(TEST_KDF_PARAMS); - expect(unlockData!.masterKeyWrappedUserKey).toBe(MASTER_KEY_WRAPPED_USER_KEY); - }, - TIMEOUT, - ); - - it( - "errors before making a request when the unlock data is missing", - async () => { - mock = installHttpMock(okRoutes()); - // No `seedMasterPasswordUnlockData`: the bridge has no master-password state. - const client = await makeInitializedPasswordmanagerClient(makeStateBridge(), SETTINGS); - - const error = await rejection( - client.user_crypto_management().change_kdf(TEST_PASSWORD, NEW_PBKDF2), - ); - - expect(error.variant).toBe("MissingMasterPasswordUnlockData"); - expect(mock.requests).toEqual([]); - }, - TIMEOUT, - ); - - it( - "errors before making a request when the new KDF is below the allowed minimum", - async () => { - mock = installHttpMock(okRoutes()); - const { stateBridge, client } = await setup(); - const belowMinimum: Kdf = { argon2id: { iterations: 1, memory: 16, parallelism: 1 } }; - - const error = await rejection( - client.user_crypto_management().change_kdf(TEST_PASSWORD, belowMinimum), - ); - - expect(error.variant).toBe("MasterPassword"); - expect(mock.requests).toEqual([]); - expect(await stateBridge.get_kdf_config()).toBeNull(); - }, - TIMEOUT, - ); - }); - - it( - "never sends the password or the user key", - async () => { - mock = installHttpMock(okRoutes()); - const { client } = await setup(); - const userKey = await client.crypto().get_user_encryption_key(); - expect(userKey).toMatch(BASE64_PATTERN); - - await client.user_crypto_management().change_kdf(TEST_PASSWORD, NEW_PBKDF2); - - expect(mock.requests).not.toEqual([]); - for (const request of mock.requests) { - expect(request.body).not.toContain(TEST_PASSWORD); - expect(request.body).not.toContain(userKey); - } - }, - TIMEOUT, - ); -}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/vault/conformance.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/conformance.test.ts new file mode 100644 index 000000000..7897556c1 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/conformance.test.ts @@ -0,0 +1,305 @@ +// The vault's encryption *format*, and what an edit does to it. +// +// Whether a cipher comes out blob-sealed or legacy field-encrypted is decided by +// `should_use_blob_encryption` from the account's security state, and whether a keyless cipher gains a +// per-item key is decided by the `enableCipherKeyEncryption` flag. Both are account-wide behaviours a unit +// test on a single cipher would not catch, and both are asserted here on the ciphertext itself. +// +// The edit matrix is the part that matters most. Every distinct cipher shape in the committed vectors — +// keyless, per-item-keyed, blob-sealed — crossed with every attachment layout — none, V0, V1, V2 — is +// edited, synced back down, and re-decrypted. The combination matters because the layouts differ in +// *where* the key lives: a V0 attachment's contents sit under the user key, a V1's under the cipher key, a +// V2's under its own key. An edit re-encrypts the cipher, and if it mishandled any of those the file would +// become unreadable while the cipher itself still decrypted fine. +// +// This file asserts ciphertext shapes and IV freshness on purpose, so it is expected to break whenever the +// storage format changes. + +import type { CipherView } from "@bitwarden/sdk-internal"; + +import { userVector } from "../test-vectors/load"; +import { expectJsonEqual } from "../test-vectors/validate"; +import { + arrangeVault, + attachmentCases, + EDIT_MATRIX_VECTORS, + featureFlags, + FOLDER_VECTOR, + normalisedByEdit, + renameRequest, + unlockForEncryption, + users, + VAULT_TIMEOUT, + variantOf, + vectorCases, + type VaultHarness, +} from "./vault-support"; + +describe("vault encryption", () => { + describe.each(vectorCases)("%s", (_name, vector) => { + const isV2 = vector.account.securityVersion >= 2; + + it( + `re-encrypts ciphers as ${isV2 ? "blobs" : "legacy fields"}, matching the account`, + async () => { + const client = await unlockForEncryption(vector); + const ciphers = client.vault().ciphers(); + + for (const item of vector.vault.ciphers) { + const { cipher } = await ciphers.encrypt(item.decrypted); + + // `data` carries the sealed blob, so its presence is the same signal + // `Cipher::is_blob_encrypted` uses. + if (isV2) { + expect(cipher.data).toBeTruthy(); + // A V2 account always keeps a per-item key. + expect(cipher.key).toBeTruthy(); + } else { + expect(cipher.data ?? null).toBeNull(); + // Legacy ciphers carry their fields individually, so the name is really encrypted. + expect(cipher.name).toBeTruthy(); + } + + // The format the account produces must match the format it was recorded in, or the + // vector and the client disagree about what this account is. + expect(Boolean(cipher.data)).toBe(item.blobEncrypted); + } + }, + VAULT_TIMEOUT, + ); + + it( + "produces different ciphertext each time, so IVs are never reused", + async () => { + const client = await unlockForEncryption(vector); + const ciphers = client.vault().ciphers(); + const item = vector.vault.ciphers[0]; + + const first = await ciphers.encrypt(item.decrypted); + const second = await ciphers.encrypt(item.decrypted); + + // Whichever field carries the payload for this account's format must differ. + const payload = (cipher: { data?: string | null; name?: unknown }) => + isV2 ? cipher.data : String(cipher.name); + expect(payload(first.cipher)).not.toBe(payload(second.cipher)); + }, + VAULT_TIMEOUT, + ); + + it( + "encrypts a list the same way it encrypts items one at a time", + async () => { + const client = await unlockForEncryption(vector); + const ciphers = client.vault().ciphers(); + const views = vector.vault.ciphers.map((item) => item.decrypted); + + const encrypted = await ciphers.encrypt_list(views); + expect(encrypted).toHaveLength(views.length); + + for (const [index, context] of encrypted.entries()) { + expect(context.encryptedFor).toBe(vector.account.userId); + expect(Boolean(context.cipher.data)).toBe(vector.vault.ciphers[index].blobEncrypted); + + const roundTripped = await ciphers.decrypt(context.cipher); + expectJsonEqual( + roundTripped, + views[index], + `${vector.name}: cipher ${vector.vault.ciphers[index].id} via encrypt_list`, + ); + } + }, + VAULT_TIMEOUT, + ); + }); +}); + +// `enableCipherKeyEncryption` decides whether `encrypt` mints a per-item key for a cipher that has +// none. It only has an observable effect on a V1 account: a V2 account's ciphers always carry a key +// already, so there is nothing for the flag to do. +describe("the enableCipherKeyEncryption flag", () => { + // Mixed V1 vault, so it has a keyless cipher — and no attachments on it, which keeps this about + // the flag rather than about attachment key rewrapping. + const vector = userVector(users, "v1-argon2id-password"); + + const keylessCipher = (): CipherView => { + const item = vector.vault.ciphers.find((cipher) => cipher.keys.cipherKey === null); + if (item === undefined) { + throw new Error(`${vector.name} was expected to contain a keyless cipher`); + } + return item.decrypted; + }; + + it( + "leaves a keyless cipher keyless when off", + async () => { + const client = await unlockForEncryption( + vector, + featureFlags({ enableCipherKeyEncryption: false }), + ); + const ciphers = client.vault().ciphers(); + const view = keylessCipher(); + + const { cipher } = await ciphers.encrypt(view); + expect(cipher.key ?? null).toBeNull(); + + expectJsonEqual(await ciphers.decrypt(cipher), view, `${vector.name}: keyless cipher`); + }, + VAULT_TIMEOUT, + ); + + it( + "mints a per-item key for a keyless cipher when on", + async () => { + const client = await unlockForEncryption( + vector, + featureFlags({ enableCipherKeyEncryption: true }), + ); + const ciphers = client.vault().ciphers(); + const view = keylessCipher(); + + const { cipher } = await ciphers.encrypt(view); + expect(cipher.key).toBeTruthy(); + + // The upgrade must not cost the contents: everything decrypts to the same view *except* + // `key`, which is a passthrough field on `CipherView` and now carries the freshly minted + // wrapped key where the original had none. Pinned to the new key rather than excluded, so a + // change to any other field still fails. + expectJsonEqual( + await ciphers.decrypt(cipher), + { ...view, key: cipher.key }, + `${vector.name}: upgraded cipher`, + ); + }, + VAULT_TIMEOUT, + ); + + it( + "leaves an already-keyed cipher's key alone", + async () => { + const client = await unlockForEncryption( + vector, + featureFlags({ enableCipherKeyEncryption: true }), + ); + const ciphers = client.vault().ciphers(); + const item = vector.vault.ciphers.find((cipher) => cipher.keys.cipherKey !== null)!; + + const { cipher } = await ciphers.encrypt(item.decrypted); + + // `CipherView.key` is passed through as-is, so the wrapped key is the one the vector recorded. + expect(String(cipher.key)).toBe(String(item.decrypted.key)); + expectJsonEqual( + await ciphers.decrypt(cipher), + item.decrypted, + `${vector.name}: keyed cipher`, + ); + }, + VAULT_TIMEOUT, + ); +}); + +describe("cipher CRUD", () => { + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + // The requirement: every cipher shape and every attachment layout must still decrypt after an edit. + describe.each(EDIT_MATRIX_VECTORS.map((name) => [name, userVector(users, name)] as const))( + "editing %s", + (_name, vector) => { + it.each(vector.vault.ciphers.map((item) => [variantOf(item), item] as const))( + "keeps a %s decryptable", + async (_variant, item) => { + // Arrange + harness = await arrangeVault(vector); + const { client, local } = harness; + const ciphers = client.vault().ciphers(); + const before: CipherView = await ciphers.decrypt(item.encrypted); + expectJsonEqual(before, item.decrypted, `${item.id} before edit`); + + // Act + const edited = await ciphers.edit({ + ...renameRequest(before, `${before.name} (edited)`), + favorite: !before.favorite, + }); + + // Assert + await harness.assertAccountIntact({ + expect: { + ciphers: { + [item.id]: { + name: `${before.name} (edited)`, + favorite: !before.favorite, + ...normalisedByEdit(before, item), + }, + }, + }, + }); + // Specific to this test: the edit returned what it stored, and the attachment keys came + // through untouched — a V2 attachment still exposes the recorded key, a V0/V1 one still + // exposes none. + expect(edited.name).toBe(`${before.name} (edited)`); + const after = await ciphers.decrypt((await local.ciphers.get(item.id))!); + for (const attachment of after.attachments ?? []) { + const recorded = item.keys.attachments[attachment.id!]; + expect(recorded).toBeDefined(); + if (recorded.version === "V2") { + expect((attachment as { decryptedKey?: string }).decryptedKey).toBe(recorded.key); + } else { + expect((attachment as { decryptedKey?: string }).decryptedKey ?? null).toBeNull(); + } + } + expect(after.attachments ?? []).toHaveLength(Object.keys(item.keys.attachments).length); + }, + VAULT_TIMEOUT, + ); + }, + ); +}); + +describe("folder CRUD", () => { + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + it( + "decrypt_list agrees with decrypting each folder individually", + async () => { + // Arrange + harness = await arrangeVault(FOLDER_VECTOR); + const { client, local } = harness; + const folders = client.vault().folders(); + const stored = local.folders.dump(); + + // Act + const batch = folders.decrypt_list(stored); + + // Assert + await harness.assertAccountIntact(); + // Specific to this test: the batch path is a different implementation from the single one, so + // every item must match what decrypting it alone produces. + expect(batch).toHaveLength(stored.length); + for (const [index, item] of stored.entries()) { + expectJsonEqual( + batch[index], + folders.decrypt(item), + `folder ${(item as any).id} batch vs single`, + ); + } + }, + VAULT_TIMEOUT, + ); +}); + +it("covers every distinct cipher and attachment variant in the vector set", () => { + // Guards the matrix above: if a regenerated vector set adds a shape, it must be covered here too. + const covered = new Set( + EDIT_MATRIX_VECTORS.flatMap((name) => userVector(users, name).vault.ciphers.map(variantOf)), + ); + const all = new Set(users.flatMap((vector) => vector.vault.ciphers.map(variantOf))); + expect([...all].sort().filter((variant) => !covered.has(variant))).toEqual([]); +}); + +it("covers all three attachment layouts", () => { + const versions = new Set(attachmentCases.map(([, , , , keys]) => keys.version)); + expect([...versions].sort()).toEqual(["V0", "V1", "V2"]); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/vault/edge-cases.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/edge-cases.test.ts new file mode 100644 index 000000000..65fa2858f --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/edge-cases.test.ts @@ -0,0 +1,92 @@ +// Two vault cases worth pinning down so neither regresses: a write built on a stale revision, and +// attachment contents encrypted under a key that does not own them. + +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { userVector } from "../test-vectors/load"; +import { expectJsonEqual } from "../test-vectors/validate"; +import { + arrangeVault, + attachmentCases, + FILE_CONTENTS, + normalisedByEdit, + renameRequest, + users, + VAULT_TIMEOUT, + type VaultHarness, +} from "./vault-support"; + +describe("cipher CRUD", () => { + const vector = userVector(users, "v1-argon2id-password"); + + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + it( + "rejects a second edit made from a stale revision", + async () => { + // Optimistic concurrency: the client sends the revision it last saw, and the server refuses a + // write built on an older one. Only testable because the model server advances revisions — + // against a mock that echoed them back, both edits would succeed and the second would silently + // clobber the first. + + // Arrange + harness = await arrangeVault(vector); + const { api, client } = harness; + const item = vector.vault.ciphers[0]; + const ciphers = client.vault().ciphers(); + const stale = await ciphers.decrypt(item.encrypted); + await ciphers.edit(renameRequest(stale, "First write")); + const afterFirst = structuredClone(api.db.ciphers.get(item.id)!.cipher); + + // Act + const second = ciphers.edit(renameRequest(stale, "Second write")); + + // Assert + await expect(second).rejects.toBeDefined(); + await harness.assertAccountIntact({ + expect: { + ciphers: { [item.id]: { name: "First write", ...normalisedByEdit(stale, item) } }, + }, + }); + // Specific to this test: the rejected write left the account byte-identical. Checking the name + // alone would pass against a server that applied the write and merely reported the old name, + // or that advanced the revision on a request it refused. + expectJsonEqual( + api.db.ciphers.get(item.id)?.cipher, + afterFirst, + `${item.id} unchanged by the rejected write`, + ); + }, + VAULT_TIMEOUT, + ); +}); + +describe("attachment contents", () => { + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + it( + "refuses contents encrypted under the wrong key", + async () => { + // Proof the round trip in `happy-path.test.ts` has teeth: if `decrypt_buffer` ignored the + // resolved content key, decrypting under an unrelated key would succeed. + const [, attachmentVector, item, attachmentId] = attachmentCases[0]; + harness = await arrangeVault(attachmentVector); + const { client } = harness; + + const view = await client.vault().ciphers().decrypt(item.encrypted); + const attachmentView = (view.attachments ?? []).find((a) => a.id === attachmentId)!; + + const wrongKey = Buffer.from(PureCrypto.make_user_key_aes256_cbc_hmac()); + const encrypted = PureCrypto.symmetric_encrypt_filedata(FILE_CONTENTS, wrongKey); + + expect(() => + client.vault().attachments().decrypt_buffer(item.encrypted, attachmentView, encrypted), + ).toThrow(); + }, + VAULT_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/vault/happy-path.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/happy-path.test.ts new file mode 100644 index 000000000..9e3c2d47a --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/happy-path.test.ts @@ -0,0 +1,160 @@ +// The vault's basic functionality against the committed vectors: everything encrypts and decrypts back +// to what was recorded, and attachment contents come back byte for byte under every layout. +// +// Encryption is non-deterministic — fresh IVs and nonces every time — so there is nothing to compare the +// ciphertext against. What can be asserted is the round trip, which is what this file does; the *format* +// each account produces is asserted in `conformance.test.ts`. +// +// The attachment half matters because the vectors record attachment *keys* but not attachment *bytes*. +// `AttachmentFile::decrypt` resolves the content key differently per layout, and getting it wrong makes a +// file permanently unreadable while the cipher it hangs off still decrypts perfectly: +// +// V0 no attachment key, cipher has no cipher key -> contents under the user/organization key +// V1 no attachment key, cipher *has* a cipher key -> contents still under the user/organization key +// V2 attachment key present -> contents under that key, which is itself +// wrapped by the cipher key +// +// The V1 case is the subtle one: the legacy branch decrypts with the user/org slot, *not* with the cipher +// key it just unwrapped, even though the cipher has one. A refactor that "tidied" that to use +// `ciphers_key` would break every V1 attachment in existence, and every other test would still pass. +// +// Ciphertext is synthesised with `PureCrypto.symmetric_encrypt_filedata` under whichever key the layout +// says owns the contents, then handed to `decrypt_buffer`. + +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { expectJsonEqual } from "../test-vectors/validate"; +import { + arrangeVault, + attachmentCases, + FILE_CONTENTS, + FOLDER_VECTOR, + unlockForEncryption, + VAULT_TIMEOUT, + vectorCases, + type VaultHarness, +} from "./vault-support"; + +it("has folders to work with", () => { + // The folder tests elsewhere would be vacuous against an empty vault. + expect(FOLDER_VECTOR.vault.folders.length).toBeGreaterThan(0); +}); + +describe("vault encryption", () => { + describe.each(vectorCases)("%s", (_name, vector) => { + it( + "round-trips every cipher through encrypt and back", + async () => { + const client = await unlockForEncryption(vector); + const ciphers = client.vault().ciphers(); + + for (const item of vector.vault.ciphers) { + const { cipher, encryptedFor } = await ciphers.encrypt(item.decrypted); + + // The cipher is stamped with the user that encrypted it, not the one that owns it. + expect(encryptedFor).toBe(vector.account.userId); + + const roundTripped = await ciphers.decrypt(cipher); + expectJsonEqual( + roundTripped, + item.decrypted, + `${vector.name}: cipher ${item.id} re-encrypted`, + ); + } + + expect(vector.vault.ciphers.length).toBeGreaterThan(0); + }, + VAULT_TIMEOUT, + ); + + if (vector.vault.folders.length > 0) { + it( + "round-trips every folder through encrypt and back", + async () => { + const client = await unlockForEncryption(vector); + const folders = client.vault().folders(); + + for (const item of vector.vault.folders) { + const encrypted = folders.encrypt(item.decrypted); + const roundTripped = folders.decrypt(encrypted); + expectJsonEqual( + roundTripped, + item.decrypted, + `${vector.name}: folder ${item.id} re-encrypted`, + ); + + // A folder is a single encrypted name, so a repeat encryption is the whole check. + const again = folders.encrypt(item.decrypted); + expect(String(encrypted.name)).not.toBe(String(again.name)); + } + }, + VAULT_TIMEOUT, + ); + } + }); +}); + +describe("attachment contents", () => { + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + it.each(attachmentCases)( + "decrypts a %s", + async (_label, vector, item, attachmentId, keys) => { + harness = await arrangeVault(vector); + const { client } = harness; + const attachments = client.vault().attachments(); + + // The view carries the unwrapped attachment key, so it is also what tells us the SDK agrees with + // the vector about which layout this is. + const view = await client.vault().ciphers().decrypt(item.encrypted); + const attachmentView = (view.attachments ?? []).find((a) => a.id === attachmentId); + expect(attachmentView).toBeDefined(); + + // Whichever key the layout says owns the contents. + const contentKey = + keys.version === "V2" + ? Buffer.from(keys.key!, "base64") + : Buffer.from(vector.rawCryptographicState.userKey, "base64"); + if (keys.version === "V2") { + // The unwrapped key the SDK exposes must be the one the vector recorded, or the round trip + // below would be testing the wrong key. + expect((attachmentView as { decryptedKey?: string }).decryptedKey).toBe(keys.key); + } else { + expect((attachmentView as { decryptedKey?: string }).decryptedKey ?? null).toBeNull(); + } + + const encrypted = PureCrypto.symmetric_encrypt_filedata(FILE_CONTENTS, contentKey); + const decrypted = attachments.decrypt_buffer(item.encrypted, attachmentView!, encrypted); + + expect(Buffer.from(decrypted)).toEqual(Buffer.from(FILE_CONTENTS)); + }, + VAULT_TIMEOUT, + ); +}); + +describe("folder CRUD", () => { + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + it( + "round-trips a folder through encrypt and decrypt", + async () => { + // Arrange + harness = await arrangeVault(FOLDER_VECTOR); + const folders = harness.client.vault().folders(); + const view = folders.decrypt(FOLDER_VECTOR.vault.folders[0].encrypted); + + // Act + const reencrypted = folders.encrypt(view); + + // Assert + await harness.assertAccountIntact(); + // Specific to this test: re-encrypting and decrypting again returns the same plaintext. + expectJsonEqual(folders.decrypt(reencrypted), view, "folder re-encrypt round trip"); + }, + VAULT_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/vault/user-facing-flow.test.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/user-facing-flow.test.ts new file mode 100644 index 000000000..d00356397 --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/user-facing-flow.test.ts @@ -0,0 +1,342 @@ +// The vault operations a user performs: add an item, rename it, delete and restore it, list what they +// have, and attach a file to something. +// +// These span all three layers at once — they encrypt, they call the API, and they write the result into a +// client-managed repository — so each one ends by asserting the account is still usable from both +// directions a real client can arrive from: lock then unlock on its own local state, and log out then log +// back in with nothing but what the server holds. + +import { asCipherId } from "../type-assertion-helpers"; +import type { CreateAttachmentRequest } from "@bitwarden/sdk-internal"; + +import { syncToLocalState, unlockMethodFor, validateLocalState } from "../model-server/sync"; +import { expectCipherFromServer } from "../model-server/sync"; +import { userVector, type CipherVectorItem } from "../test-vectors/load"; +import { expectJsonEqual } from "../test-vectors/validate"; +import { + arrangeVault, + FILE_CONTENTS, + FOLDER_VECTOR, + users, + VAULT_TIMEOUT, + type VaultHarness, +} from "./vault-support"; + +/** A mixed V1 vault with a cheap KDF: two ciphers, a folder and a send. */ +const vector = userVector(users, "v1-argon2id-password"); + +describe("cipher CRUD", () => { + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + describe("create, read, update, delete", () => { + it( + "creates a cipher", + async () => { + // Arrange + harness = await arrangeVault(vector); + + // Act + const created = await harness.client + .vault() + .ciphers() + .create({ + organizationId: undefined, + collectionIds: [], + folderId: undefined, + name: "Created Login", + notes: "a note", + favorite: true, + reprompt: 0, + type: { + login: { + username: "someone@example.com", + password: "hunter2", + passwordRevisionDate: undefined, + uris: undefined, + totp: undefined, + autofillOnPageLoad: undefined, + fido2Credentials: undefined, + }, + }, + fields: [], + }); + + // Assert + await harness.assertAccountIntact(); + // Specific to this test: the new cipher comes back off the server whole, and neither its name + // nor its password is readable in what was stored. + await expectCipherFromServer( + harness.api, + vector.account.email, + String(created.id), + created, + "created cipher", + { notInCiphertext: ["Created Login", "hunter2"] }, + ); + }, + VAULT_TIMEOUT, + ); + + it( + "lists what local state holds", + async () => { + // Arrange + harness = await arrangeVault(vector); + const { client, local } = harness; + + // Act + const listed = await client.vault().ciphers().list(); + + // Assert + await harness.assertAccountIntact(); + // Specific to this test: `list` agrees with decrypting local state directly, so it cannot have + // reordered, deduplicated or dropped a field. Comparing names alone would not catch that. + expect(listed.failures).toHaveLength(0); + const byId = (views: T[]) => + [...views].sort((a, b) => String(a.id).localeCompare(String(b.id))); + expectJsonEqual( + byId(listed.successes), + byId(await client.vault().ciphers().decrypt_list(local.ciphers.dump())), + "list vs decrypt_list of local state", + ); + expect(listed.successes).toHaveLength(vector.vault.ciphers.length); + }, + VAULT_TIMEOUT, + ); + + it( + "soft deletes, then restores", + async () => { + // Arrange + harness = await arrangeVault(vector); + const { api, client, local } = harness; + const item = vector.vault.ciphers[0]; + const ciphers = client.vault().ciphers(); + + // Act + await ciphers.soft_delete(asCipherId(item.id)); + await ciphers.restore(asCipherId(item.id)); + + // Assert + await harness.assertAccountIntact(); + // Specific to this test: the deletion marker is gone again on both sides. + expect(((await local.ciphers.get(item.id)) as any).deletedDate ?? null).toBeNull(); + expect((api.db.ciphers.get(item.id)!.cipher as any).deletedDate ?? null).toBeNull(); + }, + VAULT_TIMEOUT, + ); + + it( + "marks a soft-deleted cipher as deleted before it is restored", + async () => { + // Arrange + harness = await arrangeVault(vector); + const { api, client, local } = harness; + const item = vector.vault.ciphers[0]; + + // Act + await client.vault().ciphers().soft_delete(asCipherId(item.id)); + + // Assert + // Not `assertAccountIntact`: a soft-deleted account is not the vector's account any more. The + // vault must still decrypt, which is what validating the *stored* items shows. + await syncToLocalState(api, vector.account.email, local); + await validateLocalState( + local, + unlockMethodFor(api, vector.account.email), + { ciphers: vector.vault.ciphers, folders: vector.vault.folders }, + { ignore: ["revisionDate", "key", "deletedDate"] }, + ); + // Specific to this test: both sides agree it is deleted. + expect(((await local.ciphers.get(item.id)) as any).deletedDate).toBeTruthy(); + expect((api.db.ciphers.get(item.id)!.cipher as any).deletedDate).toBeTruthy(); + }, + VAULT_TIMEOUT, + ); + + it( + "hard deletes a cipher", + async () => { + // Arrange + harness = await arrangeVault(vector); + const { api, client, local } = harness; + const item = vector.vault.ciphers[0]; + + // Act + await client.vault().ciphers().delete(asCipherId(item.id)); + + // Assert + await syncToLocalState(api, vector.account.email, local); + // Specific to this test: gone from local state and from the server, while everything else in + // the vault still decrypts. + expect(await local.ciphers.get(item.id)).toBeNull(); + expect(api.db.ciphers.get(item.id)).toBeUndefined(); + await validateLocalState(local, unlockMethodFor(api, vector.account.email), { + ciphers: vector.vault.ciphers.filter((cipher) => cipher.id !== item.id), + folders: vector.vault.folders, + }); + }, + VAULT_TIMEOUT, + ); + }); +}); + +describe("folder CRUD", () => { + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + it( + "creates a folder", + async () => { + // Arrange + harness = await arrangeVault(FOLDER_VECTOR); + const { client, local } = harness; + + // Act + const created = await client.vault().folders().create({ name: "Created Folder" }); + + // Assert + await harness.assertAccountIntact(); + // Specific to this test: the new folder is stored encrypted and reads back whole. + const stored = await local.folders.get(String(created.id)); + expect(stored).not.toBeNull(); + expect(String((stored as any).name)).not.toBe("Created Folder"); + expect(String((stored as any).name)).toMatch(/^\d+\./); + expectJsonEqual( + client.vault().folders().decrypt(stored!), + created, + "created folder after sync", + ); + }, + VAULT_TIMEOUT, + ); + + it( + "renames a folder, advancing its revision but not its id", + async () => { + // Arrange + harness = await arrangeVault(FOLDER_VECTOR); + const { client } = harness; + const item = FOLDER_VECTOR.vault.folders[0]; + const before = client.vault().folders().decrypt(item.encrypted); + + // Act + const edited = await client + .vault() + .folders() + .edit(item.id as never, { name: "Renamed" }); + + // Assert + await harness.assertAccountIntact({ + expect: { folders: { [item.id]: { name: "Renamed" } } }, + }); + // Specific to this test: the id is stable and the server stamped a fresh revision. + expect(edited.id).toBe(item.id); + expect(new Date(edited.revisionDate).getTime()).toBeGreaterThan( + new Date(before.revisionDate).getTime(), + ); + }, + VAULT_TIMEOUT, + ); + + it( + "lists every folder in local state, decrypted", + async () => { + // Arrange + harness = await arrangeVault(FOLDER_VECTOR); + const { client, local } = harness; + + // Act + const listed = await client.vault().folders().list(); + + // Assert + await harness.assertAccountIntact(); + // Specific to this test: `list` agrees with decrypting local state directly, so it cannot have + // reordered, deduplicated or dropped a field. Comparing names alone would not catch that. + const byId = (views: T[]) => + [...views].sort((a, b) => String(a.id).localeCompare(String(b.id))); + expectJsonEqual( + byId(listed), + byId(client.vault().folders().decrypt_list(local.folders.dump())), + "list vs decrypt_list of local state", + ); + expect(listed).toHaveLength(FOLDER_VECTOR.vault.folders.length); + }, + VAULT_TIMEOUT, + ); +}); + +describe("attachment slot lifecycle", () => { + const attachmentVector = userVector(users, "v2-argon2id-blob"); + /** The cipher in that vector that already has an attachment. */ + const item = attachmentVector.vault.ciphers.find( + (cipher) => Object.keys(cipher.keys.attachments).length > 0, + ) as CipherVectorItem; + + let harness: VaultHarness; + + afterEach(() => harness.assertClean()); + + it( + "creates a slot, then reads, renews and deletes it", + async () => { + harness = await arrangeVault(attachmentVector); + const { api, client, local } = harness; + const repository = local.ciphers; + const attachments = client.vault().attachments(); + + const view = await client.vault().ciphers().decrypt(item.encrypted); + const existingCount = (view.attachments ?? []).length; + expect(existingCount).toBeGreaterThan(0); + + // The key and file name are already-encrypted values; reuse the shapes the vector holds so the + // request is well formed without inventing a new encryption here. + const existing = (item.encrypted as any).attachments[0]; + const created = await attachments.create_attachment(asCipherId(item.id), { + key: existing.key, + fileName: existing.fileName, + fileSize: FILE_CONTENTS.length, + lastKnownRevisionDate: view.revisionDate, + asAdmin: false, + } satisfies CreateAttachmentRequest); + + expect(created.attachmentId).toBeTruthy(); + expect(created.uploadUrl).toContain(created.attachmentId); + + // The merged cipher is written back to the repository, with the new slot on it. + const stored = (await repository.get(item.id)) as any; + expect(stored.attachments).toHaveLength(existingCount + 1); + // And the server holds the same count — local and remote agree. + expect((api.db.ciphers.get(item.id)!.cipher as any).attachments).toHaveLength( + existingCount + 1, + ); + + const downloadUrl = await attachments.get_attachment_download_url( + asCipherId(item.id), + created.attachmentId, + ); + expect(downloadUrl).toContain(created.attachmentId); + + const renewed = await attachments.renew_file_upload_url( + asCipherId(item.id), + created.attachmentId, + ); + expect(renewed).toContain("renewed"); + + const afterDelete = await attachments.delete_attachment( + asCipherId(item.id), + created.attachmentId, + ); + expect((afterDelete as any).attachments ?? []).toHaveLength(existingCount); + const reread = (await repository.get(item.id)) as any; + expect(reread.attachments ?? []).toHaveLength(existingCount); + expect((api.db.ciphers.get(item.id)!.cipher as any).attachments ?? []).toHaveLength( + existingCount, + ); + }, + VAULT_TIMEOUT, + ); +}); diff --git a/crates/bitwarden-wasm-internal/integration-tests/tests/vault/vault-support.ts b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/vault-support.ts new file mode 100644 index 000000000..6e30c1bfa --- /dev/null +++ b/crates/bitwarden-wasm-internal/integration-tests/tests/vault/vault-support.ts @@ -0,0 +1,221 @@ +// Shared scaffolding for the vault suites: the model-server harness the CRUD tests run on, the +// vector-only unlock the encryption matrix uses, and the case tables both are driven by. +// +// The CRUD suites all want the same four things — a seeded account, the servers installed, local state +// synced down from them, and an unlocked client — plus the same two-directional "is the account still +// usable" assertion afterwards. That is `arrangeVault`. + +import type { + CipherView, + CipherViewType, + FeatureFlags, + InitUserCryptoMethod, + PasswordManagerClient, +} from "@bitwarden/sdk-internal"; + +import { ApiServer } from "../model-server/api-server"; +import { installServers, type InstalledServers } from "../model-server/install"; +import { LocalState } from "../model-server/local-state"; +import { + syncToLocalState, + unlockMethodFor, + validateAfterLockUnlock, + validateAfterLogoutLogin, + type ValidateOptions, +} from "../model-server/sync"; +import { + loadUserVectors, + userVector, + type CipherVectorItem, + type UserVector, +} from "../test-vectors/load"; +import { makePasswordManagerClient, makeStateBridge } from "../utils"; + +/** `v1-pbkdf2-password` unlocks at 600k PBKDF2 rounds, and each case unlocks at least once. */ +export const VAULT_TIMEOUT = 180_000; + +export const users = loadUserVectors(); + +/** Every vector, shaped for `describe.each`. */ +export const vectorCases = users.map((vector) => [vector.name, vector] as const); + +/** + * `v1-pbkdf2-password` carries five of the seven distinct cipher shapes on its own — keyless, keyed, and + * one cipher each for V0, V1 and V2 attachments. `v2-argon2id-blob` supplies the two blob shapes. + */ +export const EDIT_MATRIX_VECTORS = ["v1-pbkdf2-password", "v2-argon2id-blob"] as const; + +/** A vector with folders in its vault, and a cheap KDF. */ +export const FOLDER_VECTOR = userVector(users, "v1-argon2id-password"); + +export const FILE_CONTENTS = new TextEncoder().encode("attachment contents, 1234567890, éèê"); + +// ---- the model-server harness ------------------------------------------------------------------- + +export interface VaultHarness { + api: ApiServer; + servers: InstalledServers; + local: LocalState; + vector: UserVector; + client: PasswordManagerClient; + /** The `afterEach` assertions: nothing unmatched, and no secret on the wire. */ + assertClean(): void; + /** + * Asserts the write left the account usable, from both directions a real client can arrive from. + * + * The lock/unlock half comes first and on purpose: it reads the writing client's *own* local state + * before anything overwrites it, which is the only way to catch a write that posted a correct account + * but corrupted local storage. + */ + assertAccountIntact(options?: ValidateOptions): Promise; +} + +/** Seeds the account, installs the servers, syncs it into fresh local state, and unlocks. */ +export async function arrangeVault(vector: UserVector): Promise { + const api = new ApiServer(); + api.seedUser(vector); + const servers = installServers({ api }); + const local = new LocalState(); + await syncToLocalState(api, vector.account.email, local); + const client = await local.unlock(unlockMethodFor(api, vector.account.email)); + + return { + api, + servers, + local, + vector, + client, + assertClean() { + expect(servers.unmatched.map((request) => request.route)).toEqual([]); + // No seeded account's password, user key, private key or master key may ever appear in a request + // body. Policed by the server on every request, so no individual test has to remember to look. + expect(api.secretLeaks()).toEqual([]); + servers.restore(); + }, + async assertAccountIntact(options: ValidateOptions = {}) { + const email = vector.account.email; + await validateAfterLockUnlock(local, unlockMethodFor(api, email), vector, options); + await validateAfterLogoutLogin(api, email, vector, options); + }, + }; +} + +// ---- the vector-only unlock the encryption matrix uses ------------------------------------------- + +/** + * Unlocks a vector with its first declared unlock method, with no server involved. + * + * Which method is used does not affect encryption — they all arrive at the same user key — so the + * encryption matrix does not iterate over them, and stays cheap. + */ +export async function unlockForEncryption( + vector: UserVector, + flags?: FeatureFlags, +): Promise { + const client = makePasswordManagerClient(makeStateBridge()); + if (flags !== undefined) { + await client.platform().load_flags(flags); + } + await client.crypto().initialize_user_crypto({ + userId: vector.account.userId, + kdfParams: vector.account.kdf, + email: vector.account.email, + accountCryptographicState: vector.account.accountCryptographicState, + method: vector.unlockMethods[0] as InitUserCryptoMethod, + upgradeToken: vector.account.upgradeToken, + }); + return client; +} + +export const featureFlags = (entries: Record) => + new Map(Object.entries(entries)) as FeatureFlags; + +// ---- cipher shape helpers ----------------------------------------------------------------------- + +/** Describes a cipher variant, for readable test names. */ +export function variantOf(item: CipherVectorItem): string { + const attachments = Object.values(item.keys.attachments).map((a) => a.version); + const shape = item.blobEncrypted + ? "blob" + : item.keys.cipherKey !== null + ? "legacy keyed" + : "legacy keyless"; + return `${shape}, ${attachments.length === 0 ? "no attachments" : `${attachments.join("+")} attachment`}`; +} + +/** Rebuilds the `CipherViewType` an edit request needs from a decrypted view. */ +export function cipherViewType(view: CipherView): CipherViewType { + if (view.login) return { login: view.login! }; + if (view.card) return { card: view.card! }; + if (view.identity) return { identity: view.identity! }; + if (view.secureNote) return { secureNote: view.secureNote! }; + if (view.sshKey) return { sshKey: view.sshKey! }; + if (view.bankAccount) return { bankAccount: view.bankAccount! }; + if (view.passport) return { passport: view.passport! }; + if (view.driversLicense) return { driversLicense: view.driversLicense! }; + throw new Error(`cipher ${view.id} has no recognised type payload`); +} + +/** + * How an edit leaves `fields` and `passwordHistory`, which differs by encryption mode. + * + * On the **legacy** path they come back as empty collections rather than absent: + * `convert_request_to_cipher_view` assigns `fields: Some(r.fields)` and `update_password_history` + * always assigns `Some(..)`. On the **blob** path they survive as absent, because the whole view is + * resealed rather than mapped field by field. + * + * Stated as expected values rather than added to the ignore list, so a genuine change to either still + * fails — and so the difference between the two modes is recorded rather than blurred. + */ +export function normalisedByEdit(before: CipherView, item: CipherVectorItem): Partial { + if (item.blobEncrypted) { + return {}; + } + return { + fields: before.fields ?? [], + passwordHistory: before.passwordHistory ?? [], + }; +} + +/** An edit request that changes `name`, carrying everything else through unchanged. */ +export function renameRequest(view: CipherView, name: string) { + return { + id: view.id!, + organizationId: view.organizationId ?? undefined, + folderId: view.folderId ?? undefined, + favorite: view.favorite, + reprompt: view.reprompt, + name, + notes: view.notes ?? undefined, + fields: view.fields ?? [], + type: cipherViewType(view), + revisionDate: view.revisionDate, + archivedDate: view.archivedDate ?? undefined, + // Carried through unchanged: an edit must not disturb attachment keys. + attachments: view.attachments ?? [], + key: view.key ?? undefined, + }; +} + +// ---- attachment case table ---------------------------------------------------------------------- + +/** + * Every (vector, cipher, attachment) triple in the set that actually has an attachment. + * + * `v1-pbkdf2-password` is the only account carrying a V0 and a V1 attachment; the V2 ones are elsewhere. + */ +export const attachmentCases = EDIT_MATRIX_VECTORS.flatMap((name) => { + const vector = userVector(users, name); + return vector.vault.ciphers.flatMap((item) => + Object.entries(item.keys.attachments).map( + ([attachmentId, keys]) => + [ + `${keys.version} attachment on a ${item.keys.cipherKey === null ? "keyless" : "keyed"}${item.blobEncrypted ? " blob" : ""} cipher`, + vector, + item, + attachmentId, + keys, + ] as const, + ), + ); +});