1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 11:01:17 +00:00

[PM-29208] Remove individual cryptographic-key states & migrate key service (#18164)

* Remove inividual user key states and migrate to account cryptographic state

* Fix browser

* Fix tests

* Clean up migration

* Remove key-pair creation from login strategy

* Add clearing for the account cryptographic state

* Add migration

* Cleanup

* Fix linting
This commit is contained in:
Bernd Schoolmann
2026-02-09 12:39:55 +01:00
committed by jaasen-livefront
parent d718cf6cda
commit 89c9200552
43 changed files with 562 additions and 597 deletions

View File

@@ -278,13 +278,6 @@ describe("LoginDecryptionOptionsComponent", () => {
const expectedUserKey = new SymmetricCryptoKey(new Uint8Array(mockUserKeyBytes));
// Verify keys were set
expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId);
expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId);
expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId);
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
mockSecurityState,
mockUserId,
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
expect.objectContaining({
V2: {

View File

@@ -34,11 +34,6 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import {
SignedPublicKey,
SignedSecurityState,
WrappedSigningKey,
} from "@bitwarden/common/key-management/types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -322,23 +317,6 @@ export class LoginDecryptionOptionsComponent implements OnInit {
register_result.account_cryptographic_state,
userId,
);
// Legacy individual states
await this.keyService.setPrivateKey(
register_result.account_cryptographic_state.V2.private_key,
userId,
);
await this.keyService.setSignedPublicKey(
register_result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey,
userId,
);
await this.keyService.setUserSigningKey(
register_result.account_cryptographic_state.V2.signing_key as WrappedSigningKey,
userId,
);
await this.securityStateService.setAccountSecurityState(
register_result.account_cryptographic_state.V2.security_state as SignedSecurityState,
userId,
);
// TDE unlock
await this.deviceTrustService.setDeviceKey(

View File

@@ -180,7 +180,10 @@ describe("AuthRequestLoginStrategy", () => {
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId);
expect(deviceTrustService.trustDeviceIfRequired).toHaveBeenCalled();
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: tokenResponse.privateKey } },
mockUserId,
);
});
it("sets keys after a successful authentication when only userKey provided in login credentials", async () => {
@@ -207,7 +210,10 @@ describe("AuthRequestLoginStrategy", () => {
mockUserId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(decUserKey, mockUserId);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: tokenResponse.privateKey } },
mockUserId,
);
// trustDeviceIfRequired should be called
expect(deviceTrustService.trustDeviceIfRequired).not.toHaveBeenCalled();

View File

@@ -120,20 +120,14 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
}
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
exportCache(): CacheData {

View File

@@ -19,7 +19,6 @@ import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
MasterKeyWrappedUserKey,
@@ -38,15 +37,12 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import {
PasswordStrengthServiceAbstraction,
PasswordStrengthService,
} from "@bitwarden/common/tools/password-strength";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { LoginStrategyServiceAbstraction } from "../abstractions";
@@ -109,6 +105,12 @@ export function identityTokenResponseFactory(
token_type: "Bearer",
MasterPasswordPolicy: masterPasswordPolicyResponse,
UserDecryptionOptions: userDecryptionOptions || defaultUserDecryptionOptionsServerResponse,
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: privateKey,
publicKey: "PUBLIC_KEY",
},
},
});
}
@@ -201,19 +203,7 @@ describe("LoginStrategy", () => {
});
describe("base class", () => {
const userKeyBytesLength = 64;
const masterKeyBytesLength = 64;
let userKey: UserKey;
let masterKey: MasterKey;
beforeEach(() => {
userKey = new SymmetricCryptoKey(
new Uint8Array(userKeyBytesLength).buffer as CsprngArray,
) as UserKey;
masterKey = new SymmetricCryptoKey(
new Uint8Array(masterKeyBytesLength).buffer as CsprngArray,
) as MasterKey;
const mockVaultTimeoutAction = VaultTimeoutAction.Lock;
const mockVaultTimeoutActionBSub = new BehaviorSubject<VaultTimeoutAction>(
mockVaultTimeoutAction,
@@ -335,39 +325,6 @@ describe("LoginStrategy", () => {
userId,
);
});
it("makes a new public and private key for an old account", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.privateKey = null;
keyService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]);
keyService.userKey$.mockReturnValue(new BehaviorSubject<UserKey>(userKey).asObservable());
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await passwordLoginStrategy.logIn(credentials);
// User symmetric key must be set before the new RSA keypair is generated
expect(keyService.setUserKey).toHaveBeenCalled();
expect(keyService.makeKeyPair).toHaveBeenCalled();
expect(keyService.setUserKey.mock.invocationCallOrder[0]).toBeLessThan(
keyService.makeKeyPair.mock.invocationCallOrder[0],
);
expect(apiService.postAccountKeys).toHaveBeenCalled();
});
it("throws if userKey is CoseEncrypt0 (V2 encryption) in createKeyPairForOldAccount", async () => {
keyService.userKey$.mockReturnValue(
new BehaviorSubject<UserKey>({
inner: () => ({ type: 7 }),
} as unknown as UserKey).asObservable(),
);
await expect(passwordLoginStrategy["createKeyPairForOldAccount"](userId)).resolves.toBe(
undefined,
);
});
});
describe("Two-factor authentication", () => {

View File

@@ -25,7 +25,6 @@ import {
VaultTimeoutAction,
VaultTimeoutSettingsService,
} from "@bitwarden/common/key-management/vault-timeout";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -33,7 +32,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService, KdfConfigService } from "@bitwarden/key-management";
@@ -265,7 +263,7 @@ export abstract class LoginStrategy {
await this.setMasterKey(response, userId);
await this.setUserKey(response, userId);
await this.setPrivateKey(response, userId);
await this.setAccountCryptographicState(response, userId);
// This needs to run after the keys are set because it checks for the existence of the encrypted private key
await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
@@ -283,7 +281,10 @@ export abstract class LoginStrategy {
protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setPrivateKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void>;
// Old accounts used master key for encryption. We are forcing migrations but only need to
// check on password logins
@@ -315,28 +316,6 @@ export abstract class LoginStrategy {
return true;
}
protected async createKeyPairForOldAccount(userId: UserId) {
try {
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey === null) {
throw new Error("User key is null when creating key pair for old account");
}
if (userKey.inner().type == EncryptionType.CoseEncrypt0) {
throw new Error("Cannot create key pair for account on V2 encryption");
}
const [publicKey, privateKey] = await this.keyService.makeKeyPair(userKey);
if (!privateKey.encryptedString) {
throw new Error("Failed to create encrypted private key");
}
await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString));
return privateKey.encryptedString;
} catch (e) {
this.logService.error(e);
}
}
/**
* Handles the response from the server when a 2FA is required.
* It clears any existing 2FA token, as it's no longer valid, and sets up the necessary data for the 2FA process.

View File

@@ -216,7 +216,10 @@ describe("PasswordLoginStrategy", () => {
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: tokenResponse.privateKey } },
userId,
);
});
it("does not force the user to update their master password when there are no requirements", async () => {

View File

@@ -148,20 +148,14 @@ export class PasswordLoginStrategy extends LoginStrategy {
}
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
protected override encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean {

View File

@@ -196,13 +196,14 @@ describe("SsoLoginStrategy", () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.key = null;
tokenResponse.privateKey = null;
tokenResponse.accountKeysResponseModel = null;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLoginStrategy.logIn(credentials);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled();
});
it("sets master key encrypted user key for existing SSO users", async () => {

View File

@@ -335,7 +335,7 @@ export class SsoLoginStrategy extends LoginStrategy {
await this.keyService.setUserKey(userKey, userId);
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
tokenResponse: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
@@ -345,20 +345,6 @@ export class SsoLoginStrategy extends LoginStrategy {
userId,
);
}
if (tokenResponse.hasMasterKeyEncryptedUserKey()) {
// User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey
// Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair
// and so we don't want them falling into the createKeyPairForOldAccount flow
await this.keyService.setPrivateKey(
tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
} else if (tokenResponse.privateKey) {
// User doesn't have masterKeyEncryptedUserKey but they do have a userKeyEncryptedPrivateKey
// This is just existing TDE users or a TDE offboarder on an untrusted device
await this.keyService.setPrivateKey(tokenResponse.privateKey, userId);
}
}
exportCache(): CacheData {

View File

@@ -188,7 +188,10 @@ describe("UserApiLoginStrategy", () => {
tokenResponse.key,
userId,
);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: tokenResponse.privateKey } },
userId,
);
});
it("gets and sets the master key if Key Connector is enabled", async () => {

View File

@@ -79,20 +79,14 @@ export class UserApiLoginStrategy extends LoginStrategy {
}
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
// Overridden to save client ID and secret to token service

View File

@@ -263,7 +263,10 @@ describe("WebAuthnLoginStrategy", () => {
mockPrfPrivateKey,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey, userId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: idTokenResponse.privateKey } },
userId,
);
// Master key and private key should not be set
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();

View File

@@ -100,20 +100,14 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
}
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
exportCache(): CacheData {

View File

@@ -495,6 +495,12 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
},
},
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -562,6 +568,12 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
},
},
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -627,6 +639,12 @@ describe("LoginStrategyService", () => {
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
},
},
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -690,6 +708,12 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
},
},
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",