Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .aikido
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Test vectors are derived from a throwaway account, no real secrets there.
# Committed cryptographic test vectors. They contain real private keys, master keys and passwords
# by design, generated for tests and never used by an account that exists.
exclude:
paths:
- crates/bitwarden-importers/src/importers/onepassword/access/fixtures
- test-vectors
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ crates/bitwarden-uniffi/swift/*

# Test fixtures
crates/bitwarden-exporters/resources/*
# Committed test vectors. Ignored so prettier cannot reformat them, which would change bytes that
# the Rust and TypeScript suites both read as fixed input.
test-vectors/*.json
test-vectors/**/*.json
crates/bitwarden-importers/src/importers/onepassword/access/fixtures/*

# CI output
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
// seeds an account 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 { CipherView, Kdf, PasswordManagerClient } from "@bitwarden/sdk-internal";
import type { Kdf, PasswordManagerClient } from "@bitwarden/sdk-internal";

import { PASSWORD_ACCOUNT, PASSWORD_ACCOUNT_USER_KEY } from "../fixtures/accounts";
import type { Routes } from "../http-mock";
import { ApiServer, type SeedAccount } from "../model-server/api-server";
import { installServers, type InstalledServers } from "../model-server/install";
Expand All @@ -17,6 +16,7 @@ import {
unlockMethodFor,
type ExpectedVault,
} from "../model-server/validate";
import { loadUserVectors, toSeedAccount, 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;
Expand All @@ -26,13 +26,19 @@ export const CHANGE_KDF_ROUTE = "POST /accounts/kdf";
export const NEW_PBKDF2: Kdf = { pBKDF2: { iterations: 700_000 } };
export const NEW_ARGON2: Kdf = { argon2id: { iterations: 3, memory: 16, parallelism: 4 } };

/** The item the harness puts in the account's vault, so a validation has something to prove. */
const SEEDED_ITEM = {
name: "Seeded Login",
notes: "notes that must survive a kdf change",
username: "someone@example.com",
password: "the-item-password",
} as const;
const users = loadUserVectors();

/**
* The cheapest master-password account in the set to unlock, so a test's cost 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, so there is no unlock data to re-derive. */
export const NO_MASTER_PASSWORD_VECTOR = userVector(users, "v1-argon2id-tde");

export const CHANGE_KDF_ACCOUNT = toSeedAccount(CHANGE_KDF_VECTOR);
export const NO_MASTER_PASSWORD_ACCOUNT = toSeedAccount(NO_MASTER_PASSWORD_VECTOR);

export interface ChangeKdfHarness {
api: ApiServer;
Expand All @@ -52,19 +58,10 @@ export interface ChangeKdfHarness {
* `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.
*/
/**
* Whether the harness puts an item in the account's vault.
*
* `"seeded"` for any case that validates the account afterwards — a validator with nothing to
* decrypt reports success for an account it never opened. `"empty"` for cases that only assert a
* refusal, and for accounts whose key material cannot encrypt one.
*/
export type VaultSeeding = "seeded" | "empty";

export async function setupChangeKdf(
options: { account?: SeedAccount; extraRoutes?: Routes; vault?: VaultSeeding } = {},
options: { account?: SeedAccount; extraRoutes?: Routes } = {},
): Promise<ChangeKdfHarness> {
const account = options.account ?? PASSWORD_ACCOUNT;
const account = options.account ?? CHANGE_KDF_ACCOUNT;
const api = new ApiServer();
api.seedUser(account);
const servers = installServers({ api, extraRoutes: options.extraRoutes });
Expand All @@ -74,48 +71,14 @@ export async function setupChangeKdf(
await syncToLocalState(api, email, local);
const client = await local.unlock(unlockMethodFor(api, email));

// Created through the real path, so the ciphertext is the SDK's own rather than a fixture's.
const created =
(options.vault ?? "seeded") === "empty"
? undefined
: await client
.vault()
.ciphers()
.create({
organizationId: undefined,
collectionIds: [],
folderId: undefined,
name: SEEDED_ITEM.name,
notes: SEEDED_ITEM.notes,
favorite: false,
reprompt: 0,
type: { login: loginView() },
fields: [],
});
await syncToLocalState(api, email, local);

return {
api,
servers,
local,
client,
account,
email,
expected: expectedVaultOf({
...account,
vault:
created === undefined
? {}
: {
ciphers: [
{
id: String(created.id),
encrypted: encryptedOf(api, created),
decrypted: created,
},
],
},
}),
expected: expectedVaultOf(account),
assertClean() {
expect(servers.unmatched.map((request) => request.route)).toEqual([]);
// The account's password, user key and private key are watched by the server on every
Expand All @@ -126,34 +89,15 @@ export async function setupChangeKdf(
};
}

function loginView() {
return {
username: SEEDED_ITEM.username,
password: SEEDED_ITEM.password,
passwordRevisionDate: undefined,
uris: undefined,
totp: undefined,
autofillOnPageLoad: undefined,
fido2Credentials: undefined,
};
}

/** The ciphertext the server stored for `view`. */
function encryptedOf(api: ApiServer, view: CipherView) {
const stored = api.db.ciphers.get(String(view.id));
if (stored === undefined) {
throw new Error(`the create did not reach the server: no cipher ${String(view.id)}`);
}
return stored;
}

/**
* Asserts the recorded user key is the one the account actually unwraps to.
* Asserts the vector's recorded user key is the one the account actually unwraps to.
*
* The server can only watch for a secret it was told about. If the fixture's recorded user key ever
* drifts from its key material, the leak check would keep passing while covering nothing — so it is
* re-derived once here rather than trusted.
* The server can only watch for a secret it was told about. If a vector's recorded user key ever
* drifts from its key material, the leak check keeps passing while covering nothing — so it is
* re-derived rather than trusted.
*/
export async function expectRecordedUserKeyIsLive(client: PasswordManagerClient): Promise<void> {
expect(await client.crypto().get_user_encryption_key()).toBe(PASSWORD_ACCOUNT_USER_KEY);
expect(await client.crypto().get_user_encryption_key()).toBe(
CHANGE_KDF_VECTOR.rawCryptographicState.userKey,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
import type { Kdf } from "@bitwarden/sdk-internal";
import { isChangeKdfError, type ChangeKdfError } from "@bitwarden/sdk-internal";

import { NO_MASTER_PASSWORD_ACCOUNT } from "../fixtures/accounts";
import { unlockMethodFor, validateAfterLockUnlock } from "../model-server/validate";
import {
CHANGE_KDF_ROUTE,
NO_MASTER_PASSWORD_ACCOUNT,
CHANGE_KDF_TIMEOUT,
NEW_PBKDF2,
setupChangeKdf,
Expand Down Expand Up @@ -66,7 +66,7 @@ describe("change kdf", () => {
async () => {
// An account with no master password has no unlock data, and so nothing the change could
// be based on.
harness = await setupChangeKdf({ account: NO_MASTER_PASSWORD_ACCOUNT, vault: "empty" });
harness = await setupChangeKdf({ account: NO_MASTER_PASSWORD_ACCOUNT });
const { api, client, servers } = harness;
const before = servers.requests.length;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,14 +298,19 @@ function assertSomethingLeftToCompare(
* serde_json renders a Rust `None` as `null` while serde_wasm_bindgen renders it as `undefined`, so
* a raw comparison fails on every optional field while proving nothing. Dropping is recursive
* because a nested `revisionDate` is as server-owned as a top-level one.
*
* `inAttachments` scopes one exclusion. An `AttachmentView` carries `decryptedKey` — the attachment
* key in the clear — which a vector deliberately does not record, because a vector is committed to
* git. The scope matters: `decryptedKey` is also the variant tag of `InitUserCryptoMethod`, and
* dropping that would erase whole unlock methods.
*/
function normalize(value: unknown, ignore: readonly string[]): unknown {
function normalize(value: unknown, ignore: readonly string[], inAttachments = false): unknown {
if (value === null || value === undefined) {
return undefined;
}

if (Array.isArray(value)) {
return value.map((entry) => normalize(entry, ignore));
return value.map((entry) => normalize(entry, ignore, inAttachments));
}

if (typeof value !== "object") {
Expand All @@ -317,7 +322,14 @@ function normalize(value: unknown, ignore: readonly string[]): unknown {
if (ignore.includes(key)) {
continue;
}
const entry = normalize((value as Record<string, unknown>)[key], ignore);
if (inAttachments && key === "decryptedKey") {
continue;
}
const entry = normalize(
(value as Record<string, unknown>)[key],
ignore,
inAttachments || key === "attachments",
);
if (entry !== undefined) {
normalized[key] = entry;
}
Expand Down
Loading
Loading