Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
// account into it — a sync from the server, and an unlock.

import type {
CryptoSyncData,
InitUserCryptoMethod,
PasswordManagerClient,
WasmStateBridge,
} from "@bitwarden/sdk-internal";

import { AccountKeysResponse, toKdf, type SyncResponse } from "../server-emulator/dto";
import type { ServerEmulator } from "../server-emulator/server-emulator";
import { API_URL } from "../server-emulator/urls";
import { asEncString, asKeyId } from "../tests/type-assertion-helpers";

import { LocalState, SETTINGS } from "./local-state";

Expand All @@ -27,37 +29,60 @@ export class ClientEmulator {
}

/**
* Simulates a sync from server to client
* Simulates a sync from server to client.
*/
async sync(email: string): Promise<void> {
const user = this.server.getUser(email);

this.local.setIdentity({ userId: user.userId, email: user.email });
this.local.organizationKeys = user.organizationKeys;

const data: CryptoSyncData = {
accountCryptographicState: user.accountCryptographicState,
const response = await fetch(`${API_URL}/sync`, {
headers: { Authorization: `Bearer ${user.userId}` },
});
if (!response.ok) {
throw new Error(`sync for ${email} answered ${response.status}`);
}

const synced: SyncResponse = await response.json();
const unlock = synced.userDecryption.masterPasswordUnlock;

const accountKeys = AccountKeysResponse.fromAccountKeysResponse(synced.profile.accountKeys);

const locked = makePasswordManagerClient(this.local.bridge, SETTINGS, user.userId);
await locked.crypto_sync_handler().on_sync({
accountCryptographicState: accountKeys.toAccountCryptographicState(),
userDecryption: {
...(user.masterPasswordUnlock === null
...(unlock === undefined
? {}
: {
masterPasswordUnlock: {
masterKeyWrappedUserKey: user.masterPasswordUnlock.masterKeyWrappedUserKey,
salt: user.masterPasswordUnlock.salt,
kdf: user.masterPasswordUnlock.kdf,
masterKeyWrappedUserKey: asEncString(unlock.masterKeyEncryptedUserKey),
salt: unlock.salt,
kdf: toKdf(unlock.kdf),
},
}),
...(synced.userDecryption.v2UpgradeToken === undefined
? {}
: {
v2UpgradeToken: {
wrapped_user_key_1: asEncString(
synced.userDecryption.v2UpgradeToken.wrappedUserKey1,
),
wrapped_user_key_2: asEncString(
synced.userDecryption.v2UpgradeToken.wrappedUserKey2,
),
},
}),
...(user.upgradeToken === undefined ? {} : { v2UpgradeToken: user.upgradeToken }),
...(user.userKeyId === undefined ? {} : { userKeyId: user.userKeyId }),
...(synced.userDecryption.userKeyId === undefined
? {}
: { userKeyId: asKeyId(synced.userDecryption.userKeyId) }),
},
};

const locked = makePasswordManagerClient(this.local.bridge, SETTINGS, user.userId);
await locked.crypto_sync_handler().on_sync(data);
});

// Quirk, the crypto sync handler writes the kdf only when the account has no master-password
// but clients always write it.
if (user.masterPasswordUnlock === null) {
// but clients always write it. The KDF a real client learns at `POST /accounts/prelogin`.
if (unlock === undefined) {
await this.local.bridge.set_kdf_config(user.kdf);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import type { Cipher, Folder } from "@bitwarden/sdk-internal";

import type { MockReply, Routes } from "./http-mock";

import { asKeyId, asString } from "../tests/type-assertion-helpers";
import {
asEncString,
asKeyId,
asSignedPublicKey,
asSignedSecurityState,
asString,
} from "../tests/type-assertion-helpers";

import { authenticatedRoute } from "./authentication";

Expand All @@ -27,7 +33,10 @@ import {
FolderRequest,
FolderResponse,
KdfType,
KeyRegenerationRequest,
KeyRotationDataResponse,
MasterPasswordUnlockDataModel,
RotateUserKeysRequest,
SyncResponse,
UserKeyIdRequest,
type ChangeKdfRequest,
Expand Down Expand Up @@ -55,6 +64,20 @@ export class ApiServer {
this.recordUserKeyId(user, request.json<UserKeyIdRequest>()),
),

"POST /accounts/key-management/regenerate-keys": authenticatedRoute(
this.db,
(user, request) => this.regenerateKeys(user, request.json<KeyRegenerationRequest>()),
),

"GET /accounts/key-management/key-rotation-data": authenticatedRoute(this.db, () => ({
json: KeyRotationDataResponse.empty(),
})),

"POST /accounts/key-management/rotate-user-keys": authenticatedRoute(
this.db,
(user, request) => this.rotateUserKeys(user, request.json<RotateUserKeysRequest>()),
),

"POST /ciphers": authenticatedRoute(this.db, (user, request) =>
this.createCipher(user, request.json<CipherRequest>(), []),
),
Expand Down Expand Up @@ -148,6 +171,104 @@ export class ApiServer {
return {};
}

/**
* Replaces a V1 account's public key encryption key pair.
*
* Only V1 accounts regenerate: a V2 account's public key is bound into its signed security
* state, so it cannot be swapped out on its own.
*/
private regenerateKeys(user: UserEntity, posted: KeyRegenerationRequest): MockReply {
if (!("V1" in user.accountCryptographicState)) {
return error(HTTP_BAD_REQUEST, "only a V1 account regenerates its key pair");
}

user.accountCryptographicState = {
V1: { private_key: asEncString(posted.userKeyEncryptedUserPrivateKey) },
};
user.publicKey = posted.userPublicKey;
this.db.revisions.next();

return {};
}

private rotateUserKeys(user: UserEntity, posted: RotateUserKeysRequest): MockReply {
const newUserKeyId = posted.newUserKeyId;
if (newUserKeyId === undefined || !KEY_ID_PATTERN.test(newUserKeyId)) {
return error(HTTP_BAD_REQUEST, `malformed new key id ${newUserKeyId}`);
}

const state = posted.wrappedAccountCryptographicState;
const { unlockMethod, masterPasswordUnlockData } = posted.unlockMethodData;
if (unlockMethod === "MasterPassword" && masterPasswordUnlockData === undefined) {
return error(HTTP_BAD_REQUEST, "master password unlock data required");
}

user.accountCryptographicState = {
V2: {
private_key: asEncString(state.publicKeyEncryptionKeyPair.wrappedPrivateKey),
signing_key: asEncString(state.signatureKeyPair.wrappedSigningKey),
security_state: asSignedSecurityState(state.securityState.securityState),
signed_public_key:
state.publicKeyEncryptionKeyPair.signedPublicKey === undefined
? undefined
: asSignedPublicKey(state.publicKeyEncryptionKeyPair.signedPublicKey),
},
};
user.publicKey = state.publicKeyEncryptionKeyPair.publicKey;
user.verifyingKey = state.signatureKeyPair.verifyingKey;
user.securityVersion = state.securityState.securityVersion;
user.userKeyId = asKeyId(newUserKeyId);

if (masterPasswordUnlockData !== undefined) {
user.masterPasswordUnlock = MasterPasswordUnlockDataModel.toStored(masterPasswordUnlockData);
}

// An upgrade token is only produced by a V1 to V2 rotation; a later rotation clears it.
const token = posted.unlockData.v2UpgradeToken;
if (token === undefined) {
delete user.upgradeToken;
} else {
user.upgradeToken = {
wrapped_user_key_1: asEncString(token.wrappedUserKey1),
wrapped_user_key_2: asEncString(token.wrappedUserKey2),
};
}

const now = this.db.revisions.next();
for (const cipher of posted.accountData.ciphers ?? []) {
const stored = this.db.ciphers.get(cipher.id);
if (stored === undefined) {
return error(HTTP_NOT_FOUND, `no cipher ${cipher.id} to re-encrypt`);
}
Comment on lines +238 to +242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ DEBT: The rotation's cipher and folder writes skip the ownership check every other write in this file applies.

Details and fix

rotateUserKeys resolves items with this.db.ciphers.get(...) and this.db.folders.get(...), while updateCipher goes through reachableCipher and updateFolder compares stored.userId !== user.userId. So a rotation payload that carries an organization cipher — which crates/bitwarden-user-crypto-management/src/key_rotation/data.rs documents must never happen ("Ciphers must be filtered to just contain the user's ciphers, not organization ciphers") — is accepted here and rewrites the org-owned entity under the new user key. The real server refuses that, so a regression in the SDK's filter would still pass this suite.

Suggested fix: resolve each posted cipher with this.reachableCipher(user, cipher.id) and reject one whose userId !== user.userId; apply the same userId check to folders.

While in there: the key, unlock and upgrade-token writes above happen before these lookups, so a 404 on a posted item leaves the account rotated with a partially re-encrypted vault. Validating every referenced item first keeps a rejected rotation from mutating anything.


this.db.ciphers.update(cipher.id, {
...stored,
cipher: CipherRequest.toCipher(cipher, stored.cipher, {
id: cipher.id,
organizationId: stored.organizationId,
creationDate: stored.cipher.creationDate,
revisionDate: now,
deletedDate: stored.cipher.deletedDate ?? null,
collectionIds: stored.cipher.collectionIds.map(asString),
}),
});
}

for (const folder of posted.accountData.folders ?? []) {
const stored = this.db.folders.get(folder.id);
if (stored === undefined) {
return error(HTTP_NOT_FOUND, `no folder ${folder.id} to re-encrypt`);
}

this.db.folders.update(folder.id, {
...stored,
folder: FolderRequest.toFolder(folder, folder.id, now),
});
}

return {};
}

/**
* Records the key id of an account's user key, which a backfill supplies once.
*
Expand Down
Loading
Loading