1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-06-29 11:05:54 +02:00

[PM-5537] Persist require password on startup through logout (#7825)

* Persist require password on startup through logout

* Test new methods
This commit is contained in:
Matt Gibson 2024-02-07 10:39:54 -05:00 committed by GitHub
parent 0eb9e760aa
commit 2ca34b46db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 161 additions and 121 deletions

View File

@ -12,6 +12,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DeviceType } from "@bitwarden/common/enums";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -22,7 +23,6 @@ import { DialogService } from "@bitwarden/components";
import { SetPinComponent } from "../../auth/components/set-pin.component";
import { flagEnabled } from "../../platform/flags";
import { ElectronCryptoService } from "../../platform/services/electron-crypto.service";
import { ElectronStateService } from "../../platform/services/electron-state.service.abstraction";
@Component({
selector: "app-settings",
@ -113,7 +113,7 @@ export class SettingsComponent implements OnInit {
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private stateService: ElectronStateService,
private messagingService: MessagingService,
private cryptoService: ElectronCryptoService,
private cryptoService: CryptoService,
private modalService: ModalService,
private themingService: AbstractThemingService,
private settingsService: SettingsService,
@ -457,7 +457,7 @@ export class SettingsComponent implements OnInit {
this.form.controls.requirePasswordOnStart.setValue(true);
this.form.controls.autoPromptBiometrics.setValue(false);
await this.stateService.setDisableAutoBiometricsPrompt(true);
await this.cryptoService.setBiometricClientKeyHalf();
await this.biometricStateService.setRequirePasswordOnStart(true);
await this.stateService.setDismissedBiometricRequirePasswordOnStart();
}
await this.cryptoService.refreshAdditionalKeys();
@ -491,9 +491,9 @@ export class SettingsComponent implements OnInit {
this.form.controls.autoPromptBiometrics.setValue(false);
await this.updateAutoPromptBiometrics();
await this.cryptoService.setBiometricClientKeyHalf();
await this.biometricStateService.setRequirePasswordOnStart(true);
} else {
await this.cryptoService.removeBiometricClientKeyHalf();
await this.biometricStateService.setRequirePasswordOnStart(false);
}
await this.stateService.setDismissedBiometricRequirePasswordOnStart();
await this.cryptoService.refreshAdditionalKeys();

View File

@ -48,10 +48,7 @@ import { DialogService } from "@bitwarden/components";
import { LoginGuard } from "../../auth/guards/login.guard";
import { Account } from "../../models/account";
import {
DefaultElectronCryptoService,
ElectronCryptoService,
} from "../../platform/services/electron-crypto.service";
import { ElectronCryptoService } from "../../platform/services/electron-crypto.service";
import { ElectronLogService } from "../../platform/services/electron-log.service";
import { ElectronPlatformUtilsService } from "../../platform/services/electron-platform-utils.service";
import { ElectronRendererMessagingService } from "../../platform/services/electron-renderer-messaging.service";
@ -182,11 +179,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
},
{
provide: CryptoServiceAbstraction,
useExisting: ElectronCryptoService,
},
{
provide: ElectronCryptoService,
useClass: DefaultElectronCryptoService,
useClass: ElectronCryptoService,
deps: [
CryptoFunctionServiceAbstraction,
EncryptService,

View File

@ -25,7 +25,6 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { DialogService } from "@bitwarden/components";
import { ElectronCryptoService } from "../platform/services/electron-crypto.service";
import { ElectronStateService } from "../platform/services/electron-state.service.abstraction";
import { LockComponent } from "./lock.component";
@ -80,11 +79,7 @@ describe("LockComponent", () => {
},
{
provide: CryptoService,
useExisting: ElectronCryptoService,
},
{
provide: ElectronCryptoService,
useValue: mock<ElectronCryptoService>(),
useValue: mock<CryptoService>(),
},
{
provide: VaultTimeoutService,

View File

@ -13,15 +13,16 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DeviceType } from "@bitwarden/common/enums";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
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 { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { DialogService } from "@bitwarden/components";
import { ElectronCryptoService } from "../platform/services/electron-crypto.service";
import { ElectronStateService } from "../platform/services/electron-state.service.abstraction";
const BroadcasterSubscriptionId = "LockComponent";
@ -41,7 +42,7 @@ export class LockComponent extends BaseLockComponent {
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
messagingService: MessagingService,
protected override cryptoService: ElectronCryptoService,
cryptoService: CryptoService,
vaultTimeoutService: VaultTimeoutService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
environmentService: EnvironmentService,
@ -58,6 +59,7 @@ export class LockComponent extends BaseLockComponent {
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
userVerificationService: UserVerificationService,
pinCryptoService: PinCryptoServiceAbstraction,
private biometricStateService: BiometricStateService,
) {
super(
router,
@ -175,8 +177,8 @@ export class LockComponent extends BaseLockComponent {
type: "warning",
});
await this.biometricStateService.setRequirePasswordOnStart(response);
if (response) {
await this.cryptoService.setBiometricClientKeyHalf();
await this.stateService.setDisableAutoBiometricsPrompt(true);
}
this.supportsBiometric = await this.canUseBiometric();

View File

@ -6,9 +6,8 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { makeEncString, makeStaticByteArray } from "@bitwarden/common/spec";
import { makeEncString } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
@ -18,11 +17,11 @@ import {
mockAccountServiceWith,
} from "../../../../../libs/common/spec/fake-account-service";
import { DefaultElectronCryptoService } from "./electron-crypto.service";
import { ElectronCryptoService } from "./electron-crypto.service";
import { ElectronStateService } from "./electron-state.service.abstraction";
describe("electronCryptoService", () => {
let sut: DefaultElectronCryptoService;
let sut: ElectronCryptoService;
const cryptoFunctionService = mock<CryptoFunctionService>();
const encryptService = mock<EncryptService>();
@ -39,7 +38,7 @@ describe("electronCryptoService", () => {
accountService = mockAccountServiceWith("userId" as UserId);
stateProvider = new FakeStateProvider(accountService);
sut = new DefaultElectronCryptoService(
sut = new ElectronCryptoService(
cryptoFunctionService,
encryptService,
platformUtilService,
@ -55,44 +54,6 @@ describe("electronCryptoService", () => {
jest.resetAllMocks();
});
describe("setBiometricClientKeyHalf", () => {
const userKey = new SymmetricCryptoKey(makeStaticByteArray(64, 1)) as UserKey;
const keyBytes = makeStaticByteArray(32, 2) as CsprngArray;
const encKeyHalf = makeEncString(Utils.fromBufferToUtf8(keyBytes));
beforeEach(() => {
sut.getUserKey = jest.fn().mockResolvedValue(userKey);
cryptoFunctionService.randomBytes.mockResolvedValue(keyBytes);
encryptService.encrypt.mockResolvedValue(encKeyHalf);
});
it("sets a biometric client key half for the currently active user", async () => {
await sut.setBiometricClientKeyHalf();
expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith(encKeyHalf);
});
it("should create the key from csprng bytes", async () => {
await sut.setBiometricClientKeyHalf();
expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32);
});
it("should encrypt the key half with the user key", async () => {
await sut.setBiometricClientKeyHalf();
expect(encryptService.encrypt).toHaveBeenCalledWith(expect.any(String), userKey);
});
});
describe("removeBiometricClientKeyHalf", () => {
it("removes the biometric client key half for the currently active user", async () => {
await sut.removeBiometricClientKeyHalf();
expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith(null);
});
});
describe("setUserKey", () => {
let mockUserKey: UserKey;
@ -102,15 +63,23 @@ describe("electronCryptoService", () => {
});
describe("Biometric Key refresh", () => {
const encClientKeyHalf = makeEncString();
const decClientKeyHalf = "decrypted client key half";
beforeEach(() => {
encClientKeyHalf.decrypt = jest.fn().mockResolvedValue(decClientKeyHalf);
});
it("sets an Biometric key if getBiometricUnlock is true and the platform supports secure storage", async () => {
stateService.getBiometricUnlock.mockResolvedValue(true);
platformUtilService.supportsSecureStorage.mockReturnValue(true);
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue(encClientKeyHalf);
await sut.setUserKey(mockUserKey, mockUserId);
expect(stateService.setUserKeyBiometric).toHaveBeenCalledWith(
expect.objectContaining({ key: expect.any(String), clientEncKeyHalf: null }),
expect.objectContaining({ key: expect.any(String), clientEncKeyHalf: decClientKeyHalf }),
{
userId: mockUserId,
},

View File

@ -1,5 +1,3 @@
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@ -18,18 +16,7 @@ import { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { ElectronStateService } from "./electron-state.service.abstraction";
export abstract class ElectronCryptoService extends CryptoService {
/**
* Creates and sets a new biometric client key half for the currently active user.
*/
abstract setBiometricClientKeyHalf(): Promise<void>;
/**
* Removes the biometric client key half for the currently active user.
*/
abstract removeBiometricClientKeyHalf(): Promise<void>;
}
export class DefaultElectronCryptoService extends ElectronCryptoService {
export class ElectronCryptoService extends CryptoService {
constructor(
cryptoFunctionService: CryptoFunctionService,
encryptService: EncryptService,
@ -72,19 +59,6 @@ export class DefaultElectronCryptoService extends ElectronCryptoService {
await super.clearStoredUserKey(keySuffix, userId);
}
async setBiometricClientKeyHalf(): Promise<void> {
const userKey = await this.getUserKey();
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
const biometricKey = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
const encKey = await this.encryptService.encrypt(biometricKey, userKey);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey);
}
async removeBiometricClientKeyHalf(): Promise<void> {
await this.biometricStateService.setEncryptedClientKeyHalf(null);
}
protected override async storeAdditionalKeys(key: UserKey, userId?: UserId) {
await super.storeAdditionalKeys(key, userId);
@ -112,7 +86,7 @@ export class DefaultElectronCryptoService extends ElectronCryptoService {
protected async storeBiometricKey(key: UserKey, userId?: UserId): Promise<void> {
// May resolve to null, in which case no client key have is required
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userId);
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(key, userId);
await this.stateService.setUserKeyBiometric(
{ key: key.keyB64, clientEncKeyHalf },
{ userId: userId },
@ -132,17 +106,29 @@ export class DefaultElectronCryptoService extends ElectronCryptoService {
await super.clearAllStoredUserKeys(userId);
}
private async getBiometricEncryptionClientKeyHalf(userId?: UserId): Promise<CsprngString | null> {
const encryptedKeyHalfPromise =
userId == null
? firstValueFrom(this.biometricStateService.encryptedClientKeyHalf$)
: this.biometricStateService.getEncryptedClientKeyHalf(userId);
const encryptedKeyHalf = await encryptedKeyHalfPromise;
if (encryptedKeyHalf == null) {
private async getBiometricEncryptionClientKeyHalf(
userKey: UserKey,
userId: UserId,
): Promise<CsprngString | null> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return null;
}
const userKey = await this.getUserKey();
return (await this.encryptService.decryptToUtf8(encryptedKeyHalf, userKey)) as CsprngString;
// Retrieve existing key half if it exists
let biometricKey = await this.biometricStateService
.getEncryptedClientKeyHalf(userId)
.then((result) => result?.decrypt(null /* user encrypted */, userKey))
.then((result) => result as CsprngString);
if (biometricKey == null && userKey != null) {
// Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
biometricKey = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
const encKey = await this.encryptService.encrypt(biometricKey, userKey);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}
return biometricKey;
}
// --LEGACY METHODS--

View File

@ -2,11 +2,13 @@ import { firstValueFrom } from "rxjs";
import { makeEncString } from "../../../spec";
import { mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeSingleUserState } from "../../../spec/fake-state";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { UserId } from "../../types/guid";
import { EncryptedString } from "../models/domain/enc-string";
import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service";
import { ENCRYPTED_CLIENT_KEY_HALF } from "./biometric.state";
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
describe("BiometricStateService", () => {
let sut: BiometricStateService;
@ -27,13 +29,14 @@ describe("BiometricStateService", () => {
});
describe("requirePasswordOnStart$", () => {
it("should be false when encryptedClientKeyHalf is undefined", async () => {
stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF).nextState(undefined);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false);
});
it("should track the requirePasswordOnStart state", async () => {
const state = stateProvider.activeUser.getFake(REQUIRE_PASSWORD_ON_START);
state.nextState(undefined);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false);
state.nextState(true);
it("should be true when encryptedClientKeyHalf is defined", async () => {
stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF).nextState(encryptedClientKeyHalf);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(true);
});
});
@ -58,4 +61,39 @@ describe("BiometricStateService", () => {
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
});
});
describe("setRequirePasswordOnStart", () => {
it("should update the requirePasswordOnStart$", async () => {
await sut.setRequirePasswordOnStart(true);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(true);
});
it("should remove the encryptedClientKeyHalf if the value is false", async () => {
await sut.setEncryptedClientKeyHalf(encClientKeyHalf, userId);
await sut.setRequirePasswordOnStart(false);
const keyHalfState = stateProvider.getUser(
userId,
ENCRYPTED_CLIENT_KEY_HALF,
) as FakeSingleUserState<EncryptedString>;
expect(await firstValueFrom(keyHalfState.state$)).toBe(null);
expect(keyHalfState.nextMock).toHaveBeenCalledWith(null);
});
it("should not remove the encryptedClientKeyHalf if the value is true", async () => {
await sut.setEncryptedClientKeyHalf(encClientKeyHalf);
await sut.setRequirePasswordOnStart(true);
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
});
});
describe("getRequirePasswordOnStart", () => {
it("should return the requirePasswordOnStart value", async () => {
stateProvider.singleUser.mockFor(userId, REQUIRE_PASSWORD_ON_START.key, true);
expect(await sut.getRequirePasswordOnStart(userId)).toBe(true);
});
});
});

View File

@ -4,7 +4,7 @@ import { UserId } from "../../types/guid";
import { EncryptedString, EncString } from "../models/domain/enc-string";
import { ActiveUserState, StateProvider } from "../state";
import { ENCRYPTED_CLIENT_KEY_HALF } from "./biometric.state";
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
export abstract class BiometricStateService {
/**
@ -21,27 +21,60 @@ export abstract class BiometricStateService {
*/
requirePasswordOnStart$: Observable<boolean>;
abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString): Promise<void>;
/**
* Updates the require password on start state for the currently active user.
*
* If false, the encrypted client key half will be removed.
* @param value whether or not a password is required on first unlock after opening the application
*/
abstract setRequirePasswordOnStart(value: boolean): Promise<void>;
abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise<void>;
abstract getEncryptedClientKeyHalf(userId: UserId): Promise<EncString>;
abstract getRequirePasswordOnStart(userId: UserId): Promise<boolean>;
abstract removeEncryptedClientKeyHalf(userId: UserId): Promise<void>;
}
export class DefaultBiometricStateService implements BiometricStateService {
private requirePasswordOnStartState: ActiveUserState<boolean>;
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
encryptedClientKeyHalf$: Observable<EncString | undefined>;
requirePasswordOnStart$: Observable<boolean>;
constructor(private stateProvider: StateProvider) {
this.requirePasswordOnStartState = this.stateProvider.getActive(REQUIRE_PASSWORD_ON_START);
this.requirePasswordOnStart$ = this.requirePasswordOnStartState.state$.pipe(
map((value) => !!value),
);
this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF);
this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe(
map(encryptedClientKeyHalfToEncString),
);
this.requirePasswordOnStart$ = this.encryptedClientKeyHalf$.pipe(map((keyHalf) => !!keyHalf));
}
async setEncryptedClientKeyHalf(encryptedKeyHalf: EncString): Promise<void> {
await this.encryptedClientKeyHalfState.update(() => encryptedKeyHalf?.encryptedString ?? null);
async setRequirePasswordOnStart(value: boolean): Promise<void> {
let currentActiveId: UserId;
await this.requirePasswordOnStartState.update(
(_, [userId]) => {
currentActiveId = userId;
return value;
},
{
combineLatestWith: this.requirePasswordOnStartState.combinedState$,
},
);
if (!value) {
await this.removeEncryptedClientKeyHalf(currentActiveId);
}
}
async setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise<void> {
const value = encryptedKeyHalf?.encryptedString ?? null;
if (userId) {
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => value);
} else {
await this.encryptedClientKeyHalfState.update(() => value);
}
}
async removeEncryptedClientKeyHalf(userId: UserId): Promise<void> {
@ -49,10 +82,9 @@ export class DefaultBiometricStateService implements BiometricStateService {
}
async getRequirePasswordOnStart(userId: UserId): Promise<boolean> {
if (userId == null) {
return false;
}
return !!(await this.getEncryptedClientKeyHalf(userId));
return !!(await firstValueFrom(
this.stateProvider.getUser(userId, REQUIRE_PASSWORD_ON_START).state$,
));
}
async getEncryptedClientKeyHalf(userId: UserId): Promise<EncString> {

View File

@ -1,4 +1,16 @@
import { ENCRYPTED_CLIENT_KEY_HALF } from "./biometric.state";
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
describe("require password on start", () => {
const sut = REQUIRE_PASSWORD_ON_START;
it("should deserialize require password on start state", () => {
const requirePasswordOnStart = "requirePasswordOnStart";
const result = sut.deserializer(JSON.parse(JSON.stringify(requirePasswordOnStart)));
expect(result).toEqual(requirePasswordOnStart);
});
});
describe("encrypted client key half", () => {
const sut = ENCRYPTED_CLIENT_KEY_HALF;

View File

@ -1,6 +1,19 @@
import { EncryptedString } from "../models/domain/enc-string";
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state";
/**
* Boolean indicating the user has elected to require a password to use their biometric key upon starting the application.
*
* A true setting controls whether {@link ENCRYPTED_CLIENT_KEY_HALF} is set.
*/
export const REQUIRE_PASSWORD_ON_START = new KeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK,
"requirePasswordOnStart",
{
deserializer: (value) => value,
},
);
/**
* If the user has elected to require a password on first unlock of an application instance, this key will store the
* encrypted client key half used to unlock the vault.