mirror of
https://github.com/bitwarden/browser
synced 2026-01-09 03:53:53 +00:00
Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build
This commit is contained in:
@@ -101,8 +101,7 @@
|
||||
supportsBiometric &&
|
||||
form.value.biometric &&
|
||||
isWindows &&
|
||||
(userHasMasterPassword || (form.value.pin && userHasPinSet)) &&
|
||||
isWindowsV2BiometricsEnabled
|
||||
(userHasMasterPassword || (form.value.pin && userHasPinSet))
|
||||
"
|
||||
>
|
||||
<div class="checkbox form-group-child">
|
||||
|
||||
@@ -302,7 +302,6 @@ describe("SettingsComponent", () => {
|
||||
describe("windows desktop", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
desktopBiometricsService.isWindowsV2BiometricsEnabled.mockResolvedValue(true);
|
||||
|
||||
// Recreate component to apply the correct device
|
||||
fixture = TestBed.createComponent(SettingsComponent);
|
||||
@@ -449,7 +448,6 @@ describe("SettingsComponent", () => {
|
||||
desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled);
|
||||
|
||||
await component.ngOnInit();
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
component.isWindows = true;
|
||||
component.form.value.requireMasterPasswordOnAppRestart = true;
|
||||
component.userHasMasterPassword = false;
|
||||
@@ -558,7 +556,6 @@ describe("SettingsComponent", () => {
|
||||
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
|
||||
|
||||
await component.ngOnInit();
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
component.isWindows = true;
|
||||
component.form.value.requireMasterPasswordOnAppRestart =
|
||||
requireMasterPasswordOnAppRestart;
|
||||
@@ -659,6 +656,7 @@ describe("SettingsComponent", () => {
|
||||
describe("windows test cases", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
|
||||
component.isWindows = true;
|
||||
component.isLinux = false;
|
||||
|
||||
@@ -683,8 +681,6 @@ describe("SettingsComponent", () => {
|
||||
|
||||
describe("when windows v2 biometrics is enabled", () => {
|
||||
beforeEach(() => {
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
|
||||
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
|
||||
});
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
userHasPinSet: boolean;
|
||||
|
||||
pinEnabled$: Observable<boolean> = of(true);
|
||||
isWindowsV2BiometricsEnabled: boolean = false;
|
||||
|
||||
consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
@@ -297,8 +296,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
async ngOnInit() {
|
||||
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
|
||||
|
||||
this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled();
|
||||
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
// Autotype is for Windows initially
|
||||
@@ -621,7 +618,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
// On Windows if a user turned off PIN without having a MP and has biometrics + require MP/PIN on restart enabled.
|
||||
if (
|
||||
this.isWindows &&
|
||||
this.isWindowsV2BiometricsEnabled &&
|
||||
this.supportsBiometric &&
|
||||
this.form.value.requireMasterPasswordOnAppRestart &&
|
||||
this.form.value.biometric &&
|
||||
@@ -682,14 +678,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.form.controls.autoPromptBiometrics.setValue(false);
|
||||
await this.biometricStateService.setPromptAutomatically(false);
|
||||
|
||||
if (this.isWindowsV2BiometricsEnabled) {
|
||||
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
|
||||
if (!this.userHasMasterPassword && !this.userHasPinSet) {
|
||||
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
|
||||
await this.enrollPersistentBiometricIfNeeded(activeUserId);
|
||||
} else {
|
||||
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
|
||||
}
|
||||
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
|
||||
if (!this.userHasMasterPassword && !this.userHasPinSet) {
|
||||
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
|
||||
await this.enrollPersistentBiometricIfNeeded(activeUserId);
|
||||
} else {
|
||||
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
|
||||
}
|
||||
} else if (this.isLinux) {
|
||||
// Similar to Windows
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
|
||||
import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import {
|
||||
DevicesIcon,
|
||||
RegistrationUserAddIcon,
|
||||
@@ -39,15 +40,19 @@ import {
|
||||
TwoFactorAuthGuard,
|
||||
NewDeviceVerificationComponent,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
|
||||
import { VaultComponent } from "../vault/app/vault-v3/vault.component";
|
||||
|
||||
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
|
||||
import { DesktopLayoutComponent } from "./layout/desktop-layout.component";
|
||||
import { SendComponent } from "./tools/send/send.component";
|
||||
import { SendV2Component } from "./tools/send-v2/send-v2.component";
|
||||
|
||||
/**
|
||||
* Data properties acceptable for use in route objects in the desktop
|
||||
@@ -99,7 +104,10 @@ const routes: Routes = [
|
||||
{
|
||||
path: "vault",
|
||||
component: VaultV2Component,
|
||||
canActivate: [authGuard],
|
||||
canActivate: [
|
||||
authGuard,
|
||||
canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false),
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "send",
|
||||
@@ -325,6 +333,21 @@ const routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: DesktopLayoutComponent,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{
|
||||
path: "new-vault",
|
||||
component: VaultComponent,
|
||||
},
|
||||
{
|
||||
path: "new-sends",
|
||||
component: SendV2Component,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
10
apps/desktop/src/app/layout/desktop-layout.component.html
Normal file
10
apps/desktop/src/app/layout/desktop-layout.component.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<bit-layout>
|
||||
<app-side-nav slot="side-nav">
|
||||
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
|
||||
|
||||
<bit-nav-item icon="bwi-vault" [text]="'vault' | i18n" route="new-vault"></bit-nav-item>
|
||||
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="new-sends"></bit-nav-item>
|
||||
</app-side-nav>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</bit-layout>
|
||||
61
apps/desktop/src/app/layout/desktop-layout.component.spec.ts
Normal file
61
apps/desktop/src/app/layout/desktop-layout.component.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { DesktopLayoutComponent } from "./desktop-layout.component";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe("DesktopLayoutComponent", () => {
|
||||
let component: DesktopLayoutComponent;
|
||||
let fixture: ComponentFixture<DesktopLayoutComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DesktopLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders bit-layout component", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const layoutElement = compiled.querySelector("bit-layout");
|
||||
|
||||
expect(layoutElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("supports content projection for side-nav", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const ngContent = compiled.querySelectorAll("ng-content");
|
||||
|
||||
expect(ngContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
18
apps/desktop/src/app/layout/desktop-layout.component.ts
Normal file
18
apps/desktop/src/app/layout/desktop-layout.component.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
|
||||
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-layout",
|
||||
imports: [RouterModule, I18nPipe, LayoutComponent, NavigationModule, DesktopSideNavComponent],
|
||||
templateUrl: "./desktop-layout.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DesktopLayoutComponent {
|
||||
protected readonly logo = PasswordManagerLogo;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<bit-side-nav [variant]="variant()">
|
||||
<ng-content></ng-content>
|
||||
</bit-side-nav>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe("DesktopSideNavComponent", () => {
|
||||
let component: DesktopSideNavComponent;
|
||||
let fixture: ComponentFixture<DesktopSideNavComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopSideNavComponent, NavigationModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DesktopSideNavComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders bit-side-nav component", () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const sideNavElement = compiled.querySelector("bit-side-nav");
|
||||
|
||||
expect(sideNavElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses primary variant by default", () => {
|
||||
expect(component.variant()).toBe("primary");
|
||||
});
|
||||
|
||||
it("accepts variant input", () => {
|
||||
fixture.componentRef.setInput("variant", "secondary");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.variant()).toBe("secondary");
|
||||
});
|
||||
|
||||
it.skip("passes variant to bit-side-nav", () => {
|
||||
fixture.componentRef.setInput("variant", "secondary");
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const sideNavElement = compiled.querySelector("bit-side-nav");
|
||||
|
||||
expect(sideNavElement.getAttribute("ng-reflect-variant")).toBe("secondary");
|
||||
});
|
||||
});
|
||||
14
apps/desktop/src/app/layout/desktop-side-nav.component.ts
Normal file
14
apps/desktop/src/app/layout/desktop-side-nav.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { NavigationModule, SideNavVariant } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
selector: "app-side-nav",
|
||||
templateUrl: "desktop-side-nav.component.html",
|
||||
imports: [CommonModule, NavigationModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DesktopSideNavComponent {
|
||||
readonly variant = input<SideNavVariant>("primary");
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
import { Inject, Injectable } from "@angular/core";
|
||||
import { Inject, Injectable, DOCUMENT } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
|
||||
22
apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
Normal file
22
apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { SendV2Component } from "./send-v2.component";
|
||||
|
||||
describe("SendV2Component", () => {
|
||||
let component: SendV2Component;
|
||||
let fixture: ComponentFixture<SendV2Component>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SendV2Component],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SendV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
9
apps/desktop/src/app/tools/send-v2/send-v2.component.ts
Normal file
9
apps/desktop/src/app/tools/send-v2/send-v2.component.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Component, ChangeDetectionStrategy } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "app-send-v2",
|
||||
imports: [],
|
||||
template: "<p>Sends V2 Component</p>",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendV2Component {}
|
||||
@@ -16,9 +16,6 @@ export abstract class DesktopBiometricsService extends BiometricsService {
|
||||
abstract enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
|
||||
abstract hasPersistentKey(userId: UserId): Promise<boolean>;
|
||||
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
|
||||
abstract enableWindowsV2Biometrics(): Promise<void>;
|
||||
abstract isWindowsV2BiometricsEnabled(): Promise<boolean>;
|
||||
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
|
||||
abstract enableLinuxV2Biometrics(): Promise<void>;
|
||||
abstract isLinuxV2BiometricsEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -58,10 +58,6 @@ export class MainBiometricsIPCListener {
|
||||
message.userId as UserId,
|
||||
SymmetricCryptoKey.fromString(message.key as string),
|
||||
);
|
||||
case BiometricAction.EnableWindowsV2:
|
||||
return await this.biometricService.enableWindowsV2Biometrics();
|
||||
case BiometricAction.IsWindowsV2Enabled:
|
||||
return await this.biometricService.isWindowsV2BiometricsEnabled();
|
||||
case BiometricAction.EnableLinuxV2:
|
||||
return await this.biometricService.enableLinuxV2Biometrics();
|
||||
case BiometricAction.IsLinuxV2Enabled:
|
||||
|
||||
@@ -20,7 +20,6 @@ import { MainBiometricsService } from "./main-biometrics.service";
|
||||
import { WindowsBiometricsSystem } from "./native-v2";
|
||||
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
|
||||
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
|
||||
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
jest.mock("@bitwarden/desktop-napi", () => {
|
||||
@@ -61,7 +60,7 @@ describe("MainBiometricsService", function () {
|
||||
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows);
|
||||
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
|
||||
});
|
||||
|
||||
it("Should create a biometrics service specific for MacOs", () => {
|
||||
@@ -289,78 +288,6 @@ describe("MainBiometricsService", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("enableWindowsV2Biometrics", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("enables Windows V2 biometrics when platform is win32 and not already enabled", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
"win32",
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
|
||||
await sut.enableWindowsV2Biometrics();
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
"[BiometricsMain] Loading native biometrics module v2 for windows",
|
||||
);
|
||||
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true);
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
|
||||
});
|
||||
|
||||
it("should not enable Windows V2 biometrics when platform is not win32", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
"darwin",
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
|
||||
await sut.enableWindowsV2Biometrics();
|
||||
|
||||
expect(logService.info).not.toHaveBeenCalled();
|
||||
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not enable Windows V2 biometrics when already enabled", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
"win32",
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
|
||||
// Enable it first
|
||||
await sut.enableWindowsV2Biometrics();
|
||||
|
||||
// Enable it again
|
||||
await sut.enableWindowsV2Biometrics();
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
"[BiometricsMain] Loading native biometrics module v2 for windows",
|
||||
);
|
||||
expect(logService.info).toHaveBeenCalledTimes(1);
|
||||
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true);
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pass through methods that call platform specific osBiometricsService methods", () => {
|
||||
const userId = newGuid() as UserId;
|
||||
let sut: MainBiometricsService;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { OsBiometricService } from "./os-biometrics.service";
|
||||
export class MainBiometricsService extends DesktopBiometricsService {
|
||||
private osBiometricsService: OsBiometricService;
|
||||
private shouldAutoPrompt = true;
|
||||
private windowsV2BiometricsEnabled = false;
|
||||
private linuxV2BiometricsEnabled = false;
|
||||
|
||||
constructor(
|
||||
@@ -30,15 +29,10 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
) {
|
||||
super();
|
||||
if (platform === "win32") {
|
||||
// eslint-disable-next-line
|
||||
const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default;
|
||||
this.osBiometricsService = new OsBiometricsServiceWindows(
|
||||
this.osBiometricsService = new WindowsBiometricsSystem(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
this.biometricStateService,
|
||||
this.encryptService,
|
||||
this.cryptoFunctionService,
|
||||
);
|
||||
} else if (platform === "darwin") {
|
||||
// eslint-disable-next-line
|
||||
@@ -156,22 +150,6 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
return await this.osBiometricsService.hasPersistentKey(userId);
|
||||
}
|
||||
|
||||
async enableWindowsV2Biometrics(): Promise<void> {
|
||||
if (this.platform === "win32" && !this.windowsV2BiometricsEnabled) {
|
||||
this.logService.info("[BiometricsMain] Loading native biometrics module v2 for windows");
|
||||
this.osBiometricsService = new WindowsBiometricsSystem(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
);
|
||||
this.windowsV2BiometricsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
|
||||
return this.windowsV2BiometricsEnabled;
|
||||
}
|
||||
|
||||
async enableLinuxV2Biometrics(): Promise<void> {
|
||||
if (this.platform === "linux" && !this.linuxV2BiometricsEnabled) {
|
||||
this.logService.info("[BiometricsMain] Loading native biometrics module v2 for linux");
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { BrowserWindow } from "electron";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { biometrics, passwords } from "@bitwarden/desktop-napi";
|
||||
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
||||
|
||||
import OsDerivedKey = biometrics.OsDerivedKey;
|
||||
|
||||
jest.mock("@bitwarden/desktop-napi", () => {
|
||||
return {
|
||||
biometrics: {
|
||||
available: jest.fn().mockResolvedValue(true),
|
||||
getBiometricSecret: jest.fn().mockResolvedValue(""),
|
||||
setBiometricSecret: jest.fn().mockResolvedValue(""),
|
||||
deleteBiometricSecret: jest.fn(),
|
||||
deriveKeyMaterial: jest.fn().mockResolvedValue({
|
||||
keyB64: "",
|
||||
ivB64: "",
|
||||
}),
|
||||
prompt: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
passwords: {
|
||||
getPassword: jest.fn().mockResolvedValue(null),
|
||||
deletePassword: jest.fn().mockImplementation(() => {}),
|
||||
isAvailable: jest.fn(),
|
||||
PASSWORD_NOT_FOUND: "Password not found",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("OsBiometricsServiceWindows", function () {
|
||||
const i18nService = mock<I18nService>();
|
||||
const windowMain = mock<WindowMain>();
|
||||
const browserWindow = mock<BrowserWindow>();
|
||||
const encryptionService: EncryptService = mock<EncryptService>();
|
||||
const cryptoFunctionService: CryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const biometricStateService: BiometricStateService = mock<BiometricStateService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
let service: OsBiometricsServiceWindows;
|
||||
|
||||
const key = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const userId = "test-user-id" as UserId;
|
||||
const serviceKey = "Bitwarden_biometric";
|
||||
const storageKey = `${userId}_user_biometric`;
|
||||
|
||||
beforeEach(() => {
|
||||
windowMain.win = browserWindow;
|
||||
|
||||
service = new OsBiometricsServiceWindows(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
biometricStateService,
|
||||
encryptionService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getBiometricsFirstUnlockStatusForUser", () => {
|
||||
const userId = "test-user-id" as UserId;
|
||||
it("should return Available when client key half is set", async () => {
|
||||
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
|
||||
(service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4]));
|
||||
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
|
||||
expect(result).toBe(BiometricsStatus.Available);
|
||||
});
|
||||
it("should return UnlockNeeded when client key half is not set", async () => {
|
||||
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
|
||||
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
|
||||
expect(result).toBe(BiometricsStatus.UnlockNeeded);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateBiometricEncryptionClientKeyHalf", () => {
|
||||
it("should return cached key half if already present", async () => {
|
||||
const cachedKeyHalf = new Uint8Array([10, 20, 30]);
|
||||
(service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf);
|
||||
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
|
||||
expect(result).toBe(cachedKeyHalf);
|
||||
});
|
||||
|
||||
it("should decrypt and return existing encrypted client key half", async () => {
|
||||
biometricStateService.getEncryptedClientKeyHalf = jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
const decrypted = new Uint8Array([4, 5, 6]);
|
||||
encryptionService.decryptBytes = jest.fn().mockResolvedValue(decrypted);
|
||||
|
||||
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
|
||||
|
||||
expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(userId);
|
||||
expect(encryptionService.decryptBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), key);
|
||||
expect(result).toEqual(decrypted);
|
||||
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(decrypted);
|
||||
});
|
||||
|
||||
it("should generate, encrypt, store, and cache a new key half if none exists", async () => {
|
||||
biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null);
|
||||
const randomBytes = new Uint8Array([7, 8, 9]);
|
||||
cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes);
|
||||
const encrypted = new Uint8Array([10, 11, 12]);
|
||||
encryptionService.encryptBytes = jest.fn().mockResolvedValue(encrypted);
|
||||
biometricStateService.setEncryptedClientKeyHalf = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
|
||||
|
||||
expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32);
|
||||
expect(encryptionService.encryptBytes).toHaveBeenCalledWith(randomBytes, key);
|
||||
expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith(
|
||||
encrypted,
|
||||
userId,
|
||||
);
|
||||
expect(result).toEqual(randomBytes);
|
||||
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(randomBytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsBiometrics", () => {
|
||||
it("should return true if biometrics are available", async () => {
|
||||
biometrics.available = jest.fn().mockResolvedValue(true);
|
||||
|
||||
const result = await service.supportsBiometrics();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if biometrics are not available", async () => {
|
||||
biometrics.available = jest.fn().mockResolvedValue(false);
|
||||
|
||||
const result = await service.supportsBiometrics();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBiometricKey", () => {
|
||||
beforeEach(() => {
|
||||
biometrics.prompt = jest.fn().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should return null when unsuccessfully authenticated biometrics", async () => {
|
||||
biometrics.prompt = jest.fn().mockResolvedValue(false);
|
||||
|
||||
const result = await service.getBiometricKey(userId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([null, undefined, ""])(
|
||||
"should throw error when no biometric key is found '%s'",
|
||||
async (password) => {
|
||||
passwords.getPassword = jest.fn().mockResolvedValue(password);
|
||||
|
||||
await expect(service.getBiometricKey(userId)).rejects.toThrow(
|
||||
"Biometric key not found for user",
|
||||
);
|
||||
|
||||
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[false], [true]])(
|
||||
"should return the biometricKey and setBiometricSecret called if password is not encrypted and cached clientKeyHalves is %s",
|
||||
async (haveClientKeyHalves) => {
|
||||
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
|
||||
if (haveClientKeyHalves) {
|
||||
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
|
||||
}
|
||||
const biometricKey = key.toBase64();
|
||||
passwords.getPassword = jest.fn().mockResolvedValue(biometricKey);
|
||||
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
|
||||
keyB64: "testKeyB64",
|
||||
ivB64: "testIvB64",
|
||||
} satisfies OsDerivedKey);
|
||||
|
||||
const result = await service.getBiometricKey(userId);
|
||||
|
||||
expect(result.toBase64()).toBe(biometricKey);
|
||||
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
|
||||
expect(biometrics.setBiometricSecret).toHaveBeenCalledWith(
|
||||
serviceKey,
|
||||
storageKey,
|
||||
biometricKey,
|
||||
{
|
||||
osKeyPartB64: "testKeyB64",
|
||||
clientKeyPartB64: haveClientKeyHalves
|
||||
? Utils.fromBufferToB64(clientKeyHalveBytes)
|
||||
: undefined,
|
||||
},
|
||||
"testIvB64",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[false], [true]])(
|
||||
"should return the biometricKey if password is encrypted and cached clientKeyHalves is %s",
|
||||
async (haveClientKeyHalves) => {
|
||||
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
|
||||
if (haveClientKeyHalves) {
|
||||
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
|
||||
}
|
||||
const biometricKey = key.toBase64();
|
||||
const biometricKeyEncrypted = "2.testId|data|mac";
|
||||
passwords.getPassword = jest.fn().mockResolvedValue(biometricKeyEncrypted);
|
||||
biometrics.getBiometricSecret = jest.fn().mockResolvedValue(biometricKey);
|
||||
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
|
||||
keyB64: "testKeyB64",
|
||||
ivB64: "testIvB64",
|
||||
} satisfies OsDerivedKey);
|
||||
|
||||
const result = await service.getBiometricKey(userId);
|
||||
|
||||
expect(result.toBase64()).toBe(biometricKey);
|
||||
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
|
||||
expect(biometrics.setBiometricSecret).not.toHaveBeenCalled();
|
||||
expect(biometrics.getBiometricSecret).toHaveBeenCalledWith(serviceKey, storageKey, {
|
||||
osKeyPartB64: "testKeyB64",
|
||||
clientKeyPartB64: haveClientKeyHalves
|
||||
? Utils.fromBufferToB64(clientKeyHalveBytes)
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("deleteBiometricKey", () => {
|
||||
const serviceName = "Bitwarden_biometric";
|
||||
const keyName = "test-user-id_user_biometric";
|
||||
|
||||
it("should delete biometric key successfully", async () => {
|
||||
await service.deleteBiometricKey(userId);
|
||||
|
||||
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||
});
|
||||
|
||||
it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => {
|
||||
if (!keyFound) {
|
||||
passwords.deletePassword = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND));
|
||||
}
|
||||
|
||||
await service.deleteBiometricKey(userId);
|
||||
|
||||
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||
if (!keyFound) {
|
||||
expect(logService.debug).toHaveBeenCalledWith(
|
||||
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||
keyName,
|
||||
serviceName,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error when deletePassword for key throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected error");
|
||||
passwords.deletePassword = jest.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error);
|
||||
|
||||
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateBiometric", () => {
|
||||
const hwnd = randomBytes(32).buffer;
|
||||
const consentMessage = "Test Windows Hello Consent Message";
|
||||
|
||||
beforeEach(() => {
|
||||
windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd);
|
||||
i18nService.t.mockReturnValue(consentMessage);
|
||||
});
|
||||
|
||||
it("should return true when biometric authentication is successful", async () => {
|
||||
const result = await service.authenticateBiometric();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
|
||||
});
|
||||
|
||||
it("should return false when biometric authentication fails", async () => {
|
||||
biometrics.prompt = jest.fn().mockResolvedValue(false);
|
||||
|
||||
const result = await service.authenticateBiometric();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStorageDetails", () => {
|
||||
it.each([
|
||||
["testClientKeyHalfB64", "testIvB64"],
|
||||
[undefined, "testIvB64"],
|
||||
["testClientKeyHalfB64", null],
|
||||
[undefined, null],
|
||||
])(
|
||||
"should derive key material and ivB64 and return it when os key half not saved yet",
|
||||
async (clientKeyHalfB64, ivB64) => {
|
||||
service["setIv"](ivB64);
|
||||
|
||||
const derivedKeyMaterial = {
|
||||
keyB64: "derivedKeyB64",
|
||||
ivB64: "derivedIvB64",
|
||||
};
|
||||
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
|
||||
|
||||
const result = await service["getStorageDetails"]({ clientKeyHalfB64 });
|
||||
|
||||
expect(result).toEqual({
|
||||
key_material: {
|
||||
osKeyPartB64: derivedKeyMaterial.keyB64,
|
||||
clientKeyPartB64: clientKeyHalfB64,
|
||||
},
|
||||
ivB64: derivedKeyMaterial.ivB64,
|
||||
});
|
||||
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith(ivB64);
|
||||
expect(service["_osKeyHalf"]).toEqual(derivedKeyMaterial.keyB64);
|
||||
expect(service["_iv"]).toEqual(derivedKeyMaterial.ivB64);
|
||||
},
|
||||
);
|
||||
|
||||
it("should throw an error when deriving key material and returned iv is null", async () => {
|
||||
service["setIv"]("testIvB64");
|
||||
|
||||
const derivedKeyMaterial = {
|
||||
keyB64: "derivedKeyB64",
|
||||
ivB64: null as string | undefined | null,
|
||||
};
|
||||
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
|
||||
|
||||
await expect(
|
||||
service["getStorageDetails"]({ clientKeyHalfB64: "testClientKeyHalfB64" }),
|
||||
).rejects.toThrow("Initialization Vector is null");
|
||||
|
||||
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith("testIvB64");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setIv", () => {
|
||||
it("should set the iv and reset the osKeyHalf", () => {
|
||||
const iv = "testIv";
|
||||
service["_osKeyHalf"] = "testOsKeyHalf";
|
||||
|
||||
service["setIv"](iv);
|
||||
|
||||
expect(service["_iv"]).toBe(iv);
|
||||
expect(service["_osKeyHalf"]).toBeNull();
|
||||
});
|
||||
|
||||
it("should set the iv to null when iv is undefined and reset the osKeyHalf", () => {
|
||||
service["_osKeyHalf"] = "testOsKeyHalf";
|
||||
|
||||
service["setIv"](undefined);
|
||||
|
||||
expect(service["_iv"]).toBeNull();
|
||||
expect(service["_osKeyHalf"]).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { biometrics, passwords } from "@bitwarden/desktop-napi";
|
||||
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
const SERVICE = "Bitwarden_biometric";
|
||||
|
||||
function getLookupKeyForUser(userId: UserId): string {
|
||||
return `${userId}_user_biometric`;
|
||||
}
|
||||
|
||||
export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
// Use set helper method instead of direct access
|
||||
private _iv: string | null = null;
|
||||
// Use getKeyMaterial helper instead of direct access
|
||||
private _osKeyHalf: string | null = null;
|
||||
private clientKeyHalves = new Map<UserId, Uint8Array>();
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private logService: LogService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private encryptService: EncryptService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
) {}
|
||||
|
||||
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async supportsBiometrics(): Promise<boolean> {
|
||||
return await biometrics.available();
|
||||
}
|
||||
|
||||
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
||||
const success = await this.authenticateBiometric();
|
||||
if (!success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
|
||||
if (value == null || value == "") {
|
||||
throw new Error("Biometric key not found for user");
|
||||
}
|
||||
|
||||
let clientKeyHalfB64: string | null = null;
|
||||
if (this.clientKeyHalves.has(userId)) {
|
||||
clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!);
|
||||
}
|
||||
|
||||
if (!EncString.isSerializedEncString(value)) {
|
||||
// Update to format encrypted with client key half
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
|
||||
});
|
||||
|
||||
await biometrics.setBiometricSecret(
|
||||
SERVICE,
|
||||
getLookupKeyForUser(userId),
|
||||
value,
|
||||
storageDetails.key_material,
|
||||
storageDetails.ivB64,
|
||||
);
|
||||
return SymmetricCryptoKey.fromString(value);
|
||||
} else {
|
||||
const encValue = new EncString(value);
|
||||
this.setIv(encValue.iv);
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
|
||||
});
|
||||
return SymmetricCryptoKey.fromString(
|
||||
await biometrics.getBiometricSecret(
|
||||
SERVICE,
|
||||
getLookupKeyForUser(userId),
|
||||
storageDetails.key_material,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
|
||||
const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
|
||||
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf),
|
||||
});
|
||||
await biometrics.setBiometricSecret(
|
||||
SERVICE,
|
||||
getLookupKeyForUser(userId),
|
||||
key.toBase64(),
|
||||
storageDetails.key_material,
|
||||
storageDetails.ivB64,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBiometricKey(userId: UserId): Promise<void> {
|
||||
try {
|
||||
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
|
||||
this.logService.debug(
|
||||
"[OsBiometricService] Biometric key %s not found for service %s.",
|
||||
getLookupKeyForUser(userId),
|
||||
SERVICE,
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts Windows Hello
|
||||
*/
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const hwnd = this.windowMain.win.getNativeWindowHandle();
|
||||
return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage"));
|
||||
}
|
||||
|
||||
private async getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
}: {
|
||||
clientKeyHalfB64: string | undefined;
|
||||
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
|
||||
if (this._osKeyHalf == null) {
|
||||
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
|
||||
this._osKeyHalf = keyMaterial.keyB64;
|
||||
this._iv = keyMaterial.ivB64;
|
||||
}
|
||||
|
||||
if (this._iv == null) {
|
||||
throw new Error("Initialization Vector is null");
|
||||
}
|
||||
|
||||
const result = {
|
||||
key_material: {
|
||||
osKeyPartB64: this._osKeyHalf,
|
||||
clientKeyPartB64: clientKeyHalfB64,
|
||||
},
|
||||
ivB64: this._iv,
|
||||
};
|
||||
|
||||
// napi-rs fails to convert null values
|
||||
if (result.key_material.clientKeyPartB64 == null) {
|
||||
delete result.key_material.clientKeyPartB64;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
|
||||
// when we want to force a re-derive of the key material.
|
||||
private setIv(iv?: string) {
|
||||
this._iv = iv ?? null;
|
||||
this._osKeyHalf = null;
|
||||
}
|
||||
|
||||
async needsSetup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async canAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async runSetup(): Promise<void> {}
|
||||
|
||||
async getOrCreateBiometricEncryptionClientKeyHalf(
|
||||
userId: UserId,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<Uint8Array> {
|
||||
if (this.clientKeyHalves.has(userId)) {
|
||||
return this.clientKeyHalves.get(userId)!;
|
||||
}
|
||||
|
||||
// Retrieve existing key half if it exists
|
||||
let clientKeyHalf: Uint8Array | null = null;
|
||||
const encryptedClientKeyHalf =
|
||||
await this.biometricStateService.getEncryptedClientKeyHalf(userId);
|
||||
if (encryptedClientKeyHalf != null) {
|
||||
clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key);
|
||||
}
|
||||
if (clientKeyHalf == null) {
|
||||
// Set a key half if it doesn't exist
|
||||
clientKeyHalf = await this.cryptoFunctionService.randomBytes(32);
|
||||
const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key);
|
||||
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
|
||||
}
|
||||
|
||||
this.clientKeyHalves.set(userId, clientKeyHalf);
|
||||
|
||||
return clientKeyHalf;
|
||||
}
|
||||
|
||||
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
if (this.clientKeyHalves.has(userId)) {
|
||||
return BiometricsStatus.Available;
|
||||
} else {
|
||||
return BiometricsStatus.UnlockNeeded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,14 +77,6 @@ export class RendererBiometricsService extends DesktopBiometricsService {
|
||||
return await ipc.keyManagement.biometric.hasPersistentKey(userId);
|
||||
}
|
||||
|
||||
async enableWindowsV2Biometrics(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.enableWindowsV2Biometrics();
|
||||
}
|
||||
|
||||
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled();
|
||||
}
|
||||
|
||||
async enableLinuxV2Biometrics(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.enableLinuxV2Biometrics();
|
||||
}
|
||||
|
||||
@@ -61,14 +61,6 @@ const biometric = {
|
||||
action: BiometricAction.HasPersistentKey,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
enableWindowsV2Biometrics: (): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.EnableWindowsV2,
|
||||
} satisfies BiometricMessage),
|
||||
isWindowsV2BiometricsEnabled: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.IsWindowsV2Enabled,
|
||||
} satisfies BiometricMessage),
|
||||
enableLinuxV2Biometrics: (): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.EnableLinuxV2,
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
},
|
||||
"noEditPermissions": {
|
||||
"message": "Du bist nicht berechtigt, diesen Eintrag zu bearbeiten"
|
||||
"message": "Keine Berechtigung zum Bearbeiten dieses Eintrags"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"message": "Willkommen zurück"
|
||||
@@ -2562,7 +2562,7 @@
|
||||
}
|
||||
},
|
||||
"vaultCustomTimeoutMinimum": {
|
||||
"message": "Das minimal benutzerdefinierte Timeout beträgt 1 Minute."
|
||||
"message": "Minimale benutzerdefinierte Timeout-Zeit beträgt 1 Minute."
|
||||
},
|
||||
"inviteAccepted": {
|
||||
"message": "Einladung angenommen"
|
||||
@@ -4165,7 +4165,7 @@
|
||||
"description": "Verb"
|
||||
},
|
||||
"unArchive": {
|
||||
"message": "Nicht mehr archivieren"
|
||||
"message": "Wiederherstellen"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
"message": "Einträge im Archiv"
|
||||
@@ -4177,10 +4177,10 @@
|
||||
"message": "Archivierte Einträge werden hier angezeigt und von allgemeinen Suchergebnissen sowie Auto-Ausfüllen-Vorschlägen ausgeschlossen."
|
||||
},
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Eintrag wurde ins Archiv verschoben"
|
||||
"message": "Eintrag wurde archiviert"
|
||||
},
|
||||
"itemWasUnarchived": {
|
||||
"message": "Eintrag wird nicht mehr archiviert"
|
||||
"message": "Eintrag wurde wiederhergestellt"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Eintrag archivieren"
|
||||
@@ -4201,22 +4201,22 @@
|
||||
"message": "Integrierter Authenticator"
|
||||
},
|
||||
"secureFileStorage": {
|
||||
"message": "Sicherer Dateispeicher"
|
||||
"message": "Sichere Dateispeicherung"
|
||||
},
|
||||
"emergencyAccess": {
|
||||
"message": "Notfallzugriff"
|
||||
},
|
||||
"breachMonitoring": {
|
||||
"message": "Datendiebstahl-Überwachung"
|
||||
"message": "Datenleck-Überwachung"
|
||||
},
|
||||
"andMoreFeatures": {
|
||||
"message": "Und mehr!"
|
||||
"message": "Und vieles mehr!"
|
||||
},
|
||||
"planDescPremium": {
|
||||
"message": "Umfassende Online-Sicherheit"
|
||||
"message": "Kompletter Online-Sicherheitsplan"
|
||||
},
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade auf Premium"
|
||||
"message": "Auf Premium upgraden"
|
||||
},
|
||||
"sessionTimeoutSettingsAction": {
|
||||
"message": "Timeout-Aktion"
|
||||
|
||||
@@ -2228,6 +2228,10 @@
|
||||
"contactInfo": {
|
||||
"message": "Contact information"
|
||||
},
|
||||
"send": {
|
||||
"message": "Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"allSends": {
|
||||
"message": "All Sends",
|
||||
"description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
@@ -2991,7 +2995,8 @@
|
||||
"message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected."
|
||||
},
|
||||
"vault": {
|
||||
"message": "Vault"
|
||||
"message": "Vault",
|
||||
"description": "'Vault' is a noun and refers to the Bitwarden Vault feature."
|
||||
},
|
||||
"loginWithMasterPassword": {
|
||||
"message": "Log in with master password"
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
|
||||
},
|
||||
"contactCSToAvoidDataLossPart2": {
|
||||
"message": "para evitar a perca adicional dos dados.",
|
||||
"message": "para evitar a perca de dados adicionais.",
|
||||
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
|
||||
},
|
||||
"january": {
|
||||
@@ -2088,16 +2088,16 @@
|
||||
}
|
||||
},
|
||||
"policyInEffectUppercase": {
|
||||
"message": "Contém um ou mais caracteres em maiúsculo"
|
||||
"message": "Conter um ou mais caracteres em maiúsculo"
|
||||
},
|
||||
"policyInEffectLowercase": {
|
||||
"message": "Contém um ou mais caracteres em minúsculo"
|
||||
"message": "Conter um ou mais caracteres em minúsculo"
|
||||
},
|
||||
"policyInEffectNumbers": {
|
||||
"message": "Contém um ou mais números"
|
||||
"message": "Conter um ou mais números"
|
||||
},
|
||||
"policyInEffectSpecial": {
|
||||
"message": "Contém um ou mais dos seguintes caracteres especiais $CHARS$",
|
||||
"message": "Conter um ou mais dos seguintes caracteres especiais $CHARS$",
|
||||
"placeholders": {
|
||||
"chars": {
|
||||
"content": "$1",
|
||||
|
||||
@@ -1387,7 +1387,7 @@
|
||||
"message": "语言"
|
||||
},
|
||||
"languageDesc": {
|
||||
"message": "更改应用程序所使用的语言。重启后生效。"
|
||||
"message": "更改应用程序所使用的语言。需要重启。"
|
||||
},
|
||||
"theme": {
|
||||
"message": "主题"
|
||||
@@ -3980,7 +3980,7 @@
|
||||
"message": "关于此设置"
|
||||
},
|
||||
"permitCipherDetailsDescription": {
|
||||
"message": "Bitwarden 将使用已保存的登录 URI 来识别应使用哪个图标或更改密码的 URL 来改善您的体验。当您使用此服务时,不会收集或保存任何信息。"
|
||||
"message": "Bitwarden 将使用已保存的登录 URI 来确定应使用的图标或更改密码的 URL,以提升您的使用体验。使用此服务时不会收集或保存任何信息。"
|
||||
},
|
||||
"assignToCollections": {
|
||||
"message": "分配到集合"
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/desktop",
|
||||
"productName": "Bitwarden",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.11.3",
|
||||
"version": "2025.12.0",
|
||||
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -503,19 +503,4 @@ describe("BiometricMessageHandlerService", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("enables Windows v2 biometrics when feature flag enabled", async () => {
|
||||
configService.getFeatureFlag.mockReturnValue(true);
|
||||
|
||||
await service.init();
|
||||
expect(biometricsService.enableWindowsV2Biometrics).toHaveBeenCalled();
|
||||
});
|
||||
it("does not enable Windows v2 biometrics when feature flag disabled", async () => {
|
||||
configService.getFeatureFlag.mockReturnValue(false);
|
||||
|
||||
await service.init();
|
||||
expect(biometricsService.enableWindowsV2Biometrics).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,13 +119,6 @@ export class BiometricMessageHandlerService {
|
||||
"[BiometricMessageHandlerService] Initializing biometric message handler",
|
||||
);
|
||||
|
||||
const windowsV2Enabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.WindowsBiometricsV2,
|
||||
);
|
||||
if (windowsV2Enabled) {
|
||||
await this.biometricsService.enableWindowsV2Biometrics();
|
||||
}
|
||||
|
||||
const linuxV2Enabled = await this.configService.getFeatureFlag(FeatureFlag.LinuxBiometricsV2);
|
||||
if (linuxV2Enabled) {
|
||||
await this.biometricsService.enableLinuxV2Biometrics();
|
||||
|
||||
@@ -17,9 +17,6 @@ export enum BiometricAction {
|
||||
EnrollPersistent = "enrollPersistent",
|
||||
HasPersistentKey = "hasPersistentKey",
|
||||
|
||||
EnableWindowsV2 = "enableWindowsV2",
|
||||
IsWindowsV2Enabled = "isWindowsV2Enabled",
|
||||
|
||||
EnableLinuxV2 = "enableLinuxV2",
|
||||
IsLinuxV2Enabled = "isLinuxV2Enabled",
|
||||
}
|
||||
|
||||
22
apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts
Normal file
22
apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { VaultComponent } from "./vault.component";
|
||||
|
||||
describe("VaultComponent", () => {
|
||||
let component: VaultComponent;
|
||||
let fixture: ComponentFixture<VaultComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VaultComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
9
apps/desktop/src/vault/app/vault-v3/vault.component.ts
Normal file
9
apps/desktop/src/vault/app/vault-v3/vault.component.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-v3",
|
||||
imports: [],
|
||||
template: "<p>Vault V3 Component</p>",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VaultComponent {}
|
||||
Reference in New Issue
Block a user