1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-21705] Require userID for refreshAdditionalKeys() on key-service (#14810)

* Require userID for refreshAdditionalKeys()

* Add error handling to desktop Unlock settings

* Add more unit test coverage
This commit is contained in:
Thomas Avery
2025-06-06 13:38:25 -05:00
committed by GitHub
parent 3e4c37b8b3
commit 9d743a7ee0
8 changed files with 553 additions and 76 deletions

View File

@@ -25,6 +25,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { MessageSender } from "@bitwarden/common/platform/messaging"; import { MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state"; import { StateProvider } from "@bitwarden/common/platform/state";
@@ -34,6 +35,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management"; import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service"; import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service";
@@ -55,6 +58,10 @@ describe("AccountSecurityComponent", () => {
const biometricStateService = mock<BiometricStateService>(); const biometricStateService = mock<BiometricStateService>();
const policyService = mock<PolicyService>(); const policyService = mock<PolicyService>();
const pinServiceAbstraction = mock<PinServiceAbstraction>(); const pinServiceAbstraction = mock<PinServiceAbstraction>();
const keyService = mock<KeyService>();
const validationService = mock<ValidationService>();
const dialogService = mock<DialogService>();
const platformUtilsService = mock<PlatformUtilsService>();
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -63,13 +70,13 @@ describe("AccountSecurityComponent", () => {
{ provide: AccountSecurityComponent, useValue: mock<AccountSecurityComponent>() }, { provide: AccountSecurityComponent, useValue: mock<AccountSecurityComponent>() },
{ provide: BiometricsService, useValue: mock<BiometricsService>() }, { provide: BiometricsService, useValue: mock<BiometricsService>() },
{ provide: BiometricStateService, useValue: biometricStateService }, { provide: BiometricStateService, useValue: biometricStateService },
{ provide: DialogService, useValue: mock<DialogService>() }, { provide: DialogService, useValue: dialogService },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() }, { provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: I18nService, useValue: mock<I18nService>() }, { provide: I18nService, useValue: mock<I18nService>() },
{ provide: MessageSender, useValue: mock<MessageSender>() }, { provide: MessageSender, useValue: mock<MessageSender>() },
{ provide: KeyService, useValue: mock<KeyService>() }, { provide: KeyService, useValue: keyService },
{ provide: PinServiceAbstraction, useValue: pinServiceAbstraction }, { provide: PinServiceAbstraction, useValue: pinServiceAbstraction },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() }, { provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: PolicyService, useValue: policyService }, { provide: PolicyService, useValue: policyService },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() }, { provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: StateService, useValue: mock<StateService>() }, { provide: StateService, useValue: mock<StateService>() },
@@ -84,14 +91,17 @@ describe("AccountSecurityComponent", () => {
{ provide: OrganizationService, useValue: mock<OrganizationService>() }, { provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() }, { provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ConfigService, useValue: mock<ConfigService>() }, { provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: ValidationService, useValue: validationService },
], ],
}) })
.overrideComponent(AccountSecurityComponent, { .overrideComponent(AccountSecurityComponent, {
remove: { remove: {
imports: [PopOutComponent], imports: [PopOutComponent],
providers: [DialogService],
}, },
add: { add: {
imports: [MockPopOutComponent], imports: [MockPopOutComponent],
providers: [{ provide: DialogService, useValue: dialogService }],
}, },
}) })
.compileComponents(); .compileComponents();
@@ -106,10 +116,17 @@ describe("AccountSecurityComponent", () => {
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue( vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
of(VaultTimeoutAction.Lock), of(VaultTimeoutAction.Lock),
); );
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
of(VaultTimeoutAction.Lock),
);
biometricStateService.promptAutomatically$ = of(false); biometricStateService.promptAutomatically$ = of(false);
pinServiceAbstraction.isPinSet.mockResolvedValue(false); pinServiceAbstraction.isPinSet.mockResolvedValue(false);
}); });
afterEach(() => {
jest.resetAllMocks();
});
it("pin enabled when RemoveUnlockWithPin policy is not set", async () => { it("pin enabled when RemoveUnlockWithPin policy is not set", async () => {
// @ts-strict-ignore // @ts-strict-ignore
policyService.policiesByType$.mockReturnValue(of([null])); policyService.policiesByType$.mockReturnValue(of([null]));
@@ -211,4 +228,136 @@ describe("AccountSecurityComponent", () => {
const pinInputElement = fixture.debugElement.query(By.css("#pin")); const pinInputElement = fixture.debugElement.query(By.css("#pin"));
expect(pinInputElement).toBeNull(); expect(pinInputElement).toBeNull();
}); });
describe("updateBiometric", () => {
let browserApiSpy: jest.SpyInstance;
beforeEach(() => {
policyService.policiesByType$.mockReturnValue(of([null]));
browserApiSpy = jest.spyOn(BrowserApi, "requestPermission");
browserApiSpy.mockResolvedValue(true);
});
describe("updating to false", () => {
it("calls biometricStateService methods with false when false", async () => {
await component.ngOnInit();
await component.updateBiometric(false);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(false);
expect(biometricStateService.setFingerprintValidated).toHaveBeenCalledWith(false);
});
});
describe("updating to true", () => {
let trySetupBiometricsSpy: jest.SpyInstance;
beforeEach(() => {
trySetupBiometricsSpy = jest.spyOn(component, "trySetupBiometrics");
});
it("displays permission error dialog when nativeMessaging permission is not granted", async () => {
browserApiSpy.mockResolvedValue(false);
await component.ngOnInit();
await component.updateBiometric(true);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "nativeMessaginPermissionErrorTitle" },
content: { key: "nativeMessaginPermissionErrorDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
expect(component.form.controls.biometric.value).toBe(false);
expect(trySetupBiometricsSpy).not.toHaveBeenCalled();
});
it("displays a specific sidebar dialog when nativeMessaging permissions throws an error on firefox + sidebar", async () => {
browserApiSpy.mockRejectedValue(new Error("Permission denied"));
platformUtilsService.isFirefox.mockReturnValue(true);
jest.spyOn(BrowserPopupUtils, "inSidebar").mockReturnValue(true);
await component.ngOnInit();
await component.updateBiometric(true);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "nativeMessaginPermissionSidebarTitle" },
content: { key: "nativeMessaginPermissionSidebarDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "info",
});
expect(component.form.controls.biometric.value).toBe(false);
expect(trySetupBiometricsSpy).not.toHaveBeenCalled();
});
test.each([
[false, false],
[false, true],
[true, false],
])(
"displays a generic dialog when nativeMessaging permissions throws an error and isFirefox is %s and onSidebar is %s",
async (isFirefox, inSidebar) => {
browserApiSpy.mockRejectedValue(new Error("Permission denied"));
platformUtilsService.isFirefox.mockReturnValue(isFirefox);
jest.spyOn(BrowserPopupUtils, "inSidebar").mockReturnValue(inSidebar);
await component.ngOnInit();
await component.updateBiometric(true);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "nativeMessaginPermissionErrorTitle" },
content: { key: "nativeMessaginPermissionErrorDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
expect(component.form.controls.biometric.value).toBe(false);
expect(trySetupBiometricsSpy).not.toHaveBeenCalled();
},
);
it("refreshes additional keys and attempts to setup biometrics when enabled with nativeMessaging permission", async () => {
const setupBiometricsResult = true;
trySetupBiometricsSpy.mockResolvedValue(setupBiometricsResult);
await component.ngOnInit();
await component.updateBiometric(true);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(
setupBiometricsResult,
);
expect(component.form.controls.biometric.value).toBe(setupBiometricsResult);
});
it("handles failed biometrics setup", async () => {
const setupBiometricsResult = false;
trySetupBiometricsSpy.mockResolvedValue(setupBiometricsResult);
await component.ngOnInit();
await component.updateBiometric(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(
setupBiometricsResult,
);
expect(biometricStateService.setFingerprintValidated).toHaveBeenCalledWith(
setupBiometricsResult,
);
expect(component.form.controls.biometric.value).toBe(setupBiometricsResult);
});
it("handles error during biometrics setup", async () => {
// Simulate an error during biometrics setup
keyService.refreshAdditionalKeys.mockRejectedValue(new Error("UserId is required"));
await component.ngOnInit();
await component.updateBiometric(true);
expect(validationService.showError).toHaveBeenCalledWith(new Error("UserId is required"));
expect(component.form.controls.biometric.value).toBe(false);
expect(trySetupBiometricsSpy).not.toHaveBeenCalled();
});
});
});
}); });

View File

@@ -45,6 +45,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { import {
DialogRef, DialogRef,
CardComponent, CardComponent,
@@ -153,6 +154,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
private toastService: ToastService, private toastService: ToastService,
private biometricsService: BiometricsService, private biometricsService: BiometricsService,
private vaultNudgesService: NudgesService, private vaultNudgesService: NudgesService,
private validationService: ValidationService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -520,13 +522,19 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
return; return;
} }
await this.keyService.refreshAdditionalKeys(); try {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.keyService.refreshAdditionalKeys(userId);
const successful = await this.trySetupBiometrics(); const successful = await this.trySetupBiometrics();
this.form.controls.biometric.setValue(successful); this.form.controls.biometric.setValue(successful);
await this.biometricStateService.setBiometricUnlockEnabled(successful); await this.biometricStateService.setBiometricUnlockEnabled(successful);
if (!successful) { if (!successful) {
await this.biometricStateService.setFingerprintValidated(false); await this.biometricStateService.setFingerprintValidated(false);
}
} catch (error) {
this.form.controls.biometric.setValue(false);
this.validationService.showError(error);
} }
} else { } else {
await this.biometricStateService.setBiometricUnlockEnabled(false); await this.biometricStateService.setBiometricUnlockEnabled(false);

View File

@@ -22,17 +22,20 @@ import {
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeType } from "@bitwarden/common/platform/enums";
import { MessageSender } from "@bitwarden/common/platform/messaging"; import { MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components"; import { DialogRef, DialogService } from "@bitwarden/components";
import { BiometricStateService, BiometricsStatus, KeyService } from "@bitwarden/key-management"; import { BiometricStateService, BiometricsStatus, KeyService } from "@bitwarden/key-management";
import { SetPinComponent } from "../../auth/components/set-pin.component";
import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting"; import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service"; import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
@@ -60,6 +63,11 @@ describe("SettingsComponent", () => {
const pinServiceAbstraction = mock<PinServiceAbstraction>(); const pinServiceAbstraction = mock<PinServiceAbstraction>();
const desktopBiometricsService = mock<DesktopBiometricsService>(); const desktopBiometricsService = mock<DesktopBiometricsService>();
const platformUtilsService = mock<PlatformUtilsService>(); const platformUtilsService = mock<PlatformUtilsService>();
const logService = mock<LogService>();
const validationService = mock<ValidationService>();
const messagingService = mock<MessagingService>();
const keyService = mock<KeyService>();
const dialogService = mock<DialogService>();
beforeEach(async () => { beforeEach(async () => {
originalIpc = (global as any).ipc; originalIpc = (global as any).ipc;
@@ -95,15 +103,15 @@ describe("SettingsComponent", () => {
{ provide: DesktopBiometricsService, useValue: desktopBiometricsService }, { provide: DesktopBiometricsService, useValue: desktopBiometricsService },
{ provide: DesktopSettingsService, useValue: desktopSettingsService }, { provide: DesktopSettingsService, useValue: desktopSettingsService },
{ provide: DomainSettingsService, useValue: domainSettingsService }, { provide: DomainSettingsService, useValue: domainSettingsService },
{ provide: DialogService, useValue: mock<DialogService>() }, { provide: DialogService, useValue: dialogService },
{ provide: I18nService, useValue: i18nService }, { provide: I18nService, useValue: i18nService },
{ provide: LogService, useValue: mock<LogService>() }, { provide: LogService, useValue: logService },
{ provide: MessageSender, useValue: mock<MessageSender>() }, { provide: MessageSender, useValue: mock<MessageSender>() },
{ {
provide: NativeMessagingManifestService, provide: NativeMessagingManifestService,
useValue: mock<NativeMessagingManifestService>(), useValue: mock<NativeMessagingManifestService>(),
}, },
{ provide: KeyService, useValue: mock<KeyService>() }, { provide: KeyService, useValue: keyService },
{ provide: PinServiceAbstraction, useValue: pinServiceAbstraction }, { provide: PinServiceAbstraction, useValue: pinServiceAbstraction },
{ provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: PolicyService, useValue: policyService }, { provide: PolicyService, useValue: policyService },
@@ -111,6 +119,8 @@ describe("SettingsComponent", () => {
{ provide: ThemeStateService, useValue: themeStateService }, { provide: ThemeStateService, useValue: themeStateService },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() }, { provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService }, { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
{ provide: ValidationService, useValue: validationService },
{ provide: MessagingService, useValue: messagingService },
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
@@ -324,4 +334,261 @@ describe("SettingsComponent", () => {
expect(textNodes).toContain("Require password on app start"); expect(textNodes).toContain("Require password on app start");
}); });
}); });
describe("updatePinHandler", () => {
afterEach(() => {
jest.resetAllMocks();
});
test.each([true, false])(`handles thrown errors when updated pin to %s`, async (update) => {
const error = new Error("Test error");
jest.spyOn(component, "updatePin").mockRejectedValue(error);
await component.ngOnInit();
await component.updatePinHandler(update);
expect(logService.error).toHaveBeenCalled();
expect(component.form.controls.pin.value).toBe(!update);
expect(validationService.showError).toHaveBeenCalledWith(error);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
describe("when updating to true", () => {
it("sets pin form control to false when the PIN dialog is cancelled", async () => {
jest.spyOn(SetPinComponent, "open").mockReturnValue(null);
await component.ngOnInit();
await component.updatePinHandler(true);
expect(component.form.controls.pin.value).toBe(false);
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
test.each([true, false])(
`sets the pin form control to the dialog result`,
async (dialogResult) => {
const mockDialogRef = {
closed: of(dialogResult),
} as DialogRef<boolean>;
jest.spyOn(SetPinComponent, "open").mockReturnValue(mockDialogRef);
await component.ngOnInit();
await component.updatePinHandler(true);
expect(component.form.controls.pin.value).toBe(dialogResult);
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
},
);
});
describe("when updating to false", () => {
let updateRequirePasswordOnStartSpy: jest.SpyInstance;
beforeEach(() => {
updateRequirePasswordOnStartSpy = jest
.spyOn(component, "updateRequirePasswordOnStart")
.mockImplementation(() => Promise.resolve());
});
it("updates requires password on start when the user doesn't have a MP and has requirePasswordOnStart on", async () => {
await component.ngOnInit();
component.form.controls.requirePasswordOnStart.setValue(true, { emitEvent: false });
component.userHasMasterPassword = false;
await component.updatePinHandler(false);
expect(component.form.controls.pin.value).toBe(false);
expect(component.form.controls.requirePasswordOnStart.value).toBe(false);
expect(updateRequirePasswordOnStartSpy).toHaveBeenCalled();
expect(vaultTimeoutSettingsService.clear).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
test.each([
[true, true],
[false, true],
[false, false],
])(
`doesn't updates requires password on start when the user's requirePasswordOnStart is %s and userHasMasterPassword is %s`,
async (requirePasswordOnStart, userHasMasterPassword) => {
await component.ngOnInit();
component.form.controls.requirePasswordOnStart.setValue(requirePasswordOnStart, {
emitEvent: false,
});
component.userHasMasterPassword = userHasMasterPassword;
await component.updatePinHandler(false);
expect(component.form.controls.pin.value).toBe(false);
expect(component.form.controls.requirePasswordOnStart.value).toBe(requirePasswordOnStart);
expect(updateRequirePasswordOnStartSpy).not.toHaveBeenCalled();
expect(vaultTimeoutSettingsService.clear).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
},
);
});
});
describe("updateBiometricHandler", () => {
afterEach(() => {
jest.resetAllMocks();
});
test.each([true, false])(
`handles thrown errors when updated biometrics to %s`,
async (update) => {
const error = new Error("Test error");
jest.spyOn(component, "updateBiometric").mockRejectedValue(error);
await component.ngOnInit();
await component.updateBiometricHandler(update);
expect(logService.error).toHaveBeenCalled();
expect(component.form.controls.biometric.value).toBe(false);
expect(validationService.showError).toHaveBeenCalledWith(error);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
},
);
describe("when updating to true", () => {
beforeEach(async () => {
await component.ngOnInit();
component.supportsBiometric = true;
});
it("calls services to clear biometrics when supportsBiometric is false", async () => {
component.supportsBiometric = false;
await component.updateBiometricHandler(true);
expect(component.form.controls.biometric.value).toBe(false);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenLastCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
test.each([true, false])(
`launches a dialog and exits when man setup is needed, dialog result is %s`,
async (dialogResult) => {
dialogService.openSimpleDialog.mockResolvedValue(dialogResult);
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(
BiometricsStatus.ManualSetupNeeded,
);
await component.updateBiometricHandler(true);
expect(biometricStateService.setBiometricUnlockEnabled).not.toHaveBeenCalled();
expect(keyService.refreshAdditionalKeys).not.toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
if (dialogResult) {
expect(platformUtilsService.launchUri).toHaveBeenCalledWith(
"https://bitwarden.com/help/biometrics/",
);
} else {
expect(platformUtilsService.launchUri).not.toHaveBeenCalled();
}
},
);
it("sets up biometrics when auto setup is needed", async () => {
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(
BiometricsStatus.AutoSetupNeeded,
);
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.Available,
);
await component.updateBiometricHandler(true);
expect(desktopBiometricsService.setupBiometrics).toHaveBeenCalled();
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.biometric.value).toBe(true);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
it("handles windows case", async () => {
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.Available,
);
component.isWindows = true;
component.isLinux = false;
await component.updateBiometricHandler(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.requirePasswordOnStart.value).toBe(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(biometricStateService.setRequirePasswordOnStart).toHaveBeenCalledWith(true);
expect(biometricStateService.setDismissedRequirePasswordOnStartCallout).toHaveBeenCalled();
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
it("handles linux case", async () => {
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.Available,
);
component.isWindows = false;
component.isLinux = true;
await component.updateBiometricHandler(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.requirePasswordOnStart.value).toBe(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(biometricStateService.setRequirePasswordOnStart).toHaveBeenCalledWith(true);
expect(biometricStateService.setDismissedRequirePasswordOnStartCallout).toHaveBeenCalled();
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
test.each([
BiometricsStatus.UnlockNeeded,
BiometricsStatus.HardwareUnavailable,
BiometricsStatus.AutoSetupNeeded,
BiometricsStatus.ManualSetupNeeded,
BiometricsStatus.PlatformUnsupported,
BiometricsStatus.DesktopDisconnected,
BiometricsStatus.NotEnabledLocally,
BiometricsStatus.NotEnabledInConnectedDesktopApp,
BiometricsStatus.NativeMessagingPermissionMissing,
])(
`disables biometric when biometrics status check for the user returns %s`,
async (status) => {
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(
BiometricsStatus.Available,
);
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(status);
await component.updateBiometricHandler(true);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(false);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledTimes(2);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenLastCalledWith(false);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
},
);
});
describe("when updating to false", () => {
it("calls services to clear biometrics", async () => {
await component.ngOnInit();
await component.updateBiometricHandler(false);
expect(component.form.controls.biometric.value).toBe(false);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenLastCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
});
});
}); });

View File

@@ -37,6 +37,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
@@ -162,6 +163,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private logService: LogService, private logService: LogService,
private nativeMessagingManifestService: NativeMessagingManifestService, private nativeMessagingManifestService: NativeMessagingManifestService,
private configService: ConfigService, private configService: ConfigService,
private validationService: ValidationService,
) { ) {
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
@@ -379,7 +381,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.pin.valueChanges this.form.controls.pin.valueChanges
.pipe( .pipe(
concatMap(async (value) => { concatMap(async (value) => {
await this.updatePin(value); await this.updatePinHandler(value);
this.refreshTimeoutSettings$.next(); this.refreshTimeoutSettings$.next();
}), }),
takeUntil(this.destroy$), takeUntil(this.destroy$),
@@ -389,7 +391,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.biometric.valueChanges this.form.controls.biometric.valueChanges
.pipe( .pipe(
concatMap(async (enabled) => { concatMap(async (enabled) => {
await this.updateBiometric(enabled); await this.updateBiometricHandler(enabled);
this.refreshTimeoutSettings$.next(); this.refreshTimeoutSettings$.next();
}), }),
takeUntil(this.destroy$), takeUntil(this.destroy$),
@@ -485,6 +487,18 @@ export class SettingsComponent implements OnInit, OnDestroy {
); );
} }
async updatePinHandler(value: boolean) {
try {
await this.updatePin(value);
} catch (error) {
this.logService.error("Error updating unlock with PIN: ", error);
this.form.controls.pin.setValue(!value, { emitEvent: false });
this.validationService.showError(error);
} finally {
this.messagingService.send("redrawMenu");
}
}
async updatePin(value: boolean) { async updatePin(value: boolean) {
if (value) { if (value) {
const dialogRef = SetPinComponent.open(this.dialogService); const dialogRef = SetPinComponent.open(this.dialogService);
@@ -509,8 +523,18 @@ export class SettingsComponent implements OnInit, OnDestroy {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.vaultTimeoutSettingsService.clear(userId); await this.vaultTimeoutSettingsService.clear(userId);
} }
}
this.messagingService.send("redrawMenu"); async updateBiometricHandler(value: boolean) {
try {
await this.updateBiometric(value);
} catch (error) {
this.logService.error("Error updating unlock with biometrics: ", error);
this.form.controls.biometric.setValue(false, { emitEvent: false });
this.validationService.showError(error);
} finally {
this.messagingService.send("redrawMenu");
}
} }
async updateBiometric(enabled: boolean) { async updateBiometric(enabled: boolean) {
@@ -519,61 +543,55 @@ export class SettingsComponent implements OnInit, OnDestroy {
// The bug should resolve itself once the angular issue is resolved. // The bug should resolve itself once the angular issue is resolved.
// See: https://github.com/angular/angular/issues/13063 // See: https://github.com/angular/angular/issues/13063
try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (!enabled || !this.supportsBiometric) { if (!enabled || !this.supportsBiometric) {
this.form.controls.biometric.setValue(false, { emitEvent: false }); this.form.controls.biometric.setValue(false, { emitEvent: false });
await this.biometricStateService.setBiometricUnlockEnabled(false); await this.biometricStateService.setBiometricUnlockEnabled(false);
await this.keyService.refreshAdditionalKeys(); await this.keyService.refreshAdditionalKeys(activeUserId);
return; return;
} }
const status = await this.biometricsService.getBiometricsStatus(); const status = await this.biometricsService.getBiometricsStatus();
if (status === BiometricsStatus.AutoSetupNeeded) { if (status === BiometricsStatus.AutoSetupNeeded) {
await this.biometricsService.setupBiometrics(); await this.biometricsService.setupBiometrics();
} else if (status === BiometricsStatus.ManualSetupNeeded) { } else if (status === BiometricsStatus.ManualSetupNeeded) {
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "biometricsManualSetupTitle" }, title: { key: "biometricsManualSetupTitle" },
content: { key: "biometricsManualSetupDesc" }, content: { key: "biometricsManualSetupDesc" },
type: "warning", type: "warning",
}); });
if (confirmed) { if (confirmed) {
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/"); this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
}
return;
} }
return;
}
await this.biometricStateService.setBiometricUnlockEnabled(true); await this.biometricStateService.setBiometricUnlockEnabled(true);
if (this.isWindows) { if (this.isWindows) {
// Recommended settings for Windows Hello // Recommended settings for Windows Hello
this.form.controls.requirePasswordOnStart.setValue(true); this.form.controls.requirePasswordOnStart.setValue(true);
this.form.controls.autoPromptBiometrics.setValue(false); this.form.controls.autoPromptBiometrics.setValue(false);
await this.biometricStateService.setPromptAutomatically(false); await this.biometricStateService.setPromptAutomatically(false);
await this.biometricStateService.setRequirePasswordOnStart(true); await this.biometricStateService.setRequirePasswordOnStart(true);
await this.biometricStateService.setDismissedRequirePasswordOnStartCallout(); await this.biometricStateService.setDismissedRequirePasswordOnStartCallout();
} else if (this.isLinux) { } else if (this.isLinux) {
// Similar to Windows // Similar to Windows
this.form.controls.requirePasswordOnStart.setValue(true); this.form.controls.requirePasswordOnStart.setValue(true);
this.form.controls.autoPromptBiometrics.setValue(false); this.form.controls.autoPromptBiometrics.setValue(false);
await this.biometricStateService.setPromptAutomatically(false); await this.biometricStateService.setPromptAutomatically(false);
await this.biometricStateService.setRequirePasswordOnStart(true); await this.biometricStateService.setRequirePasswordOnStart(true);
await this.biometricStateService.setDismissedRequirePasswordOnStartCallout(); await this.biometricStateService.setDismissedRequirePasswordOnStartCallout();
} }
await this.keyService.refreshAdditionalKeys(); await this.keyService.refreshAdditionalKeys(activeUserId);
const activeUserId = await firstValueFrom( // Validate the key is stored in case biometrics fail.
this.accountService.activeAccount$.pipe(map((a) => a?.id)), const biometricSet =
); (await this.biometricsService.getBiometricsStatusForUser(activeUserId)) ===
// Validate the key is stored in case biometrics fail. BiometricsStatus.Available;
const biometricSet = this.form.controls.biometric.setValue(biometricSet, { emitEvent: false });
(await this.biometricsService.getBiometricsStatusForUser(activeUserId)) === if (!biometricSet) {
BiometricsStatus.Available; await this.biometricStateService.setBiometricUnlockEnabled(false);
this.form.controls.biometric.setValue(biometricSet, { emitEvent: false });
if (!biometricSet) {
await this.biometricStateService.setBiometricUnlockEnabled(false);
}
} finally {
this.messagingService.send("redrawMenu");
} }
} }
@@ -599,7 +617,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
await this.biometricStateService.setRequirePasswordOnStart(false); await this.biometricStateService.setRequirePasswordOnStart(false);
} }
await this.biometricStateService.setDismissedRequirePasswordOnStartCallout(); await this.biometricStateService.setDismissedRequirePasswordOnStartCallout();
await this.keyService.refreshAdditionalKeys(); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.keyService.refreshAdditionalKeys(userId);
} }
async saveFavicons() { async saveFavicons() {

View File

@@ -92,7 +92,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
clientSecret, clientSecret,
]); ]);
await this.keyService.refreshAdditionalKeys(); await this.keyService.refreshAdditionalKeys(userId);
} }
availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> { availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> {

View File

@@ -83,8 +83,11 @@ export abstract class KeyService {
* Gets the user key from memory and sets it again, * Gets the user key from memory and sets it again,
* kicking off a refresh of any additional keys * kicking off a refresh of any additional keys
* (such as auto, biometrics, or pin) * (such as auto, biometrics, or pin)
* @param userId The target user to refresh keys for.
* @throws Error when userId is null or undefined.
* @throws When userKey doesn't exist in memory for the target user.
*/ */
abstract refreshAdditionalKeys(): Promise<void>; abstract refreshAdditionalKeys(userId: UserId): Promise<void>;
/** /**
* Observable value that returns whether or not the user has ever had a userKey, * Observable value that returns whether or not the user has ever had a userKey,

View File

@@ -90,6 +90,35 @@ describe("keyService", () => {
expect(keyService).not.toBeFalsy(); expect(keyService).not.toBeFalsy();
}); });
describe("refreshAdditionalKeys", () => {
test.each([null as unknown as UserId, undefined as unknown as UserId])(
"throws when the provided userId is %s",
async (userId) => {
await expect(keyService.refreshAdditionalKeys(userId)).rejects.toThrow(
"UserId is required",
);
},
);
it("throws error if user key not found", async () => {
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(null);
await expect(keyService.refreshAdditionalKeys(mockUserId)).rejects.toThrow(
"No user key found for: " + mockUserId,
);
});
it("refreshes additional keys when user key is available", async () => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey);
const setUserKeySpy = jest.spyOn(keyService, "setUserKey");
await keyService.refreshAdditionalKeys(mockUserId);
expect(setUserKeySpy).toHaveBeenCalledWith(mockUserKey, mockUserId);
});
});
describe("getUserKey", () => { describe("getUserKey", () => {
let mockUserKey: UserKey; let mockUserKey: UserKey;

View File

@@ -122,15 +122,17 @@ export class DefaultKeyService implements KeyServiceAbstraction {
await this.setPrivateKey(encPrivateKey, userId); await this.setPrivateKey(encPrivateKey, userId);
} }
async refreshAdditionalKeys(): Promise<void> { async refreshAdditionalKeys(userId: UserId): Promise<void> {
const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$); if (userId == null) {
throw new Error("UserId is required.");
if (activeUserId == null) {
throw new Error("Can only refresh keys while there is an active user.");
} }
const key = await this.getUserKey(activeUserId); const key = await firstValueFrom(this.userKey$(userId));
await this.setUserKey(key, activeUserId); if (key == null) {
throw new Error("No user key found for: " + userId);
}
await this.setUserKey(key, userId);
} }
everHadUserKey$(userId: UserId): Observable<boolean> { everHadUserKey$(userId: UserId): Observable<boolean> {