mirror of
https://github.com/bitwarden/browser
synced 2026-02-13 06:54:07 +00:00
Merge branch 'main' into dirt/pm-23680/new-report-applications
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<!doctype html>
|
||||
<!-- eslint-disable tailwindcss/no-custom-classname -->
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Bitwarden</title>
|
||||
|
||||
@@ -59,6 +59,14 @@ export class AutoFillConstants {
|
||||
"neue e-mail",
|
||||
];
|
||||
|
||||
static readonly RegistrationKeywords: string[] = [
|
||||
"register",
|
||||
"signup",
|
||||
"sign-up",
|
||||
"join",
|
||||
"create",
|
||||
];
|
||||
|
||||
static readonly NewsletterFormNames: string[] = ["newsletter"];
|
||||
|
||||
static readonly FieldIgnoreList: string[] = ["captcha", "findanything", "forgot"];
|
||||
|
||||
@@ -856,13 +856,28 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
options.fillNewPassword,
|
||||
);
|
||||
|
||||
const loginPasswordFields: AutofillField[] = [];
|
||||
const registrationPasswordFields: AutofillField[] = [];
|
||||
|
||||
passwordFields.forEach((passField) => {
|
||||
if (this.isRegistrationPasswordField(pageDetails, passField)) {
|
||||
registrationPasswordFields.push(passField);
|
||||
} else {
|
||||
loginPasswordFields.push(passField);
|
||||
}
|
||||
});
|
||||
|
||||
// Prefer login fields over registration fields
|
||||
const prioritizedPasswordFields =
|
||||
loginPasswordFields.length > 0 ? loginPasswordFields : registrationPasswordFields;
|
||||
|
||||
for (const formKey in pageDetails.forms) {
|
||||
// eslint-disable-next-line
|
||||
if (!pageDetails.forms.hasOwnProperty(formKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
passwordFields.forEach((passField) => {
|
||||
prioritizedPasswordFields.forEach((passField) => {
|
||||
pf = passField;
|
||||
passwords.push(pf);
|
||||
|
||||
@@ -887,8 +902,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
if (passwordFields.length && !passwords.length) {
|
||||
// The page does not have any forms with password fields. Use the first password field on the page and the
|
||||
// input field just before it as the username.
|
||||
|
||||
pf = passwordFields[0];
|
||||
pf = prioritizedPasswordFields[0];
|
||||
passwords.push(pf);
|
||||
|
||||
if (login.username && pf.elementNumber > 0) {
|
||||
@@ -2251,6 +2265,38 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a password field is part of a registration/signup form.
|
||||
* @param {AutofillPageDetails} pageDetails
|
||||
* @param {AutofillField} passwordField
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
private isRegistrationPasswordField(
|
||||
pageDetails: AutofillPageDetails,
|
||||
passwordField: AutofillField,
|
||||
): boolean {
|
||||
if (!passwordField.form || !pageDetails.forms) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const form = pageDetails.forms[passwordField.form];
|
||||
if (!form) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const formIdentifierValues = [
|
||||
form.htmlID?.toLowerCase?.(),
|
||||
form.htmlName?.toLowerCase?.(),
|
||||
passwordField?.htmlID?.toLowerCase?.(),
|
||||
passwordField?.htmlName?.toLowerCase?.(),
|
||||
].filter(Boolean);
|
||||
|
||||
return formIdentifierValues.some((value) =>
|
||||
AutoFillConstants.RegistrationKeywords.some((keyword) => value.includes(keyword)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a pageDetails object with a list of fields and returns a list of
|
||||
* fields that are likely to be username fields.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { LogoutService } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
VaultTimeoutAction,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
const IdleInterval = 60 * 5; // 5 minutes
|
||||
|
||||
@@ -21,6 +23,7 @@ export default class IdleBackground {
|
||||
private serverNotificationsService: ServerNotificationsService,
|
||||
private accountService: AccountService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private logoutService: LogoutService,
|
||||
) {
|
||||
this.idle = chrome.idle || (browser != null ? browser.idle : null);
|
||||
}
|
||||
@@ -61,7 +64,7 @@ export default class IdleBackground {
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId),
|
||||
);
|
||||
if (action === VaultTimeoutAction.LogOut) {
|
||||
await this.vaultTimeoutService.logOut(userId);
|
||||
await this.logoutService.logout(userId as UserId, "vaultTimeout");
|
||||
} else {
|
||||
await this.vaultTimeoutService.lock(userId);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AuthRequestServiceAbstraction,
|
||||
DefaultAuthRequestApiService,
|
||||
DefaultLockService,
|
||||
DefaultLogoutService,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
LogoutReason,
|
||||
@@ -976,6 +977,7 @@ export default class MainBackground {
|
||||
this.restrictedItemTypesService,
|
||||
);
|
||||
|
||||
const logoutService = new DefaultLogoutService(this.messagingService);
|
||||
this.vaultTimeoutService = new VaultTimeoutService(
|
||||
this.accountService,
|
||||
this.masterPasswordService,
|
||||
@@ -994,7 +996,7 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
this.biometricsService,
|
||||
lockedCallback,
|
||||
logoutCallback,
|
||||
logoutService,
|
||||
);
|
||||
this.containerService = new ContainerService(this.keyService, this.encryptService);
|
||||
|
||||
@@ -1386,6 +1388,7 @@ export default class MainBackground {
|
||||
this.serverNotificationsService,
|
||||
this.accountService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
logoutService,
|
||||
);
|
||||
|
||||
this.usernameGenerationService = legacyUsernameGenerationServiceFactory(
|
||||
|
||||
@@ -87,7 +87,7 @@ export default {
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<auth-anon-layout
|
||||
[hideIcon]="true"
|
||||
[icon]="null"
|
||||
[hideBackgroundIllustration]="true"
|
||||
>
|
||||
<dirt-phishing-warning></dirt-phishing-warning>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Component } from "@angular/core";
|
||||
|
||||
import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-remove-password",
|
||||
templateUrl: "remove-password.component.html",
|
||||
|
||||
@@ -13,8 +13,4 @@ export class ForegroundVaultTimeoutService implements BaseVaultTimeoutService {
|
||||
async lock(userId?: UserId): Promise<void> {
|
||||
this.messagingService.send("lockVault", { userId });
|
||||
}
|
||||
|
||||
async logOut(userId?: string): Promise<void> {
|
||||
this.messagingService.send("logout", { userId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
UserLockIcon,
|
||||
VaultIcon,
|
||||
LockIcon,
|
||||
DomainIcon,
|
||||
TwoFactorAuthSecurityKeyIcon,
|
||||
} from "@bitwarden/assets/svg";
|
||||
import {
|
||||
@@ -565,6 +566,8 @@ const routes: Routes = [
|
||||
key: "verifyYourIdentity",
|
||||
},
|
||||
showBackButton: true,
|
||||
// `TwoFactorAuthComponent` manually sets its icon based on the 2fa type
|
||||
pageIcon: null,
|
||||
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -572,6 +575,7 @@ const routes: Routes = [
|
||||
data: {
|
||||
elevation: 1,
|
||||
hideFooter: true,
|
||||
pageIcon: LockIcon,
|
||||
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
@@ -617,9 +621,9 @@ const routes: Routes = [
|
||||
path: "",
|
||||
component: IntroCarouselComponent,
|
||||
data: {
|
||||
hideIcon: true,
|
||||
pageIcon: null,
|
||||
hideFooter: true,
|
||||
},
|
||||
} satisfies ExtensionAnonLayoutWrapperData,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -637,6 +641,7 @@ const routes: Routes = [
|
||||
key: "confirmKeyConnectorDomain",
|
||||
},
|
||||
showBackButton: true,
|
||||
pageIcon: DomainIcon,
|
||||
} satisfies ExtensionAnonLayoutWrapperData,
|
||||
},
|
||||
],
|
||||
@@ -722,7 +727,7 @@ const routes: Routes = [
|
||||
},
|
||||
],
|
||||
data: {
|
||||
hideIcon: true,
|
||||
pageIcon: null,
|
||||
hideBackgroundIllustration: true,
|
||||
showReadonlyHostname: true,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
|
||||
@@ -39,7 +39,6 @@ import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popou
|
||||
import { AppRoutingModule } from "./app-routing.module";
|
||||
import { AppComponent } from "./app.component";
|
||||
import { ExtensionAnonLayoutWrapperComponent } from "./components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component";
|
||||
import { UserVerificationComponent } from "./components/user-verification.component";
|
||||
import { ServicesModule } from "./services/services.module";
|
||||
import { TabsV2Component } from "./tabs-v2.component";
|
||||
|
||||
@@ -91,7 +90,6 @@ import "../platform/popup/locales";
|
||||
ColorPasswordPipe,
|
||||
ColorPasswordCountPipe,
|
||||
TabsV2Component,
|
||||
UserVerificationComponent,
|
||||
RemovePasswordComponent,
|
||||
],
|
||||
exports: [],
|
||||
|
||||
@@ -11,13 +11,15 @@ export class ExtensionAnonLayoutWrapperDataService
|
||||
extends DefaultAnonLayoutWrapperDataService
|
||||
implements AnonLayoutWrapperDataService
|
||||
{
|
||||
protected override anonLayoutWrapperDataSubject = new Subject<ExtensionAnonLayoutWrapperData>();
|
||||
protected override anonLayoutWrapperDataSubject = new Subject<
|
||||
Partial<ExtensionAnonLayoutWrapperData>
|
||||
>();
|
||||
|
||||
override setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData): void {
|
||||
override setAnonLayoutWrapperData(data: Partial<ExtensionAnonLayoutWrapperData>): void {
|
||||
this.anonLayoutWrapperDataSubject.next(data);
|
||||
}
|
||||
|
||||
override anonLayoutWrapperData$(): Observable<ExtensionAnonLayoutWrapperData> {
|
||||
override anonLayoutWrapperData$(): Observable<Partial<ExtensionAnonLayoutWrapperData>> {
|
||||
return this.anonLayoutWrapperDataSubject.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
[hideLogo]="true"
|
||||
[maxWidth]="maxWidth"
|
||||
[hideFooter]="hideFooter"
|
||||
[hideIcon]="hideIcon"
|
||||
[hideCardWrapper]="hideCardWrapper"
|
||||
>
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData {
|
||||
showBackButton?: boolean;
|
||||
showLogo?: boolean;
|
||||
hideFooter?: boolean;
|
||||
hideIcon?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -50,7 +49,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
protected showAcctSwitcher: boolean;
|
||||
protected showBackButton: boolean;
|
||||
protected showLogo: boolean = true;
|
||||
protected hideIcon: boolean = false;
|
||||
|
||||
protected pageTitle: string;
|
||||
protected pageSubtitle: string;
|
||||
@@ -134,10 +132,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
this.showLogo = Boolean(firstChildRouteData["showLogo"]);
|
||||
}
|
||||
|
||||
if (firstChildRouteData["hideIcon"] !== undefined) {
|
||||
this.hideIcon = Boolean(firstChildRouteData["hideIcon"]);
|
||||
}
|
||||
|
||||
if (firstChildRouteData["hideCardWrapper"] !== undefined) {
|
||||
this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]);
|
||||
}
|
||||
@@ -196,10 +190,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
if (data.showLogo !== undefined) {
|
||||
this.showLogo = data.showLogo;
|
||||
}
|
||||
|
||||
if (data.hideIcon !== undefined) {
|
||||
this.hideIcon = data.hideIcon;
|
||||
}
|
||||
}
|
||||
|
||||
private handleStringOrTranslation(value: string | Translation): string {
|
||||
@@ -222,7 +212,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
this.showLogo = null;
|
||||
this.maxWidth = null;
|
||||
this.hideFooter = null;
|
||||
this.hideIcon = null;
|
||||
this.hideCardWrapper = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,9 @@ export const DefaultContentExample: Story = {
|
||||
children: [
|
||||
{
|
||||
path: "default-example",
|
||||
data: {},
|
||||
data: {
|
||||
pageIcon: LockIcon,
|
||||
} satisfies ExtensionAnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
@@ -244,7 +246,6 @@ const initialData: ExtensionAnonLayoutWrapperData = {
|
||||
showAcctSwitcher: true,
|
||||
showBackButton: true,
|
||||
showLogo: true,
|
||||
hideIcon: false,
|
||||
};
|
||||
|
||||
const changedData: ExtensionAnonLayoutWrapperData = {
|
||||
@@ -258,7 +259,6 @@ const changedData: ExtensionAnonLayoutWrapperData = {
|
||||
showAcctSwitcher: false,
|
||||
showBackButton: false,
|
||||
showLogo: false,
|
||||
hideIcon: false,
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -337,9 +337,9 @@ export const HasLoggedInAccountExample: Story = {
|
||||
{
|
||||
path: "has-logged-in-account",
|
||||
data: {
|
||||
hasLoggedInAccount: true,
|
||||
showAcctSwitcher: true,
|
||||
},
|
||||
pageIcon: LockIcon,
|
||||
} satisfies ExtensionAnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<ng-container *ngIf="hasMasterPassword">
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
|
||||
<input
|
||||
id="masterPassword"
|
||||
type="password"
|
||||
name="MasterPasswordHash"
|
||||
aria-describedby="confirmIdentityHelp"
|
||||
class="form-control"
|
||||
[formControl]="secret"
|
||||
required
|
||||
appAutofocus
|
||||
appInputVerbatim
|
||||
/>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!hasMasterPassword">
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<label class="d-block">{{ "sendVerificationCode" | i18n }}</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
(click)="requestOTP()"
|
||||
[disabled]="disableRequestOTP"
|
||||
>
|
||||
{{ "sendCode" | i18n }}
|
||||
</button>
|
||||
<span class="ml-2 text-success" role="alert" @sent *ngIf="sentCode">
|
||||
<i class="bwi bwi-check-circle" aria-hidden="true"></i>
|
||||
{{ "codeSent" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<label for="verificationCode">{{ "verificationCode" | i18n }}</label>
|
||||
<input
|
||||
id="verificationCode"
|
||||
type="input"
|
||||
name="verificationCode"
|
||||
class="form-control"
|
||||
[formControl]="secret"
|
||||
required
|
||||
appAutofocus
|
||||
appInputVerbatim
|
||||
/>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -1,27 +0,0 @@
|
||||
import { animate, style, transition, trigger } from "@angular/animations";
|
||||
import { Component } from "@angular/core";
|
||||
import { NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
|
||||
import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/auth/components/user-verification.component";
|
||||
/**
|
||||
* @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead.
|
||||
* Each client specific component should eventually be converted over to use one of these new components.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-user-verification",
|
||||
templateUrl: "user-verification.component.html",
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
multi: true,
|
||||
useExisting: UserVerificationComponent,
|
||||
},
|
||||
],
|
||||
animations: [
|
||||
trigger("sent", [
|
||||
transition(":enter", [style({ opacity: 0 }), animate("100ms", style({ opacity: 1 }))]),
|
||||
]),
|
||||
],
|
||||
standalone: false,
|
||||
})
|
||||
export class UserVerificationComponent extends BaseComponent {}
|
||||
@@ -7,6 +7,8 @@ import { CalloutModule } from "@bitwarden/components";
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
import { FilePopoutUtilsService } from "../services/file-popout-utils.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "tools-file-popout-callout",
|
||||
templateUrl: "file-popout-callout.component.html",
|
||||
|
||||
@@ -25,6 +25,8 @@ import { PopupFooterComponent } from "../../../platform/popup/layout/popup-foote
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-credential-generator-history",
|
||||
templateUrl: "credential-generator-history.component.html",
|
||||
@@ -52,6 +54,8 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input()
|
||||
account: Account | null;
|
||||
|
||||
@@ -60,6 +64,8 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O
|
||||
*
|
||||
* @warning this may reveal sensitive information in plaintext.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input()
|
||||
debug: boolean = false;
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "credential-generator",
|
||||
templateUrl: "credential-generator.component.html",
|
||||
|
||||
@@ -60,6 +60,8 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
|
||||
/**
|
||||
* Component for adding or editing a send item.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "tools-send-add-edit",
|
||||
templateUrl: "send-add-edit.component.html",
|
||||
|
||||
@@ -20,6 +20,8 @@ import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-fo
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-send-created",
|
||||
templateUrl: "./send-created.component.html",
|
||||
|
||||
@@ -10,12 +10,16 @@ import { FilePopoutUtilsService } from "../../services/file-popout-utils.service
|
||||
|
||||
import { SendFilePopoutDialogComponent } from "./send-file-popout-dialog.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "send-file-popout-dialog-container",
|
||||
templateUrl: "./send-file-popout-dialog-container.component.html",
|
||||
imports: [JslibModule, CommonModule],
|
||||
})
|
||||
export class SendFilePopoutDialogContainerComponent implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
config = input.required<SendFormConfig>();
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bi
|
||||
|
||||
import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "send-file-popout-dialog",
|
||||
templateUrl: "./send-file-popout-dialog.component.html",
|
||||
|
||||
@@ -39,6 +39,8 @@ export enum SendState {
|
||||
NoResults,
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "send-v2.component.html",
|
||||
providers: [
|
||||
|
||||
@@ -16,11 +16,15 @@ import {
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "about-dialog.component.html",
|
||||
imports: [CommonModule, JslibModule, DialogModule, ButtonModule, TypographyModule],
|
||||
})
|
||||
export class AboutDialogComponent {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild("version") protected version!: ElementRef;
|
||||
|
||||
protected year = new Date().getFullYear();
|
||||
|
||||
@@ -29,6 +29,8 @@ const RateUrls = {
|
||||
[DeviceType.SafariExtension]: "https://apps.apple.com/app/bitwarden/id1352778147",
|
||||
};
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "about-page-v2.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -12,6 +12,8 @@ import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-fo
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "export-browser-v2.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -20,6 +20,8 @@ import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-fo
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "import-browser-v2.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -24,6 +24,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "settings-v2.component.html",
|
||||
imports: [
|
||||
|
||||
32
apps/desktop/desktop_native/Cargo.lock
generated
32
apps/desktop/desktop_native/Cargo.lock
generated
@@ -591,6 +591,19 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -904,6 +917,7 @@ dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"cbc",
|
||||
"chacha20poly1305",
|
||||
"core-foundation",
|
||||
"desktop_objc",
|
||||
"dirs",
|
||||
@@ -923,6 +937,8 @@ dependencies = [
|
||||
"secmem-proc",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ssh-encoding",
|
||||
"ssh-key",
|
||||
@@ -1817,13 +1833,14 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"desktop_core",
|
||||
"futures",
|
||||
"log",
|
||||
"oslog",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-oslog",
|
||||
"tracing-subscriber",
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
@@ -2569,6 +2586,7 @@ dependencies = [
|
||||
"ctor 0.5.0",
|
||||
"desktop_core",
|
||||
"libc",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3413,6 +3431,18 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-oslog"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"tracing-core",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
|
||||
@@ -27,6 +27,7 @@ bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", re
|
||||
byteorder = "=1.5.0"
|
||||
bytes = "=1.10.1"
|
||||
cbc = "=0.1.2"
|
||||
chacha20poly1305 = "=0.10.1"
|
||||
core-foundation = "=0.10.1"
|
||||
ctor = "=0.5.0"
|
||||
dirs = "=6.0.0"
|
||||
@@ -78,6 +79,10 @@ zbus_polkit = "=5.0.0"
|
||||
zeroizing-alloc = "=0.1.0"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
# Dis-allow println and eprintln, which are typically used in debugging.
|
||||
# Use `tracing` and `tracing-subscriber` crates for observability needs.
|
||||
print_stderr = "deny"
|
||||
print_stdout = "deny"
|
||||
string_slice = "warn"
|
||||
unused_async = "deny"
|
||||
unwrap_used = "deny"
|
||||
string_slice = "warn"
|
||||
|
||||
@@ -331,7 +331,6 @@ mod tests {
|
||||
fn get_alphabetic_hot_key_happy() {
|
||||
for c in ('a'..='z').chain('A'..='Z') {
|
||||
let letter = c.to_string();
|
||||
println!("{}", letter);
|
||||
let converted = get_alphabetic_hotkey(letter).unwrap();
|
||||
assert_eq!(converted, c as u16);
|
||||
}
|
||||
|
||||
@@ -173,6 +173,8 @@ mod tests {
|
||||
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
|
||||
|
||||
let expected: HashSet<String> = HashSet::from([
|
||||
"bravecsv".to_string(),
|
||||
"chromecsv".to_string(),
|
||||
"chromiumcsv".to_string(),
|
||||
"edgecsv".to_string(),
|
||||
"operacsv".to_string(),
|
||||
@@ -192,7 +194,14 @@ mod tests {
|
||||
#[test]
|
||||
fn windows_specific_loaders_match_const_array() {
|
||||
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
|
||||
let ids = ["chromiumcsv", "edgecsv", "operacsv", "vivaldicsv"];
|
||||
let ids = [
|
||||
"bravecsv",
|
||||
"chromecsv",
|
||||
"chromiumcsv",
|
||||
"edgecsv",
|
||||
"operacsv",
|
||||
"vivaldicsv",
|
||||
];
|
||||
|
||||
for id in ids {
|
||||
let loaders = get_loaders(&map, id);
|
||||
|
||||
@@ -16,7 +16,15 @@ use crate::util;
|
||||
//
|
||||
|
||||
// IMPORTANT adjust array size when enabling / disabling chromium importers here
|
||||
pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [
|
||||
pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [
|
||||
BrowserConfig {
|
||||
name: "Brave",
|
||||
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chrome",
|
||||
data_dir: "AppData/Local/Google/Chrome/User Data",
|
||||
},
|
||||
BrowserConfig {
|
||||
name: "Chromium",
|
||||
data_dir: "AppData/Local/Chromium/User Data",
|
||||
|
||||
@@ -26,6 +26,7 @@ bitwarden-russh = { workspace = true }
|
||||
byteorder = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
cbc = { workspace = true, features = ["alloc"] }
|
||||
chacha20poly1305 = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
ed25519 = { workspace = true, features = ["pkcs8"] }
|
||||
futures = { workspace = true }
|
||||
@@ -38,6 +39,8 @@ rsa = { workspace = true }
|
||||
russh-cryptovec = { workspace = true }
|
||||
scopeguard = { workspace = true }
|
||||
secmem-proc = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
ssh-encoding = { workspace = true }
|
||||
ssh-key = { workspace = true, features = [
|
||||
@@ -64,6 +67,7 @@ windows = { workspace = true, features = [
|
||||
"Storage_Streams",
|
||||
"Win32_Foundation",
|
||||
"Win32_Security_Credentials",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_System_WinRT",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
|
||||
33
apps/desktop/desktop_native/core/src/biometric_v2/mod.rs
Normal file
33
apps/desktop/desktop_native/core/src/biometric_v2/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use anyhow::Result;
|
||||
|
||||
#[allow(clippy::module_inception)]
|
||||
#[cfg_attr(target_os = "linux", path = "unimplemented.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||
mod biometric_v2;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows_focus;
|
||||
|
||||
pub use biometric_v2::BiometricLockSystem;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait BiometricTrait: Send + Sync {
|
||||
/// Authenticate the user
|
||||
async fn authenticate(&self, hwnd: Vec<u8>, message: String) -> Result<bool>;
|
||||
/// Check if biometric authentication is available
|
||||
async fn authenticate_available(&self) -> Result<bool>;
|
||||
/// Enroll a key for persistent unlock. If the implementation does not support persistent enrollment,
|
||||
/// this function should do nothing.
|
||||
async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>;
|
||||
/// Clear the persistent and ephemeral keys
|
||||
async fn unenroll(&self, user_id: &str) -> Result<()>;
|
||||
/// Check if a persistent (survives app restarts and reboots) key is set for a user
|
||||
async fn has_persistent(&self, user_id: &str) -> Result<bool>;
|
||||
/// Provide a key to be ephemerally held. This should be called on every unlock.
|
||||
async fn provide_key(&self, user_id: &str, key: &[u8]);
|
||||
/// Perform biometric unlock and return the key
|
||||
async fn unlock(&self, user_id: &str, hwnd: Vec<u8>) -> Result<Vec<u8>>;
|
||||
/// Check if biometric unlock is available based on whether a key is present and whether authentication is possible
|
||||
async fn unlock_available(&self, user_id: &str) -> Result<bool>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
pub struct BiometricLockSystem {}
|
||||
|
||||
impl BiometricLockSystem {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BiometricLockSystem {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl super::BiometricTrait for BiometricLockSystem {
|
||||
async fn authenticate(&self, _hwnd: Vec<u8>, _message: String) -> Result<bool, anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn authenticate_available(&self) -> Result<bool, anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<(), anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn provide_key(&self, _user_id: &str, _key: &[u8]) {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unlock(&self, _user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>, anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unlock_available(&self, _user_id: &str) -> Result<bool, anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn has_persistent(&self, _user_id: &str) -> Result<bool, anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unenroll(&self, _user_id: &str) -> Result<(), anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
506
apps/desktop/desktop_native/core/src/biometric_v2/windows.rs
Normal file
506
apps/desktop/desktop_native/core/src/biometric_v2/windows.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
//! This file implements Windows-Hello based biometric unlock.
|
||||
//!
|
||||
//! There are two paths implemented here.
|
||||
//! The former via UV + ephemerally (but protected) keys. This only works after first unlock.
|
||||
//! The latter via a signing API, that deterministically signs a challenge, from which a windows hello key is derived. This key
|
||||
//! is used to encrypt the protected key.
|
||||
//!
|
||||
//! # Security
|
||||
//! The security goal is that a locked vault - a running app - cannot be unlocked when the device (user-space)
|
||||
//! is compromised in this state.
|
||||
//!
|
||||
//! ## UV path
|
||||
//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory,
|
||||
//! protected by DPAPI. This makes it inaccessible to other processes, unless they compromise the system administrator, or kernel.
|
||||
//! While the app is running this key is held in memory, even if locked. When unlocking, the app will prompt the user via
|
||||
//! `windows_hello_authenticate` to get a yes/no decision on whether to release the key to the app.
|
||||
//! Note: Further process isolation is needed here so that code cannot be injected into the running process, which may
|
||||
//! circumvent DPAPI.
|
||||
//!
|
||||
//! ## Sign path
|
||||
//! In this scenario, when enrolling, the app sends the user-key to this module, which derives the windows hello key
|
||||
//! with the Windows Hello prompt. This is done by signing a per-user challenge, which produces a deterministic
|
||||
//! signature which is hashed to obtain a key. This key is used to encrypt and persist the vault unlock key (user key).
|
||||
//!
|
||||
//! Since the keychain can be accessed by all user-space processes, the challenge is known to all userspace processes.
|
||||
//! Therefore, to circumvent the security measure, the attacker would need to create a fake Windows-Hello prompt, and
|
||||
//! get the user to confirm it.
|
||||
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use aes::cipher::KeyInit;
|
||||
use anyhow::{anyhow, Result};
|
||||
use chacha20poly1305::{aead::Aead, XChaCha20Poly1305, XNonce};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Mutex;
|
||||
use windows::{
|
||||
core::{factory, h, Interface, HSTRING},
|
||||
Security::{
|
||||
Credentials::{
|
||||
KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus,
|
||||
UI::{
|
||||
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
|
||||
},
|
||||
},
|
||||
Cryptography::CryptographicBuffer,
|
||||
},
|
||||
Storage::Streams::IBuffer,
|
||||
Win32::{
|
||||
System::WinRT::{IBufferByteAccess, IUserConsentVerifierInterop},
|
||||
UI::WindowsAndMessaging::GetForegroundWindow,
|
||||
},
|
||||
};
|
||||
use windows_future::IAsyncOperation;
|
||||
|
||||
use super::windows_focus::{focus_security_prompt, restore_focus};
|
||||
use crate::{
|
||||
password::{self, PASSWORD_NOT_FOUND},
|
||||
secure_memory::*,
|
||||
};
|
||||
|
||||
const KEYCHAIN_SERVICE_NAME: &str = "BitwardenBiometricsV2";
|
||||
const CREDENTIAL_NAME: &HSTRING = h!("BitwardenBiometricsV2");
|
||||
const CHALLENGE_LENGTH: usize = 16;
|
||||
const XCHACHA20POLY1305_NONCE_LENGTH: usize = 24;
|
||||
const XCHACHA20POLY1305_KEY_LENGTH: usize = 32;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct WindowsHelloKeychainEntry {
|
||||
nonce: [u8; XCHACHA20POLY1305_NONCE_LENGTH],
|
||||
challenge: [u8; CHALLENGE_LENGTH],
|
||||
wrapped_key: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The Windows OS implementation of the biometric trait.
|
||||
pub struct BiometricLockSystem {
|
||||
// The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure
|
||||
// locked vaults cannot be unlocked
|
||||
secure_memory: Arc<Mutex<crate::secure_memory::dpapi::DpapiSecretKVStore>>,
|
||||
}
|
||||
|
||||
impl BiometricLockSystem {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
secure_memory: Arc::new(Mutex::new(
|
||||
crate::secure_memory::dpapi::DpapiSecretKVStore::new(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BiometricLockSystem {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl super::BiometricTrait for BiometricLockSystem {
|
||||
async fn authenticate(&self, _hwnd: Vec<u8>, message: String) -> Result<bool> {
|
||||
windows_hello_authenticate(message).await
|
||||
}
|
||||
|
||||
async fn authenticate_available(&self) -> Result<bool> {
|
||||
match UserConsentVerifier::CheckAvailabilityAsync()?.await? {
|
||||
UserConsentVerifierAvailability::Available
|
||||
| UserConsentVerifierAvailability::DeviceBusy => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn unenroll(&self, user_id: &str) -> Result<()> {
|
||||
self.secure_memory.lock().await.remove(user_id);
|
||||
delete_keychain_entry(user_id).await
|
||||
}
|
||||
|
||||
async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()> {
|
||||
// Enrollment works by first generating a random challenge unique to the user / enrollment. Then,
|
||||
// with the challenge and a Windows-Hello prompt, the "windows hello key" is derived. The windows
|
||||
// hello key is used to encrypt the key to store with XChaCha20Poly1305. The bundle of nonce,
|
||||
// challenge and wrapped-key are stored to the keychain
|
||||
|
||||
// Each enrollment (per user) has a unique challenge, so that the windows-hello key is unique
|
||||
let challenge: [u8; CHALLENGE_LENGTH] = rand::random();
|
||||
|
||||
// This key is unique to the challenge
|
||||
let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge).await?;
|
||||
let (wrapped_key, nonce) = encrypt_data(&windows_hello_key, key)?;
|
||||
|
||||
set_keychain_entry(
|
||||
user_id,
|
||||
&WindowsHelloKeychainEntry {
|
||||
nonce,
|
||||
challenge,
|
||||
wrapped_key,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn provide_key(&self, user_id: &str, key: &[u8]) {
|
||||
self.secure_memory
|
||||
.lock()
|
||||
.await
|
||||
.put(user_id.to_string(), key);
|
||||
}
|
||||
|
||||
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
|
||||
// Allow restoring focus to the previous window (browser)
|
||||
let previous_active_window = super::windows_focus::get_active_window();
|
||||
let _focus_scopeguard = scopeguard::guard((), |_| {
|
||||
if let Some(hwnd) = previous_active_window {
|
||||
debug!("Restoring focus to previous window");
|
||||
restore_focus(hwnd.0);
|
||||
}
|
||||
});
|
||||
|
||||
let mut secure_memory = self.secure_memory.lock().await;
|
||||
// If the key is held ephemerally, always use UV API. Only use signing API if the key is not held
|
||||
// ephemerally but the keychain holds it persistently.
|
||||
if secure_memory.has(user_id) {
|
||||
if windows_hello_authenticate("Unlock your vault".to_string()).await? {
|
||||
secure_memory
|
||||
.get(user_id)
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("No key found for user"))
|
||||
} else {
|
||||
Err(anyhow!("Authentication failed"))
|
||||
}
|
||||
} else {
|
||||
let keychain_entry = get_keychain_entry(user_id).await?;
|
||||
let windows_hello_key =
|
||||
windows_hello_authenticate_with_crypto(&keychain_entry.challenge).await?;
|
||||
let decrypted_key = decrypt_data(
|
||||
&windows_hello_key,
|
||||
&keychain_entry.wrapped_key,
|
||||
&keychain_entry.nonce,
|
||||
)?;
|
||||
// The first unlock already sets the key for subsequent unlocks. The key may again be set externally after unlock finishes.
|
||||
secure_memory.put(user_id.to_string(), &decrypted_key.clone());
|
||||
Ok(decrypted_key)
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
|
||||
let secure_memory = self.secure_memory.lock().await;
|
||||
let has_key =
|
||||
secure_memory.has(user_id) || has_keychain_entry(user_id).await.unwrap_or(false);
|
||||
Ok(has_key && self.authenticate_available().await.unwrap_or(false))
|
||||
}
|
||||
|
||||
async fn has_persistent(&self, user_id: &str) -> Result<bool> {
|
||||
Ok(get_keychain_entry(user_id).await.is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a yes/no authorization without any cryptographic backing.
|
||||
/// This API has better focusing behavior
|
||||
async fn windows_hello_authenticate(message: String) -> Result<bool> {
|
||||
debug!(
|
||||
"[Windows Hello] Authenticating to perform UV with message: {}",
|
||||
message
|
||||
);
|
||||
|
||||
let userconsent_result: IAsyncOperation<UserConsentVerificationResult> = unsafe {
|
||||
// Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint
|
||||
// unlock will not work. We get the current foreground window, which will either be the
|
||||
// Bitwarden desktop app or the browser extension.
|
||||
let foreground_window = GetForegroundWindow();
|
||||
factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?
|
||||
.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
|
||||
};
|
||||
|
||||
match userconsent_result.await? {
|
||||
UserConsentVerificationResult::Verified => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the symmetric encryption key from the Windows Hello signature.
|
||||
///
|
||||
/// This works by signing a static challenge string with Windows Hello protected key store. The
|
||||
/// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the
|
||||
/// Windows Hello protected keys.
|
||||
///
|
||||
/// Windows will only sign the challenge if the user has successfully authenticated with Windows,
|
||||
/// ensuring user presence.
|
||||
///
|
||||
/// Note: This API has inconsistent focusing behavior when called from another window
|
||||
async fn windows_hello_authenticate_with_crypto(
|
||||
challenge: &[u8; CHALLENGE_LENGTH],
|
||||
) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> {
|
||||
debug!("[Windows Hello] Authenticating to sign challenge");
|
||||
|
||||
// Ugly hack: We need to focus the window via window focusing APIs until Microsoft releases a new API.
|
||||
// This is unreliable, and if it does not work, the operation may fail
|
||||
let stop_focusing = Arc::new(AtomicBool::new(false));
|
||||
let stop_focusing_clone = stop_focusing.clone();
|
||||
let _ = std::thread::spawn(move || loop {
|
||||
if !stop_focusing_clone.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
focus_security_prompt();
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
});
|
||||
// Only stop focusing once this function exits. The focus MUST run both during the initial creation
|
||||
// with RequestCreateAsync, and also with the subsequent use with RequestSignAsync.
|
||||
let _guard = scopeguard::guard((), |_| {
|
||||
stop_focusing.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
|
||||
// First create or replace the Bitwarden Biometrics signing key
|
||||
let credential = {
|
||||
let key_credential_creation_result = KeyCredentialManager::RequestCreateAsync(
|
||||
CREDENTIAL_NAME,
|
||||
KeyCredentialCreationOption::FailIfExists,
|
||||
)?
|
||||
.await?;
|
||||
match key_credential_creation_result.Status()? {
|
||||
KeyCredentialStatus::CredentialAlreadyExists => {
|
||||
KeyCredentialManager::OpenAsync(CREDENTIAL_NAME)?.await?
|
||||
}
|
||||
KeyCredentialStatus::Success => key_credential_creation_result,
|
||||
_ => return Err(anyhow!("Failed to create key credential")),
|
||||
}
|
||||
}
|
||||
.Credential()?;
|
||||
|
||||
let signature = {
|
||||
let sign_operation = credential.RequestSignAsync(
|
||||
&CryptographicBuffer::CreateFromByteArray(challenge.as_slice())?,
|
||||
)?;
|
||||
|
||||
// We need to drop the credential here to avoid holding it across an await point.
|
||||
drop(credential);
|
||||
sign_operation.await?
|
||||
};
|
||||
|
||||
if signature.Status()? != KeyCredentialStatus::Success {
|
||||
return Err(anyhow!("Failed to sign data"));
|
||||
}
|
||||
|
||||
let signature_buffer = signature.Result()?;
|
||||
let signature_value = unsafe { as_mut_bytes(&signature_buffer)? };
|
||||
|
||||
// The signature is deterministic based on the challenge and keychain key. Thus, it can be hashed to a key.
|
||||
// It is unclear what entropy this key provides.
|
||||
let windows_hello_key = Sha256::digest(signature_value).into();
|
||||
Ok(windows_hello_key)
|
||||
}
|
||||
|
||||
async fn set_keychain_entry(user_id: &str, entry: &WindowsHelloKeychainEntry) -> Result<()> {
|
||||
password::set_password(
|
||||
KEYCHAIN_SERVICE_NAME,
|
||||
user_id,
|
||||
&serde_json::to_string(entry)?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_keychain_entry(user_id: &str) -> Result<WindowsHelloKeychainEntry> {
|
||||
serde_json::from_str(&password::get_password(KEYCHAIN_SERVICE_NAME, user_id).await?)
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
async fn delete_keychain_entry(user_id: &str) -> Result<()> {
|
||||
password::delete_password(KEYCHAIN_SERVICE_NAME, user_id)
|
||||
.await
|
||||
.or_else(|e| {
|
||||
if e.to_string() == PASSWORD_NOT_FOUND {
|
||||
debug!(
|
||||
"[Windows Hello] No keychain entry found for user {}, nothing to delete",
|
||||
user_id
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn has_keychain_entry(user_id: &str) -> Result<bool> {
|
||||
password::get_password(KEYCHAIN_SERVICE_NAME, user_id)
|
||||
.await
|
||||
.map(|entry| !entry.is_empty())
|
||||
.or_else(|e| {
|
||||
if e.to_string() == PASSWORD_NOT_FOUND {
|
||||
Ok(false)
|
||||
} else {
|
||||
warn!(
|
||||
"[Windows Hello] Error checking keychain entry for user {}: {}",
|
||||
user_id, e
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt data with XChaCha20Poly1305
|
||||
fn encrypt_data(
|
||||
key: &[u8; XCHACHA20POLY1305_KEY_LENGTH],
|
||||
plaintext: &[u8],
|
||||
) -> Result<(Vec<u8>, [u8; XCHACHA20POLY1305_NONCE_LENGTH])> {
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LENGTH];
|
||||
rand::fill(&mut nonce);
|
||||
let ciphertext = cipher
|
||||
.encrypt(XNonce::from_slice(&nonce), plaintext)
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok((ciphertext, nonce))
|
||||
}
|
||||
|
||||
/// Decrypt data with XChaCha20Poly1305
|
||||
fn decrypt_data(
|
||||
key: &[u8; XCHACHA20POLY1305_KEY_LENGTH],
|
||||
ciphertext: &[u8],
|
||||
nonce: &[u8; XCHACHA20POLY1305_NONCE_LENGTH],
|
||||
) -> Result<Vec<u8>> {
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
let plaintext = cipher
|
||||
.decrypt(XNonce::from_slice(nonce), ciphertext)
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> {
|
||||
let interop = buffer.cast::<IBufferByteAccess>()?;
|
||||
|
||||
unsafe {
|
||||
let data = interop.Buffer()?;
|
||||
Ok(std::slice::from_raw_parts_mut(
|
||||
data,
|
||||
buffer.Length()? as usize,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::print_stdout)]
|
||||
mod tests {
|
||||
use crate::biometric_v2::{
|
||||
biometric_v2::{
|
||||
decrypt_data, encrypt_data, has_keychain_entry, windows_hello_authenticate,
|
||||
windows_hello_authenticate_with_crypto, CHALLENGE_LENGTH, XCHACHA20POLY1305_KEY_LENGTH,
|
||||
},
|
||||
BiometricLockSystem, BiometricTrait,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() {
|
||||
let key = [0u8; 32];
|
||||
let plaintext = b"Test data";
|
||||
let (ciphertext, nonce) = encrypt_data(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt_data(&key, &ciphertext, &nonce).unwrap();
|
||||
assert_eq!(plaintext.to_vec(), decrypted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_has_keychain_entry_no_entry() {
|
||||
let user_id = "test_user";
|
||||
let has_entry = has_keychain_entry(user_id).await.unwrap();
|
||||
assert!(!has_entry);
|
||||
}
|
||||
|
||||
// Note: These tests are ignored because they require manual intervention to run
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_windows_hello_authenticate_with_crypto_manual() {
|
||||
let challenge = [0u8; CHALLENGE_LENGTH];
|
||||
let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge)
|
||||
.await
|
||||
.unwrap();
|
||||
println!(
|
||||
"Windows hello key {:?} for challenge {:?}",
|
||||
windows_hello_key, challenge
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_windows_hello_authenticate() {
|
||||
let authenticated =
|
||||
windows_hello_authenticate("Test Windows Hello authentication".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
println!("Windows Hello authentication result: {:?}", authenticated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_double_unenroll() {
|
||||
let user_id = "test_user";
|
||||
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
|
||||
rand::fill(&mut key);
|
||||
|
||||
let windows_hello_lock_system = BiometricLockSystem::new();
|
||||
|
||||
println!("Enrolling user");
|
||||
windows_hello_lock_system
|
||||
.enroll_persistent(user_id, &key)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
println!("Unlocking user");
|
||||
let key_after_unlock = windows_hello_lock_system
|
||||
.unlock(user_id, Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(key_after_unlock, key);
|
||||
|
||||
println!("Unenrolling user");
|
||||
windows_hello_lock_system.unenroll(user_id).await.unwrap();
|
||||
assert!(!windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
println!("Unenrolling user again");
|
||||
|
||||
// This throws PASSWORD_NOT_FOUND but our code should handle that and not throw.
|
||||
windows_hello_lock_system.unenroll(user_id).await.unwrap();
|
||||
assert!(!windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_enroll_unlock_unenroll() {
|
||||
let user_id = "test_user";
|
||||
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
|
||||
rand::fill(&mut key);
|
||||
|
||||
let windows_hello_lock_system = BiometricLockSystem::new();
|
||||
|
||||
println!("Enrolling user");
|
||||
windows_hello_lock_system
|
||||
.enroll_persistent(user_id, &key)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
println!("Unlocking user");
|
||||
let key_after_unlock = windows_hello_lock_system
|
||||
.unlock(user_id, Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(key_after_unlock, key);
|
||||
|
||||
println!("Unenrolling user");
|
||||
windows_hello_lock_system.unenroll(user_id).await.unwrap();
|
||||
assert!(!windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use windows::{
|
||||
core::s,
|
||||
Win32::{
|
||||
Foundation::HWND,
|
||||
System::Threading::{AttachThreadInput, GetCurrentThreadId},
|
||||
UI::{
|
||||
Input::KeyboardAndMouse::{EnableWindow, SetActiveWindow, SetCapture, SetFocus},
|
||||
WindowsAndMessaging::{
|
||||
BringWindowToTop, FindWindowA, GetForegroundWindow, GetWindowThreadProcessId,
|
||||
SetForegroundWindow, SwitchToThisWindow, SystemParametersInfoW, SPIF_SENDCHANGE,
|
||||
SPIF_UPDATEINIFILE, SPI_GETFOREGROUNDLOCKTIMEOUT, SPI_SETFOREGROUNDLOCKTIMEOUT,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) struct HwndHolder(pub(crate) HWND);
|
||||
unsafe impl Send for HwndHolder {}
|
||||
|
||||
pub(crate) fn get_active_window() -> Option<HwndHolder> {
|
||||
unsafe { Some(HwndHolder(GetForegroundWindow())) }
|
||||
}
|
||||
|
||||
/// Searches for a window that looks like a security prompt and set it as focused.
|
||||
/// Only works when the process has permission to foreground, either by being in foreground
|
||||
/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
|
||||
pub fn focus_security_prompt() {
|
||||
let hwnd_result = unsafe { FindWindowA(s!("Credential Dialog Xaml Host"), None) };
|
||||
if let Ok(hwnd) = hwnd_result {
|
||||
set_focus(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets focus to a window using a few unstable methods
|
||||
fn set_focus(hwnd: HWND) {
|
||||
unsafe {
|
||||
// Windows REALLY does not like apps stealing focus, even if it is for fixing Windows-Hello bugs.
|
||||
// The windows hello signing prompt NEEDS to be focused instantly, or it will error, but it does
|
||||
// not focus itself.
|
||||
|
||||
// This function implements forced focusing of windows using a few hacks.
|
||||
// The conditions to successfully foreground a window are:
|
||||
// All of the following conditions are true:
|
||||
// The calling process belongs to a desktop application, not a UWP app or a Windows Store app designed for Windows 8 or 8.1.
|
||||
// The foreground process has not disabled calls to SetForegroundWindow by a previous call to the LockSetForegroundWindow function.
|
||||
// The foreground lock time-out has expired (see SPI_GETFOREGROUNDLOCKTIMEOUT in SystemParametersInfo).
|
||||
// No menus are active.
|
||||
// Additionally, at least one of the following conditions is true:
|
||||
// The calling process is the foreground process.
|
||||
// The calling process was started by the foreground process.
|
||||
// There is currently no foreground window, and thus no foreground process.
|
||||
// The calling process received the last input event.
|
||||
// Either the foreground process or the calling process is being debugged.
|
||||
|
||||
// Update the foreground lock timeout temporarily
|
||||
let mut old_timeout = 0;
|
||||
let _ = SystemParametersInfoW(
|
||||
SPI_GETFOREGROUNDLOCKTIMEOUT,
|
||||
0,
|
||||
Some(&mut old_timeout as *mut _ as *mut std::ffi::c_void),
|
||||
windows::Win32::UI::WindowsAndMessaging::SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
|
||||
);
|
||||
let _ = SystemParametersInfoW(
|
||||
SPI_SETFOREGROUNDLOCKTIMEOUT,
|
||||
0,
|
||||
None,
|
||||
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
|
||||
);
|
||||
let _scopeguard = scopeguard::guard((), |_| {
|
||||
let _ = SystemParametersInfoW(
|
||||
SPI_SETFOREGROUNDLOCKTIMEOUT,
|
||||
old_timeout,
|
||||
None,
|
||||
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
|
||||
);
|
||||
});
|
||||
|
||||
// Attach to the foreground thread once attached, we can foreground, even if in the background
|
||||
let dw_current_thread = GetCurrentThreadId();
|
||||
let dw_fg_thread = GetWindowThreadProcessId(GetForegroundWindow(), None);
|
||||
|
||||
let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, true);
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
SetCapture(hwnd);
|
||||
let _ = SetFocus(Some(hwnd));
|
||||
let _ = SetActiveWindow(hwnd);
|
||||
let _ = EnableWindow(hwnd, true);
|
||||
let _ = BringWindowToTop(hwnd);
|
||||
SwitchToThisWindow(hwnd, true);
|
||||
let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// When restoring focus to the application window, we need a less aggressive method so the electron window doesn't get frozen.
|
||||
pub(crate) fn restore_focus(hwnd: HWND) {
|
||||
unsafe {
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
let _ = SetFocus(Some(hwnd));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
pub mod autofill;
|
||||
pub mod autostart;
|
||||
pub mod biometric;
|
||||
pub mod biometric_v2;
|
||||
pub mod clipboard;
|
||||
pub mod crypto;
|
||||
pub(crate) mod crypto;
|
||||
pub mod error;
|
||||
pub mod ipc;
|
||||
pub mod password;
|
||||
pub mod powermonitor;
|
||||
pub mod process_isolation;
|
||||
pub(crate) mod secure_memory;
|
||||
pub mod ssh_agent;
|
||||
|
||||
use zeroizing_alloc::ZeroAlloc;
|
||||
|
||||
134
apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs
Normal file
134
apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use windows::Win32::Security::Cryptography::{
|
||||
CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE,
|
||||
CRYPTPROTECTMEMORY_SAME_PROCESS,
|
||||
};
|
||||
|
||||
use crate::secure_memory::SecureMemoryStore;
|
||||
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata
|
||||
/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound
|
||||
/// to the current process, and cannot be decrypted by other user-mode processes.
|
||||
///
|
||||
/// Note: Admin processes can still decrypt this memory:
|
||||
/// https://blog.slowerzs.net/posts/cryptdecryptmemory/
|
||||
pub(crate) struct DpapiSecretKVStore {
|
||||
map: HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl DpapiSecretKVStore {
|
||||
pub(crate) fn new() -> Self {
|
||||
DpapiSecretKVStore {
|
||||
map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecureMemoryStore for DpapiSecretKVStore {
|
||||
fn put(&mut self, key: String, value: &[u8]) {
|
||||
let length_header_len = std::mem::size_of::<usize>();
|
||||
|
||||
// The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it and write the length in front
|
||||
// We are storing LENGTH|DATA|00..00, where LENGTH is the length of DATA, the total length is a multiple
|
||||
// of CRYPTPROTECTMEMORY_BLOCK_SIZE, and the padding is filled with zeros.
|
||||
|
||||
let data_len = value.len();
|
||||
let len_with_header = data_len + length_header_len;
|
||||
let padded_length = len_with_header + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize
|
||||
- (len_with_header % CRYPTPROTECTMEMORY_BLOCK_SIZE as usize);
|
||||
let mut padded_data = vec![0u8; padded_length];
|
||||
padded_data[..length_header_len].copy_from_slice(&data_len.to_le_bytes());
|
||||
padded_data[length_header_len..][..data_len].copy_from_slice(value);
|
||||
|
||||
// Protect the memory using DPAPI
|
||||
unsafe {
|
||||
CryptProtectMemory(
|
||||
padded_data.as_mut_ptr() as *mut core::ffi::c_void,
|
||||
padded_length as u32,
|
||||
CRYPTPROTECTMEMORY_SAME_PROCESS,
|
||||
)
|
||||
}
|
||||
.expect("crypt_protect_memory should work");
|
||||
|
||||
self.map.insert(key, padded_data);
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.map.get(key).map(|data| {
|
||||
// A copy is created, that is then mutated by the DPAPI unprotect function.
|
||||
let mut data = data.clone();
|
||||
unsafe {
|
||||
CryptUnprotectMemory(
|
||||
data.as_mut_ptr() as *mut core::ffi::c_void,
|
||||
data.len() as u32,
|
||||
CRYPTPROTECTMEMORY_SAME_PROCESS,
|
||||
)
|
||||
}
|
||||
.expect("crypt_unprotect_memory should work");
|
||||
|
||||
// Unpad the data to retrieve the original value
|
||||
let length_header_size = std::mem::size_of::<usize>();
|
||||
let length_bytes = &data[..length_header_size];
|
||||
let data_length = usize::from_le_bytes(
|
||||
length_bytes
|
||||
.try_into()
|
||||
.expect("length header should be usize"),
|
||||
);
|
||||
|
||||
data[length_header_size..length_header_size + data_length].to_vec()
|
||||
})
|
||||
}
|
||||
|
||||
fn has(&self, key: &str) -> bool {
|
||||
self.map.contains_key(key)
|
||||
}
|
||||
|
||||
fn remove(&mut self, key: &str) {
|
||||
self.map.remove(key);
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DpapiSecretKVStore {
|
||||
fn drop(&mut self) {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dpapi_secret_kv_store_various_sizes() {
|
||||
let mut store = DpapiSecretKVStore::new();
|
||||
for size in 0..=2048 {
|
||||
let key = format!("test_key_{}", size);
|
||||
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
|
||||
store.put(key.clone(), &value);
|
||||
assert!(store.has(&key), "Store should have key for size {}", size);
|
||||
assert_eq!(
|
||||
store.get(&key),
|
||||
Some(value),
|
||||
"Value mismatch for size {}",
|
||||
size
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dpapi_crud() {
|
||||
let mut store = DpapiSecretKVStore::new();
|
||||
let key = "test_key".to_string();
|
||||
let value = vec![1, 2, 3, 4, 5];
|
||||
store.put(key.clone(), &value);
|
||||
assert!(store.has(&key));
|
||||
assert_eq!(store.get(&key), Some(value));
|
||||
store.remove(&key);
|
||||
assert!(!store.has(&key));
|
||||
}
|
||||
}
|
||||
22
apps/desktop/desktop_native/core/src/secure_memory/mod.rs
Normal file
22
apps/desktop/desktop_native/core/src/secure_memory/mod.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod dpapi;
|
||||
|
||||
/// The secure memory store provides an ephemeral key-value store for sensitive data.
|
||||
/// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally,
|
||||
/// platform-specific protections are applied to prevent memory dumps or debugger access from
|
||||
/// reading the stored values.
|
||||
#[allow(unused)]
|
||||
pub(crate) trait SecureMemoryStore {
|
||||
/// Stores a copy of the provided value in secure memory.
|
||||
fn put(&mut self, key: String, value: &[u8]);
|
||||
/// Retrieves a copy of the value associated with the given key from secure memory.
|
||||
/// This copy does not have additional memory protections applied, and should be zeroed when no
|
||||
/// longer needed.
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>>;
|
||||
/// Checks if a value is stored under the given key.
|
||||
fn has(&self, key: &str) -> bool;
|
||||
/// Removes the value associated with the given key from secure memory.
|
||||
fn remove(&mut self, key: &str);
|
||||
/// Clears all values stored in secure memory.
|
||||
fn clear(&mut self);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicU32},
|
||||
Arc,
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU32},
|
||||
Arc, RwLock,
|
||||
},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
@@ -25,8 +28,8 @@ pub mod peerinfo;
|
||||
mod request_parser;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BitwardenDesktopAgent<Key> {
|
||||
keystore: ssh_agent::KeyStore<Key>,
|
||||
pub struct BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore<BitwardenSshKey>,
|
||||
cancellation_token: CancellationToken,
|
||||
show_ui_request_tx: tokio::sync::mpsc::Sender<SshAgentUIRequest>,
|
||||
get_ui_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
@@ -77,9 +80,7 @@ impl SshKey for BitwardenSshKey {
|
||||
}
|
||||
}
|
||||
|
||||
impl ssh_agent::Agent<peerinfo::models::PeerInfo, BitwardenSshKey>
|
||||
for BitwardenDesktopAgent<BitwardenSshKey>
|
||||
{
|
||||
impl ssh_agent::Agent<peerinfo::models::PeerInfo, BitwardenSshKey> for BitwardenDesktopAgent {
|
||||
async fn confirm(
|
||||
&self,
|
||||
ssh_key: BitwardenSshKey,
|
||||
@@ -179,7 +180,23 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo, BitwardenSshKey>
|
||||
}
|
||||
}
|
||||
|
||||
impl BitwardenDesktopAgent<BitwardenSshKey> {
|
||||
impl BitwardenDesktopAgent {
|
||||
/// Create a new `BitwardenDesktopAgent` from the provided auth channel handles.
|
||||
pub fn new(
|
||||
auth_request_tx: tokio::sync::mpsc::Sender<SshAgentUIRequest>,
|
||||
auth_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
show_ui_request_tx: auth_request_tx,
|
||||
get_ui_response_rx: auth_response_rx,
|
||||
request_id: Arc::new(AtomicU32::new(0)),
|
||||
needs_unlock: Arc::new(AtomicBool::new(true)),
|
||||
is_running: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
if !self.is_running() {
|
||||
error!("Tried to stop agent while it is not running");
|
||||
|
||||
@@ -1,94 +1,53 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
os::unix::fs::PermissionsExt,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU32},
|
||||
Arc, RwLock,
|
||||
},
|
||||
};
|
||||
use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use bitwarden_russh::ssh_agent;
|
||||
use homedir::my_home;
|
||||
use tokio::{net::UnixListener, sync::Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream;
|
||||
|
||||
use super::{BitwardenDesktopAgent, BitwardenSshKey, SshAgentUIRequest};
|
||||
use super::{BitwardenDesktopAgent, SshAgentUIRequest};
|
||||
|
||||
impl BitwardenDesktopAgent<BitwardenSshKey> {
|
||||
/// User can override the default socket path with this env var
|
||||
const ENV_BITWARDEN_SSH_AUTH_SOCK: &str = "BITWARDEN_SSH_AUTH_SOCK";
|
||||
|
||||
const FLATPAK_DATA_DIR: &str = ".var/app/com.bitwarden.desktop/data";
|
||||
|
||||
const SOCKFILE_NAME: &str = ".bitwarden-ssh-agent.sock";
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
/// Starts the Bitwarden Desktop SSH Agent server.
|
||||
/// # Errors
|
||||
/// Will return `Err` if unable to create and set permissions for socket file path or
|
||||
/// if unable to bind to the socket path.
|
||||
pub fn start_server(
|
||||
auth_request_tx: tokio::sync::mpsc::Sender<SshAgentUIRequest>,
|
||||
auth_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
let agent = BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
show_ui_request_tx: auth_request_tx,
|
||||
get_ui_response_rx: auth_response_rx,
|
||||
request_id: Arc::new(AtomicU32::new(0)),
|
||||
needs_unlock: Arc::new(AtomicBool::new(true)),
|
||||
is_running: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
let cloned_agent_state = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
let ssh_path = match std::env::var("BITWARDEN_SSH_AUTH_SOCK") {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
info!("BITWARDEN_SSH_AUTH_SOCK not set, using default path");
|
||||
let agent_state = BitwardenDesktopAgent::new(auth_request_tx, auth_response_rx);
|
||||
|
||||
let ssh_agent_directory = match my_home() {
|
||||
Ok(Some(home)) => home,
|
||||
_ => {
|
||||
info!("Could not determine home directory");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let socket_path = get_socket_path()?;
|
||||
|
||||
let is_flatpak = std::env::var("container") == Ok("flatpak".to_string());
|
||||
if !is_flatpak {
|
||||
ssh_agent_directory
|
||||
.join(".bitwarden-ssh-agent.sock")
|
||||
.to_str()
|
||||
.expect("Path should be valid")
|
||||
.to_owned()
|
||||
} else {
|
||||
ssh_agent_directory
|
||||
.join(".var/app/com.bitwarden.desktop/data/.bitwarden-ssh-agent.sock")
|
||||
.to_str()
|
||||
.expect("Path should be valid")
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
};
|
||||
// if the socket is already present and wasn't cleanly removed during a previous
|
||||
// runtime, remove it before beginning anew.
|
||||
remove_path(&socket_path)?;
|
||||
|
||||
info!(socket = %ssh_path, "Starting SSH Agent server");
|
||||
let sockname = std::path::Path::new(&ssh_path);
|
||||
if let Err(e) = std::fs::remove_file(sockname) {
|
||||
error!(error = %e, socket = %ssh_path, "Could not remove existing socket file");
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return;
|
||||
}
|
||||
}
|
||||
info!(?socket_path, "Starting SSH Agent server");
|
||||
|
||||
match UnixListener::bind(sockname) {
|
||||
Ok(listener) => {
|
||||
// Only the current user should be able to access the socket
|
||||
if let Err(e) = fs::set_permissions(sockname, fs::Permissions::from_mode(0o600))
|
||||
{
|
||||
error!(error = %e, socket = ?sockname, "Could not set socket permissions");
|
||||
return;
|
||||
}
|
||||
match UnixListener::bind(socket_path.clone()) {
|
||||
Ok(listener) => {
|
||||
// Only the current user should be able to access the socket
|
||||
set_user_permissions(&socket_path)?;
|
||||
|
||||
let stream = PeercredUnixListenerStream::new(listener);
|
||||
let stream = PeercredUnixListenerStream::new(listener);
|
||||
|
||||
let cloned_keystore = cloned_agent_state.keystore.clone();
|
||||
let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone();
|
||||
cloned_agent_state
|
||||
.is_running
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let cloned_agent_state = agent_state.clone();
|
||||
let cloned_keystore = cloned_agent_state.keystore.clone();
|
||||
let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = ssh_agent::serve(
|
||||
stream,
|
||||
cloned_agent_state.clone(),
|
||||
@@ -96,17 +55,132 @@ impl BitwardenDesktopAgent<BitwardenSshKey> {
|
||||
cloned_cancellation_token,
|
||||
)
|
||||
.await;
|
||||
|
||||
cloned_agent_state
|
||||
.is_running
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
info!("SSH Agent server exited");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, socket = %ssh_path, "Unable to start start agent server");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(agent)
|
||||
info!("SSH Agent server exited");
|
||||
});
|
||||
|
||||
agent_state
|
||||
.is_running
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
info!(?socket_path, "SSH Agent is running.");
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, ?socket_path, "Unable to start start agent server");
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(agent_state)
|
||||
}
|
||||
}
|
||||
|
||||
// one of the following:
|
||||
// - only the env var socket path if it is defined
|
||||
// - the $HOME path and our well known extension
|
||||
fn get_socket_path() -> Result<PathBuf, anyhow::Error> {
|
||||
if let Ok(path) = std::env::var(ENV_BITWARDEN_SSH_AUTH_SOCK) {
|
||||
Ok(PathBuf::from(path))
|
||||
} else {
|
||||
info!("BITWARDEN_SSH_AUTH_SOCK not set, using default path");
|
||||
get_default_socket_path()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_flatpak() -> bool {
|
||||
std::env::var("container") == Ok("flatpak".to_string())
|
||||
}
|
||||
|
||||
// use the $HOME directory
|
||||
fn get_default_socket_path() -> Result<PathBuf, anyhow::Error> {
|
||||
let Ok(Some(mut ssh_agent_directory)) = my_home() else {
|
||||
error!("Could not determine home directory");
|
||||
return Err(anyhow!("Could not determine home directory."));
|
||||
};
|
||||
|
||||
if is_flatpak() {
|
||||
ssh_agent_directory = ssh_agent_directory.join(FLATPAK_DATA_DIR);
|
||||
}
|
||||
|
||||
ssh_agent_directory = ssh_agent_directory.join(SOCKFILE_NAME);
|
||||
|
||||
Ok(ssh_agent_directory)
|
||||
}
|
||||
|
||||
fn set_user_permissions(path: &PathBuf) -> Result<(), anyhow::Error> {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| anyhow!("Could not set socket permissions for {path:?}: {e}"))
|
||||
}
|
||||
|
||||
// try to remove the given path if it exists
|
||||
fn remove_path(path: &PathBuf) -> Result<(), anyhow::Error> {
|
||||
if let Ok(true) = std::fs::exists(path) {
|
||||
std::fs::remove_file(path).map_err(|e| anyhow!("Error removing socket {path:?}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rand::{distr::Alphanumeric, Rng};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_socket_path_success() {
|
||||
let path = get_default_socket_path().unwrap();
|
||||
let expected = PathBuf::from_iter([
|
||||
std::env::var("HOME").unwrap(),
|
||||
".bitwarden-ssh-agent.sock".to_string(),
|
||||
]);
|
||||
assert_eq!(path, expected);
|
||||
}
|
||||
|
||||
fn rand_file_in_temp() -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
let s: String = rand::rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(16)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
path.push(s);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_path_exists_success() {
|
||||
let path = rand_file_in_temp();
|
||||
fs::write(&path, "").unwrap();
|
||||
remove_path(&path).unwrap();
|
||||
|
||||
assert!(!fs::exists(&path).unwrap());
|
||||
}
|
||||
|
||||
// the remove_path should not fail if the path does not exist
|
||||
#[test]
|
||||
fn test_remove_path_not_found_success() {
|
||||
let path = rand_file_in_temp();
|
||||
remove_path(&path).unwrap();
|
||||
|
||||
assert!(!fs::exists(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sock_path_file_permissions() {
|
||||
let path = rand_file_in_temp();
|
||||
fs::write(&path, "").unwrap();
|
||||
|
||||
set_user_permissions(&path).unwrap();
|
||||
|
||||
let metadata = fs::metadata(&path).unwrap();
|
||||
let permissions = metadata.permissions().mode();
|
||||
|
||||
assert_eq!(permissions, 0o100_600);
|
||||
|
||||
remove_path(&path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
use bitwarden_russh::ssh_agent;
|
||||
pub mod named_pipe_listener_stream;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU32},
|
||||
Arc, RwLock,
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{BitwardenDesktopAgent, BitwardenSshKey, SshAgentUIRequest};
|
||||
use super::{BitwardenDesktopAgent, SshAgentUIRequest};
|
||||
|
||||
impl BitwardenDesktopAgent<BitwardenSshKey> {
|
||||
impl BitwardenDesktopAgent {
|
||||
pub fn start_server(
|
||||
auth_request_tx: tokio::sync::mpsc::Sender<SshAgentUIRequest>,
|
||||
auth_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
let agent_state = BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))),
|
||||
show_ui_request_tx: auth_request_tx,
|
||||
get_ui_response_rx: auth_response_rx,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
request_id: Arc::new(AtomicU32::new(0)),
|
||||
needs_unlock: Arc::new(AtomicBool::new(true)),
|
||||
is_running: Arc::new(AtomicBool::new(true)),
|
||||
};
|
||||
let agent_state = BitwardenDesktopAgent::new(auth_request_tx, auth_response_rx);
|
||||
|
||||
let stream = named_pipe_listener_stream::NamedPipeServerStream::new(
|
||||
agent_state.cancellation_token.clone(),
|
||||
agent_state.is_running.clone(),
|
||||
|
||||
40
apps/desktop/desktop_native/deny.toml
Normal file
40
apps/desktop/desktop_native/deny.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
|
||||
[advisories]
|
||||
ignore = [
|
||||
# Vulnerability in `rsa` crate: https://rustsec.org/advisories/RUSTSEC-2023-0071.html
|
||||
{ id = "RUSTSEC-2023-0071", reason = "There is no fix available yet." },
|
||||
{ id = "RUSTSEC-2024-0436", reason = "paste crate is unmaintained."}
|
||||
]
|
||||
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
|
||||
[licenses]
|
||||
# See https://spdx.org/licenses/ for list of possible licenses
|
||||
allow = [
|
||||
"0BSD",
|
||||
"Apache-2.0",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSL-1.0",
|
||||
"ISC",
|
||||
"MIT",
|
||||
"MPL-2.0",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
]
|
||||
|
||||
|
||||
[licenses.private]
|
||||
# If true, ignores workspace crates that aren't published, or are only
|
||||
# published to private registries.
|
||||
# To see how to mark a crate as unpublished (to the official registry),
|
||||
# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
|
||||
ignore = true
|
||||
|
||||
# This section is considered when running `cargo deny check bans`.
|
||||
# More documentation about the 'bans' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
|
||||
[bans]
|
||||
deny = [
|
||||
# TODO: enable after https://github.com/bitwarden/clients/pull/16761 is merged
|
||||
# { name = "log", wrappers = [], reason = "Use `tracing` and `tracing-subscriber` for observability needs." },
|
||||
]
|
||||
@@ -16,12 +16,13 @@ bench = false
|
||||
[dependencies]
|
||||
desktop_core = { path = "../core" }
|
||||
futures = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-oslog = "0.3.0"
|
||||
tracing-subscriber = { workspace = true }
|
||||
uniffi = { workspace = true, features = ["cli"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{atomic::AtomicU32, Arc, Mutex},
|
||||
sync::{atomic::AtomicU32, Arc, Mutex, Once},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use futures::FutureExt;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{
|
||||
filter::{EnvFilter, LevelFilter},
|
||||
layer::SubscriberExt,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
@@ -21,6 +26,8 @@ use assertion::{
|
||||
};
|
||||
use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback};
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
#[derive(uniffi::Enum, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UserVerification {
|
||||
@@ -65,9 +72,20 @@ impl MacOSProviderClient {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[uniffi::constructor]
|
||||
pub fn connect() -> Self {
|
||||
let _ = oslog::OsLogger::new("com.bitwarden.desktop.autofill-extension")
|
||||
.level_filter(log::LevelFilter::Trace)
|
||||
.init();
|
||||
INIT.call_once(|| {
|
||||
let filter = EnvFilter::builder()
|
||||
// Everything logs at `INFO`
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_oslog::OsLogger::new(
|
||||
"com.bitwarden.desktop.autofill-extension",
|
||||
"default",
|
||||
))
|
||||
.init();
|
||||
});
|
||||
|
||||
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
|
||||
12
apps/desktop/desktop_native/napi/index.d.ts
vendored
12
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -58,6 +58,18 @@ export declare namespace biometrics {
|
||||
ivB64: string
|
||||
}
|
||||
}
|
||||
export declare namespace biometrics_v2 {
|
||||
export function initBiometricSystem(): BiometricLockSystem
|
||||
export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise<boolean>
|
||||
export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise<boolean>
|
||||
export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
|
||||
export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
|
||||
export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise<Buffer>
|
||||
export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
|
||||
export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
|
||||
export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise<void>
|
||||
export class BiometricLockSystem { }
|
||||
}
|
||||
export declare namespace clipboards {
|
||||
export function read(): Promise<string>
|
||||
export function write(text: string, password: boolean): Promise<void>
|
||||
|
||||
@@ -149,6 +149,123 @@ pub mod biometrics {
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub mod biometrics_v2 {
|
||||
use desktop_core::biometric_v2::BiometricTrait;
|
||||
|
||||
#[napi]
|
||||
pub struct BiometricLockSystem {
|
||||
inner: desktop_core::biometric_v2::BiometricLockSystem,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn init_biometric_system() -> napi::Result<BiometricLockSystem> {
|
||||
Ok(BiometricLockSystem {
|
||||
inner: desktop_core::biometric_v2::BiometricLockSystem::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn authenticate(
|
||||
biometric_lock_system: &BiometricLockSystem,
|
||||
hwnd: napi::bindgen_prelude::Buffer,
|
||||
message: String,
|
||||
) -> napi::Result<bool> {
|
||||
biometric_lock_system
|
||||
.inner
|
||||
.authenticate(hwnd.into(), message)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn authenticate_available(
|
||||
biometric_lock_system: &BiometricLockSystem,
|
||||
) -> napi::Result<bool> {
|
||||
biometric_lock_system
|
||||
.inner
|
||||
.authenticate_available()
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn enroll_persistent(
|
||||
biometric_lock_system: &BiometricLockSystem,
|
||||
user_id: String,
|
||||
key: napi::bindgen_prelude::Buffer,
|
||||
) -> napi::Result<()> {
|
||||
biometric_lock_system
|
||||
.inner
|
||||
.enroll_persistent(&user_id, &key)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn provide_key(
|
||||
biometric_lock_system: &BiometricLockSystem,
|
||||
user_id: String,
|
||||
key: napi::bindgen_prelude::Buffer,
|
||||
) -> napi::Result<()> {
|
||||
biometric_lock_system
|
||||
.inner
|
||||
.provide_key(&user_id, &key)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn unlock(
|
||||
biometric_lock_system: &BiometricLockSystem,
|
||||
user_id: String,
|
||||
hwnd: napi::bindgen_prelude::Buffer,
|
||||
) -> napi::Result<napi::bindgen_prelude::Buffer> {
|
||||
biometric_lock_system
|
||||
.inner
|
||||
.unlock(&user_id, hwnd.into())
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
.map(|v| v.into())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn unlock_available(
|
||||
biometric_lock_system: &BiometricLockSystem,
|
||||
user_id: String,
|
||||
) -> napi::Result<bool> {
|
||||
biometric_lock_system
|
||||
.inner
|
||||
.unlock_available(&user_id)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn has_persistent(
|
||||
biometric_lock_system: &BiometricLockSystem,
|
||||
user_id: String,
|
||||
) -> napi::Result<bool> {
|
||||
biometric_lock_system
|
||||
.inner
|
||||
.has_persistent(&user_id)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn unenroll(
|
||||
biometric_lock_system: &BiometricLockSystem,
|
||||
user_id: String,
|
||||
) -> napi::Result<()> {
|
||||
biometric_lock_system
|
||||
.inner
|
||||
.unenroll(&user_id)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub mod clipboards {
|
||||
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
|
||||
@@ -169,7 +286,6 @@ pub mod clipboards {
|
||||
pub mod sshagent {
|
||||
use std::sync::Arc;
|
||||
|
||||
use desktop_core::ssh_agent::BitwardenSshKey;
|
||||
use napi::{
|
||||
bindgen_prelude::Promise,
|
||||
threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction},
|
||||
@@ -179,7 +295,7 @@ pub mod sshagent {
|
||||
|
||||
#[napi]
|
||||
pub struct SshAgentState {
|
||||
state: desktop_core::ssh_agent::BitwardenDesktopAgent<BitwardenSshKey>,
|
||||
state: desktop_core::ssh_agent::BitwardenDesktopAgent,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
|
||||
@@ -12,3 +12,4 @@ crate-type = ["cdylib"]
|
||||
ctor = { workspace = true }
|
||||
desktop_core = { path = "../core" }
|
||||
libc = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use desktop_core::process_isolation;
|
||||
use std::{ffi::c_char, sync::LazyLock};
|
||||
use tracing::info;
|
||||
|
||||
static ORIGINAL_UNSETENV: LazyLock<unsafe extern "C" fn(*const c_char) -> i32> =
|
||||
LazyLock::new(|| unsafe {
|
||||
@@ -38,8 +39,8 @@ unsafe extern "C" fn unsetenv(name: *const c_char) -> i32 {
|
||||
#[ctor::ctor]
|
||||
fn preload_init() {
|
||||
let pid = unsafe { libc::getpid() };
|
||||
info!(pid, "Enabling memory security for process.");
|
||||
unsafe {
|
||||
println!("[Process Isolation] Enabling memory security for process {pid}");
|
||||
process_isolation::isolate_process();
|
||||
process_isolation::disable_coredumps();
|
||||
}
|
||||
|
||||
@@ -81,6 +81,31 @@
|
||||
"additionalTouchIdSettings" | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
*ngIf="
|
||||
supportsBiometric &&
|
||||
form.value.biometric &&
|
||||
isWindows &&
|
||||
(userHasMasterPassword || (form.value.pin && userHasPinSet)) &&
|
||||
isWindowsV2BiometricsEnabled
|
||||
"
|
||||
>
|
||||
<div class="checkbox form-group-child">
|
||||
<label for="requireMasterPasswordOnAppRestart">
|
||||
<input
|
||||
id="requireMasterPasswordOnAppRestart"
|
||||
type="checkbox"
|
||||
formControlName="requireMasterPasswordOnAppRestart"
|
||||
/>
|
||||
@if (pinEnabled$ | async) {
|
||||
{{ "requireMasterPasswordOrPinOnAppRestart" | i18n }}
|
||||
} @else {
|
||||
{{ "requireMasterPasswordOnAppRestart" | i18n }}
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
*ngIf="supportsBiometric && this.form.value.biometric && this.isMac"
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -73,6 +74,9 @@ describe("SettingsComponent", () => {
|
||||
const desktopAutotypeService = mock<DesktopAutotypeService>();
|
||||
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
const configService = mock<ConfigService>();
|
||||
const userVerificationService = mock<UserVerificationService>();
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
@@ -92,6 +96,7 @@ describe("SettingsComponent", () => {
|
||||
};
|
||||
|
||||
i18nService.supportedTranslationLocales = [];
|
||||
i18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
@@ -124,7 +129,7 @@ describe("SettingsComponent", () => {
|
||||
{ provide: PolicyService, useValue: policyService },
|
||||
{ provide: StateService, useValue: mock<StateService>() },
|
||||
{ provide: ThemeStateService, useValue: themeStateService },
|
||||
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
|
||||
{ provide: UserVerificationService, useValue: userVerificationService },
|
||||
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
|
||||
{ provide: ValidationService, useValue: validationService },
|
||||
{ provide: MessagingService, useValue: messagingService },
|
||||
@@ -153,6 +158,7 @@ describe("SettingsComponent", () => {
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(
|
||||
of(VaultTimeoutStringType.OnLocked),
|
||||
);
|
||||
@@ -296,43 +302,81 @@ 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);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("require password or pin on app start not visible when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => {
|
||||
const policy = new Policy();
|
||||
policy.type = PolicyType.RemoveUnlockWithPin;
|
||||
policy.enabled = false;
|
||||
policyService.policiesByType$.mockReturnValue(of([policy]));
|
||||
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
|
||||
test.each([true, false])(
|
||||
`correct message display for require MP/PIN on app restart when pin is set, windows desktop, and policy is %s`,
|
||||
async (policyEnabled) => {
|
||||
const policy = new Policy();
|
||||
policy.type = PolicyType.RemoveUnlockWithPin;
|
||||
policy.enabled = policyEnabled;
|
||||
policyService.policiesByType$.mockReturnValue(of([policy]));
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
|
||||
By.css("label[for='requirePasswordOnStart']"),
|
||||
);
|
||||
expect(requirePasswordOnStartLabelElement).toBeNull();
|
||||
const textNodes = checkRequireMasterPasswordOnAppRestartElement(fixture);
|
||||
|
||||
if (policyEnabled) {
|
||||
expect(textNodes).toContain("requireMasterPasswordOnAppRestart");
|
||||
} else {
|
||||
expect(textNodes).toContain("requireMasterPasswordOrPinOnAppRestart");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe("users without a master password", () => {
|
||||
beforeEach(() => {
|
||||
userVerificationService.hasMasterPassword.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("displays require MP/PIN on app restart checkbox when pin is set", async () => {
|
||||
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
checkRequireMasterPasswordOnAppRestartElement(fixture);
|
||||
});
|
||||
|
||||
it("does not display require MP/PIN on app restart checkbox when pin is not set", async () => {
|
||||
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query(
|
||||
By.css("label[for='requireMasterPasswordOnAppRestart']"),
|
||||
);
|
||||
expect(requireMasterPasswordOnAppRestartLabelElement).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => {
|
||||
const policy = new Policy();
|
||||
policy.type = PolicyType.RemoveUnlockWithPin;
|
||||
policy.enabled = true;
|
||||
policyService.policiesByType$.mockReturnValue(of([policy]));
|
||||
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
|
||||
By.css("label[for='requirePasswordOnStart']"),
|
||||
function checkRequireMasterPasswordOnAppRestartElement(
|
||||
fixture: ComponentFixture<SettingsComponent>,
|
||||
) {
|
||||
const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query(
|
||||
By.css("label[for='requireMasterPasswordOnAppRestart']"),
|
||||
);
|
||||
expect(requirePasswordOnStartLabelElement).toBeNull();
|
||||
});
|
||||
expect(requireMasterPasswordOnAppRestartLabelElement).not.toBeNull();
|
||||
expect(requireMasterPasswordOnAppRestartLabelElement.children).toHaveLength(1);
|
||||
expect(requireMasterPasswordOnAppRestartLabelElement.children[0].name).toBe("input");
|
||||
expect(requireMasterPasswordOnAppRestartLabelElement.children[0].attributes).toMatchObject({
|
||||
id: "requireMasterPasswordOnAppRestart",
|
||||
type: "checkbox",
|
||||
});
|
||||
const textNodes = requireMasterPasswordOnAppRestartLabelElement.childNodes
|
||||
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.nativeNode.wholeText?.trim());
|
||||
return textNodes;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,7 +406,7 @@ describe("SettingsComponent", () => {
|
||||
await component.updatePinHandler(true);
|
||||
|
||||
expect(component.form.controls.pin.value).toBe(false);
|
||||
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
|
||||
expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled();
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
});
|
||||
|
||||
@@ -378,7 +422,7 @@ describe("SettingsComponent", () => {
|
||||
await component.updatePinHandler(true);
|
||||
|
||||
expect(component.form.controls.pin.value).toBe(dialogResult);
|
||||
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
|
||||
expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled();
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
},
|
||||
);
|
||||
@@ -390,9 +434,147 @@ describe("SettingsComponent", () => {
|
||||
await component.updatePinHandler(false);
|
||||
|
||||
expect(component.form.controls.pin.value).toBe(false);
|
||||
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
|
||||
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
});
|
||||
|
||||
describe("when windows biometric v2 feature flag is enabled", () => {
|
||||
beforeEach(() => {
|
||||
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"enrolls persistent biometric if needed, enrolled is %s",
|
||||
async (enrolled) => {
|
||||
desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled);
|
||||
|
||||
await component.ngOnInit();
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
component.isWindows = true;
|
||||
component.form.value.requireMasterPasswordOnAppRestart = true;
|
||||
component.userHasMasterPassword = false;
|
||||
component.supportsBiometric = true;
|
||||
component.form.value.biometric = true;
|
||||
|
||||
await component.updatePinHandler(false);
|
||||
|
||||
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
|
||||
expect(component.form.controls.pin.value).toBe(false);
|
||||
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
|
||||
if (enrolled) {
|
||||
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockUserKey,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{
|
||||
userHasMasterPassword: true,
|
||||
supportsBiometric: false,
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: false,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: true,
|
||||
supportsBiometric: false,
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: true,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: true,
|
||||
supportsBiometric: false,
|
||||
biometric: true,
|
||||
requireMasterPasswordOnAppRestart: false,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: true,
|
||||
supportsBiometric: false,
|
||||
biometric: true,
|
||||
requireMasterPasswordOnAppRestart: true,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: true,
|
||||
supportsBiometric: true,
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: false,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: true,
|
||||
supportsBiometric: true,
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: true,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: false,
|
||||
supportsBiometric: false,
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: false,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: false,
|
||||
supportsBiometric: false,
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: true,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: false,
|
||||
supportsBiometric: false,
|
||||
biometric: true,
|
||||
requireMasterPasswordOnAppRestart: false,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: false,
|
||||
supportsBiometric: false,
|
||||
biometric: true,
|
||||
requireMasterPasswordOnAppRestart: true,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: false,
|
||||
supportsBiometric: true,
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: false,
|
||||
},
|
||||
{
|
||||
userHasMasterPassword: false,
|
||||
supportsBiometric: true,
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: true,
|
||||
},
|
||||
])(
|
||||
"does not enroll persistent biometric when conditions are not met: userHasMasterPassword=$userHasMasterPassword, supportsBiometric=$supportsBiometric, biometric=$biometric, requireMasterPasswordOnAppRestart=$requireMasterPasswordOnAppRestart",
|
||||
async ({
|
||||
userHasMasterPassword,
|
||||
supportsBiometric,
|
||||
biometric,
|
||||
requireMasterPasswordOnAppRestart,
|
||||
}) => {
|
||||
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
|
||||
|
||||
await component.ngOnInit();
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
component.isWindows = true;
|
||||
component.form.value.requireMasterPasswordOnAppRestart =
|
||||
requireMasterPasswordOnAppRestart;
|
||||
component.userHasMasterPassword = userHasMasterPassword;
|
||||
component.supportsBiometric = supportsBiometric;
|
||||
component.form.value.biometric = biometric;
|
||||
|
||||
await component.updatePinHandler(false);
|
||||
|
||||
expect(component.form.controls.pin.value).toBe(false);
|
||||
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,22 +656,92 @@ describe("SettingsComponent", () => {
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
});
|
||||
|
||||
it("handles windows case", async () => {
|
||||
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
|
||||
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
|
||||
BiometricsStatus.Available,
|
||||
);
|
||||
describe("windows test cases", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
component.isWindows = true;
|
||||
component.isLinux = false;
|
||||
|
||||
component.isWindows = true;
|
||||
component.isLinux = false;
|
||||
await component.updateBiometricHandler(true);
|
||||
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(
|
||||
BiometricsStatus.Available,
|
||||
);
|
||||
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
|
||||
BiometricsStatus.Available,
|
||||
);
|
||||
});
|
||||
|
||||
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
|
||||
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
|
||||
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
|
||||
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
|
||||
expect(component.form.controls.biometric.value).toBe(true);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
it("handles windows case", async () => {
|
||||
await component.updateBiometricHandler(true);
|
||||
|
||||
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
|
||||
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
|
||||
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
|
||||
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
|
||||
expect(component.form.controls.biometric.value).toBe(true);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
});
|
||||
|
||||
describe("when windows v2 biometrics is enabled", () => {
|
||||
beforeEach(() => {
|
||||
component.isWindowsV2BiometricsEnabled = true;
|
||||
|
||||
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
|
||||
});
|
||||
|
||||
it("when the user doesn't have a master password or a PIN set, allows biometric unlock on app restart", async () => {
|
||||
component.userHasMasterPassword = false;
|
||||
component.userHasPinSet = false;
|
||||
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
|
||||
|
||||
await component.updateBiometricHandler(true);
|
||||
|
||||
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockUserKey,
|
||||
);
|
||||
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
|
||||
|
||||
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
|
||||
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
|
||||
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
|
||||
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
|
||||
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
|
||||
expect(component.form.controls.biometric.value).toBe(true);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[true, true],
|
||||
[true, false],
|
||||
[false, true],
|
||||
])(
|
||||
"when the userHasMasterPassword is %s and userHasPinSet is %s, require master password/PIN on app restart is the default setting",
|
||||
async (userHasMasterPassword, userHasPinSet) => {
|
||||
component.userHasMasterPassword = userHasMasterPassword;
|
||||
component.userHasPinSet = userHasPinSet;
|
||||
|
||||
await component.updateBiometricHandler(true);
|
||||
|
||||
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
|
||||
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(true);
|
||||
expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
);
|
||||
expect(
|
||||
desktopBiometricsService.setBiometricProtectedUnlockKeyForUser,
|
||||
).toHaveBeenCalledWith(mockUserId, mockUserKey);
|
||||
|
||||
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
|
||||
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
|
||||
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
|
||||
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
|
||||
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
|
||||
expect(component.form.controls.biometric.value).toBe(true);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles linux case", async () => {
|
||||
@@ -553,6 +805,57 @@ describe("SettingsComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateRequireMasterPasswordOnAppRestartHandler", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
|
||||
});
|
||||
|
||||
test.each([true, false])(`handles thrown errors when updated to %s`, async (update) => {
|
||||
const error = new Error("Test error");
|
||||
jest.spyOn(component, "updateRequireMasterPasswordOnAppRestart").mockRejectedValue(error);
|
||||
|
||||
await component.ngOnInit();
|
||||
await component.updateRequireMasterPasswordOnAppRestartHandler(update, mockUserId);
|
||||
|
||||
expect(logService.error).toHaveBeenCalled();
|
||||
expect(validationService.showError).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
describe("when updating to true", () => {
|
||||
it("calls the biometrics service to clear and reset biometric key", async () => {
|
||||
await component.ngOnInit();
|
||||
await component.updateRequireMasterPasswordOnAppRestartHandler(true, mockUserId);
|
||||
|
||||
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
);
|
||||
expect(desktopBiometricsService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockUserKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when updating to false", () => {
|
||||
it("doesn't enroll persistent biometric if already enrolled", async () => {
|
||||
biometricStateService.hasPersistentKey.mockResolvedValue(false);
|
||||
|
||||
await component.ngOnInit();
|
||||
await component.updateRequireMasterPasswordOnAppRestartHandler(false, mockUserId);
|
||||
|
||||
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockUserKey,
|
||||
);
|
||||
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveVaultTimeout", () => {
|
||||
const DEFAULT_VAULT_TIMEOUT: VaultTimeout = 123;
|
||||
const DEFAULT_VAULT_TIMEOUT_ACTION = VaultTimeoutAction.Lock;
|
||||
|
||||
@@ -142,6 +142,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
userHasPinSet: boolean;
|
||||
|
||||
pinEnabled$: Observable<boolean> = of(true);
|
||||
isWindowsV2BiometricsEnabled: boolean = false;
|
||||
|
||||
form = this.formBuilder.group({
|
||||
// Security
|
||||
@@ -149,6 +150,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
vaultTimeoutAction: [VaultTimeoutAction.Lock],
|
||||
pin: [null as boolean | null],
|
||||
biometric: false,
|
||||
requireMasterPasswordOnAppRestart: true,
|
||||
autoPromptBiometrics: false,
|
||||
// Account Preferences
|
||||
clearClipboard: [null],
|
||||
@@ -281,6 +283,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled();
|
||||
|
||||
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
@@ -372,6 +376,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
),
|
||||
pin: this.userHasPinSet,
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey(
|
||||
activeAccount.id,
|
||||
)),
|
||||
autoPromptBiometrics: await firstValueFrom(this.biometricStateService.promptAutomatically$),
|
||||
clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$),
|
||||
minimizeOnCopyToClipboard: await firstValueFrom(this.desktopSettingsService.minimizeOnCopy$),
|
||||
@@ -479,6 +486,15 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.requireMasterPasswordOnAppRestart.valueChanges
|
||||
.pipe(
|
||||
concatMap(async (value) => {
|
||||
await this.updateRequireMasterPasswordOnAppRestartHandler(value, activeAccount.id);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.form.controls.enableBrowserIntegration.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((enabled) => {
|
||||
@@ -588,6 +604,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.form.controls.pin.setValue(this.userHasPinSet, { emitEvent: false });
|
||||
} else {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
// 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 &&
|
||||
!this.userHasMasterPassword
|
||||
) {
|
||||
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
|
||||
await this.enrollPersistentBiometricIfNeeded(userId);
|
||||
}
|
||||
await this.pinService.unsetPin(userId);
|
||||
}
|
||||
}
|
||||
@@ -639,6 +668,16 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
// Recommended settings for Windows Hello
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else if (this.isLinux) {
|
||||
// Similar to Windows
|
||||
this.form.controls.autoPromptBiometrics.setValue(false);
|
||||
@@ -656,6 +695,37 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async updateRequireMasterPasswordOnAppRestartHandler(enabled: boolean, userId: UserId) {
|
||||
try {
|
||||
await this.updateRequireMasterPasswordOnAppRestart(enabled, userId);
|
||||
} catch (error) {
|
||||
this.logService.error("Error updating require master password on app restart: ", error);
|
||||
this.validationService.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateRequireMasterPasswordOnAppRestart(enabled: boolean, userId: UserId) {
|
||||
if (enabled) {
|
||||
// Require master password or PIN on app restart
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
await this.biometricsService.deleteBiometricUnlockKeyForUser(userId);
|
||||
await this.biometricsService.setBiometricProtectedUnlockKeyForUser(userId, userKey);
|
||||
} else {
|
||||
// Allow biometric unlock on app restart
|
||||
await this.enrollPersistentBiometricIfNeeded(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async enrollPersistentBiometricIfNeeded(userId: UserId): Promise<void> {
|
||||
if (!(await this.biometricsService.hasPersistentKey(userId))) {
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
await this.biometricsService.enrollPersistent(userId, userKey);
|
||||
this.form.controls.requireMasterPasswordOnAppRestart.setValue(false, {
|
||||
emitEvent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateAutoPromptBiometrics() {
|
||||
if (this.form.value.autoPromptBiometrics) {
|
||||
await this.biometricStateService.setPromptAutomatically(true);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
UserLockIcon,
|
||||
VaultIcon,
|
||||
LockIcon,
|
||||
DomainIcon,
|
||||
} from "@bitwarden/assets/svg";
|
||||
import {
|
||||
LoginComponent,
|
||||
@@ -289,6 +290,8 @@ const routes: Routes = [
|
||||
pageTitle: {
|
||||
key: "verifyYourIdentity",
|
||||
},
|
||||
// `TwoFactorAuthComponent` manually sets its icon based on the 2fa type
|
||||
pageIcon: null,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -297,12 +300,16 @@ const routes: Routes = [
|
||||
component: SetInitialPasswordComponent,
|
||||
data: {
|
||||
maxWidth: "lg",
|
||||
pageIcon: LockIcon,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "change-password",
|
||||
component: ChangePasswordComponent,
|
||||
canActivate: [authGuard],
|
||||
data: {
|
||||
pageIcon: LockIcon,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "confirm-key-connector-domain",
|
||||
@@ -312,6 +319,7 @@ const routes: Routes = [
|
||||
pageTitle: {
|
||||
key: "confirmKeyConnectorDomain",
|
||||
},
|
||||
pageIcon: DomainIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "../../autofill/services/desktop-fido2-user-interface.service";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype
|
||||
import { SshAgentService } from "../../autofill/services/ssh-agent.service";
|
||||
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
||||
import { VersionService } from "../../platform/services/version.service";
|
||||
import { BiometricMessageHandlerService } from "../../services/biometric-message-handler.service";
|
||||
import { NativeMessagingService } from "../../services/native-messaging.service";
|
||||
|
||||
@Injectable()
|
||||
@@ -53,6 +54,7 @@ export class InitService {
|
||||
private autofillService: DesktopAutofillService,
|
||||
private autotypeService: DesktopAutotypeService,
|
||||
private sdkLoadService: SdkLoadService,
|
||||
private biometricMessageHandlerService: BiometricMessageHandlerService,
|
||||
private configService: ConfigService,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
private readonly migrationRunner: MigrationRunner,
|
||||
@@ -95,6 +97,7 @@ export class InitService {
|
||||
const containerService = new ContainerService(this.keyService, this.encryptService);
|
||||
containerService.attachToGlobal(this.win);
|
||||
|
||||
await this.biometricMessageHandlerService.init();
|
||||
await this.autofillService.init();
|
||||
await this.autotypeService.init();
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { DialogRef, AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components";
|
||||
import { ExportComponent } from "@bitwarden/vault-export-ui";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "export-desktop.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
GeneratorModule,
|
||||
} from "@bitwarden/generator-components";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "credential-generator",
|
||||
templateUrl: "credential-generator.component.html",
|
||||
|
||||
@@ -13,6 +13,8 @@ import { safeProvider } from "@bitwarden/ui-common";
|
||||
|
||||
import { DesktopImportMetadataService } from "./desktop-import-metadata.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "import-desktop.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -21,6 +21,8 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { CalloutModule, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-send-add-edit",
|
||||
templateUrl: "add-edit.component.html",
|
||||
|
||||
@@ -35,12 +35,16 @@ enum Action {
|
||||
|
||||
const BroadcasterSubscriptionId = "SendComponent";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-send",
|
||||
templateUrl: "send.component.html",
|
||||
imports: [CommonModule, JslibModule, FormsModule, NavComponent, AddEditComponent],
|
||||
})
|
||||
export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild(AddEditComponent) addEditComponent: AddEditComponent;
|
||||
|
||||
sendId: string;
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
IconButtonModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "autotype-shortcut.component.html",
|
||||
imports: [
|
||||
|
||||
@@ -13,4 +13,9 @@ export abstract class DesktopBiometricsService extends BiometricsService {
|
||||
): Promise<void>;
|
||||
abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void>;
|
||||
abstract setupBiometrics(): Promise<void>;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,17 @@ export class MainBiometricsIPCListener {
|
||||
return await this.biometricService.setShouldAutopromptNow(message.data as boolean);
|
||||
case BiometricAction.GetShouldAutoprompt:
|
||||
return await this.biometricService.getShouldAutopromptNow();
|
||||
case BiometricAction.HasPersistentKey:
|
||||
return await this.biometricService.hasPersistentKey(message.userId as UserId);
|
||||
case BiometricAction.EnrollPersistent:
|
||||
return await this.biometricService.enrollPersistent(
|
||||
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();
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import {
|
||||
BiometricsService,
|
||||
BiometricsStatus,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
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";
|
||||
@@ -28,6 +30,13 @@ jest.mock("@bitwarden/desktop-napi", () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("./native-v2", () => ({
|
||||
WindowsBiometricsSystem: jest.fn(),
|
||||
biometrics_v2: {
|
||||
initBiometricSystem: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const unlockKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
|
||||
describe("MainBiometricsService", function () {
|
||||
@@ -38,24 +47,6 @@ describe("MainBiometricsService", function () {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
|
||||
it("Should call the platformspecific methods", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
process.platform,
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
|
||||
const mockService = mock<OsBiometricService>();
|
||||
(sut as any).osBiometricsService = mockService;
|
||||
|
||||
await sut.authenticateBiometric();
|
||||
expect(mockService.authenticateBiometric).toBeCalled();
|
||||
});
|
||||
|
||||
describe("Should create a platform specific service", function () {
|
||||
it("Should create a biometrics service specific for Windows", () => {
|
||||
const sut = new MainBiometricsService(
|
||||
@@ -207,46 +198,6 @@ describe("MainBiometricsService", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupBiometrics", () => {
|
||||
it("should call the platform specific setup method", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
process.platform,
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
const osBiometricsService = mock<OsBiometricService>();
|
||||
(sut as any).osBiometricsService = osBiometricsService;
|
||||
|
||||
await sut.setupBiometrics();
|
||||
|
||||
expect(osBiometricsService.runSetup).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateWithBiometrics", () => {
|
||||
it("should call the platform specific authenticate method", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
process.platform,
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
const osBiometricsService = mock<OsBiometricService>();
|
||||
(sut as any).osBiometricsService = osBiometricsService;
|
||||
|
||||
await sut.authenticateWithBiometrics();
|
||||
|
||||
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockWithBiometricsForUser", () => {
|
||||
let sut: MainBiometricsService;
|
||||
let osBiometricsService: MockProxy<OsBiometricService>;
|
||||
@@ -288,55 +239,6 @@ describe("MainBiometricsService", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setBiometricProtectedUnlockKeyForUser", () => {
|
||||
let sut: MainBiometricsService;
|
||||
let osBiometricsService: MockProxy<OsBiometricService>;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
process.platform,
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
osBiometricsService = mock<OsBiometricService>();
|
||||
(sut as any).osBiometricsService = osBiometricsService;
|
||||
});
|
||||
|
||||
it("should call the platform specific setBiometricKey method", async () => {
|
||||
const userId = "test" as UserId;
|
||||
|
||||
await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey);
|
||||
|
||||
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteBiometricUnlockKeyForUser", () => {
|
||||
it("should call the platform specific deleteBiometricKey method", async () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
process.platform,
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
const osBiometricsService = mock<OsBiometricService>();
|
||||
(sut as any).osBiometricsService = osBiometricsService;
|
||||
|
||||
const userId = "test" as UserId;
|
||||
|
||||
await sut.deleteBiometricUnlockKeyForUser(userId);
|
||||
|
||||
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setShouldAutopromptNow", () => {
|
||||
let sut: MainBiometricsService;
|
||||
|
||||
@@ -386,4 +288,138 @@ describe("MainBiometricsService", function () {
|
||||
expect(shouldAutoPrompt).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
let osBiometricsService: MockProxy<OsBiometricService>;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
process.platform,
|
||||
biometricStateService,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
);
|
||||
osBiometricsService = mock<OsBiometricService>();
|
||||
(sut as any).osBiometricsService = osBiometricsService;
|
||||
});
|
||||
|
||||
it("calls the platform specific setBiometricKey method", async () => {
|
||||
await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey);
|
||||
|
||||
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey);
|
||||
});
|
||||
|
||||
it("calls the platform specific enrollPersistent method", async () => {
|
||||
await sut.enrollPersistent(userId, unlockKey);
|
||||
|
||||
expect(osBiometricsService.enrollPersistent).toHaveBeenCalledWith(userId, unlockKey);
|
||||
});
|
||||
|
||||
it("calls the platform specific hasPersistentKey method", async () => {
|
||||
await sut.hasPersistentKey(userId);
|
||||
|
||||
expect(osBiometricsService.hasPersistentKey).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("calls the platform specific deleteBiometricUnlockKeyForUser method", async () => {
|
||||
await sut.deleteBiometricUnlockKeyForUser(userId);
|
||||
|
||||
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("calls the platform specific authenticateWithBiometrics method", async () => {
|
||||
await sut.authenticateWithBiometrics();
|
||||
|
||||
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the platform specific authenticateBiometric method", async () => {
|
||||
await sut.authenticateBiometric();
|
||||
|
||||
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the platform specific setupBiometrics method", async () => {
|
||||
await sut.setupBiometrics();
|
||||
|
||||
expect(osBiometricsService.runSetup).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,17 +10,19 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
import { WindowsBiometricsSystem } from "./native-v2";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
export class MainBiometricsService extends DesktopBiometricsService {
|
||||
private osBiometricsService: OsBiometricService;
|
||||
private shouldAutoPrompt = true;
|
||||
private windowsV2BiometricsEnabled = false;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private logService: LogService,
|
||||
platform: NodeJS.Platform,
|
||||
private platform: NodeJS.Platform,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private encryptService: EncryptService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
@@ -144,4 +146,28 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
|
||||
return await this.osBiometricsService.enrollPersistent(userId, key);
|
||||
}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service";
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { biometrics_v2 } from "@bitwarden/desktop-napi";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
||||
|
||||
jest.mock("@bitwarden/desktop-napi", () => ({
|
||||
biometrics_v2: {
|
||||
initBiometricSystem: jest.fn(() => "mockSystem"),
|
||||
provideKey: jest.fn(),
|
||||
enrollPersistent: jest.fn(),
|
||||
unenroll: jest.fn(),
|
||||
unlock: jest.fn(),
|
||||
authenticate: jest.fn(),
|
||||
authenticateAvailable: jest.fn(),
|
||||
unlockAvailable: jest.fn(),
|
||||
hasPersistent: jest.fn(),
|
||||
},
|
||||
passwords: {
|
||||
isAvailable: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockKey = new Uint8Array(64);
|
||||
|
||||
jest.mock("../../../utils", () => ({
|
||||
isFlatpak: jest.fn(() => false),
|
||||
isLinux: jest.fn(() => true),
|
||||
isSnapStore: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("OsBiometricsServiceWindows", () => {
|
||||
const userId = "user-id" as UserId;
|
||||
|
||||
let service: OsBiometricsServiceWindows;
|
||||
let i18nService: I18nService;
|
||||
let windowMain: WindowMain;
|
||||
let logService: LogService;
|
||||
|
||||
beforeEach(() => {
|
||||
i18nService = mock<I18nService>();
|
||||
windowMain = mock<WindowMain>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(Buffer.from([1, 2, 3, 4]));
|
||||
service = new OsBiometricsServiceWindows(i18nService, windowMain, logService);
|
||||
});
|
||||
|
||||
it("should enroll persistent biometric key", async () => {
|
||||
await service.enrollPersistent("user-id" as UserId, new SymmetricCryptoKey(mockKey));
|
||||
expect(biometrics_v2.enrollPersistent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set biometric key", async () => {
|
||||
await service.setBiometricKey(userId, new SymmetricCryptoKey(mockKey));
|
||||
expect(biometrics_v2.provideKey).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should delete biometric key", async () => {
|
||||
await service.deleteBiometricKey(userId);
|
||||
expect(biometrics_v2.unenroll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should get biometric key", async () => {
|
||||
(biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey);
|
||||
const result = await service.getBiometricKey(userId);
|
||||
expect(result).toBeInstanceOf(SymmetricCryptoKey);
|
||||
});
|
||||
|
||||
it("should return null if no biometric key", async () => {
|
||||
const error = new Error("No key found");
|
||||
(biometrics_v2.unlock as jest.Mock).mockRejectedValue(error);
|
||||
const result = await service.getBiometricKey(userId);
|
||||
expect(result).toBeNull();
|
||||
expect(logService.warning).toHaveBeenCalledWith(
|
||||
`[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should authenticate biometric", async () => {
|
||||
(biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true);
|
||||
const result = await service.authenticateBiometric();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should check if biometrics is supported", async () => {
|
||||
(biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(true);
|
||||
const result = await service.supportsBiometrics();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return needs setup false", async () => {
|
||||
const result = await service.needsSetup();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return auto setup false", async () => {
|
||||
const result = await service.canAutoSetup();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should get biometrics first unlock status for user", async () => {
|
||||
(biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true);
|
||||
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
|
||||
expect(result).toBe(BiometricsStatus.Available);
|
||||
});
|
||||
|
||||
it("should return false for hasPersistentKey false", async () => {
|
||||
(biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(false);
|
||||
const result = await service.hasPersistentKey(userId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for hasPersistentKey true", async () => {
|
||||
(biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(true);
|
||||
const result = await service.hasPersistentKey(userId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { biometrics_v2 } from "@bitwarden/desktop-napi";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { WindowMain } from "../../../main/window.main";
|
||||
import { OsBiometricService } from "../os-biometrics.service";
|
||||
|
||||
export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
private biometricsSystem: biometrics_v2.BiometricLockSystem;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private logService: LogService,
|
||||
) {
|
||||
this.biometricsSystem = biometrics_v2.initBiometricSystem();
|
||||
}
|
||||
|
||||
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
|
||||
await biometrics_v2.enrollPersistent(
|
||||
this.biometricsSystem,
|
||||
userId,
|
||||
Buffer.from(key.toEncoded().buffer),
|
||||
);
|
||||
}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
return await biometrics_v2.hasPersistent(this.biometricsSystem, userId);
|
||||
}
|
||||
|
||||
async supportsBiometrics(): Promise<boolean> {
|
||||
return await biometrics_v2.authenticateAvailable(this.biometricsSystem);
|
||||
}
|
||||
|
||||
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
||||
try {
|
||||
const key = await biometrics_v2.unlock(
|
||||
this.biometricsSystem,
|
||||
userId,
|
||||
this.windowMain.win.getNativeWindowHandle(),
|
||||
);
|
||||
return key ? new SymmetricCryptoKey(Uint8Array.from(key)) : null;
|
||||
} catch (error) {
|
||||
this.logService.warning(
|
||||
`[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
|
||||
await biometrics_v2.provideKey(
|
||||
this.biometricsSystem,
|
||||
userId,
|
||||
Buffer.from(key.toEncoded().buffer),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBiometricKey(userId: UserId): Promise<void> {
|
||||
await biometrics_v2.unenroll(this.biometricsSystem, userId);
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const hwnd = this.windowMain.win.getNativeWindowHandle();
|
||||
return await biometrics_v2.authenticate(
|
||||
this.biometricsSystem,
|
||||
hwnd,
|
||||
this.i18nService.t("windowsHelloConsentMessage"),
|
||||
);
|
||||
}
|
||||
|
||||
async needsSetup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async canAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async runSetup(): Promise<void> {}
|
||||
|
||||
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
return (await biometrics_v2.hasPersistent(this.biometricsSystem, userId)) ||
|
||||
(await biometrics_v2.unlockAvailable(this.biometricsSystem, userId))
|
||||
? BiometricsStatus.Available
|
||||
: BiometricsStatus.UnlockNeeded;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,12 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
private _iv: string | null = null;
|
||||
// Use getKeyMaterial helper instead of direct access
|
||||
private _osKeyHalf: string | null = null;
|
||||
|
||||
@@ -20,6 +20,14 @@ export default class OsBiometricsServiceMac implements OsBiometricService {
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
|
||||
return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64());
|
||||
}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
return (await passwords.getPassword(SERVICE, getLookupKeyForUser(userId))) != null;
|
||||
}
|
||||
|
||||
async supportsBiometrics(): Promise<boolean> {
|
||||
return systemPreferences.canPromptTouchID();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -25,4 +25,6 @@ export interface OsBiometricService {
|
||||
setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
|
||||
deleteBiometricKey(userId: UserId): Promise<void>;
|
||||
getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus>;
|
||||
enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
|
||||
hasPersistentKey(userId: UserId): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -68,4 +68,20 @@ export class RendererBiometricsService extends DesktopBiometricsService {
|
||||
BiometricsStatus.ManualSetupNeeded,
|
||||
].includes(biometricStatus);
|
||||
}
|
||||
|
||||
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.enrollPersistent(userId, key.toBase64());
|
||||
}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Component } from "@angular/core";
|
||||
|
||||
import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-remove-password",
|
||||
templateUrl: "remove-password.component.html",
|
||||
|
||||
@@ -50,6 +50,25 @@ const biometric = {
|
||||
action: BiometricAction.SetShouldAutoprompt,
|
||||
data: should,
|
||||
} satisfies BiometricMessage),
|
||||
enrollPersistent: (userId: string, keyB64: string): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.EnrollPersistent,
|
||||
userId: userId,
|
||||
key: keyB64,
|
||||
} satisfies BiometricMessage),
|
||||
hasPersistentKey: (userId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke("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),
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1852,6 +1852,12 @@
|
||||
"lockWithMasterPassOnRestart1": {
|
||||
"message": "Lock with master password on restart"
|
||||
},
|
||||
"requireMasterPasswordOrPinOnAppRestart": {
|
||||
"message": "Require master password or PIN on app restart"
|
||||
},
|
||||
"requireMasterPasswordOnAppRestart": {
|
||||
"message": "Require master password on app restart"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"message": "Delete account"
|
||||
},
|
||||
@@ -4167,7 +4173,7 @@
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemUnarchived": {
|
||||
"itemWasUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"archiveItem": {
|
||||
|
||||
@@ -82,7 +82,12 @@ export class WindowMain {
|
||||
|
||||
ipcMain.on("window-hide", () => {
|
||||
if (this.win != null) {
|
||||
this.win.hide();
|
||||
if (isWindows()) {
|
||||
// On windows, to return focus we need minimize
|
||||
this.win.minimize();
|
||||
} else {
|
||||
this.win.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -13,13 +13,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { FakeAccountService } from "@bitwarden/common/spec";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, I18nMockService } from "@bitwarden/components";
|
||||
import {
|
||||
KeyService,
|
||||
BiometricsService,
|
||||
BiometricStateService,
|
||||
BiometricsCommands,
|
||||
} from "@bitwarden/key-management";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService, BiometricsService, BiometricsCommands } from "@bitwarden/key-management";
|
||||
import { ConfigService } from "@bitwarden/services/config.service";
|
||||
|
||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||
|
||||
@@ -47,15 +43,14 @@ describe("BiometricMessageHandlerService", () => {
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let desktopSettingsService: DesktopSettingsService;
|
||||
let biometricStateService: BiometricStateService;
|
||||
let biometricsService: MockProxy<BiometricsService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let accountService: AccountService;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let ngZone: MockProxy<NgZone>;
|
||||
let i18nService: MockProxy<I18nMockService>;
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
@@ -64,14 +59,13 @@ describe("BiometricMessageHandlerService", () => {
|
||||
logService = mock<LogService>();
|
||||
messagingService = mock<MessagingService>();
|
||||
desktopSettingsService = mock<DesktopSettingsService>();
|
||||
biometricStateService = mock<BiometricStateService>();
|
||||
configService = mock<ConfigService>();
|
||||
biometricsService = mock<BiometricsService>();
|
||||
dialogService = mock<DialogService>();
|
||||
|
||||
accountService = new FakeAccountService(accounts);
|
||||
authService = mock<AuthService>();
|
||||
ngZone = mock<NgZone>();
|
||||
i18nService = mock<I18nMockService>();
|
||||
|
||||
desktopSettingsService.browserIntegrationEnabled$ = of(false);
|
||||
desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(false);
|
||||
@@ -94,7 +88,7 @@ describe("BiometricMessageHandlerService", () => {
|
||||
cryptoFunctionService.rsaEncrypt.mockResolvedValue(
|
||||
Utils.fromUtf8ToArray("encrypted") as CsprngArray,
|
||||
);
|
||||
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
service = new BiometricMessageHandlerService(
|
||||
cryptoFunctionService,
|
||||
keyService,
|
||||
@@ -102,13 +96,12 @@ describe("BiometricMessageHandlerService", () => {
|
||||
logService,
|
||||
messagingService,
|
||||
desktopSettingsService,
|
||||
biometricStateService,
|
||||
biometricsService,
|
||||
dialogService,
|
||||
accountService,
|
||||
authService,
|
||||
ngZone,
|
||||
i18nService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,13 +153,12 @@ describe("BiometricMessageHandlerService", () => {
|
||||
logService,
|
||||
messagingService,
|
||||
desktopSettingsService,
|
||||
biometricStateService,
|
||||
biometricsService,
|
||||
dialogService,
|
||||
accountService,
|
||||
authService,
|
||||
ngZone,
|
||||
i18nService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -511,4 +503,19 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,25 +4,21 @@ import { combineLatest, concatMap, firstValueFrom } from "rxjs";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
BiometricStateService,
|
||||
BiometricsCommands,
|
||||
BiometricsService,
|
||||
BiometricsStatus,
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { BiometricsCommands, BiometricsStatus, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
|
||||
import { DesktopBiometricsService } from "../key-management/biometrics/desktop.biometrics.service";
|
||||
import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging";
|
||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||
|
||||
@@ -82,13 +78,12 @@ export class BiometricMessageHandlerService {
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
private desktopSettingService: DesktopSettingsService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private biometricsService: BiometricsService,
|
||||
private biometricsService: DesktopBiometricsService,
|
||||
private dialogService: DialogService,
|
||||
private accountService: AccountService,
|
||||
private authService: AuthService,
|
||||
private ngZone: NgZone,
|
||||
private i18nService: I18nService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
combineLatest([
|
||||
this.desktopSettingService.browserIntegrationEnabled$,
|
||||
@@ -119,6 +114,19 @@ export class BiometricMessageHandlerService {
|
||||
|
||||
private connectedApps: ConnectedApps = new ConnectedApps();
|
||||
|
||||
async init() {
|
||||
this.logService.debug(
|
||||
"[BiometricMessageHandlerService] Initializing biometric message handler",
|
||||
);
|
||||
|
||||
const windowsV2Enabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.WindowsBiometricsV2,
|
||||
);
|
||||
if (windowsV2Enabled) {
|
||||
await this.biometricsService.enableWindowsV2Biometrics();
|
||||
}
|
||||
}
|
||||
|
||||
async handleMessage(msg: LegacyMessageWrapper) {
|
||||
const { appId, message: rawMessage } = msg as LegacyMessageWrapper;
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ export enum BiometricAction {
|
||||
|
||||
GetShouldAutoprompt = "getShouldAutoprompt",
|
||||
SetShouldAutoprompt = "setShouldAutoprompt",
|
||||
|
||||
EnrollPersistent = "enrollPersistent",
|
||||
HasPersistentKey = "hasPersistentKey",
|
||||
|
||||
EnableWindowsV2 = "enableWindowsV2",
|
||||
IsWindowsV2Enabled = "isWindowsV2Enabled",
|
||||
}
|
||||
|
||||
export type BiometricMessage =
|
||||
@@ -22,7 +28,15 @@ export type BiometricMessage =
|
||||
key: string;
|
||||
}
|
||||
| {
|
||||
action: Exclude<BiometricAction, BiometricAction.SetKeyForUser>;
|
||||
action: BiometricAction.EnrollPersistent;
|
||||
userId: string;
|
||||
key: string;
|
||||
}
|
||||
| {
|
||||
action: Exclude<
|
||||
BiometricAction,
|
||||
BiometricAction.SetKeyForUser | BiometricAction.EnrollPersistent
|
||||
>;
|
||||
userId?: string;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
@@ -46,7 +46,23 @@
|
||||
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
<div class="right" *ngIf="cipher.permissions.delete && (action === 'edit' || action === 'view')">
|
||||
<div class="right" *ngIf="hasFooterAction">
|
||||
<button
|
||||
type="button"
|
||||
*ngIf="showArchiveButton"
|
||||
(click)="archive()"
|
||||
appA11yTitle="{{ 'archiveVerb' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-archive bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
*ngIf="showUnarchiveButton"
|
||||
(click)="unarchive()"
|
||||
appA11yTitle="{{ 'unarchive' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-unarchive bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="delete()"
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Input, Output, EventEmitter, Component, OnInit, ViewChild } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
Component,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
} from "@angular/core";
|
||||
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -8,19 +17,20 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { ButtonComponent, ButtonModule, DialogService, ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { ArchiveCipherUtilitiesService, PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-item-footer",
|
||||
templateUrl: "item-footer.component.html",
|
||||
imports: [ButtonModule, CommonModule, JslibModule],
|
||||
})
|
||||
export class ItemFooterComponent implements OnInit {
|
||||
export class ItemFooterComponent implements OnInit, OnChanges {
|
||||
@Input({ required: true }) cipher: CipherView = new CipherView();
|
||||
@Input() collectionId: string | null = null;
|
||||
@Input({ required: true }) action: string = "view";
|
||||
@@ -30,11 +40,15 @@ export class ItemFooterComponent implements OnInit {
|
||||
@Output() onDelete = new EventEmitter<CipherView>();
|
||||
@Output() onRestore = new EventEmitter<CipherView>();
|
||||
@Output() onCancel = new EventEmitter<CipherView>();
|
||||
@Output() onArchiveToggle = new EventEmitter<CipherView>();
|
||||
@ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null;
|
||||
|
||||
activeUserId: UserId | null = null;
|
||||
passwordReprompted: boolean = false;
|
||||
|
||||
protected showArchiveButton = false;
|
||||
protected showUnarchiveButton = false;
|
||||
|
||||
constructor(
|
||||
protected cipherService: CipherService,
|
||||
protected dialogService: DialogService,
|
||||
@@ -44,11 +58,20 @@ export class ItemFooterComponent implements OnInit {
|
||||
protected toastService: ToastService,
|
||||
protected i18nService: I18nService,
|
||||
protected logService: LogService,
|
||||
protected cipherArchiveService: CipherArchiveService,
|
||||
protected archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.passwordReprompted = this.masterPasswordAlreadyPrompted;
|
||||
await this.checkArchiveState();
|
||||
}
|
||||
|
||||
async ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.cipher) {
|
||||
await this.checkArchiveState();
|
||||
}
|
||||
}
|
||||
|
||||
async clone() {
|
||||
@@ -76,6 +99,14 @@ export class ItemFooterComponent implements OnInit {
|
||||
this.onEdit.emit(this.cipher);
|
||||
}
|
||||
|
||||
protected get hasFooterAction() {
|
||||
return (
|
||||
this.showArchiveButton ||
|
||||
this.showUnarchiveButton ||
|
||||
(this.cipher.permissions?.delete && (this.action === "edit" || this.action === "view"))
|
||||
);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.onCancel.emit(this.cipher);
|
||||
}
|
||||
@@ -151,4 +182,36 @@ export class ItemFooterComponent implements OnInit {
|
||||
|
||||
return (this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt());
|
||||
}
|
||||
|
||||
protected async archive() {
|
||||
await this.archiveCipherUtilitiesService.archiveCipher(this.cipher);
|
||||
this.onArchiveToggle.emit();
|
||||
}
|
||||
|
||||
protected async unarchive() {
|
||||
await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher);
|
||||
this.onArchiveToggle.emit();
|
||||
}
|
||||
|
||||
private async checkArchiveState() {
|
||||
const cipherCanBeArchived = !this.cipher.isDeleted && this.cipher.organizationId == null;
|
||||
const [userCanArchive, hasArchiveFlagEnabled] = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((id) =>
|
||||
combineLatest([
|
||||
this.cipherArchiveService.userCanArchive$(id),
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.showArchiveButton =
|
||||
cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived;
|
||||
|
||||
// A user should always be able to unarchive an archived item
|
||||
this.showUnarchiveButton =
|
||||
hasArchiveFlagEnabled && this.action === "view" && this.cipher.isArchived;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,22 @@
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="filter-option"
|
||||
*ngIf="!hideArchive"
|
||||
[ngClass]="{ active: activeFilter.status === 'archive' }"
|
||||
>
|
||||
<span class="filter-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-button"
|
||||
(click)="applyFilter('archive')"
|
||||
[attr.aria-pressed]="activeFilter.status === 'archive'"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i> {{ "archiveNoun" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="filter-option"
|
||||
*ngIf="!hideTrash"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
class="filter"
|
||||
[hideFavorites]="hideFavorites"
|
||||
[hideTrash]="hideTrash"
|
||||
[hideArchive]="!showArchiveVaultFilter"
|
||||
[activeFilter]="activeFilter"
|
||||
(onFilterChange)="applyFilter($event)"
|
||||
></app-status-filter>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { distinctUntilChanged, debounceTime } from "rxjs";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
@@ -33,8 +34,9 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
|
||||
cipherService: CipherService,
|
||||
accountService: AccountService,
|
||||
restrictedItemTypesService: RestrictedItemTypesService,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
super(searchService, cipherService, accountService, restrictedItemTypesService);
|
||||
super(searchService, cipherService, accountService, restrictedItemTypesService, configService);
|
||||
|
||||
this.searchBarService.searchText$
|
||||
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
(onClone)="cloneCipher($event)"
|
||||
(onDelete)="deleteCipher()"
|
||||
(onCancel)="cancelCipher($event)"
|
||||
(onArchiveToggle)="refreshCurrentCipher()"
|
||||
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
||||
></app-vault-item-footer>
|
||||
<div class="content">
|
||||
|
||||
@@ -20,6 +20,8 @@ import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -33,6 +35,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { getByIds } from "@bitwarden/common/platform/misc";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
@@ -74,6 +77,7 @@ import {
|
||||
DefaultCipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
CipherFormComponent,
|
||||
ArchiveCipherUtilitiesService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { NavComponent } from "../../../app/layout/nav.component";
|
||||
@@ -211,6 +215,9 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
private folderService: FolderService,
|
||||
private configService: ConfigService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private policyService: PolicyService,
|
||||
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -490,6 +497,12 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
async viewCipherMenu(c: CipherViewLike) {
|
||||
const cipher = await this.cipherService.getFullCipherView(c);
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const userCanArchive = await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId));
|
||||
const orgOwnershipPolicy = await firstValueFrom(
|
||||
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
|
||||
);
|
||||
|
||||
const menu: RendererMenuItem[] = [
|
||||
{
|
||||
label: this.i18nService.t("view"),
|
||||
@@ -514,7 +527,11 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!cipher.organizationId) {
|
||||
|
||||
const archivedWithOrgOwnership = cipher.isArchived && orgOwnershipPolicy;
|
||||
const canCloneArchived = !cipher.isArchived || userCanArchive;
|
||||
|
||||
if (!cipher.organizationId && !archivedWithOrgOwnership && canCloneArchived) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("clone"),
|
||||
click: () => {
|
||||
@@ -538,6 +555,26 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
}
|
||||
}
|
||||
|
||||
if (userCanArchive && !cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("archiveVerb"),
|
||||
click: async () => {
|
||||
await this.archiveCipherUtilitiesService.archiveCipher(cipher);
|
||||
await this.refreshCurrentCipher();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cipher.isArchived) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("unArchive"),
|
||||
click: async () => {
|
||||
await this.archiveCipherUtilitiesService.unarchiveCipher(cipher);
|
||||
await this.refreshCurrentCipher();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
switch (cipher.type) {
|
||||
case CipherType.Login:
|
||||
if (
|
||||
@@ -723,8 +760,6 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
|
||||
await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {});
|
||||
await this.go().catch(() => {});
|
||||
await this.vaultItemsComponent?.refresh().catch(() => {});
|
||||
}
|
||||
@@ -757,7 +792,11 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
);
|
||||
this.activeFilter = vaultFilter;
|
||||
await this.vaultItemsComponent
|
||||
?.reload(this.activeFilter.buildFilter(), vaultFilter.status === "trash")
|
||||
?.reload(
|
||||
this.activeFilter.buildFilter(),
|
||||
vaultFilter.status === "trash",
|
||||
vaultFilter.status === "archive",
|
||||
)
|
||||
.catch(() => {});
|
||||
await this.go().catch(() => {});
|
||||
}
|
||||
@@ -831,6 +870,20 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh the current cipher object */
|
||||
protected async refreshCurrentCipher() {
|
||||
if (!this.cipher) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipher = await firstValueFrom(
|
||||
this.cipherService.cipherViews$(this.activeUserId!).pipe(
|
||||
filter((c) => !!c),
|
||||
map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private dirtyInput(): boolean {
|
||||
return (
|
||||
(this.action === "add" || this.action === "edit" || this.action === "clone") &&
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { inject, NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
|
||||
import { componentRouteSwap } from "@bitwarden/angular/utils/component-route-swap";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component";
|
||||
|
||||
import { BillingHistoryViewComponent } from "./billing-history-view.component";
|
||||
@@ -23,15 +26,22 @@ const routes: Routes = [
|
||||
component: UserSubscriptionComponent,
|
||||
data: { titleId: "premiumMembership" },
|
||||
},
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: PremiumComponent,
|
||||
flaggedComponent: PremiumVNextComponent,
|
||||
featureFlag: FeatureFlag.PM24033PremiumUpgradeNewDesign,
|
||||
routeOptions: {
|
||||
...componentRouteSwap(
|
||||
PremiumComponent,
|
||||
PremiumVNextComponent,
|
||||
() => {
|
||||
const configService = inject(ConfigService);
|
||||
const platformUtilsService = inject(PlatformUtilsService);
|
||||
|
||||
return configService
|
||||
.getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign)
|
||||
.pipe(map((flagValue) => flagValue === true && !platformUtilsService.isSelfHost()));
|
||||
},
|
||||
{
|
||||
data: { titleId: "goPremium" },
|
||||
path: "premium",
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
path: "payment-details",
|
||||
component: AccountPaymentDetailsComponent,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { DIALOG_DATA, ToastService } from "@bitwarden/components";
|
||||
import { KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-change-kdf-confirmation",
|
||||
templateUrl: "change-kdf-confirmation.component.html",
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
|
||||
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-change-kdf",
|
||||
templateUrl: "change-kdf.component.html",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Component } from "@angular/core";
|
||||
import { ConfirmKeyConnectorDomainComponent as BaseConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
import { RouterService } from "@bitwarden/web-vault/app/core";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-confirm-key-connector-domain",
|
||||
template: ` <confirm-key-connector-domain [onBeforeNavigation]="onBeforeNavigation" /> `,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Component } from "@angular/core";
|
||||
|
||||
import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-remove-password",
|
||||
templateUrl: "remove-password.component.html",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom, Observable } from "rxjs";
|
||||
|
||||
import { LogoutService } from "@bitwarden/auth/common";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
WrappedPrivateKey,
|
||||
WrappedSigningKey,
|
||||
} from "@bitwarden/common/key-management/types";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -89,7 +89,7 @@ export class UserKeyRotationService {
|
||||
private syncService: SyncService,
|
||||
private webauthnLoginAdminService: WebauthnLoginAdminService,
|
||||
private logService: LogService,
|
||||
private vaultTimeoutService: VaultTimeoutService,
|
||||
private logoutService: LogoutService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
@@ -189,8 +189,7 @@ export class UserKeyRotationService {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// temporary until userkey can be better verified
|
||||
await this.vaultTimeoutService.logOut();
|
||||
await this.logoutService.logout(user.id);
|
||||
}
|
||||
|
||||
protected async ensureIsAllowedToRotateUserKey(): Promise<void> {
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
SsoKeyIcon,
|
||||
LockIcon,
|
||||
BrowserExtensionIcon,
|
||||
ActiveSendIcon,
|
||||
TwoFactorAuthAuthenticatorIcon,
|
||||
AccountWarning,
|
||||
BusinessWelcome,
|
||||
DomainIcon,
|
||||
} from "@bitwarden/assets/svg";
|
||||
import {
|
||||
PasswordHintComponent,
|
||||
@@ -295,6 +300,7 @@ const routes: Routes = [
|
||||
key: "viewSend",
|
||||
},
|
||||
showReadonlyHostname: true,
|
||||
pageIcon: ActiveSendIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
@@ -314,6 +320,7 @@ const routes: Routes = [
|
||||
component: SetInitialPasswordComponent,
|
||||
data: {
|
||||
maxWidth: "lg",
|
||||
pageIcon: LockIcon,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -379,6 +386,8 @@ const routes: Routes = [
|
||||
pageTitle: {
|
||||
key: "verifyYourIdentity",
|
||||
},
|
||||
// `TwoFactorAuthComponent` manually sets its icon based on the 2fa type
|
||||
pageIcon: null,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -439,6 +448,7 @@ const routes: Routes = [
|
||||
key: "recoverAccountTwoStep",
|
||||
},
|
||||
titleId: "recoverAccountTwoStep",
|
||||
pageIcon: TwoFactorAuthAuthenticatorIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -469,6 +479,7 @@ const routes: Routes = [
|
||||
},
|
||||
titleId: "acceptEmergency",
|
||||
doNotSaveUrl: false,
|
||||
pageIcon: VaultIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
@@ -488,6 +499,7 @@ const routes: Routes = [
|
||||
key: "deleteAccount",
|
||||
},
|
||||
titleId: "deleteAccount",
|
||||
pageIcon: AccountWarning,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
@@ -509,6 +521,7 @@ const routes: Routes = [
|
||||
key: "deleteAccount",
|
||||
},
|
||||
titleId: "deleteAccount",
|
||||
pageIcon: AccountWarning,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
@@ -526,6 +539,7 @@ const routes: Routes = [
|
||||
key: "removeMasterPassword",
|
||||
},
|
||||
titleId: "removeMasterPassword",
|
||||
pageIcon: LockIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -537,6 +551,7 @@ const routes: Routes = [
|
||||
key: "confirmKeyConnectorDomain",
|
||||
},
|
||||
titleId: "confirmKeyConnectorDomain",
|
||||
pageIcon: DomainIcon,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -548,6 +563,7 @@ const routes: Routes = [
|
||||
},
|
||||
data: {
|
||||
maxWidth: "3xl",
|
||||
pageIcon: BusinessWelcome,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -559,6 +575,7 @@ const routes: Routes = [
|
||||
},
|
||||
data: {
|
||||
maxWidth: "3xl",
|
||||
pageIcon: BusinessWelcome,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
@@ -582,12 +599,15 @@ const routes: Routes = [
|
||||
path: "change-password",
|
||||
component: ChangePasswordComponent,
|
||||
canActivate: [authGuard],
|
||||
data: {
|
||||
pageIcon: LockIcon,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "setup-extension",
|
||||
data: {
|
||||
hideCardWrapper: true,
|
||||
hideIcon: true,
|
||||
pageIcon: null,
|
||||
maxWidth: "4xl",
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
children: [
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { HeaderModule } from "../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "credential-generator",
|
||||
templateUrl: "credential-generator.component.html",
|
||||
|
||||
@@ -15,6 +15,8 @@ import { safeProvider } from "@bitwarden/ui-common";
|
||||
import { HeaderModule } from "../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "import-web.component.html",
|
||||
imports: [SharedModule, ImportComponent, HeaderModule],
|
||||
|
||||
@@ -27,6 +27,8 @@ import { SharedModule } from "../../shared";
|
||||
|
||||
import { ImportCollectionAdminService } from "./import-collection-admin.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "org-import.component.html",
|
||||
imports: [SharedModule, ImportComponent, HeaderModule],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user