mirror of
https://github.com/bitwarden/browser
synced 2026-02-17 18:09:17 +00:00
Merge branch 'main' into km/default-argon2
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Subject, firstValueFrom, map, takeUntil } from "rxjs";
|
||||
import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -52,9 +53,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
|
||||
this.email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
);
|
||||
this.policyService
|
||||
.masterPasswordPolicyOptions$()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe(
|
||||
(enforcedPasswordPolicyOptions) =>
|
||||
(this.enforcedPolicyOptions ??= enforcedPasswordPolicyOptions),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { Directive, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
@@ -9,6 +8,7 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { DialogRef } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@Directive()
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Directive, EventEmitter, OnInit, Output } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
TwoFactorProviderDetails,
|
||||
TwoFactorService,
|
||||
} from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
@Directive()
|
||||
export class TwoFactorOptionsComponentV1 implements OnInit {
|
||||
@Output() onProviderSelected = new EventEmitter<TwoFactorProviderType>();
|
||||
@Output() onRecoverSelected = new EventEmitter();
|
||||
|
||||
providers: any[] = [];
|
||||
|
||||
constructor(
|
||||
protected twoFactorService: TwoFactorService,
|
||||
protected router: Router,
|
||||
protected i18nService: I18nService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected win: Window,
|
||||
protected environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.providers = await this.twoFactorService.getSupportedProviders(this.win);
|
||||
}
|
||||
|
||||
async choose(p: TwoFactorProviderDetails) {
|
||||
this.onProviderSelected.emit(p.type);
|
||||
}
|
||||
|
||||
async recover() {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webVault = env.getWebVaultUrl();
|
||||
this.platformUtilsService.launchUri(webVault + "/#/recover-2fa");
|
||||
this.onRecoverSelected.emit();
|
||||
}
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, convertToParamMap, Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption,
|
||||
FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption,
|
||||
FakeUserDecryptionOptions as UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { TwoFactorComponentV1 } from "./two-factor-v1.component";
|
||||
|
||||
// test component that extends the TwoFactorComponent
|
||||
@Component({})
|
||||
class TestTwoFactorComponent extends TwoFactorComponentV1 {}
|
||||
|
||||
interface TwoFactorComponentProtected {
|
||||
trustedDeviceEncRoute: string;
|
||||
changePasswordRoute: string;
|
||||
forcePasswordResetRoute: string;
|
||||
successRoute: string;
|
||||
}
|
||||
|
||||
describe("TwoFactorComponent", () => {
|
||||
let component: TestTwoFactorComponent;
|
||||
let _component: TwoFactorComponentProtected;
|
||||
|
||||
let fixture: ComponentFixture<TestTwoFactorComponent>;
|
||||
const userId = "userId" as UserId;
|
||||
|
||||
// Mock Services
|
||||
let mockLoginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockApiService: MockProxy<ApiService>;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let mockWin: MockProxy<Window>;
|
||||
let mockEnvironmentService: MockProxy<EnvironmentService>;
|
||||
let mockStateService: MockProxy<StateService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockTwoFactorService: MockProxy<TwoFactorService>;
|
||||
let mockAppIdService: MockProxy<AppIdService>;
|
||||
let mockLoginEmailService: MockProxy<LoginEmailServiceAbstraction>;
|
||||
let mockUserDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockMasterPasswordService: FakeMasterPasswordService;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
|
||||
let mockUserDecryptionOpts: {
|
||||
noMasterPassword: UserDecryptionOptions;
|
||||
withMasterPassword: UserDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDevice: UserDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
|
||||
withMasterPasswordAndKeyConnector: UserDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDevice: UserDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
|
||||
noMasterPasswordWithKeyConnector: UserDecryptionOptions;
|
||||
};
|
||||
|
||||
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
|
||||
let authenticationSessionTimeoutSubject: BehaviorSubject<boolean>;
|
||||
|
||||
beforeEach(() => {
|
||||
authenticationSessionTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
|
||||
mockLoginStrategyService.authenticationSessionTimeout$ = authenticationSessionTimeoutSubject;
|
||||
mockRouter = mock<Router>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockApiService = mock<ApiService>();
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
mockWin = mock<Window>();
|
||||
mockEnvironmentService = mock<EnvironmentService>();
|
||||
mockStateService = mock<StateService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockTwoFactorService = mock<TwoFactorService>();
|
||||
mockAppIdService = mock<AppIdService>();
|
||||
mockLoginEmailService = mock<LoginEmailServiceAbstraction>();
|
||||
mockUserDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockAccountService = mockAccountServiceWith(userId);
|
||||
mockToastService = mock<ToastService>();
|
||||
mockMasterPasswordService = new FakeMasterPasswordService();
|
||||
|
||||
mockUserDecryptionOpts = {
|
||||
noMasterPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndTrustedDevice: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndKeyConnector: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
|
||||
}),
|
||||
noMasterPasswordWithTrustedDevice: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
noMasterPasswordWithKeyConnector: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
|
||||
}),
|
||||
};
|
||||
|
||||
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
|
||||
mockUserDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [TestTwoFactorComponent],
|
||||
providers: [
|
||||
{ provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: WINDOW, useValue: mockWin },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
{ provide: StateService, useValue: mockStateService },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
// Default to standard 2FA flow - not SSO + 2FA
|
||||
queryParamMap: convertToParamMap({ sso: "false" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: TwoFactorService, useValue: mockTwoFactorService },
|
||||
{ provide: AppIdService, useValue: mockAppIdService },
|
||||
{ provide: LoginEmailServiceAbstraction, useValue: mockLoginEmailService },
|
||||
{
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
useValue: mockUserDecryptionOptionsService,
|
||||
},
|
||||
{ provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(TestTwoFactorComponent);
|
||||
component = fixture.componentInstance;
|
||||
_component = component as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset all mocks after each test
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
// Shared tests
|
||||
const testChangePasswordOnSuccessfulLogin = () => {
|
||||
it("navigates to the component's defined change password route when user doesn't have a MP and key connector isn't enabled", async () => {
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
|
||||
// Assert
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], {
|
||||
queryParams: {
|
||||
identifier: component.orgIdentifier,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
|
||||
it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => {
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
|
||||
// expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], {
|
||||
queryParams: {
|
||||
identifier: component.orgIdentifier,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe("Standard 2FA scenarios", () => {
|
||||
describe("doSubmit", () => {
|
||||
const token = "testToken";
|
||||
const remember = false;
|
||||
const captchaToken = "testCaptchaToken";
|
||||
|
||||
beforeEach(() => {
|
||||
component.token = token;
|
||||
component.remember = remember;
|
||||
component.captchaToken = captchaToken;
|
||||
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
});
|
||||
|
||||
it("calls authService.logInTwoFactor with correct parameters when form is submitted", async () => {
|
||||
// Arrange
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
|
||||
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
|
||||
// Assert
|
||||
expect(mockLoginStrategyService.logInTwoFactor).toHaveBeenCalledWith(
|
||||
new TokenTwoFactorRequest(component.selectedProviderType, token, remember),
|
||||
captchaToken,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return when handleCaptchaRequired returns true", async () => {
|
||||
// Arrange
|
||||
const captchaSiteKey = "testCaptchaSiteKey";
|
||||
const authResult = new AuthResult();
|
||||
authResult.captchaSiteKey = captchaSiteKey;
|
||||
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
|
||||
// Note: the any casts are required b/c typescript cant recognize that
|
||||
// handleCaptureRequired is a method on TwoFactorComponent b/c it is inherited
|
||||
// from the CaptchaProtectedComponent
|
||||
const handleCaptchaRequiredSpy = jest
|
||||
.spyOn<any, any>(component, "handleCaptchaRequired")
|
||||
.mockReturnValue(true);
|
||||
|
||||
// Act
|
||||
const result = await component.doSubmit();
|
||||
|
||||
// Assert
|
||||
expect(handleCaptchaRequiredSpy).toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls onSuccessfulLogin when defined", async () => {
|
||||
// Arrange
|
||||
component.onSuccessfulLogin = jest.fn().mockResolvedValue(undefined);
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
|
||||
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
|
||||
// Assert
|
||||
expect(component.onSuccessfulLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls loginEmailService.clearValues() when login is successful", async () => {
|
||||
// Arrange
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
|
||||
// spy on loginEmailService.clearValues
|
||||
const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues");
|
||||
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
|
||||
// Assert
|
||||
expect(clearValuesSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Set Master Password scenarios", () => {
|
||||
beforeEach(() => {
|
||||
const authResult = new AuthResult();
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
describe("Given user needs to set a master password", () => {
|
||||
beforeEach(() => {
|
||||
// Only need to test the case where the user has no master password to test the primary change mp flow here
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword);
|
||||
});
|
||||
|
||||
testChangePasswordOnSuccessfulLogin();
|
||||
});
|
||||
|
||||
it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => {
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
|
||||
);
|
||||
|
||||
await component.doSubmit();
|
||||
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], {
|
||||
queryParams: {
|
||||
identifier: component.orgIdentifier,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Force Master Password Reset scenarios", () => {
|
||||
[
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
].forEach((forceResetPasswordReason) => {
|
||||
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
|
||||
|
||||
beforeEach(() => {
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = forceResetPasswordReason;
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
testForceResetOnSuccessfulLogin(reasonString);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onSuccessfulLoginNavigate when the callback is defined", async () => {
|
||||
// Arrange
|
||||
component.onSuccessfulLoginNavigate = jest.fn().mockResolvedValue(undefined);
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
|
||||
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
|
||||
// Assert
|
||||
expect(component.onSuccessfulLoginNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates to the component's defined success route when the login is successful and onSuccessfulLoginNavigate is undefined", async () => {
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
|
||||
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
|
||||
// Assert
|
||||
expect(component.onSuccessfulLoginNavigate).not.toBeDefined();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSO > 2FA scenarios", () => {
|
||||
beforeEach(() => {
|
||||
const mockActivatedRoute = TestBed.inject(ActivatedRoute);
|
||||
mockActivatedRoute.snapshot.queryParamMap.get = jest.fn().mockReturnValue("true");
|
||||
});
|
||||
|
||||
describe("doSubmit", () => {
|
||||
const token = "testToken";
|
||||
const remember = false;
|
||||
const captchaToken = "testCaptchaToken";
|
||||
|
||||
beforeEach(() => {
|
||||
component.token = token;
|
||||
component.remember = remember;
|
||||
component.captchaToken = captchaToken;
|
||||
});
|
||||
|
||||
describe("Trusted Device Encryption scenarios", () => {
|
||||
beforeEach(() => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => {
|
||||
beforeEach(() => {
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword,
|
||||
);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
it("navigates to the component's defined trusted device encryption route and sets correct flag when user doesn't have a MP and key connector isn't enabled", async () => {
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
|
||||
// Assert
|
||||
expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(
|
||||
[_component.trustedDeviceEncRoute],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
|
||||
[
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
].forEach((forceResetPasswordReason) => {
|
||||
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
|
||||
|
||||
beforeEach(() => {
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = forceResetPasswordReason;
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
testForceResetOnSuccessfulLogin(reasonString);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
|
||||
let authResult;
|
||||
beforeEach(() => {
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
);
|
||||
|
||||
authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = ForceSetPasswordReason.None;
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
it("navigates to the component's defined trusted device encryption route when login is successful and onSuccessfulLoginTdeNavigate is undefined", async () => {
|
||||
await component.doSubmit();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(
|
||||
[_component.trustedDeviceEncRoute],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => {
|
||||
component.onSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await component.doSubmit();
|
||||
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
expect(component.onSuccessfulLoginTdeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to the timeout route when timeout expires", async () => {
|
||||
authenticationSessionTimeoutSubject.next(true);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["authentication-timeout"]);
|
||||
});
|
||||
});
|
||||
@@ -1,514 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, Inject, OnInit, OnDestroy } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
TrustedDeviceUserDecryptionOption,
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
|
||||
import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service";
|
||||
import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { CaptchaProtectedComponent } from "./captcha-protected.component";
|
||||
|
||||
@Directive()
|
||||
export class TwoFactorComponentV1 extends CaptchaProtectedComponent implements OnInit, OnDestroy {
|
||||
token = "";
|
||||
remember = false;
|
||||
webAuthnReady = false;
|
||||
webAuthnNewTab = false;
|
||||
providers = TwoFactorProviders;
|
||||
providerType = TwoFactorProviderType;
|
||||
selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator;
|
||||
webAuthnSupported = false;
|
||||
webAuthn: WebAuthnIFrame = null;
|
||||
title = "";
|
||||
twoFactorEmail: string = null;
|
||||
formPromise: Promise<any>;
|
||||
emailPromise: Promise<any>;
|
||||
orgIdentifier: string = null;
|
||||
|
||||
duoFramelessUrl: string = null;
|
||||
duoResultListenerInitialized = false;
|
||||
|
||||
onSuccessfulLogin: () => Promise<void>;
|
||||
onSuccessfulLoginNavigate: () => Promise<void>;
|
||||
|
||||
onSuccessfulLoginTde: () => Promise<void>;
|
||||
onSuccessfulLoginTdeNavigate: () => Promise<void>;
|
||||
|
||||
protected loginRoute = "login";
|
||||
|
||||
protected trustedDeviceEncRoute = "login-initiated";
|
||||
protected changePasswordRoute = "set-password";
|
||||
protected forcePasswordResetRoute = "update-temp-password";
|
||||
protected successRoute = "vault";
|
||||
protected twoFactorTimeoutRoute = "authentication-timeout";
|
||||
|
||||
get isDuoProvider(): boolean {
|
||||
return (
|
||||
this.selectedProviderType === TwoFactorProviderType.Duo ||
|
||||
this.selectedProviderType === TwoFactorProviderType.OrganizationDuo
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
protected router: Router,
|
||||
protected i18nService: I18nService,
|
||||
protected apiService: ApiService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
@Inject(WINDOW) protected win: Window,
|
||||
protected environmentService: EnvironmentService,
|
||||
protected stateService: StateService,
|
||||
protected route: ActivatedRoute,
|
||||
protected logService: LogService,
|
||||
protected twoFactorService: TwoFactorService,
|
||||
protected appIdService: AppIdService,
|
||||
protected loginEmailService: LoginEmailServiceAbstraction,
|
||||
protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
protected ssoLoginService: SsoLoginServiceAbstraction,
|
||||
protected configService: ConfigService,
|
||||
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
protected accountService: AccountService,
|
||||
protected toastService: ToastService,
|
||||
) {
|
||||
super(environmentService, i18nService, platformUtilsService, toastService);
|
||||
|
||||
this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
|
||||
|
||||
// Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired
|
||||
this.loginStrategyService.authenticationSessionTimeout$
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(async (expired) => {
|
||||
if (!expired) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.router.navigate([this.twoFactorTimeoutRoute]);
|
||||
} catch (err) {
|
||||
this.logService.error(`Failed to navigate to ${this.twoFactorTimeoutRoute} route`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
if (!(await this.authing()) || (await this.twoFactorService.getProviders()) == null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.loginRoute]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.route.queryParams.pipe(first()).subscribe((qParams) => {
|
||||
if (qParams.identifier != null) {
|
||||
this.orgIdentifier = qParams.identifier;
|
||||
}
|
||||
});
|
||||
|
||||
if (await this.needsLock()) {
|
||||
this.successRoute = "lock";
|
||||
}
|
||||
|
||||
if (this.win != null && this.webAuthnSupported) {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webVaultUrl = env.getWebVaultUrl();
|
||||
this.webAuthn = new WebAuthnIFrame(
|
||||
this.win,
|
||||
webVaultUrl,
|
||||
this.webAuthnNewTab,
|
||||
this.platformUtilsService,
|
||||
this.i18nService,
|
||||
(token: string) => {
|
||||
this.token = token;
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.submit();
|
||||
},
|
||||
(error: string) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("webauthnCancelOrTimeout"),
|
||||
});
|
||||
},
|
||||
(info: string) => {
|
||||
if (info === "ready") {
|
||||
this.webAuthnReady = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
this.selectedProviderType = await this.twoFactorService.getDefaultProvider(
|
||||
this.webAuthnSupported,
|
||||
);
|
||||
await this.init();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cleanupWebAuthn();
|
||||
this.webAuthn = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.selectedProviderType == null) {
|
||||
this.title = this.i18nService.t("loginUnavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupWebAuthn();
|
||||
this.title = (TwoFactorProviders as any)[this.selectedProviderType].name;
|
||||
const providerData = await this.twoFactorService.getProviders().then((providers) => {
|
||||
return providers.get(this.selectedProviderType);
|
||||
});
|
||||
switch (this.selectedProviderType) {
|
||||
case TwoFactorProviderType.WebAuthn:
|
||||
if (!this.webAuthnNewTab) {
|
||||
setTimeout(async () => {
|
||||
await this.authWebAuthn();
|
||||
}, 500);
|
||||
}
|
||||
break;
|
||||
case TwoFactorProviderType.Duo:
|
||||
case TwoFactorProviderType.OrganizationDuo:
|
||||
// Setup listener for duo-redirect.ts connector to send back the code
|
||||
if (!this.duoResultListenerInitialized) {
|
||||
// setup client specific duo result listener
|
||||
this.setupDuoResultListener();
|
||||
this.duoResultListenerInitialized = true;
|
||||
}
|
||||
// flow must be launched by user so they can choose to remember the device or not.
|
||||
this.duoFramelessUrl = providerData.AuthUrl;
|
||||
break;
|
||||
case TwoFactorProviderType.Email:
|
||||
this.twoFactorEmail = providerData.Email;
|
||||
if ((await this.twoFactorService.getProviders()).size > 1) {
|
||||
await this.sendEmail(false);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.setupCaptcha();
|
||||
|
||||
if (this.token == null || this.token === "") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("verificationCodeRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedProviderType === TwoFactorProviderType.WebAuthn) {
|
||||
if (this.webAuthn != null) {
|
||||
this.webAuthn.stop();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
this.selectedProviderType === TwoFactorProviderType.Email ||
|
||||
this.selectedProviderType === TwoFactorProviderType.Authenticator
|
||||
) {
|
||||
this.token = this.token.replace(" ", "").trim();
|
||||
}
|
||||
|
||||
await this.doSubmit();
|
||||
if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && this.webAuthn != null) {
|
||||
this.webAuthn.start();
|
||||
}
|
||||
}
|
||||
|
||||
async doSubmit() {
|
||||
this.formPromise = this.loginStrategyService.logInTwoFactor(
|
||||
new TokenTwoFactorRequest(this.selectedProviderType, this.token, this.remember),
|
||||
this.captchaToken,
|
||||
);
|
||||
const authResult: AuthResult = await this.formPromise;
|
||||
|
||||
await this.handleLoginResponse(authResult);
|
||||
}
|
||||
|
||||
protected handleMigrateEncryptionKey(result: AuthResult): boolean {
|
||||
if (!result.requiresEncryptionKeyMigration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccured"),
|
||||
message: this.i18nService.t("encryptionKeyMigrationRequired"),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Each client will have own implementation
|
||||
protected setupDuoResultListener(): void {}
|
||||
|
||||
private async handleLoginResponse(authResult: AuthResult) {
|
||||
if (this.handleCaptchaRequired(authResult)) {
|
||||
return;
|
||||
} else if (this.handleMigrateEncryptionKey(authResult)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save off the OrgSsoIdentifier for use in the TDE flows
|
||||
// - TDE login decryption options component
|
||||
// - Browser SSO on extension open
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier, userId);
|
||||
this.loginEmailService.clearValues();
|
||||
|
||||
// note: this flow affects both TDE & standard users
|
||||
if (this.isForcePasswordResetRequired(authResult)) {
|
||||
return await this.handleForcePasswordReset(this.orgIdentifier);
|
||||
}
|
||||
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
|
||||
const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption);
|
||||
|
||||
if (tdeEnabled) {
|
||||
return await this.handleTrustedDeviceEncryptionEnabled(
|
||||
authResult,
|
||||
this.orgIdentifier,
|
||||
userDecryptionOpts,
|
||||
);
|
||||
}
|
||||
|
||||
// User must set password if they don't have one and they aren't using either TDE or key connector.
|
||||
const requireSetPassword =
|
||||
!userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined;
|
||||
|
||||
if (requireSetPassword || authResult.resetMasterPassword) {
|
||||
// Change implies going no password -> password in this case
|
||||
return await this.handleChangePasswordRequired(this.orgIdentifier);
|
||||
}
|
||||
|
||||
return await this.handleSuccessfulLogin();
|
||||
}
|
||||
|
||||
private async isTrustedDeviceEncEnabled(
|
||||
trustedDeviceOption: TrustedDeviceUserDecryptionOption,
|
||||
): Promise<boolean> {
|
||||
const ssoTo2faFlowActive = this.route.snapshot.queryParamMap.get("sso") === "true";
|
||||
|
||||
return ssoTo2faFlowActive && trustedDeviceOption !== undefined;
|
||||
}
|
||||
|
||||
private async handleTrustedDeviceEncryptionEnabled(
|
||||
authResult: AuthResult,
|
||||
orgIdentifier: string,
|
||||
userDecryptionOpts: UserDecryptionOptions,
|
||||
): Promise<void> {
|
||||
// If user doesn't have a MP, but has reset password permission, they must set a MP
|
||||
if (
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
|
||||
) {
|
||||
// Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device)
|
||||
// Note: we cannot directly navigate to the set password screen in this scenario as we are in a pre-decryption state, and
|
||||
// if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key.
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.onSuccessfulLoginTde != null) {
|
||||
// Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete
|
||||
// before navigating to the success route.
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLoginTde();
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.navigateViaCallbackOrRoute(
|
||||
this.onSuccessfulLoginTdeNavigate,
|
||||
// Navigate to TDE page (if user was on trusted device and TDE has decrypted
|
||||
// their user key, the login-initiated guard will redirect them to the vault)
|
||||
[this.trustedDeviceEncRoute],
|
||||
);
|
||||
}
|
||||
|
||||
private async handleChangePasswordRequired(orgIdentifier: string) {
|
||||
await this.router.navigate([this.changePasswordRoute], {
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a user needs to reset their password based on certain conditions.
|
||||
* Users can be forced to reset their password via an admin or org policy disallowing weak passwords.
|
||||
* Note: this is different from the SSO component login flow as a user can
|
||||
* login with MP and then have to pass 2FA to finish login and we can actually
|
||||
* evaluate if they have a weak password at that time.
|
||||
*
|
||||
* @param {AuthResult} authResult - The authentication result.
|
||||
* @returns {boolean} Returns true if a password reset is required, false otherwise.
|
||||
*/
|
||||
private isForcePasswordResetRequired(authResult: AuthResult): boolean {
|
||||
const forceResetReasons = [
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
];
|
||||
|
||||
return forceResetReasons.includes(authResult.forcePasswordReset);
|
||||
}
|
||||
|
||||
private async handleForcePasswordReset(orgIdentifier: string) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.forcePasswordResetRoute], {
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSuccessfulLogin() {
|
||||
if (this.onSuccessfulLogin != null) {
|
||||
// Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete
|
||||
// before navigating to the success route.
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onSuccessfulLogin();
|
||||
}
|
||||
await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]);
|
||||
}
|
||||
|
||||
private async navigateViaCallbackOrRoute(
|
||||
callback: () => Promise<unknown>,
|
||||
commands: unknown[],
|
||||
extras?: NavigationExtras,
|
||||
): Promise<void> {
|
||||
if (callback) {
|
||||
await callback();
|
||||
} else {
|
||||
await this.router.navigate(commands, extras);
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(doToast: boolean) {
|
||||
if (this.selectedProviderType !== TwoFactorProviderType.Email) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.emailPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((await this.loginStrategyService.getEmail()) == null) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("sessionTimeout"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request = new TwoFactorEmailRequest();
|
||||
request.email = await this.loginStrategyService.getEmail();
|
||||
request.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
|
||||
request.ssoEmail2FaSessionToken =
|
||||
await this.loginStrategyService.getSsoEmail2FaSessionToken();
|
||||
request.deviceIdentifier = await this.appIdService.getAppId();
|
||||
request.authRequestAccessCode = await this.loginStrategyService.getAccessCode();
|
||||
request.authRequestId = await this.loginStrategyService.getAuthRequestId();
|
||||
this.emailPromise = this.apiService.postTwoFactorEmail(request);
|
||||
await this.emailPromise;
|
||||
if (doToast) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("verificationCodeEmailSent", this.twoFactorEmail),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
this.emailPromise = null;
|
||||
}
|
||||
|
||||
async authWebAuthn() {
|
||||
const providerData = await this.twoFactorService.getProviders().then((providers) => {
|
||||
return providers.get(this.selectedProviderType);
|
||||
});
|
||||
|
||||
if (!this.webAuthnSupported || this.webAuthn == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.webAuthn.init(providerData);
|
||||
}
|
||||
|
||||
private cleanupWebAuthn() {
|
||||
if (this.webAuthn != null) {
|
||||
this.webAuthn.stop();
|
||||
this.webAuthn.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private async authing(): Promise<boolean> {
|
||||
return (await firstValueFrom(this.loginStrategyService.currentAuthType$)) !== null;
|
||||
}
|
||||
|
||||
private async needsLock(): Promise<boolean> {
|
||||
const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$);
|
||||
return authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey;
|
||||
}
|
||||
|
||||
async launchDuoFrameless() {
|
||||
if (this.duoFramelessUrl === null) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.platformUtilsService.launchUri(this.duoFramelessUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Type, inject } from "@angular/core";
|
||||
import { Route, Routes } from "@angular/router";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { componentRouteSwap } from "../../utils/component-route-swap";
|
||||
|
||||
/**
|
||||
* Helper function to swap between two components based on the UnauthenticatedExtensionUIRefresh feature flag.
|
||||
* We need this because the auth teams's authenticated UI will be refreshed as part of the MVP but the
|
||||
* unauthenticated UIs will not necessarily make the cut.
|
||||
* Note: Even though this is primarily an extension refresh initiative, this will be used across clients
|
||||
* as we are consolidating the unauthenticated UIs into single libs/auth components which affects all clients.
|
||||
* @param defaultComponent - The current non-refreshed component to render.
|
||||
* @param refreshedComponent - The new refreshed component to render.
|
||||
* @param options - The shared route options to apply to both components.
|
||||
* @param altOptions - The alt route options to apply to the alt component. If not provided, the base options will be used.
|
||||
*/
|
||||
export function unauthUiRefreshSwap(
|
||||
defaultComponent: Type<any>,
|
||||
refreshedComponent: Type<any>,
|
||||
options: Route,
|
||||
altOptions?: Route,
|
||||
): Routes {
|
||||
return componentRouteSwap(
|
||||
defaultComponent,
|
||||
refreshedComponent,
|
||||
async () => {
|
||||
const configService = inject(ConfigService);
|
||||
return configService.getFeatureFlag(FeatureFlag.UnauthenticatedExtensionUIRefresh);
|
||||
},
|
||||
options,
|
||||
altOptions,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
@@ -16,7 +15,7 @@ import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/b
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
|
||||
export type AddAccountCreditDialogParams = {
|
||||
organizationId?: string;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{{ "open" | i18n | titlecase }}
|
||||
</span>
|
||||
<span *ngIf="expandedInvoiceStatus === 'unpaid'">
|
||||
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
|
||||
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
|
||||
{{ "unpaid" | i18n | titlecase }}
|
||||
</span>
|
||||
<span *ngIf="expandedInvoiceStatus === 'paid'">
|
||||
|
||||
@@ -77,6 +77,18 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => {
|
||||
this.taxInformation = {
|
||||
country: values.country,
|
||||
postalCode: values.postalCode,
|
||||
taxId: values.taxId,
|
||||
line1: values.line1,
|
||||
line2: values.line2,
|
||||
city: values.city,
|
||||
state: values.state,
|
||||
};
|
||||
});
|
||||
|
||||
if (this.startWith) {
|
||||
this.formGroup.controls.country.setValue(this.startWith.country);
|
||||
this.formGroup.controls.postalCode.setValue(this.startWith.postalCode);
|
||||
@@ -95,18 +107,6 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => {
|
||||
this.taxInformation = {
|
||||
country: values.country,
|
||||
postalCode: values.postalCode,
|
||||
taxId: values.taxId,
|
||||
line1: values.line1,
|
||||
line2: values.line2,
|
||||
city: values.city,
|
||||
state: values.state,
|
||||
};
|
||||
});
|
||||
|
||||
this.formGroup.controls.country.valueChanges
|
||||
.pipe(debounceTime(1000), takeUntil(this.destroy$))
|
||||
.subscribe((country: string) => {
|
||||
|
||||
@@ -4,48 +4,48 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
DefaultOrganizationUserApiService,
|
||||
CollectionService,
|
||||
DefaultCollectionService,
|
||||
DefaultOrganizationUserApiService,
|
||||
OrganizationUserApiService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
SetPasswordJitService,
|
||||
DefaultSetPasswordJitService,
|
||||
RegistrationFinishService as RegistrationFinishServiceAbstraction,
|
||||
DefaultRegistrationFinishService,
|
||||
AnonLayoutWrapperDataService,
|
||||
DefaultAnonLayoutWrapperDataService,
|
||||
LoginComponentService,
|
||||
DefaultLoginApprovalComponentService,
|
||||
DefaultLoginComponentService,
|
||||
LoginDecryptionOptionsService,
|
||||
DefaultLoginDecryptionOptionsService,
|
||||
TwoFactorAuthComponentService,
|
||||
DefaultRegistrationFinishService,
|
||||
DefaultSetPasswordJitService,
|
||||
DefaultTwoFactorAuthComponentService,
|
||||
DefaultTwoFactorAuthEmailComponentService,
|
||||
TwoFactorAuthEmailComponentService,
|
||||
DefaultTwoFactorAuthWebAuthnComponentService,
|
||||
LoginComponentService,
|
||||
LoginDecryptionOptionsService,
|
||||
RegistrationFinishService as RegistrationFinishServiceAbstraction,
|
||||
SetPasswordJitService,
|
||||
TwoFactorAuthComponentService,
|
||||
TwoFactorAuthEmailComponentService,
|
||||
TwoFactorAuthWebAuthnComponentService,
|
||||
DefaultLoginApprovalComponentService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
AuthRequestServiceAbstraction,
|
||||
AuthRequestService,
|
||||
PinServiceAbstraction,
|
||||
PinService,
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginStrategyService,
|
||||
LoginEmailServiceAbstraction,
|
||||
LoginEmailService,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
UserDecryptionOptionsService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
LogoutReason,
|
||||
AuthRequestApiService,
|
||||
AuthRequestService,
|
||||
AuthRequestServiceAbstraction,
|
||||
DefaultAuthRequestApiService,
|
||||
DefaultLoginSuccessHandlerService,
|
||||
LoginSuccessHandlerService,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LoginApprovalComponentServiceAbstraction,
|
||||
LoginEmailService,
|
||||
LoginEmailServiceAbstraction,
|
||||
LoginStrategyService,
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginSuccessHandlerService,
|
||||
LogoutReason,
|
||||
PinService,
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptionsService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
@@ -75,12 +75,13 @@ import { OrganizationApiService } from "@bitwarden/common/admin-console/services
|
||||
import { OrgDomainApiService } from "@bitwarden/common/admin-console/services/organization-domain/org-domain-api.service";
|
||||
import { OrgDomainService } from "@bitwarden/common/admin-console/services/organization-domain/org-domain.service";
|
||||
import { DefaultOrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/services/organization-management-preferences/default-organization-management-preferences.service";
|
||||
import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
|
||||
import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
|
||||
import { ProviderApiService } from "@bitwarden/common/admin-console/services/provider/provider-api.service";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
|
||||
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import {
|
||||
AccountService,
|
||||
AccountService as AccountServiceAbstraction,
|
||||
InternalAccountService,
|
||||
} from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -117,16 +118,16 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
|
||||
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
|
||||
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
|
||||
import {
|
||||
AutofillSettingsServiceAbstraction,
|
||||
AutofillSettingsService,
|
||||
AutofillSettingsServiceAbstraction,
|
||||
} from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import {
|
||||
BadgeSettingsServiceAbstraction,
|
||||
BadgeSettingsService,
|
||||
BadgeSettingsServiceAbstraction,
|
||||
} from "@bitwarden/common/autofill/services/badge-settings.service";
|
||||
import {
|
||||
DomainSettingsService,
|
||||
DefaultDomainSettingsService,
|
||||
DomainSettingsService,
|
||||
} from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import {
|
||||
BillingApiServiceAbstraction,
|
||||
@@ -199,8 +200,8 @@ import {
|
||||
WebPushNotificationsApiService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
import {
|
||||
TaskSchedulerService,
|
||||
DefaultTaskSchedulerService,
|
||||
TaskSchedulerService,
|
||||
} from "@bitwarden/common/platform/scheduling";
|
||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||
@@ -221,10 +222,10 @@ import { ValidationService } from "@bitwarden/common/platform/services/validatio
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||
import {
|
||||
ActiveUserStateProvider,
|
||||
DerivedStateProvider,
|
||||
GlobalStateProvider,
|
||||
SingleUserStateProvider,
|
||||
StateProvider,
|
||||
DerivedStateProvider,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
/* eslint-disable import/no-restricted-paths -- We need the implementations to inject, but generally these should not be accessed */
|
||||
import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider";
|
||||
@@ -279,6 +280,7 @@ import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder
|
||||
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
|
||||
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import {
|
||||
GeneratorHistoryService,
|
||||
@@ -291,34 +293,32 @@ import {
|
||||
UsernameGenerationServiceAbstraction,
|
||||
} from "@bitwarden/generator-legacy";
|
||||
import {
|
||||
KeyService,
|
||||
DefaultKeyService,
|
||||
BiometricsService,
|
||||
BiometricStateService,
|
||||
DefaultBiometricStateService,
|
||||
BiometricsService,
|
||||
DefaultKdfConfigService,
|
||||
KdfConfigService,
|
||||
UserAsymmetricKeysRegenerationService,
|
||||
DefaultUserAsymmetricKeysRegenerationService,
|
||||
UserAsymmetricKeysRegenerationApiService,
|
||||
DefaultKeyService,
|
||||
DefaultUserAsymmetricKeysRegenerationApiService,
|
||||
DefaultUserAsymmetricKeysRegenerationService,
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
UserAsymmetricKeysRegenerationApiService,
|
||||
UserAsymmetricKeysRegenerationService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
import {
|
||||
DefaultTaskService,
|
||||
DefaultEndUserNotificationService,
|
||||
EndUserNotificationService,
|
||||
NewDeviceVerificationNoticeService,
|
||||
PasswordRepromptService,
|
||||
TaskService,
|
||||
} from "@bitwarden/vault";
|
||||
import {
|
||||
VaultExportService,
|
||||
VaultExportServiceAbstraction,
|
||||
OrganizationVaultExportService,
|
||||
OrganizationVaultExportServiceAbstraction,
|
||||
IndividualVaultExportService,
|
||||
IndividualVaultExportServiceAbstraction,
|
||||
OrganizationVaultExportService,
|
||||
OrganizationVaultExportServiceAbstraction,
|
||||
VaultExportService,
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
|
||||
@@ -333,24 +333,24 @@ import { AbstractThemingService } from "../platform/services/theming/theming.ser
|
||||
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
|
||||
|
||||
import {
|
||||
CLIENT_TYPE,
|
||||
DEFAULT_VAULT_TIMEOUT,
|
||||
ENV_ADDITIONAL_REGIONS,
|
||||
INTRAPROCESS_MESSAGING_SUBJECT,
|
||||
LOCALES_DIRECTORY,
|
||||
LOCKED_CALLBACK,
|
||||
LOGOUT_CALLBACK,
|
||||
LOG_MAC_FAILURES,
|
||||
LOGOUT_CALLBACK,
|
||||
MEMORY_STORAGE,
|
||||
OBSERVABLE_DISK_STORAGE,
|
||||
OBSERVABLE_MEMORY_STORAGE,
|
||||
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
|
||||
SECURE_STORAGE,
|
||||
STATE_FACTORY,
|
||||
SUPPORTS_SECURE_STORAGE,
|
||||
SYSTEM_LANGUAGE,
|
||||
SYSTEM_THEME_OBSERVABLE,
|
||||
WINDOW,
|
||||
DEFAULT_VAULT_TIMEOUT,
|
||||
INTRAPROCESS_MESSAGING_SUBJECT,
|
||||
CLIENT_TYPE,
|
||||
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
|
||||
ENV_ADDITIONAL_REGIONS,
|
||||
} from "./injection-tokens";
|
||||
import { ModalService } from "./modal.service";
|
||||
|
||||
@@ -505,6 +505,7 @@ const safeProviders: SafeProvider[] = [
|
||||
configService: ConfigService,
|
||||
stateProvider: StateProvider,
|
||||
accountService: AccountServiceAbstraction,
|
||||
logService: LogService,
|
||||
) =>
|
||||
new CipherService(
|
||||
keyService,
|
||||
@@ -520,6 +521,7 @@ const safeProviders: SafeProvider[] = [
|
||||
configService,
|
||||
stateProvider,
|
||||
accountService,
|
||||
logService,
|
||||
),
|
||||
deps: [
|
||||
KeyService,
|
||||
@@ -535,6 +537,7 @@ const safeProviders: SafeProvider[] = [
|
||||
ConfigService,
|
||||
StateProvider,
|
||||
AccountServiceAbstraction,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -846,6 +849,7 @@ const safeProviders: SafeProvider[] = [
|
||||
CryptoFunctionServiceAbstraction,
|
||||
KdfConfigService,
|
||||
AccountServiceAbstraction,
|
||||
ApiServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -946,7 +950,7 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: InternalPolicyService,
|
||||
useClass: PolicyService,
|
||||
useClass: DefaultPolicyService,
|
||||
deps: [StateProvider, OrganizationServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -956,7 +960,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: PolicyApiServiceAbstraction,
|
||||
useClass: PolicyApiService,
|
||||
deps: [InternalPolicyService, ApiServiceAbstraction],
|
||||
deps: [InternalPolicyService, ApiServiceAbstraction, AccountService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: InternalMasterPasswordServiceAbstraction,
|
||||
@@ -1171,9 +1175,7 @@ const safeProviders: SafeProvider[] = [
|
||||
KdfConfigService,
|
||||
KeyGenerationServiceAbstraction,
|
||||
LogService,
|
||||
MasterPasswordServiceAbstraction,
|
||||
StateProvider,
|
||||
StateServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1258,7 +1260,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: AutofillSettingsServiceAbstraction,
|
||||
useClass: AutofillSettingsService,
|
||||
deps: [StateProvider, PolicyServiceAbstraction],
|
||||
deps: [StateProvider, PolicyServiceAbstraction, AccountService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BadgeSettingsServiceAbstraction,
|
||||
@@ -1478,7 +1480,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: EndUserNotificationService,
|
||||
useClass: DefaultEndUserNotificationService,
|
||||
deps: [StateProvider, ApiServiceAbstraction],
|
||||
deps: [StateProvider, ApiServiceAbstraction, NotificationsService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DeviceTrustToastServiceAbstraction,
|
||||
|
||||
@@ -155,9 +155,14 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.policyService
|
||||
.policyAppliesToActiveUser$(PolicyType.DisableSend)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((policyAppliesToActiveUser) => {
|
||||
this.disableSend = policyAppliesToActiveUser;
|
||||
if (this.disableSend) {
|
||||
@@ -168,7 +173,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.policyService.getAll$(PolicyType.SendOptions, userId)),
|
||||
switchMap((userId) => this.policyService.policiesByType$(PolicyType.SendOptions, userId)),
|
||||
map((policies) => policies?.some((p) => p.data.disableHideEmail)),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
from,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
combineLatest,
|
||||
} from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
@@ -85,18 +86,23 @@ export class SendComponent implements OnInit, OnDestroy {
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
this.policyService
|
||||
.policyAppliesToActiveUser$(PolicyType.DisableSend)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((policyAppliesToActiveUser) => {
|
||||
this.disableSend = policyAppliesToActiveUser;
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((policyAppliesToUser) => {
|
||||
this.disableSend = policyAppliesToUser;
|
||||
});
|
||||
|
||||
this._searchText$
|
||||
combineLatest([this._searchText$, this.accountService.activeAccount$.pipe(getUserId)])
|
||||
.pipe(
|
||||
switchMap((searchText) => from(this.searchService.isSearchable(userId, searchText))),
|
||||
switchMap(([searchText, userId]) =>
|
||||
from(this.searchService.isSearchable(userId, searchText)),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((isSearchable) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { concatMap, firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
|
||||
import { concatMap, firstValueFrom, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
@@ -193,9 +193,12 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.policyService
|
||||
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
|
||||
),
|
||||
concatMap(async (policyAppliesToActiveUser) => {
|
||||
this.personalOwnershipPolicyAppliesToActiveUser = policyAppliesToActiveUser;
|
||||
await this.init();
|
||||
|
||||
@@ -59,6 +59,7 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
@Output() onRestoredCipher = new EventEmitter<CipherView>();
|
||||
|
||||
canDeleteCipher$: Observable<boolean>;
|
||||
canRestoreCipher$: Observable<boolean>;
|
||||
cipher: CipherView;
|
||||
showPassword: boolean;
|
||||
showPasswordCount: boolean;
|
||||
@@ -159,6 +160,7 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
|
||||
this.collectionId as CollectionId,
|
||||
]);
|
||||
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
|
||||
|
||||
if (this.cipher.folderId) {
|
||||
this.folder = await (
|
||||
@@ -203,12 +205,7 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
async edit() {
|
||||
if (await this.promptPassword()) {
|
||||
this.onEditCipher.emit(this.cipher);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
this.onEditCipher.emit(this.cipher);
|
||||
}
|
||||
|
||||
async clone() {
|
||||
|
||||
@@ -112,13 +112,23 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
|
||||
|
||||
async checkForSingleOrganizationPolicy(): Promise<boolean> {
|
||||
return await firstValueFrom(
|
||||
this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg),
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async checkForPersonalOwnershipPolicy(): Promise<boolean> {
|
||||
return await firstValueFrom(
|
||||
this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership),
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
class="tw-text-center tw-mb-4 sm:tw-mb-6"
|
||||
[ngClass]="{ 'tw-max-w-md tw-mx-auto': titleAreaMaxWidth === 'md' }"
|
||||
>
|
||||
<div class="tw-mx-auto tw-max-w-24 sm:tw-max-w-28 md:tw-max-w-32">
|
||||
<div *ngIf="!hideIcon" class="tw-mx-auto tw-max-w-24 sm:tw-max-w-28 md:tw-max-w-32">
|
||||
<bit-icon [icon]="icon"></bit-icon>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
@Input() showReadonlyHostname: boolean;
|
||||
@Input() hideLogo: boolean = false;
|
||||
@Input() hideFooter: boolean = false;
|
||||
@Input() hideIcon: boolean = false;
|
||||
|
||||
/**
|
||||
* Max width of the title area content
|
||||
|
||||
@@ -163,6 +163,22 @@ export const WithCustomIcon: Story = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const HideIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideIcon]="true" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const HideLogo: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
|
||||
import { DIALOG_DATA, ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
|
||||
|
||||
export type FingerprintDialogData = {
|
||||
fingerprint: string[];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
@@ -15,7 +14,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { LoginApprovalComponent } from "./login-approval.component";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, OnDestroy, Inject } from "@angular/core";
|
||||
import { Subject, firstValueFrom, map } from "rxjs";
|
||||
@@ -19,6 +18,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogRef,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import {
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import {
|
||||
DialogRef,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DialogModule } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DialogModule } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
@@ -8,6 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DialogModule } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
|
||||
@@ -14,6 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DialogModule } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { ReactiveFormsModule, FormsModule } from "@angular/forms";
|
||||
@@ -15,6 +14,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DialogModule } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
} from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import {
|
||||
DialogRef,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
@@ -12,6 +11,8 @@ import { VerificationWithSecret } from "@bitwarden/common/auth/types/verificatio
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogRef,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
|
||||
@@ -2,25 +2,32 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
VaultTimeoutSettingsService,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { VaultTimeoutInputComponent } from "./vault-timeout-input.component";
|
||||
|
||||
describe("VaultTimeoutInputComponent", () => {
|
||||
let component: VaultTimeoutInputComponent;
|
||||
let fixture: ComponentFixture<VaultTimeoutInputComponent>;
|
||||
const get$ = jest.fn().mockReturnValue(new BehaviorSubject({}));
|
||||
const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({}));
|
||||
const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([]));
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const accountService = mockAccountServiceWith(mockUserId);
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultTimeoutInputComponent],
|
||||
providers: [
|
||||
{ provide: PolicyService, useValue: { get$ } },
|
||||
{ provide: PolicyService, useValue: { policiesByType$ } },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: VaultTimeoutSettingsService, useValue: { availableVaultTimeoutActions$ } },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
ValidationErrors,
|
||||
Validator,
|
||||
} from "@angular/forms";
|
||||
import { filter, map, Observable, Subject, takeUntil } from "rxjs";
|
||||
import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutAction,
|
||||
@@ -123,12 +126,17 @@ export class VaultTimeoutInputComponent
|
||||
private policyService: PolicyService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private i18nService: I18nService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.policyService
|
||||
.get$(PolicyType.MaximumVaultTimeout)
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
|
||||
),
|
||||
getFirstPolicy,
|
||||
filter((policy) => policy != null),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
@@ -136,7 +144,6 @@ export class VaultTimeoutInputComponent
|
||||
this.vaultTimeoutPolicy = policy;
|
||||
this.applyVaultTimeoutPolicy();
|
||||
});
|
||||
|
||||
this.form.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((value: VaultTimeoutFormValue) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { PinKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -90,17 +90,6 @@ export abstract class PinServiceAbstraction {
|
||||
*/
|
||||
abstract clearUserKeyEncryptedPin(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets the old MasterKey, encrypted by the PinKey (formerly called `pinProtected`).
|
||||
* Deprecated and used for migration purposes only.
|
||||
*/
|
||||
abstract getOldPinKeyEncryptedMasterKey: (userId: UserId) => Promise<EncryptedString | null>;
|
||||
|
||||
/**
|
||||
* Clears the old MasterKey, encrypted by the PinKey.
|
||||
*/
|
||||
abstract clearOldPinKeyEncryptedMasterKey: (userId: UserId) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Makes a PinKey from the provided PIN.
|
||||
*/
|
||||
|
||||
@@ -4,11 +4,9 @@ import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
@@ -18,7 +16,7 @@ import {
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { PinKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfig, KdfConfigService } from "@bitwarden/key-management";
|
||||
|
||||
import { PinServiceAbstraction } from "../../abstractions/pin.service.abstraction";
|
||||
@@ -73,19 +71,6 @@ export const USER_KEY_ENCRYPTED_PIN = new UserKeyDefinition<EncryptedString>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The old MasterKey, encrypted by the PinKey (formerly called `pinProtected`).
|
||||
* Deprecated and used for migration purposes only.
|
||||
*/
|
||||
export const OLD_PIN_KEY_ENCRYPTED_MASTER_KEY = new UserKeyDefinition<EncryptedString>(
|
||||
PIN_DISK,
|
||||
"oldPinKeyEncryptedMasterKey",
|
||||
{
|
||||
deserializer: (jsonValue) => jsonValue,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export class PinService implements PinServiceAbstraction {
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
@@ -94,9 +79,7 @@ export class PinService implements PinServiceAbstraction {
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private logService: LogService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private stateProvider: StateProvider,
|
||||
private stateService: StateService,
|
||||
) {}
|
||||
|
||||
async getPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null> {
|
||||
@@ -190,9 +173,7 @@ export class PinService implements PinServiceAbstraction {
|
||||
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
|
||||
);
|
||||
const kdfConfig = await this.kdfConfigService.getKdfConfig();
|
||||
|
||||
const pinKey = await this.makePinKey(pin, email, kdfConfig);
|
||||
|
||||
return await this.encryptService.encrypt(userKey.key, pinKey);
|
||||
}
|
||||
|
||||
@@ -242,45 +223,24 @@ export class PinService implements PinServiceAbstraction {
|
||||
return await this.encryptService.encrypt(pin, userKey);
|
||||
}
|
||||
|
||||
async getOldPinKeyEncryptedMasterKey(userId: UserId): Promise<EncryptedString | null> {
|
||||
this.validateUserId(userId, "Cannot get oldPinKeyEncryptedMasterKey.");
|
||||
|
||||
return await firstValueFrom(
|
||||
this.stateProvider.getUserState$(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, userId),
|
||||
);
|
||||
}
|
||||
|
||||
async clearOldPinKeyEncryptedMasterKey(userId: UserId): Promise<void> {
|
||||
this.validateUserId(userId, "Cannot clear oldPinKeyEncryptedMasterKey.");
|
||||
|
||||
await this.stateProvider.setUserState(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, null, userId);
|
||||
}
|
||||
|
||||
async makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey> {
|
||||
const start = Date.now();
|
||||
const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdfConfig);
|
||||
this.logService.info(`[Pin Service] deriving pin key took ${Date.now() - start}ms`);
|
||||
|
||||
return (await this.keyGenerationService.stretchKey(pinKey)) as PinKey;
|
||||
}
|
||||
|
||||
async getPinLockType(userId: UserId): Promise<PinLockType> {
|
||||
this.validateUserId(userId, "Cannot get PinLockType.");
|
||||
|
||||
/**
|
||||
* We can't check the `userKeyEncryptedPin` (formerly called `protectedPin`) for both because old
|
||||
* accounts only used it for MP on Restart
|
||||
*/
|
||||
const aUserKeyEncryptedPinIsSet = !!(await this.getUserKeyEncryptedPin(userId));
|
||||
const aPinKeyEncryptedUserKeyPersistentIsSet =
|
||||
!!(await this.getPinKeyEncryptedUserKeyPersistent(userId));
|
||||
const anOldPinKeyEncryptedMasterKeyIsSet =
|
||||
!!(await this.getOldPinKeyEncryptedMasterKey(userId));
|
||||
|
||||
if (aPinKeyEncryptedUserKeyPersistentIsSet || anOldPinKeyEncryptedMasterKeyIsSet) {
|
||||
if (aPinKeyEncryptedUserKeyPersistentIsSet) {
|
||||
return "PERSISTENT";
|
||||
} else if (
|
||||
aUserKeyEncryptedPinIsSet &&
|
||||
!aPinKeyEncryptedUserKeyPersistentIsSet &&
|
||||
!anOldPinKeyEncryptedMasterKeyIsSet
|
||||
) {
|
||||
} else if (aUserKeyEncryptedPinIsSet && !aPinKeyEncryptedUserKeyPersistentIsSet) {
|
||||
return "EPHEMERAL";
|
||||
} else {
|
||||
return "DISABLED";
|
||||
@@ -302,7 +262,7 @@ export class PinService implements PinServiceAbstraction {
|
||||
case "DISABLED":
|
||||
return false;
|
||||
case "PERSISTENT":
|
||||
// The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey or OldPinKeyEncryptedMasterKey set.
|
||||
// The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey set.
|
||||
return true;
|
||||
case "EPHEMERAL": {
|
||||
// The above getPinLockType call ensures that we have a UserKeyEncryptedPin set.
|
||||
@@ -326,31 +286,21 @@ export class PinService implements PinServiceAbstraction {
|
||||
|
||||
try {
|
||||
const pinLockType = await this.getPinLockType(userId);
|
||||
const requireMasterPasswordOnClientRestart = pinLockType === "EPHEMERAL";
|
||||
|
||||
const { pinKeyEncryptedUserKey, oldPinKeyEncryptedMasterKey } =
|
||||
await this.getPinKeyEncryptedKeys(pinLockType, userId);
|
||||
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedKeys(pinLockType, userId);
|
||||
|
||||
const email = await firstValueFrom(
|
||||
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
|
||||
);
|
||||
const kdfConfig = await this.kdfConfigService.getKdfConfig();
|
||||
|
||||
let userKey: UserKey;
|
||||
|
||||
if (oldPinKeyEncryptedMasterKey) {
|
||||
userKey = await this.decryptAndMigrateOldPinKeyEncryptedMasterKey(
|
||||
userId,
|
||||
pin,
|
||||
email,
|
||||
kdfConfig,
|
||||
requireMasterPasswordOnClientRestart,
|
||||
oldPinKeyEncryptedMasterKey,
|
||||
);
|
||||
} else {
|
||||
userKey = await this.decryptUserKey(userId, pin, email, kdfConfig, pinKeyEncryptedUserKey);
|
||||
}
|
||||
|
||||
const userKey: UserKey = await this.decryptUserKey(
|
||||
userId,
|
||||
pin,
|
||||
email,
|
||||
kdfConfig,
|
||||
pinKeyEncryptedUserKey,
|
||||
);
|
||||
if (!userKey) {
|
||||
this.logService.warning(`User key null after pin key decryption.`);
|
||||
return null;
|
||||
@@ -394,109 +344,23 @@ export class PinService implements PinServiceAbstraction {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new `pinKeyEncryptedUserKey` and clears the `oldPinKeyEncryptedMasterKey`.
|
||||
* @returns UserKey
|
||||
*/
|
||||
private async decryptAndMigrateOldPinKeyEncryptedMasterKey(
|
||||
userId: UserId,
|
||||
pin: string,
|
||||
email: string,
|
||||
kdfConfig: KdfConfig,
|
||||
requireMasterPasswordOnClientRestart: boolean,
|
||||
oldPinKeyEncryptedMasterKey: EncString,
|
||||
): Promise<UserKey> {
|
||||
this.validateUserId(userId, "Cannot decrypt and migrate oldPinKeyEncryptedMasterKey.");
|
||||
|
||||
const masterKey = await this.decryptMasterKeyWithPin(
|
||||
userId,
|
||||
pin,
|
||||
email,
|
||||
kdfConfig,
|
||||
oldPinKeyEncryptedMasterKey,
|
||||
);
|
||||
|
||||
const encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ userId: userId });
|
||||
|
||||
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
|
||||
masterKey,
|
||||
userId,
|
||||
encUserKey ? new EncString(encUserKey) : undefined,
|
||||
);
|
||||
|
||||
const pinKeyEncryptedUserKey = await this.createPinKeyEncryptedUserKey(pin, userKey, userId);
|
||||
await this.storePinKeyEncryptedUserKey(
|
||||
pinKeyEncryptedUserKey,
|
||||
requireMasterPasswordOnClientRestart,
|
||||
userId,
|
||||
);
|
||||
|
||||
const userKeyEncryptedPin = await this.createUserKeyEncryptedPin(pin, userKey);
|
||||
await this.setUserKeyEncryptedPin(userKeyEncryptedPin, userId);
|
||||
|
||||
await this.clearOldPinKeyEncryptedMasterKey(userId);
|
||||
|
||||
return userKey;
|
||||
}
|
||||
|
||||
// Only for migration purposes
|
||||
private async decryptMasterKeyWithPin(
|
||||
userId: UserId,
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdfConfig: KdfConfig,
|
||||
oldPinKeyEncryptedMasterKey?: EncString,
|
||||
): Promise<MasterKey> {
|
||||
this.validateUserId(userId, "Cannot decrypt master key with PIN.");
|
||||
|
||||
if (!oldPinKeyEncryptedMasterKey) {
|
||||
const oldPinKeyEncryptedMasterKeyString = await this.getOldPinKeyEncryptedMasterKey(userId);
|
||||
|
||||
if (oldPinKeyEncryptedMasterKeyString == null) {
|
||||
throw new Error("No oldPinKeyEncrytedMasterKey found.");
|
||||
}
|
||||
|
||||
oldPinKeyEncryptedMasterKey = new EncString(oldPinKeyEncryptedMasterKeyString);
|
||||
}
|
||||
|
||||
const pinKey = await this.makePinKey(pin, salt, kdfConfig);
|
||||
const masterKey = await this.encryptService.decryptToBytes(oldPinKeyEncryptedMasterKey, pinKey);
|
||||
|
||||
return new SymmetricCryptoKey(masterKey) as MasterKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral) and `oldPinKeyEncryptedMasterKey`
|
||||
* Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral)
|
||||
* (if one exists) based on the user's PinLockType.
|
||||
*
|
||||
* @remarks The `oldPinKeyEncryptedMasterKey` (formerly `pinProtected`) is only used for migration and
|
||||
* will be null for all migrated accounts.
|
||||
* @throws If PinLockType is 'DISABLED' or if userId is not provided
|
||||
*/
|
||||
private async getPinKeyEncryptedKeys(
|
||||
pinLockType: PinLockType,
|
||||
userId: UserId,
|
||||
): Promise<{ pinKeyEncryptedUserKey: EncString; oldPinKeyEncryptedMasterKey?: EncString }> {
|
||||
): Promise<EncString> {
|
||||
this.validateUserId(userId, "Cannot get PinKey encrypted keys.");
|
||||
|
||||
switch (pinLockType) {
|
||||
case "PERSISTENT": {
|
||||
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyPersistent(userId);
|
||||
const oldPinKeyEncryptedMasterKey = await this.getOldPinKeyEncryptedMasterKey(userId);
|
||||
|
||||
return {
|
||||
pinKeyEncryptedUserKey,
|
||||
oldPinKeyEncryptedMasterKey: oldPinKeyEncryptedMasterKey
|
||||
? new EncString(oldPinKeyEncryptedMasterKey)
|
||||
: undefined,
|
||||
};
|
||||
return await this.getPinKeyEncryptedUserKeyPersistent(userId);
|
||||
}
|
||||
case "EPHEMERAL": {
|
||||
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyEphemeral(userId);
|
||||
|
||||
return {
|
||||
pinKeyEncryptedUserKey,
|
||||
oldPinKeyEncryptedMasterKey: undefined, // Going forward, we only migrate non-ephemeral version
|
||||
};
|
||||
return await this.getPinKeyEncryptedUserKeyEphemeral(userId);
|
||||
}
|
||||
case "DISABLED":
|
||||
throw new Error("Pin is disabled");
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
@@ -15,14 +13,13 @@ import {
|
||||
mockAccountServiceWith,
|
||||
} from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { PinKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfigService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
PinService,
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_EPHEMERAL,
|
||||
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
|
||||
USER_KEY_ENCRYPTED_PIN,
|
||||
PinLockType,
|
||||
} from "./pin.service.implementation";
|
||||
@@ -31,7 +28,6 @@ describe("PinService", () => {
|
||||
let sut: PinService;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
@@ -39,11 +35,9 @@ describe("PinService", () => {
|
||||
const kdfConfigService = mock<KdfConfigService>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const logService = mock<LogService>();
|
||||
const stateService = mock<StateService>();
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const mockUserKey = new SymmetricCryptoKey(randomBytes(64)) as UserKey;
|
||||
const mockMasterKey = new SymmetricCryptoKey(randomBytes(32)) as MasterKey;
|
||||
const mockPinKey = new SymmetricCryptoKey(randomBytes(32)) as PinKey;
|
||||
const mockUserEmail = "user@example.com";
|
||||
const mockPin = "1234";
|
||||
@@ -57,15 +51,10 @@ describe("PinService", () => {
|
||||
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=",
|
||||
);
|
||||
|
||||
const oldPinKeyEncryptedMasterKeyPostMigration: any = null;
|
||||
const oldPinKeyEncryptedMasterKeyPreMigrationPersistent =
|
||||
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail });
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sut = new PinService(
|
||||
@@ -75,9 +64,7 @@ describe("PinService", () => {
|
||||
kdfConfigService,
|
||||
keyGenerationService,
|
||||
logService,
|
||||
masterPasswordService,
|
||||
stateProvider,
|
||||
stateService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -111,12 +98,6 @@ describe("PinService", () => {
|
||||
await expect(sut.clearUserKeyEncryptedPin(undefined)).rejects.toThrow(
|
||||
"User ID is required. Cannot clear userKeyEncryptedPin.",
|
||||
);
|
||||
await expect(sut.getOldPinKeyEncryptedMasterKey(undefined)).rejects.toThrow(
|
||||
"User ID is required. Cannot get oldPinKeyEncryptedMasterKey.",
|
||||
);
|
||||
await expect(sut.clearOldPinKeyEncryptedMasterKey(undefined)).rejects.toThrow(
|
||||
"User ID is required. Cannot clear oldPinKeyEncryptedMasterKey.",
|
||||
);
|
||||
await expect(
|
||||
sut.createPinKeyEncryptedUserKey(mockPin, mockUserKey, undefined),
|
||||
).rejects.toThrow("User ID is required. Cannot create pinKeyEncryptedUserKey.");
|
||||
@@ -288,31 +269,6 @@ describe("PinService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("oldPinKeyEncryptedMasterKey methods", () => {
|
||||
describe("getOldPinKeyEncryptedMasterKey()", () => {
|
||||
it("should get the oldPinKeyEncryptedMasterKey of the specified userId", async () => {
|
||||
await sut.getOldPinKeyEncryptedMasterKey(mockUserId);
|
||||
|
||||
expect(stateProvider.mock.getUserState$).toHaveBeenCalledWith(
|
||||
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearOldPinKeyEncryptedMasterKey()", () => {
|
||||
it("should clear the oldPinKeyEncryptedMasterKey of the specified userId", async () => {
|
||||
await sut.clearOldPinKeyEncryptedMasterKey(mockUserId);
|
||||
|
||||
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
|
||||
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
|
||||
null,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("makePinKey()", () => {
|
||||
it("should make a PinKey", async () => {
|
||||
// Arrange
|
||||
@@ -346,26 +302,10 @@ describe("PinService", () => {
|
||||
expect(result).toBe("PERSISTENT");
|
||||
});
|
||||
|
||||
it("should return 'PERSISTENT' if an old oldPinKeyEncryptedMasterKey is found", async () => {
|
||||
// Arrange
|
||||
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(null);
|
||||
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
|
||||
sut.getOldPinKeyEncryptedMasterKey = jest
|
||||
.fn()
|
||||
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPreMigrationPersistent);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("PERSISTENT");
|
||||
});
|
||||
|
||||
it("should return 'EPHEMERAL' if neither a pinKeyEncryptedUserKey (persistent version) nor an old oldPinKeyEncryptedMasterKey are found, but a userKeyEncryptedPin is found", async () => {
|
||||
it("should return 'EPHEMERAL' if a pinKeyEncryptedUserKey (persistent version) is not found but a userKeyEncryptedPin is found", async () => {
|
||||
// Arrange
|
||||
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
|
||||
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
|
||||
sut.getOldPinKeyEncryptedMasterKey = jest.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
@@ -374,11 +314,10 @@ describe("PinService", () => {
|
||||
expect(result).toBe("EPHEMERAL");
|
||||
});
|
||||
|
||||
it("should return 'DISABLED' if ALL three of these are NOT found: userKeyEncryptedPin, pinKeyEncryptedUserKey (persistent version), oldPinKeyEncryptedMasterKey", async () => {
|
||||
it("should return 'DISABLED' if both of these are NOT found: userKeyEncryptedPin, pinKeyEncryptedUserKey (persistent version)", async () => {
|
||||
// Arrange
|
||||
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(null);
|
||||
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
|
||||
sut.getOldPinKeyEncryptedMasterKey = jest.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
@@ -476,46 +415,20 @@ describe("PinService", () => {
|
||||
});
|
||||
|
||||
describe("decryptUserKeyWithPin()", () => {
|
||||
async function setupDecryptUserKeyWithPinMocks(
|
||||
pinLockType: PinLockType,
|
||||
migrationStatus: "PRE" | "POST" = "POST",
|
||||
) {
|
||||
async function setupDecryptUserKeyWithPinMocks(pinLockType: PinLockType) {
|
||||
sut.getPinLockType = jest.fn().mockResolvedValue(pinLockType);
|
||||
|
||||
mockPinEncryptedKeyDataByPinLockType(pinLockType, migrationStatus);
|
||||
mockPinEncryptedKeyDataByPinLockType(pinLockType);
|
||||
|
||||
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
|
||||
|
||||
if (pinLockType === "PERSISTENT" && migrationStatus === "PRE") {
|
||||
await mockDecryptAndMigrateOldPinKeyEncryptedMasterKeyFn();
|
||||
} else {
|
||||
mockDecryptUserKeyFn();
|
||||
}
|
||||
mockDecryptUserKeyFn();
|
||||
|
||||
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
|
||||
encryptService.decryptToUtf8.mockResolvedValue(mockPin);
|
||||
cryptoFunctionService.compareFast.calledWith(mockPin, "1234").mockResolvedValue(true);
|
||||
}
|
||||
|
||||
async function mockDecryptAndMigrateOldPinKeyEncryptedMasterKeyFn() {
|
||||
sut.makePinKey = jest.fn().mockResolvedValue(mockPinKey);
|
||||
encryptService.decryptToBytes.mockResolvedValue(mockMasterKey.key);
|
||||
|
||||
stateService.getEncryptedCryptoSymmetricKey.mockResolvedValue(mockUserKey.keyB64);
|
||||
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey);
|
||||
|
||||
sut.createPinKeyEncryptedUserKey = jest
|
||||
.fn()
|
||||
.mockResolvedValue(pinKeyEncryptedUserKeyPersistant);
|
||||
|
||||
await sut.storePinKeyEncryptedUserKey(pinKeyEncryptedUserKeyPersistant, false, mockUserId);
|
||||
|
||||
sut.createUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
|
||||
await sut.setUserKeyEncryptedPin(mockUserKeyEncryptedPin, mockUserId);
|
||||
|
||||
await sut.clearOldPinKeyEncryptedMasterKey(mockUserId);
|
||||
}
|
||||
|
||||
function mockDecryptUserKeyFn() {
|
||||
sut.getPinKeyEncryptedUserKeyPersistent = jest
|
||||
.fn()
|
||||
@@ -524,26 +437,12 @@ describe("PinService", () => {
|
||||
encryptService.decryptToBytes.mockResolvedValue(mockUserKey.key);
|
||||
}
|
||||
|
||||
function mockPinEncryptedKeyDataByPinLockType(
|
||||
pinLockType: PinLockType,
|
||||
migrationStatus: "PRE" | "POST" = "POST",
|
||||
) {
|
||||
function mockPinEncryptedKeyDataByPinLockType(pinLockType: PinLockType) {
|
||||
switch (pinLockType) {
|
||||
case "PERSISTENT":
|
||||
sut.getPinKeyEncryptedUserKeyPersistent = jest
|
||||
.fn()
|
||||
.mockResolvedValue(pinKeyEncryptedUserKeyPersistant);
|
||||
|
||||
if (migrationStatus === "PRE") {
|
||||
sut.getOldPinKeyEncryptedMasterKey = jest
|
||||
.fn()
|
||||
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPreMigrationPersistent);
|
||||
} else {
|
||||
sut.getOldPinKeyEncryptedMasterKey = jest
|
||||
.fn()
|
||||
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPostMigration); // null
|
||||
}
|
||||
|
||||
break;
|
||||
case "EPHEMERAL":
|
||||
sut.getPinKeyEncryptedUserKeyEphemeral = jest
|
||||
@@ -557,49 +456,16 @@ describe("PinService", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const testCases: { pinLockType: PinLockType; migrationStatus: "PRE" | "POST" }[] = [
|
||||
{ pinLockType: "PERSISTENT", migrationStatus: "PRE" },
|
||||
{ pinLockType: "PERSISTENT", migrationStatus: "POST" },
|
||||
{ pinLockType: "EPHEMERAL", migrationStatus: "POST" },
|
||||
const testCases: { pinLockType: PinLockType }[] = [
|
||||
{ pinLockType: "PERSISTENT" },
|
||||
{ pinLockType: "EPHEMERAL" },
|
||||
];
|
||||
|
||||
testCases.forEach(({ pinLockType, migrationStatus }) => {
|
||||
describe(`given a ${pinLockType} PIN (${migrationStatus} migration)`, () => {
|
||||
if (pinLockType === "PERSISTENT" && migrationStatus === "PRE") {
|
||||
it("should clear the oldPinKeyEncryptedMasterKey from state", async () => {
|
||||
// Arrange
|
||||
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
|
||||
|
||||
// Act
|
||||
await sut.decryptUserKeyWithPin(mockPin, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
|
||||
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
|
||||
null,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should set the new pinKeyEncrypterUserKeyPersistent to state", async () => {
|
||||
// Arrange
|
||||
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
|
||||
|
||||
// Act
|
||||
await sut.decryptUserKeyWithPin(mockPin, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
pinKeyEncryptedUserKeyPersistant.encryptedString,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
testCases.forEach(({ pinLockType }) => {
|
||||
describe(`given a ${pinLockType} PIN)`, () => {
|
||||
it(`should successfully decrypt and return user key when using a valid PIN`, async () => {
|
||||
// Arrange
|
||||
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
|
||||
await setupDecryptUserKeyWithPinMocks(pinLockType);
|
||||
|
||||
// Act
|
||||
const result = await sut.decryptUserKeyWithPin(mockPin, mockUserId);
|
||||
@@ -610,7 +476,7 @@ describe("PinService", () => {
|
||||
|
||||
it(`should return null when PIN is incorrect and user key cannot be decrypted`, async () => {
|
||||
// Arrange
|
||||
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
|
||||
await setupDecryptUserKeyWithPinMocks(pinLockType);
|
||||
sut.decryptUserKeyWithPin = jest.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
@@ -623,7 +489,7 @@ describe("PinService", () => {
|
||||
// not sure if this is a realistic scenario but going to test it anyway
|
||||
it(`should return null when PIN doesn't match after successful user key decryption`, async () => {
|
||||
// Arrange
|
||||
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
|
||||
await setupDecryptUserKeyWithPinMocks(pinLockType);
|
||||
encryptService.decryptToUtf8.mockResolvedValue("9999"); // non matching PIN
|
||||
|
||||
// Act
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
@@ -7,19 +5,23 @@ import { Policy } from "../../models/domain/policy";
|
||||
import { PolicyRequest } from "../../models/request/policy.request";
|
||||
import { PolicyResponse } from "../../models/response/policy.response";
|
||||
|
||||
export class PolicyApiServiceAbstraction {
|
||||
getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
|
||||
getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
|
||||
export abstract class PolicyApiServiceAbstraction {
|
||||
abstract getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
|
||||
abstract getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
|
||||
|
||||
getPoliciesByToken: (
|
||||
abstract getPoliciesByToken: (
|
||||
organizationId: string,
|
||||
token: string,
|
||||
email: string,
|
||||
organizationUserId: string,
|
||||
) => Promise<Policy[] | undefined>;
|
||||
|
||||
getMasterPasswordPolicyOptsForOrgUser: (
|
||||
abstract getMasterPasswordPolicyOptsForOrgUser: (
|
||||
orgId: string,
|
||||
) => Promise<MasterPasswordPolicyOptions | null>;
|
||||
putPolicy: (organizationId: string, type: PolicyType, request: PolicyRequest) => Promise<any>;
|
||||
abstract putPolicy: (
|
||||
organizationId: string,
|
||||
type: PolicyType,
|
||||
request: PolicyRequest,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -11,43 +9,27 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p
|
||||
|
||||
export abstract class PolicyService {
|
||||
/**
|
||||
* All policies for the active user from sync data.
|
||||
* All policies for the provided user from sync data.
|
||||
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
|
||||
* Consider using {@link get$} or {@link getAll$} instead, which will only return policies that should be enforced against the user.
|
||||
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
|
||||
*/
|
||||
policies$: Observable<Policy[]>;
|
||||
abstract policies$: (userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns the first {@link Policy} found that applies to the active user.
|
||||
* @returns all {@link Policy} objects of a given type that apply to the specified user.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* @param policyType the {@link PolicyType} to search for
|
||||
* @see {@link getAll$} if you need all policies of a given type
|
||||
* @param userId the {@link UserId} to search against
|
||||
*/
|
||||
get$: (policyType: PolicyType) => Observable<Policy>;
|
||||
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns all {@link Policy} objects of a given type that apply to the specified user (or the active user if not specified).
|
||||
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* @param policyType the {@link PolicyType} to search for
|
||||
*/
|
||||
getAll$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* All {@link Policy} objects for the specified user (from sync data).
|
||||
* May include policies that are disabled or otherwise do not apply to the user.
|
||||
* Consider using {@link getAll$} instead, which will only return policies that should be enforced against the user.
|
||||
*/
|
||||
getAll: (policyType: PolicyType) => Promise<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns true if a policy of the specified type applies to the active user, otherwise false.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* This does not take into account the policy's configuration - if that is important, use {@link getAll$} to get the
|
||||
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
|
||||
* {@link Policy} objects and then filter by Policy.data.
|
||||
*/
|
||||
policyAppliesToActiveUser$: (policyType: PolicyType) => Observable<boolean>;
|
||||
|
||||
policyAppliesToUser: (policyType: PolicyType) => Promise<boolean>;
|
||||
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
|
||||
|
||||
// Policy specific interfaces
|
||||
|
||||
@@ -56,28 +38,31 @@ export abstract class PolicyService {
|
||||
* @returns a set of options which represent the minimum Master Password settings that the user must
|
||||
* comply with in order to comply with **all** Master Password policies.
|
||||
*/
|
||||
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
|
||||
abstract masterPasswordPolicyOptions$: (
|
||||
userId: UserId,
|
||||
policies?: Policy[],
|
||||
) => Observable<MasterPasswordPolicyOptions | undefined>;
|
||||
|
||||
/**
|
||||
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
|
||||
*/
|
||||
evaluateMasterPassword: (
|
||||
abstract evaluateMasterPassword: (
|
||||
passwordStrength: number,
|
||||
newPassword: string,
|
||||
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* @returns Reset Password policy options for the specified organization and a boolean indicating whether the policy
|
||||
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
|
||||
* is enabled
|
||||
*/
|
||||
getResetPasswordPolicyOptions: (
|
||||
abstract getResetPasswordPolicyOptions: (
|
||||
policies: Policy[],
|
||||
orgId: string,
|
||||
) => [ResetPasswordPolicyOptions, boolean];
|
||||
}
|
||||
|
||||
export abstract class InternalPolicyService extends PolicyService {
|
||||
upsert: (policy: PolicyData) => Promise<void>;
|
||||
replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
|
||||
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
|
||||
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
import { Policy } from "../../models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
|
||||
|
||||
export abstract class vNextPolicyService {
|
||||
/**
|
||||
* All policies for the provided user from sync data.
|
||||
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
|
||||
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
|
||||
*/
|
||||
abstract policies$: (userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns all {@link Policy} objects of a given type that apply to the specified user.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* @param policyType the {@link PolicyType} to search for
|
||||
* @param userId the {@link UserId} to search against
|
||||
*/
|
||||
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
|
||||
* {@link Policy} objects and then filter by Policy.data.
|
||||
*/
|
||||
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
|
||||
|
||||
// Policy specific interfaces
|
||||
|
||||
/**
|
||||
* Combines all Master Password policies that apply to the user.
|
||||
* @returns a set of options which represent the minimum Master Password settings that the user must
|
||||
* comply with in order to comply with **all** Master Password policies.
|
||||
*/
|
||||
abstract masterPasswordPolicyOptions$: (
|
||||
userId: UserId,
|
||||
policies?: Policy[],
|
||||
) => Observable<MasterPasswordPolicyOptions | undefined>;
|
||||
|
||||
/**
|
||||
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
|
||||
*/
|
||||
abstract evaluateMasterPassword: (
|
||||
passwordStrength: number,
|
||||
newPassword: string,
|
||||
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
|
||||
* is enabled
|
||||
*/
|
||||
abstract getResetPasswordPolicyOptions: (
|
||||
policies: Policy[],
|
||||
orgId: string,
|
||||
) => [ResetPasswordPolicyOptions, boolean];
|
||||
}
|
||||
|
||||
export abstract class vNextInternalPolicyService extends vNextPolicyService {
|
||||
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
|
||||
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
|
||||
}
|
||||
@@ -15,11 +15,11 @@ import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domai
|
||||
import { Organization } from "../../../admin-console/models/domain/organization";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
|
||||
import { POLICIES } from "../../../admin-console/services/policy/policy.service";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
|
||||
import { DefaultvNextPolicyService, getFirstPolicy } from "./default-vnext-policy.service";
|
||||
import { DefaultPolicyService, getFirstPolicy } from "./default-policy.service";
|
||||
import { POLICIES } from "./policy-state";
|
||||
|
||||
describe("PolicyService", () => {
|
||||
const userId = "userId" as UserId;
|
||||
@@ -27,7 +27,7 @@ describe("PolicyService", () => {
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
|
||||
|
||||
let policyService: DefaultvNextPolicyService;
|
||||
let policyService: DefaultPolicyService;
|
||||
|
||||
beforeEach(() => {
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
@@ -59,7 +59,7 @@ describe("PolicyService", () => {
|
||||
|
||||
organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$);
|
||||
|
||||
policyService = new DefaultvNextPolicyService(stateProvider, organizationService);
|
||||
policyService = new DefaultPolicyService(stateProvider, organizationService);
|
||||
});
|
||||
|
||||
it("upsert", async () => {
|
||||
@@ -3,7 +3,7 @@ import { combineLatest, map, Observable, of } from "rxjs";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { vNextPolicyService } from "../../abstractions/policy/vnext-policy.service";
|
||||
import { PolicyService } from "../../abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserStatusType, PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
@@ -11,7 +11,7 @@ import { Organization } from "../../models/domain/organization";
|
||||
import { Policy } from "../../models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
|
||||
|
||||
import { POLICIES } from "./vnext-policy-state";
|
||||
import { POLICIES } from "./policy-state";
|
||||
|
||||
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) {
|
||||
return Object.values(policiesMap || {}).map((f) => new Policy(f));
|
||||
@@ -21,7 +21,7 @@ export const getFirstPolicy = map<Policy[], Policy | undefined>((policies) => {
|
||||
return policies.at(0) ?? undefined;
|
||||
});
|
||||
|
||||
export class DefaultvNextPolicyService implements vNextPolicyService {
|
||||
export class DefaultPolicyService implements PolicyService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private organizationService: OrganizationService,
|
||||
@@ -89,7 +89,7 @@ export class DefaultvNextPolicyService implements vNextPolicyService {
|
||||
const policies$ = policies ? of(policies) : this.policies$(userId);
|
||||
return policies$.pipe(
|
||||
map((obsPolicies) => {
|
||||
const enforcedOptions: MasterPasswordPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined;
|
||||
const filteredPolicies =
|
||||
obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? [];
|
||||
|
||||
@@ -102,6 +102,10 @@ export class DefaultvNextPolicyService implements vNextPolicyService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enforcedOptions) {
|
||||
enforcedOptions = new MasterPasswordPolicyOptions();
|
||||
}
|
||||
|
||||
if (
|
||||
currentPolicy.data.minComplexity != null &&
|
||||
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
|
||||
@@ -1,6 +1,8 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, map, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { getUserId } from "../../../auth/services/account.service";
|
||||
import { HttpStatusCode } from "../../../enums";
|
||||
import { ErrorResponse } from "../../../models/response/error.response";
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
@@ -18,6 +20,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
|
||||
constructor(
|
||||
private policyService: InternalPolicyService,
|
||||
private apiService: ApiService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async getPolicy(organizationId: string, type: PolicyType): Promise<PolicyResponse> {
|
||||
@@ -93,8 +96,14 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.policyService.masterPasswordPolicyOptions$([masterPasswordPolicy]),
|
||||
return firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.masterPasswordPolicyOptions$(userId, [masterPasswordPolicy]),
|
||||
),
|
||||
map((policy) => policy ?? null),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
// If policy not found, return null
|
||||
@@ -114,8 +123,9 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const response = new PolicyResponse(r);
|
||||
const data = new PolicyData(response);
|
||||
await this.policyService.upsert(data);
|
||||
await this.policyService.upsert(data, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { FakeActiveUserState, FakeSingleUserState } from "../../../../spec/fake-state";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
PolicyType,
|
||||
} from "../../../admin-console/enums";
|
||||
import { PermissionsApi } from "../../../admin-console/models/api/permissions.api";
|
||||
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
|
||||
import { PolicyData } from "../../../admin-console/models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domain/master-password-policy-options";
|
||||
import { Organization } from "../../../admin-console/models/domain/organization";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
|
||||
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
|
||||
describe("PolicyService", () => {
|
||||
const userId = "userId" as UserId;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let activeUserState: FakeActiveUserState<Record<PolicyId, PolicyData>>;
|
||||
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
|
||||
|
||||
let policyService: PolicyService;
|
||||
|
||||
beforeEach(() => {
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
organizationService = mock<OrganizationService>();
|
||||
|
||||
activeUserState = stateProvider.activeUser.getFake(POLICIES);
|
||||
singleUserState = stateProvider.singleUser.getFake(activeUserState.userId, POLICIES);
|
||||
|
||||
const organizations$ = of([
|
||||
// User
|
||||
organization("org1", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Owner
|
||||
organization(
|
||||
"org2",
|
||||
true,
|
||||
true,
|
||||
OrganizationUserStatusType.Confirmed,
|
||||
false,
|
||||
OrganizationUserType.Owner,
|
||||
),
|
||||
// Does not use policies
|
||||
organization("org3", true, false, OrganizationUserStatusType.Confirmed, false),
|
||||
// Another User
|
||||
organization("org4", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Another User
|
||||
organization("org5", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Can manage policies
|
||||
organization("org6", true, true, OrganizationUserStatusType.Confirmed, true),
|
||||
]);
|
||||
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
|
||||
policyService = new PolicyService(stateProvider, organizationService);
|
||||
});
|
||||
|
||||
it("upsert", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await policyService.upsert(policyData("99", "test-organization", PolicyType.DisableSend, true));
|
||||
|
||||
expect(await firstValueFrom(policyService.policies$)).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.MaximumVaultTimeout,
|
||||
enabled: true,
|
||||
data: { minutes: 14 },
|
||||
},
|
||||
{
|
||||
id: "99",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replace", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await policyService.replace(
|
||||
{
|
||||
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(await firstValueFrom(policyService.policies$)).toEqual([
|
||||
{
|
||||
id: "2",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("masterPasswordPolicyOptions", () => {
|
||||
it("returns default policy options", async () => {
|
||||
const data: any = {
|
||||
minComplexity: 5,
|
||||
minLength: 20,
|
||||
requireUpper: true,
|
||||
};
|
||||
const model = [
|
||||
new Policy(policyData("1", "test-organization-3", PolicyType.MasterPassword, true, data)),
|
||||
];
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
|
||||
|
||||
expect(result).toEqual({
|
||||
minComplexity: 5,
|
||||
minLength: 20,
|
||||
requireLower: false,
|
||||
requireNumbers: false,
|
||||
requireSpecial: false,
|
||||
requireUpper: true,
|
||||
enforceOnLogin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null", async () => {
|
||||
const data: any = {};
|
||||
const model = [
|
||||
new Policy(
|
||||
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
|
||||
),
|
||||
new Policy(
|
||||
policyData("4", "test-organization-3", PolicyType.MaximumVaultTimeout, true, data),
|
||||
),
|
||||
];
|
||||
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
|
||||
it("returns specified policy options", async () => {
|
||||
const data: any = {
|
||||
minLength: 14,
|
||||
};
|
||||
const model = [
|
||||
new Policy(
|
||||
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
|
||||
),
|
||||
new Policy(policyData("4", "test-organization-3", PolicyType.MasterPassword, true, data)),
|
||||
];
|
||||
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
|
||||
|
||||
expect(result).toEqual({
|
||||
minComplexity: 0,
|
||||
minLength: 14,
|
||||
requireLower: false,
|
||||
requireNumbers: false,
|
||||
requireSpecial: false,
|
||||
requireUpper: false,
|
||||
enforceOnLogin: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateMasterPassword", () => {
|
||||
it("false", async () => {
|
||||
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
enforcedPolicyOptions.minLength = 14;
|
||||
const result = policyService.evaluateMasterPassword(10, "password", enforcedPolicyOptions);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
|
||||
it("true", async () => {
|
||||
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
const result = policyService.evaluateMasterPassword(0, "password", enforcedPolicyOptions);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResetPasswordPolicyOptions", () => {
|
||||
it("default", async () => {
|
||||
const result = policyService.getResetPasswordPolicyOptions([], "");
|
||||
|
||||
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
|
||||
});
|
||||
|
||||
it("returns autoEnrollEnabled true", async () => {
|
||||
const data: any = {
|
||||
autoEnrollEnabled: true,
|
||||
};
|
||||
const policies = [
|
||||
new Policy(policyData("5", "test-organization-3", PolicyType.ResetPassword, true, data)),
|
||||
];
|
||||
const result = policyService.getResetPasswordPolicyOptions(policies, "test-organization-3");
|
||||
|
||||
expect(result).toEqual([{ autoEnrollEnabled: true }, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get$", () => {
|
||||
it("returns the specified PolicyType", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy3", "org1", PolicyType.RemoveUnlockWithPin, true),
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.ActivateAutofill)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy1",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
});
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.DisablePersonalVaultExport)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
});
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.RemoveUnlockWithPin)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy3",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not return disabled policies", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.get$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.get$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["owners", "org2"],
|
||||
["administrators", "org6"],
|
||||
])("returns the password generator policy for %s", async (_, organization) => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, false),
|
||||
policyData("policy2", organization, PolicyType.PasswordGenerator, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.get$(PolicyType.PasswordGenerator));
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not return policies for organizations that do not use policies", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.get$(PolicyType.ActivateAutofill));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAll$", () => {
|
||||
it("returns the specified PolicyTypes", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return disabled policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return policies for organizations that do not use policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policyAppliesToActiveUser$", () => {
|
||||
it("returns true when the policyType applies to the user", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when policyType is disabled", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for organizations that do not use policies", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function policyData(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
type: PolicyType,
|
||||
enabled: boolean,
|
||||
data?: any,
|
||||
) {
|
||||
const policyData = new PolicyData({} as any);
|
||||
policyData.id = id as PolicyId;
|
||||
policyData.organizationId = organizationId;
|
||||
policyData.type = type;
|
||||
policyData.enabled = enabled;
|
||||
policyData.data = data;
|
||||
|
||||
return policyData;
|
||||
}
|
||||
|
||||
function organizationData(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
usePolicies: boolean,
|
||||
status: OrganizationUserStatusType,
|
||||
managePolicies: boolean,
|
||||
type: OrganizationUserType = OrganizationUserType.User,
|
||||
) {
|
||||
const organizationData = new OrganizationData({} as any, {} as any);
|
||||
organizationData.id = id;
|
||||
organizationData.enabled = enabled;
|
||||
organizationData.usePolicies = usePolicies;
|
||||
organizationData.status = status;
|
||||
organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any);
|
||||
organizationData.type = type;
|
||||
return organizationData;
|
||||
}
|
||||
|
||||
function organization(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
usePolicies: boolean,
|
||||
status: OrganizationUserStatusType,
|
||||
managePolicies: boolean,
|
||||
type: OrganizationUserType = OrganizationUserType.User,
|
||||
) {
|
||||
return new Organization(
|
||||
organizationData(id, enabled, usePolicies, status, managePolicies, type),
|
||||
);
|
||||
}
|
||||
|
||||
function arrayToRecord(input: PolicyData[]): Record<PolicyId, PolicyData> {
|
||||
return Object.fromEntries(input.map((i) => [i.id, i]));
|
||||
}
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserStatusType, PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
import { Policy } from "../../models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
|
||||
|
||||
const policyRecordToArray = (policiesMap: { [id: string]: PolicyData }) =>
|
||||
Object.values(policiesMap || {}).map((f) => new Policy(f));
|
||||
|
||||
export const POLICIES = UserKeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK, "policies", {
|
||||
deserializer: (policyData) => policyData,
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
|
||||
export class PolicyService implements InternalPolicyServiceAbstraction {
|
||||
private activeUserPolicyState = this.stateProvider.getActive(POLICIES);
|
||||
private activeUserPolicies$ = this.activeUserPolicyState.state$.pipe(
|
||||
map((policyData) => policyRecordToArray(policyData)),
|
||||
);
|
||||
|
||||
policies$ = this.activeUserPolicies$;
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
get$(policyType: PolicyType): Observable<Policy> {
|
||||
const filteredPolicies$ = this.activeUserPolicies$.pipe(
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
const organizations$ = this.stateProvider.activeUserId$.pipe(
|
||||
switchMap((userId) => this.organizationService.organizations$(userId)),
|
||||
);
|
||||
|
||||
return combineLatest([filteredPolicies$, organizations$]).pipe(
|
||||
map(
|
||||
([policies, organizations]) =>
|
||||
this.enforcedPolicyFilter(policies, organizations)?.at(0) ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getAll$(policyType: PolicyType, userId: UserId) {
|
||||
const filteredPolicies$ = this.stateProvider.getUserState$(POLICIES, userId).pipe(
|
||||
map((policyData) => policyRecordToArray(policyData)),
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
return combineLatest([filteredPolicies$, this.organizationService.organizations$(userId)]).pipe(
|
||||
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
|
||||
);
|
||||
}
|
||||
|
||||
async getAll(policyType: PolicyType) {
|
||||
return await firstValueFrom(
|
||||
this.policies$.pipe(map((policies) => policies.filter((p) => p.type === policyType))),
|
||||
);
|
||||
}
|
||||
|
||||
policyAppliesToActiveUser$(policyType: PolicyType) {
|
||||
return this.get$(policyType).pipe(map((policy) => policy != null));
|
||||
}
|
||||
|
||||
async policyAppliesToUser(policyType: PolicyType) {
|
||||
return await firstValueFrom(this.policyAppliesToActiveUser$(policyType));
|
||||
}
|
||||
|
||||
private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) {
|
||||
const orgDict = Object.fromEntries(organizations.map((o) => [o.id, o]));
|
||||
return policies.filter((policy) => {
|
||||
const organization = orgDict[policy.organizationId];
|
||||
|
||||
// This shouldn't happen, i.e. the user should only have policies for orgs they are a member of
|
||||
// But if it does, err on the side of enforcing the policy
|
||||
if (organization == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
policy.enabled &&
|
||||
organization.status >= OrganizationUserStatusType.Accepted &&
|
||||
organization.usePolicies &&
|
||||
!this.isExemptFromPolicy(policy.type, organization)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
masterPasswordPolicyOptions$(policies?: Policy[]): Observable<MasterPasswordPolicyOptions> {
|
||||
const observable = policies ? of(policies) : this.policies$;
|
||||
return observable.pipe(
|
||||
map((obsPolicies) => {
|
||||
let enforcedOptions: MasterPasswordPolicyOptions = null;
|
||||
const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword);
|
||||
|
||||
if (filteredPolicies == null || filteredPolicies.length === 0) {
|
||||
return enforcedOptions;
|
||||
}
|
||||
|
||||
filteredPolicies.forEach((currentPolicy) => {
|
||||
if (!currentPolicy.enabled || currentPolicy.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enforcedOptions == null) {
|
||||
enforcedOptions = new MasterPasswordPolicyOptions();
|
||||
}
|
||||
|
||||
if (
|
||||
currentPolicy.data.minComplexity != null &&
|
||||
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
|
||||
) {
|
||||
enforcedOptions.minComplexity = currentPolicy.data.minComplexity;
|
||||
}
|
||||
|
||||
if (
|
||||
currentPolicy.data.minLength != null &&
|
||||
currentPolicy.data.minLength > enforcedOptions.minLength
|
||||
) {
|
||||
enforcedOptions.minLength = currentPolicy.data.minLength;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireUpper) {
|
||||
enforcedOptions.requireUpper = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireLower) {
|
||||
enforcedOptions.requireLower = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireNumbers) {
|
||||
enforcedOptions.requireNumbers = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireSpecial) {
|
||||
enforcedOptions.requireSpecial = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.enforceOnLogin) {
|
||||
enforcedOptions.enforceOnLogin = true;
|
||||
}
|
||||
});
|
||||
|
||||
return enforcedOptions;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
evaluateMasterPassword(
|
||||
passwordStrength: number,
|
||||
newPassword: string,
|
||||
enforcedPolicyOptions: MasterPasswordPolicyOptions,
|
||||
): boolean {
|
||||
if (enforcedPolicyOptions == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
enforcedPolicyOptions.minComplexity > 0 &&
|
||||
enforcedPolicyOptions.minComplexity > passwordStrength
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
enforcedPolicyOptions.minLength > 0 &&
|
||||
enforcedPolicyOptions.minLength > newPassword.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireUpper && newPassword.toLocaleLowerCase() === newPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireLower && newPassword.toLocaleUpperCase() === newPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireNumbers && !/[0-9]/.test(newPassword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
if (enforcedPolicyOptions.requireSpecial && !/[!@#$%\^&*]/g.test(newPassword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getResetPasswordPolicyOptions(
|
||||
policies: Policy[],
|
||||
orgId: string,
|
||||
): [ResetPasswordPolicyOptions, boolean] {
|
||||
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
|
||||
|
||||
if (policies == null || orgId == null) {
|
||||
return [resetPasswordPolicyOptions, false];
|
||||
}
|
||||
|
||||
const policy = policies.find(
|
||||
(p) => p.organizationId === orgId && p.type === PolicyType.ResetPassword && p.enabled,
|
||||
);
|
||||
resetPasswordPolicyOptions.autoEnrollEnabled = policy?.data?.autoEnrollEnabled ?? false;
|
||||
|
||||
return [resetPasswordPolicyOptions, policy?.enabled ?? false];
|
||||
}
|
||||
|
||||
async upsert(policy: PolicyData): Promise<void> {
|
||||
await this.activeUserPolicyState.update((policies) => {
|
||||
policies ??= {};
|
||||
policies[policy.id] = policy;
|
||||
return policies;
|
||||
});
|
||||
}
|
||||
|
||||
async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise<void> {
|
||||
await this.stateProvider.setUserState(POLICIES, policies, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an orgUser is exempt from a specific policy because of their role
|
||||
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
|
||||
*/
|
||||
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
|
||||
switch (policyType) {
|
||||
case PolicyType.MaximumVaultTimeout:
|
||||
// Max Vault Timeout applies to everyone except owners
|
||||
return organization.isOwner;
|
||||
case PolicyType.PasswordGenerator:
|
||||
// password generation policy applies to everyone
|
||||
return false;
|
||||
case PolicyType.PersonalOwnership:
|
||||
// individual vault policy applies to everyone except admins and owners
|
||||
return organization.isAdmin;
|
||||
case PolicyType.FreeFamiliesSponsorshipPolicy:
|
||||
// free Bitwarden families policy applies to everyone
|
||||
return false;
|
||||
case PolicyType.RemoveUnlockWithPin:
|
||||
// free Remove Unlock with PIN policy applies to everyone
|
||||
return false;
|
||||
default:
|
||||
return organization.canManagePolicies;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
|
||||
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
|
||||
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
|
||||
|
||||
@@ -25,10 +24,7 @@ export abstract class DevicesApiServiceAbstraction {
|
||||
deviceIdentifier: string,
|
||||
) => Promise<void>;
|
||||
|
||||
getDeviceKeys: (
|
||||
deviceIdentifier: string,
|
||||
secretVerificationRequest: SecretVerificationRequest,
|
||||
) => Promise<ProtectedDeviceResponse>;
|
||||
getDeviceKeys: (deviceIdentifier: string) => Promise<ProtectedDeviceResponse>;
|
||||
|
||||
/**
|
||||
* Notifies the server that the device has a device key, but didn't receive any associated decryption keys.
|
||||
|
||||
@@ -13,5 +13,5 @@ export class DeviceKeysUpdateRequest {
|
||||
}
|
||||
|
||||
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { RotateableKeySet } from "@bitwarden/auth/common";
|
||||
|
||||
import { DeviceType } from "../../../enums";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
@@ -38,4 +40,12 @@ export class ProtectedDeviceResponse extends BaseResponse {
|
||||
* This enabled a user to rotate the keys for all of their devices.
|
||||
*/
|
||||
encryptedPublicKey: EncString;
|
||||
|
||||
getRotateableKeyset(): RotateableKeySet {
|
||||
return new RotateableKeySet(this.encryptedUserKey, this.encryptedPublicKey);
|
||||
}
|
||||
|
||||
isTrusted(): boolean {
|
||||
return this.encryptedUserKey != null && this.encryptedPublicKey != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ListResponse } from "../../models/response/list.response";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
|
||||
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
|
||||
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
|
||||
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
|
||||
|
||||
@@ -90,14 +89,11 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
|
||||
);
|
||||
}
|
||||
|
||||
async getDeviceKeys(
|
||||
deviceIdentifier: string,
|
||||
secretVerificationRequest: SecretVerificationRequest,
|
||||
): Promise<ProtectedDeviceResponse> {
|
||||
async getDeviceKeys(deviceIdentifier: string): Promise<ProtectedDeviceResponse> {
|
||||
const result = await this.apiService.send(
|
||||
"POST",
|
||||
`/devices/${deviceIdentifier}/retrieve-keys`,
|
||||
secretVerificationRequest,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { map, Observable } from "rxjs";
|
||||
import { map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "../../admin-console/enums";
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { getUserId } from "../../auth/services/account.service";
|
||||
import {
|
||||
AUTOFILL_SETTINGS_DISK,
|
||||
AUTOFILL_SETTINGS_DISK_LOCAL,
|
||||
@@ -152,6 +154,7 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private policyService: PolicyService,
|
||||
private accountService: AccountService,
|
||||
) {
|
||||
this.autofillOnPageLoadState = this.stateProvider.getActive(AUTOFILL_ON_PAGE_LOAD);
|
||||
this.autofillOnPageLoad$ = this.autofillOnPageLoadState.state$.pipe(map((x) => x ?? false));
|
||||
@@ -169,8 +172,11 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
|
||||
this.autofillOnPageLoadCalloutIsDismissed$ =
|
||||
this.autofillOnPageLoadCalloutIsDismissedState.state$.pipe(map((x) => x ?? false));
|
||||
|
||||
this.activateAutofillOnPageLoadFromPolicy$ = this.policyService.policyAppliesToActiveUser$(
|
||||
PolicyType.ActivateAutofill,
|
||||
this.activateAutofillOnPageLoadFromPolicy$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.ActivateAutofill, userId),
|
||||
),
|
||||
);
|
||||
|
||||
this.autofillOnPageLoadPolicyToastHasDisplayedState = this.stateProvider.getActive(
|
||||
|
||||
38
libs/common/src/enums/feature-flag.enum.spec.ts
Normal file
38
libs/common/src/enums/feature-flag.enum.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
|
||||
import { getFeatureFlagValue, FeatureFlag, DefaultFeatureFlagValue } from "./feature-flag.enum";
|
||||
|
||||
describe("getFeatureFlagValue", () => {
|
||||
const testFlag = Object.values(FeatureFlag)[0];
|
||||
const testFlagDefaultValue = DefaultFeatureFlagValue[testFlag];
|
||||
|
||||
it("returns default flag value when serverConfig is null", () => {
|
||||
const result = getFeatureFlagValue(null, testFlag);
|
||||
expect(result).toBe(testFlagDefaultValue);
|
||||
});
|
||||
|
||||
it("returns default flag value when serverConfig.featureStates is undefined", () => {
|
||||
const serverConfig = {} as ServerConfig;
|
||||
const result = getFeatureFlagValue(serverConfig, testFlag);
|
||||
expect(result).toBe(testFlagDefaultValue);
|
||||
});
|
||||
|
||||
it("returns default flag value when the feature flag is not in serverConfig.featureStates", () => {
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
serverConfig.featureStates = {};
|
||||
|
||||
const result = getFeatureFlagValue(serverConfig, testFlag);
|
||||
expect(result).toBe(testFlagDefaultValue);
|
||||
});
|
||||
|
||||
it("returns the flag value from serverConfig.featureStates when the feature flag exists", () => {
|
||||
const expectedValue = true;
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
serverConfig.featureStates = { [testFlag]: expectedValue };
|
||||
|
||||
const result = getFeatureFlagValue(serverConfig, testFlag);
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,22 @@
|
||||
import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
|
||||
/**
|
||||
* Feature flags.
|
||||
*
|
||||
* Flags MUST be short lived and SHALL be removed once enabled.
|
||||
*
|
||||
* Flags should be grouped by team to have visibility of ownership and cleanup.
|
||||
*/
|
||||
export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
||||
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
|
||||
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
|
||||
SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility",
|
||||
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
|
||||
|
||||
/* Auth */
|
||||
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
|
||||
|
||||
/* Autofill */
|
||||
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
|
||||
@@ -15,11 +24,21 @@ export enum FeatureFlag {
|
||||
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
|
||||
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
|
||||
IdpAutoSubmitLogin = "idp-auto-submit-login",
|
||||
InlineMenuFieldQualification = "inline-menu-field-qualification",
|
||||
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
|
||||
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
|
||||
NotificationRefresh = "notification-refresh",
|
||||
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
|
||||
/* Billing */
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
|
||||
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
|
||||
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
UserKeyRotationV2 = "userkey-rotation-v2",
|
||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||
|
||||
/* Tools */
|
||||
ItemShare = "item-share",
|
||||
@@ -41,19 +60,10 @@ export enum FeatureFlag {
|
||||
|
||||
/* Key Management */
|
||||
Argon2Default = "argon2-default",
|
||||
|
||||
UserKeyRotationV2 = "userkey-rotation-v2",
|
||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
|
||||
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
|
||||
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
|
||||
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -66,12 +76,16 @@ const FALSE = false as boolean;
|
||||
*
|
||||
* DO NOT enable previously disabled flags, REMOVE them instead.
|
||||
* We support true as a value as we prefer flags to "enable" not "disable".
|
||||
*
|
||||
* Flags should be grouped by team to have visibility of ownership and cleanup.
|
||||
*/
|
||||
export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
||||
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
|
||||
[FeatureFlag.LimitItemDeletion]: FALSE,
|
||||
[FeatureFlag.SsoExternalIdVisibility]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE,
|
||||
@@ -79,11 +93,10 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
||||
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
|
||||
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
|
||||
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
|
||||
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
|
||||
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
|
||||
[FeatureFlag.NotificationRefresh]: FALSE,
|
||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.ItemShare]: FALSE,
|
||||
@@ -99,22 +112,20 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.Argon2Default]: FALSE,
|
||||
|
||||
[FeatureFlag.UserKeyRotationV2]: FALSE,
|
||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.Argon2Default]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
|
||||
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
|
||||
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
|
||||
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
|
||||
@@ -123,3 +134,14 @@ export const DefaultFeatureFlagValue = {
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
export type FeatureFlagValueType<Flag extends FeatureFlag> = DefaultFeatureFlagValueType[Flag];
|
||||
|
||||
export function getFeatureFlagValue<Flag extends FeatureFlag>(
|
||||
serverConfig: ServerConfig | null,
|
||||
flag: Flag,
|
||||
) {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
|
||||
return DefaultFeatureFlagValue[flag];
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
|
||||
}
|
||||
|
||||
@@ -24,4 +24,6 @@ export enum NotificationType {
|
||||
SyncOrganizations = 17,
|
||||
SyncOrganizationStatusChanged = 18,
|
||||
SyncOrganizationCollectionSettingChanged = 19,
|
||||
Notification = 20,
|
||||
NotificationStatus = 21,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class BulkEncryptService {
|
||||
abstract decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<T[]>;
|
||||
|
||||
abstract onServerConfigChange(newConfig: ServerConfig): void;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { Encrypted } from "../../../platform/interfaces/encrypted";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class EncryptService {
|
||||
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
|
||||
@@ -54,4 +55,6 @@ export abstract class EncryptService {
|
||||
value: string | Uint8Array,
|
||||
algorithm: "sha1" | "sha256" | "sha512",
|
||||
): Promise<string>;
|
||||
|
||||
abstract onServerConfigChange(newConfig: ServerConfig): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import * as rxjs from "rxjs";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { BulkEncryptServiceImplementation } from "./bulk-encrypt.service.implementation";
|
||||
|
||||
describe("BulkEncryptServiceImplementation", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
let sut: BulkEncryptServiceImplementation;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new BulkEncryptServiceImplementation(cryptoFunctionService, logService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
const mockWorker = mock<Worker>();
|
||||
let globalWindow: any;
|
||||
|
||||
beforeEach(() => {
|
||||
globalWindow = global.window;
|
||||
|
||||
// Mock creating a worker.
|
||||
global.Worker = jest.fn().mockImplementation(() => mockWorker);
|
||||
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
|
||||
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
global.URL.canParse = jest.fn().mockReturnValue(true);
|
||||
|
||||
// Mock the workers returned response.
|
||||
const mockMessageEvent = {
|
||||
id: "mock-guid",
|
||||
data: ["decrypted1", "decrypted2"],
|
||||
};
|
||||
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
|
||||
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.window = globalWindow;
|
||||
});
|
||||
|
||||
it("throws error if key is null", async () => {
|
||||
const nullKey = null as unknown as SymmetricCryptoKey;
|
||||
await expect(sut.decryptItems([], nullKey)).rejects.toThrow("No encryption key provided.");
|
||||
});
|
||||
|
||||
it("returns an empty array when items is null", async () => {
|
||||
const result = await sut.decryptItems(null as any, key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when items is empty", async () => {
|
||||
const result = await sut.decryptItems([], key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("decrypts items sequentially when window is undefined", async () => {
|
||||
// Make global window undefined.
|
||||
delete (global as any).window;
|
||||
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
"Window not available in BulkEncryptService, decrypting sequentially",
|
||||
);
|
||||
expect(result).toEqual(["item1", "item2"]);
|
||||
expect(mockItems[0].decrypt).toHaveBeenCalledWith(key);
|
||||
expect(mockItems[1].decrypt).toHaveBeenCalledWith(key);
|
||||
});
|
||||
|
||||
it("uses workers for decryption when window is available", async () => {
|
||||
const mockDecryptedItems = ["decrypted1", "decrypted2"];
|
||||
jest
|
||||
.spyOn<any, any>(sut, "getDecryptedItemsFromWorkers")
|
||||
.mockResolvedValue(mockDecryptedItems);
|
||||
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(sut["getDecryptedItemsFromWorkers"]).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
|
||||
it("creates new worker when none exist", async () => {
|
||||
(sut as any).currentServerConfig = undefined;
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create worker if one exists", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
(sut as any).workers = [mockWorker];
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).not.toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
|
||||
sut.onServerConfigChange(newConfig);
|
||||
|
||||
expect((sut as any).currentServerConfig).toBe(newConfig);
|
||||
});
|
||||
|
||||
it("does send a SetConfigMessage to workers when there is a worker", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
const mockWorker = mock<Worker>();
|
||||
(sut as any).workers = [mockWorker];
|
||||
|
||||
sut.onServerConfigChange(newConfig);
|
||||
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(buildSetConfigMessage({ newConfig }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockDecryptable<T extends InitializerMetadata>(
|
||||
returnValue: any,
|
||||
): MockProxy<Decryptable<T>> {
|
||||
const mockDecryptable = mock<Decryptable<T>>();
|
||||
mockDecryptable.decrypt.mockResolvedValue(returnValue);
|
||||
return mockDecryptable;
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
const workerTTL = 60000; // 1 minute
|
||||
const maxWorkers = 8;
|
||||
@@ -20,6 +23,7 @@ const minNumberOfItemsForMultithreading = 400;
|
||||
export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
private workers: Worker[] = [];
|
||||
private timeout: any;
|
||||
private currentServerConfig: ServerConfig | undefined = undefined;
|
||||
|
||||
private clear$ = new Subject<void>();
|
||||
|
||||
@@ -57,6 +61,11 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
return decryptedItems;
|
||||
}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
this.updateWorkerServerConfigs(newConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends items to a set of web workers to decrypt them. This utilizes multiple workers to decrypt items
|
||||
* faster without interrupting other operations (e.g. updating UI).
|
||||
@@ -93,6 +102,9 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (this.currentServerConfig != undefined) {
|
||||
this.updateWorkerServerConfigs(this.currentServerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
const itemsPerWorker = Math.floor(items.length / this.workers.length);
|
||||
@@ -108,17 +120,18 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
itemsForWorker.push(...items.slice(end));
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: Utils.newGuid(),
|
||||
const id = Utils.newGuid();
|
||||
const request = buildDecryptMessage({
|
||||
id,
|
||||
items: itemsForWorker,
|
||||
key: key,
|
||||
};
|
||||
});
|
||||
|
||||
worker.postMessage(JSON.stringify(request));
|
||||
worker.postMessage(request);
|
||||
results.push(
|
||||
firstValueFrom(
|
||||
fromEvent(worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === request.id),
|
||||
filter((response: MessageEvent) => response.data?.id === id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
@@ -143,6 +156,13 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
return decryptedItems;
|
||||
}
|
||||
|
||||
private updateWorkerServerConfigs(newConfig: ServerConfig) {
|
||||
this.workers.forEach((worker) => {
|
||||
const request = buildSetConfigMessage({ newConfig });
|
||||
worker.postMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.clear$.next();
|
||||
for (const worker of this.workers) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
export class EncryptServiceImplementation implements EncryptService {
|
||||
@@ -24,6 +25,11 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
protected logMacFailures: boolean,
|
||||
) {}
|
||||
|
||||
// Handle updating private properties to turn on/off feature flags.
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
return;
|
||||
}
|
||||
|
||||
async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ConsoleLogService } from "../../../platform/services/console-log.service";
|
||||
import { ContainerService } from "../../../platform/services/container.service";
|
||||
import { getClassInitializer } from "../../../platform/services/cryptography/get-class-initializer";
|
||||
import { WebCryptoFunctionService } from "../../../platform/services/web-crypto-function.service";
|
||||
import {
|
||||
DECRYPT_COMMAND,
|
||||
SET_CONFIG_COMMAND,
|
||||
ParsedDecryptCommandData,
|
||||
} from "../types/worker-command.type";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
@@ -15,13 +22,14 @@ const workerApi: Worker = self as any;
|
||||
|
||||
let inited = false;
|
||||
let encryptService: EncryptServiceImplementation;
|
||||
let logService: LogService;
|
||||
|
||||
/**
|
||||
* Bootstrap the worker environment with services required for decryption
|
||||
*/
|
||||
export function init() {
|
||||
const cryptoFunctionService = new WebCryptoFunctionService(self);
|
||||
const logService = new ConsoleLogService(false);
|
||||
logService = new ConsoleLogService(false);
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
|
||||
const bitwardenContainerService = new ContainerService(null, encryptService);
|
||||
@@ -39,11 +47,22 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
|
||||
}
|
||||
|
||||
const request: {
|
||||
id: string;
|
||||
items: Jsonify<Decryptable<any>>[];
|
||||
key: Jsonify<SymmetricCryptoKey>;
|
||||
command: string;
|
||||
} = JSON.parse(event.data);
|
||||
|
||||
switch (request.command) {
|
||||
case DECRYPT_COMMAND:
|
||||
return await handleDecrypt(request as unknown as ParsedDecryptCommandData);
|
||||
case SET_CONFIG_COMMAND: {
|
||||
const newConfig = (request as unknown as { newConfig: Jsonify<ServerConfig> }).newConfig;
|
||||
return await handleSetConfig(newConfig);
|
||||
}
|
||||
default:
|
||||
logService.error(`[EncryptWorker] unknown worker command`, request.command, request);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleDecrypt(request: ParsedDecryptCommandData) {
|
||||
const key = SymmetricCryptoKey.fromJSON(request.key);
|
||||
const items = request.items.map((jsonItem) => {
|
||||
const initializer = getClassInitializer<Decryptable<any>>(jsonItem.initializerKey);
|
||||
@@ -55,4 +74,8 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
|
||||
id: request.id,
|
||||
items: JSON.stringify(result),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSetConfig(newConfig: Jsonify<ServerConfig>) {
|
||||
encryptService.onServerConfigChange(ServerConfig.fromJSON(newConfig));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { BulkEncryptService } from "../abstractions/bulk-encrypt.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
import { FallbackBulkEncryptService } from "./fallback-bulk-encrypt.service";
|
||||
|
||||
describe("FallbackBulkEncryptService", () => {
|
||||
const encryptService = mock<EncryptService>();
|
||||
const featureFlagEncryptService = mock<BulkEncryptService>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
|
||||
let sut: FallbackBulkEncryptService;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new FallbackBulkEncryptService(encryptService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const mockItems = [{ id: "guid", name: "encryptedValue" }] as any[];
|
||||
const mockDecryptedItems = [{ id: "guid", name: "decryptedValue" }] as any[];
|
||||
|
||||
it("calls decryptItems on featureFlagEncryptService when it is set", async () => {
|
||||
featureFlagEncryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(featureFlagEncryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(encryptService.decryptItems).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
|
||||
it("calls decryptItems on encryptService when featureFlagEncryptService is not set", async () => {
|
||||
encryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(encryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFeatureFlagEncryptService", () => {
|
||||
it("sets the featureFlagEncryptService property", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
|
||||
it("does not call onServerConfigChange when currentServerConfig is undefined", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).not.toHaveBeenCalled();
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange with currentServerConfig when it is defined", async () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config", async () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect((sut as any).currentServerConfig).toBe(serverConfig);
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange on featureFlagEncryptService when it is set", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
expect(encryptService.onServerConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange on encryptService when featureFlagEncryptService is not set", () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(encryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.i
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,7 @@ import { EncryptService } from "../abstractions/encrypt.service";
|
||||
*/
|
||||
export class FallbackBulkEncryptService implements BulkEncryptService {
|
||||
private featureFlagEncryptService: BulkEncryptService;
|
||||
private currentServerConfig: ServerConfig | undefined = undefined;
|
||||
|
||||
constructor(protected encryptService: EncryptService) {}
|
||||
|
||||
@@ -31,6 +33,14 @@ export class FallbackBulkEncryptService implements BulkEncryptService {
|
||||
}
|
||||
|
||||
async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {
|
||||
if (this.currentServerConfig !== undefined) {
|
||||
featureFlagEncryptService.onServerConfigChange(this.currentServerConfig);
|
||||
}
|
||||
this.featureFlagEncryptService = featureFlagEncryptService;
|
||||
}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
(this.featureFlagEncryptService ?? this.encryptService).onServerConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import * as rxjs from "rxjs";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
import { MultithreadEncryptServiceImplementation } from "./multithread-encrypt.service.implementation";
|
||||
|
||||
describe("MultithreadEncryptServiceImplementation", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const logService = mock<LogService>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
|
||||
let sut: MultithreadEncryptServiceImplementation;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new MultithreadEncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const mockWorker = mock<Worker>();
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock creating a worker.
|
||||
global.Worker = jest.fn().mockImplementation(() => mockWorker);
|
||||
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
|
||||
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
global.URL.canParse = jest.fn().mockReturnValue(true);
|
||||
|
||||
// Mock the workers returned response.
|
||||
const mockMessageEvent = {
|
||||
id: "mock-guid",
|
||||
data: ["decrypted1", "decrypted2"],
|
||||
};
|
||||
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
|
||||
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
|
||||
});
|
||||
|
||||
it("returns empty array if items is null", async () => {
|
||||
const items = null as unknown as Decryptable<any>[];
|
||||
const result = await sut.decryptItems(items, key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array if items is empty", async () => {
|
||||
const result = await sut.decryptItems([], key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates worker if none exists", async () => {
|
||||
// Make sure currentServerConfig is undefined so a SetConfigMessage is not sent.
|
||||
(sut as any).currentServerConfig = undefined;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
|
||||
// Populate currentServerConfig so a SetConfigMessage is sent.
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create worker if one exists", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
(sut as any).worker = mockWorker;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).not.toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config and calls super", () => {
|
||||
const superSpy = jest.spyOn(EncryptServiceImplementation.prototype, "onServerConfigChange");
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(superSpy).toHaveBeenCalledWith(serverConfig);
|
||||
expect((sut as any).currentServerConfig).toBe(serverConfig);
|
||||
});
|
||||
|
||||
it("sends config update to worker if worker exists", () => {
|
||||
const mockWorker = mock<Worker>();
|
||||
(sut as any).worker = mockWorker;
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
@@ -20,6 +23,7 @@ const workerTTL = 3 * 60000; // 3 minutes
|
||||
export class MultithreadEncryptServiceImplementation extends EncryptServiceImplementation {
|
||||
private worker: Worker;
|
||||
private timeout: any;
|
||||
private currentServerConfig: ServerConfig | undefined = undefined;
|
||||
|
||||
private clear$ = new Subject<void>();
|
||||
|
||||
@@ -37,27 +41,33 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
|
||||
|
||||
this.logService.info("Starting decryption using multithreading");
|
||||
|
||||
this.worker ??= new Worker(
|
||||
new URL(
|
||||
/* webpackChunkName: 'encrypt-worker' */
|
||||
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
if (this.worker == null) {
|
||||
this.worker = new Worker(
|
||||
new URL(
|
||||
/* webpackChunkName: 'encrypt-worker' */
|
||||
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
if (this.currentServerConfig !== undefined) {
|
||||
this.updateWorkerServerConfig(this.currentServerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
this.restartTimeout();
|
||||
|
||||
const request = {
|
||||
id: Utils.newGuid(),
|
||||
const id = Utils.newGuid();
|
||||
const request = buildDecryptMessage({
|
||||
id,
|
||||
items: items,
|
||||
key: key,
|
||||
};
|
||||
});
|
||||
|
||||
this.worker.postMessage(JSON.stringify(request));
|
||||
this.worker.postMessage(request);
|
||||
|
||||
return await firstValueFrom(
|
||||
fromEvent(this.worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === request.id),
|
||||
filter((response: MessageEvent) => response.data?.id === id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
@@ -71,6 +81,19 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
|
||||
);
|
||||
}
|
||||
|
||||
override onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
super.onServerConfigChange(newConfig);
|
||||
this.updateWorkerServerConfig(newConfig);
|
||||
}
|
||||
|
||||
private updateWorkerServerConfig(newConfig: ServerConfig) {
|
||||
if (this.worker != null) {
|
||||
const request = buildSetConfigMessage({ newConfig });
|
||||
this.worker.postMessage(request);
|
||||
}
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.clear$.next();
|
||||
this.worker?.terminate();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import {
|
||||
DECRYPT_COMMAND,
|
||||
DecryptCommandData,
|
||||
SET_CONFIG_COMMAND,
|
||||
buildDecryptMessage,
|
||||
buildSetConfigMessage,
|
||||
} from "./worker-command.type";
|
||||
|
||||
describe("Worker command types", () => {
|
||||
describe("buildDecryptMessage", () => {
|
||||
it("builds a message with the correct command", () => {
|
||||
const commandData = createDecryptCommandData();
|
||||
|
||||
const result = buildDecryptMessage(commandData);
|
||||
|
||||
const parsedResult = JSON.parse(result);
|
||||
expect(parsedResult.command).toBe(DECRYPT_COMMAND);
|
||||
});
|
||||
|
||||
it("includes the provided data in the message", () => {
|
||||
const mockItems = [{ encrypted: "test-encrypted" } as unknown as Decryptable<any>];
|
||||
const commandData = createDecryptCommandData(mockItems);
|
||||
|
||||
const result = buildDecryptMessage(commandData);
|
||||
|
||||
const parsedResult = JSON.parse(result);
|
||||
expect(parsedResult.command).toBe(DECRYPT_COMMAND);
|
||||
expect(parsedResult.id).toBe("test-id");
|
||||
expect(parsedResult.items).toEqual(mockItems);
|
||||
expect(SymmetricCryptoKey.fromJSON(parsedResult.key)).toEqual(commandData.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSetConfigMessage", () => {
|
||||
it("builds a message with the correct command", () => {
|
||||
const result = buildSetConfigMessage({ newConfig: mock<ServerConfig>() });
|
||||
|
||||
const parsedResult = JSON.parse(result);
|
||||
expect(parsedResult.command).toBe(SET_CONFIG_COMMAND);
|
||||
});
|
||||
|
||||
it("includes the provided data in the message", () => {
|
||||
const serverConfig = { version: "test-version" } as unknown as ServerConfig;
|
||||
|
||||
const result = buildSetConfigMessage({ newConfig: serverConfig });
|
||||
|
||||
const parsedResult = JSON.parse(result);
|
||||
expect(parsedResult.command).toBe(SET_CONFIG_COMMAND);
|
||||
expect(ServerConfig.fromJSON(parsedResult.newConfig).version).toEqual(serverConfig.version);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createDecryptCommandData(items?: Decryptable<any>[]): DecryptCommandData {
|
||||
return {
|
||||
id: "test-id",
|
||||
items: items ?? [],
|
||||
key: new SymmetricCryptoKey(makeStaticByteArray(64)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export const DECRYPT_COMMAND = "decrypt";
|
||||
export const SET_CONFIG_COMMAND = "updateConfig";
|
||||
|
||||
export type DecryptCommandData = {
|
||||
id: string;
|
||||
items: Decryptable<any>[];
|
||||
key: SymmetricCryptoKey;
|
||||
};
|
||||
|
||||
export type ParsedDecryptCommandData = {
|
||||
id: string;
|
||||
items: Jsonify<Decryptable<any>>[];
|
||||
key: Jsonify<SymmetricCryptoKey>;
|
||||
};
|
||||
|
||||
type SetConfigCommandData = { newConfig: ServerConfig };
|
||||
|
||||
export function buildDecryptMessage(data: DecryptCommandData): string {
|
||||
return JSON.stringify({
|
||||
command: DECRYPT_COMMAND,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSetConfigMessage(data: SetConfigCommandData): string {
|
||||
return JSON.stringify({
|
||||
command: SET_CONFIG_COMMAND,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
|
||||
|
||||
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -55,4 +57,9 @@ export abstract class DeviceTrustServiceAbstraction {
|
||||
* Note: For debugging purposes only.
|
||||
*/
|
||||
recordDeviceTrustLoss: () => Promise<void>;
|
||||
getRotatedData: (
|
||||
oldUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
) => Promise<DeviceKeysUpdateRequest[]>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map, Observable, Subject } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { RotateableKeySet, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
|
||||
@@ -10,6 +10,7 @@ import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices
|
||||
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
|
||||
import {
|
||||
DeviceKeysUpdateRequest,
|
||||
OtherDeviceKeysUpdateRequest,
|
||||
UpdateDevicesTrustRequest,
|
||||
} from "../../../auth/models/request/update-devices-trust.request";
|
||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||
@@ -187,6 +188,51 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
return deviceResponse;
|
||||
}
|
||||
|
||||
async getRotatedData(
|
||||
oldUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<DeviceKeysUpdateRequest[]> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot get rotated data.");
|
||||
}
|
||||
if (!oldUserKey) {
|
||||
throw new Error("Old user key is required. Cannot get rotated data.");
|
||||
}
|
||||
if (!newUserKey) {
|
||||
throw new Error("New user key is required. Cannot get rotated data.");
|
||||
}
|
||||
|
||||
const devices = await this.devicesApiService.getDevices();
|
||||
return await Promise.all(
|
||||
devices.data
|
||||
.filter((device) => device.isTrusted)
|
||||
.map(async (device) => {
|
||||
const deviceWithKeys = await this.devicesApiService.getDeviceKeys(device.identifier);
|
||||
const publicKey = await this.encryptService.decryptToBytes(
|
||||
deviceWithKeys.encryptedPublicKey,
|
||||
oldUserKey,
|
||||
);
|
||||
const newEncryptedPublicKey = await this.encryptService.encrypt(publicKey, newUserKey);
|
||||
const newEncryptedUserKey = await this.encryptService.rsaEncrypt(
|
||||
newUserKey.key,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
const newRotateableKeySet = new RotateableKeySet(
|
||||
newEncryptedUserKey,
|
||||
newEncryptedPublicKey,
|
||||
);
|
||||
|
||||
const request = new OtherDeviceKeysUpdateRequest();
|
||||
request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString;
|
||||
request.encryptedUserKey = newRotateableKeySet.encryptedUserKey.encryptedString;
|
||||
request.deviceId = device.id;
|
||||
return request;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async rotateDevicesTrust(
|
||||
userId: UserId,
|
||||
newUserKey: UserKey,
|
||||
@@ -216,10 +262,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
secretVerificationRequest.masterPasswordHash = masterPasswordHash;
|
||||
|
||||
// Get the keys that are used in rotating a devices keys from the server
|
||||
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(
|
||||
deviceIdentifier,
|
||||
secretVerificationRequest,
|
||||
);
|
||||
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(deviceIdentifier);
|
||||
|
||||
// Decrypt the existing device public key with the old user key
|
||||
const decryptedDevicePublicKey = await this.encryptService.decryptToBytes(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
UserDecryptionOptions,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service";
|
||||
@@ -655,6 +656,86 @@ describe("deviceTrustService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
let fakeNewUserKey: UserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
let fakeOldUserKey: UserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const userId: UserId = Utils.newGuid() as UserId;
|
||||
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustService.getRotatedData(fakeOldUserKey, fakeNewUserKey, null),
|
||||
).rejects.toThrow("UserId is required. Cannot get rotated data.");
|
||||
});
|
||||
|
||||
it("throws an error when a null old user key is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustService.getRotatedData(null, fakeNewUserKey, userId),
|
||||
).rejects.toThrow("Old user key is required. Cannot get rotated data.");
|
||||
});
|
||||
|
||||
it("throws an error when a null new user key is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustService.getRotatedData(fakeOldUserKey, null, userId),
|
||||
).rejects.toThrow("New user key is required. Cannot get rotated data.");
|
||||
});
|
||||
|
||||
it("returns the expected data when all required parameters are provided", async () => {
|
||||
const deviceResponse = {
|
||||
id: "",
|
||||
userId: "",
|
||||
name: "",
|
||||
identifier: "",
|
||||
type: DeviceType.Android,
|
||||
creationDate: "",
|
||||
revisionDate: "",
|
||||
isTrusted: true,
|
||||
};
|
||||
devicesApiService.getDevices.mockResolvedValue(
|
||||
new ListResponse(
|
||||
{
|
||||
data: [deviceResponse],
|
||||
},
|
||||
DeviceResponse,
|
||||
),
|
||||
);
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(64));
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.rsaEncrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
|
||||
const protectedDeviceResponse = new ProtectedDeviceResponse({
|
||||
id: "",
|
||||
creationDate: "",
|
||||
identifier: "test_device_identifier",
|
||||
name: "Firefox",
|
||||
type: DeviceType.FirefoxBrowser,
|
||||
encryptedPublicKey: "",
|
||||
encryptedUserKey: "",
|
||||
});
|
||||
devicesApiService.getDeviceKeys.mockResolvedValue(protectedDeviceResponse);
|
||||
const fakeOldUserKeyData = new Uint8Array(64);
|
||||
fakeOldUserKeyData.fill(5, 0, 1);
|
||||
fakeOldUserKey = new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey;
|
||||
|
||||
const fakeNewUserKeyData = new Uint8Array(64);
|
||||
fakeNewUserKeyData.fill(1, 0, 1);
|
||||
fakeNewUserKey = new SymmetricCryptoKey(fakeNewUserKeyData) as UserKey;
|
||||
|
||||
const result = await deviceTrustService.getRotatedData(
|
||||
fakeOldUserKey,
|
||||
fakeNewUserKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
deviceId: "",
|
||||
encryptedUserKey: "test_encrypted_data",
|
||||
encryptedPublicKey: "test_encrypted_data",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotateDevicesTrust", () => {
|
||||
let fakeNewUserKey: UserKey = null;
|
||||
|
||||
@@ -708,11 +789,8 @@ describe("deviceTrustService", () => {
|
||||
|
||||
appIdService.getAppId.mockResolvedValue("test_device_identifier");
|
||||
|
||||
devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier, secretRequest) => {
|
||||
if (
|
||||
deviceIdentifier !== "test_device_identifier" ||
|
||||
secretRequest.masterPasswordHash !== "my_password_hash"
|
||||
) {
|
||||
devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier) => {
|
||||
if (deviceIdentifier !== "test_device_identifier") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,19 +163,6 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
throw new Error("No master key found.");
|
||||
}
|
||||
|
||||
// Try one more way to get the user key if it still wasn't found.
|
||||
if (userKey == null) {
|
||||
const deprecatedKey = await this.stateService.getEncryptedCryptoSymmetricKey({
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
if (deprecatedKey == null) {
|
||||
throw new Error("No encrypted user key found.");
|
||||
}
|
||||
|
||||
userKey = new EncString(deprecatedKey);
|
||||
}
|
||||
|
||||
let decUserKey: Uint8Array;
|
||||
|
||||
if (userKey.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"returns $expected when policy is $policy, and user preference is $userPreference",
|
||||
async ({ policy, userPreference, expected }) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.getAll$.mockReturnValue(
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
@@ -213,7 +213,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
userDecryptionOptionsSubject.next(
|
||||
new UserDecryptionOptions({ hasMasterPassword: false }),
|
||||
);
|
||||
policyService.getAll$.mockReturnValue(
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
@@ -257,7 +257,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"when policy is %s, and vault timeout is %s, returns %s",
|
||||
async (policy, vaultTimeout, expected) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.getAll$.mockReturnValue(
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
@@ -24,6 +23,7 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management";
|
||||
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
@@ -266,8 +266,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
}
|
||||
|
||||
return this.policyService
|
||||
.getAll$(PolicyType.MaximumVaultTimeout, userId)
|
||||
.pipe(map((policies) => policies[0] ?? null));
|
||||
.policiesByType$(PolicyType.MaximumVaultTimeout, userId)
|
||||
.pipe(getFirstPolicy);
|
||||
}
|
||||
|
||||
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {
|
||||
|
||||
@@ -147,7 +147,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
await this.masterPasswordService.clearMasterKey(lockingUserId);
|
||||
|
||||
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
|
||||
await this.stateService.setCryptoMasterKeyAuto(null, { userId: lockingUserId });
|
||||
|
||||
await this.cipherService.clearCache(lockingUserId);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { import_ssh_key } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
|
||||
@@ -18,18 +17,16 @@ export class SshKeyExport {
|
||||
}
|
||||
|
||||
static toView(req: SshKeyExport, view = new SshKeyView()) {
|
||||
const parsedKey = import_ssh_key(req.privateKey);
|
||||
view.privateKey = parsedKey.privateKey;
|
||||
view.publicKey = parsedKey.publicKey;
|
||||
view.keyFingerprint = parsedKey.fingerprint;
|
||||
view.privateKey = req.privateKey;
|
||||
view.publicKey = req.publicKey;
|
||||
view.keyFingerprint = req.keyFingerprint;
|
||||
return view;
|
||||
}
|
||||
|
||||
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
|
||||
const parsedKey = import_ssh_key(req.privateKey);
|
||||
domain.privateKey = new EncString(parsedKey.privateKey);
|
||||
domain.publicKey = new EncString(parsedKey.publicKey);
|
||||
domain.keyFingerprint = new EncString(parsedKey.fingerprint);
|
||||
domain.privateKey = new EncString(req.privateKey);
|
||||
domain.publicKey = new EncString(req.publicKey);
|
||||
domain.keyFingerprint = new EncString(req.keyFingerprint);
|
||||
return domain;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,14 +50,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
value: boolean,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* @deprecated For migration purposes only, use getUserKeyMasterKey instead
|
||||
*/
|
||||
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
|
||||
/**
|
||||
* @deprecated For migration purposes only, use setUserKeyAuto instead
|
||||
*/
|
||||
setCryptoMasterKeyAuto: (value: string | null, options?: StorageOptions) => Promise<void>;
|
||||
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
|
||||
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { clearCaches, sequentialize } from "./sequentialize";
|
||||
|
||||
describe("sequentialize decorator", () => {
|
||||
it("should call the function once", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function once for each instance of the object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
promises.push(foo2.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
expect(foo2.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function once with key function", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function again when already resolved", async () => {
|
||||
const foo = new Foo();
|
||||
await foo.bar(1);
|
||||
expect(foo.calls).toBe(1);
|
||||
await foo.bar(1);
|
||||
expect(foo.calls).toBe(2);
|
||||
});
|
||||
|
||||
it("should call the function again when already resolved with a key function", async () => {
|
||||
const foo = new Foo();
|
||||
await foo.baz(1);
|
||||
expect(foo.calls).toBe(1);
|
||||
await foo.baz(1);
|
||||
expect(foo.calls).toBe(2);
|
||||
});
|
||||
|
||||
it("should call the function for each argument", async () => {
|
||||
const foo = new Foo();
|
||||
await Promise.all([foo.bar(1), foo.bar(1), foo.bar(2), foo.bar(2), foo.bar(3), foo.bar(3)]);
|
||||
expect(foo.calls).toBe(3);
|
||||
});
|
||||
|
||||
it("should call the function for each argument with key function", async () => {
|
||||
const foo = new Foo();
|
||||
await Promise.all([foo.baz(1), foo.baz(1), foo.baz(2), foo.baz(2), foo.baz(3), foo.baz(3)]);
|
||||
expect(foo.calls).toBe(3);
|
||||
});
|
||||
|
||||
it("should return correct result for each call", async () => {
|
||||
const foo = new Foo();
|
||||
const allRes: number[] = [];
|
||||
|
||||
await Promise.all([
|
||||
foo.bar(1).then((res) => allRes.push(res)),
|
||||
foo.bar(1).then((res) => allRes.push(res)),
|
||||
foo.bar(2).then((res) => allRes.push(res)),
|
||||
foo.bar(2).then((res) => allRes.push(res)),
|
||||
foo.bar(3).then((res) => allRes.push(res)),
|
||||
foo.bar(3).then((res) => allRes.push(res)),
|
||||
]);
|
||||
expect(foo.calls).toBe(3);
|
||||
expect(allRes.length).toBe(6);
|
||||
allRes.sort();
|
||||
expect(allRes).toEqual([2, 2, 4, 4, 6, 6]);
|
||||
});
|
||||
|
||||
it("should return correct result for each call with key function", async () => {
|
||||
const foo = new Foo();
|
||||
const allRes: number[] = [];
|
||||
|
||||
await Promise.all([
|
||||
foo.baz(1).then((res) => allRes.push(res)),
|
||||
foo.baz(1).then((res) => allRes.push(res)),
|
||||
foo.baz(2).then((res) => allRes.push(res)),
|
||||
foo.baz(2).then((res) => allRes.push(res)),
|
||||
foo.baz(3).then((res) => allRes.push(res)),
|
||||
foo.baz(3).then((res) => allRes.push(res)),
|
||||
]);
|
||||
expect(foo.calls).toBe(3);
|
||||
expect(allRes.length).toBe(6);
|
||||
allRes.sort();
|
||||
expect(allRes).toEqual([3, 3, 6, 6, 9, 9]);
|
||||
});
|
||||
|
||||
describe("clearCaches", () => {
|
||||
it("should clear all caches", async () => {
|
||||
const foo = new Foo();
|
||||
const promise = Promise.all([foo.bar(1), foo.bar(1)]);
|
||||
clearCaches();
|
||||
await foo.bar(1);
|
||||
await promise;
|
||||
// one call for the first two, one for the third after the cache was cleared
|
||||
expect(foo.calls).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class Foo {
|
||||
calls = 0;
|
||||
|
||||
@sequentialize((args) => "bar" + args[0])
|
||||
bar(a: number): Promise<number> {
|
||||
this.calls++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res(a * 2);
|
||||
}, Math.random() * 100);
|
||||
});
|
||||
}
|
||||
|
||||
@sequentialize((args) => "baz" + args[0])
|
||||
baz(a: number): Promise<number> {
|
||||
this.calls++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res(a * 3);
|
||||
}, Math.random() * 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
const caches = new Map<any, Map<string, Promise<any>>>();
|
||||
|
||||
const getCache = (obj: any) => {
|
||||
let cache = caches.get(obj);
|
||||
if (cache != null) {
|
||||
return cache;
|
||||
}
|
||||
cache = new Map<string, Promise<any>>();
|
||||
caches.set(obj, cache);
|
||||
return cache;
|
||||
};
|
||||
|
||||
export function clearCaches() {
|
||||
caches.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use as a Decorator on async functions, it will prevent multiple 'active' calls as the same time
|
||||
*
|
||||
* If a promise was returned from a previous call to this function, that hasn't yet resolved it will
|
||||
* be returned, instead of calling the original function again
|
||||
*
|
||||
* Results are not cached, once the promise has returned, the next call will result in a fresh call
|
||||
*
|
||||
* Read more at https://github.com/bitwarden/jslib/pull/7
|
||||
*/
|
||||
export function sequentialize(cacheKey: (args: any[]) => string) {
|
||||
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||
const originalMethod: () => Promise<any> = descriptor.value;
|
||||
|
||||
return {
|
||||
value: function (...args: any[]) {
|
||||
const cache = getCache(this);
|
||||
const argsCacheKey = cacheKey(args);
|
||||
let response = cache.get(argsCacheKey);
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const onFinally = () => {
|
||||
cache.delete(argsCacheKey);
|
||||
if (cache.size === 0) {
|
||||
caches.delete(this);
|
||||
}
|
||||
};
|
||||
response = originalMethod
|
||||
.apply(this, args)
|
||||
.then((val: any) => {
|
||||
onFinally();
|
||||
return val;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
onFinally();
|
||||
throw err;
|
||||
});
|
||||
|
||||
cache.set(argsCacheKey, response);
|
||||
return response;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sequentialize } from "./sequentialize";
|
||||
import { throttle } from "./throttle";
|
||||
|
||||
describe("throttle decorator", () => {
|
||||
@@ -51,17 +50,6 @@ describe("throttle decorator", () => {
|
||||
expect(foo.calls).toBe(10);
|
||||
expect(foo2.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should work together with sequentialize", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.qux(Math.floor(i / 2) * 2));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
class Foo {
|
||||
@@ -94,7 +82,6 @@ class Foo {
|
||||
});
|
||||
}
|
||||
|
||||
@sequentialize((args) => "qux" + args[0])
|
||||
@throttle(1, () => "qux")
|
||||
qux(a: number) {
|
||||
this.calls++;
|
||||
|
||||
@@ -48,8 +48,6 @@ export class EncryptionPair<TEncrypted, TDecrypted> {
|
||||
export class AccountKeys {
|
||||
publicKey?: Uint8Array;
|
||||
|
||||
/** @deprecated July 2023, left for migration purposes*/
|
||||
cryptoMasterKeyAuto?: string;
|
||||
/** @deprecated July 2023, left for migration purposes*/
|
||||
cryptoSymmetricKey?: EncryptionPair<string, SymmetricCryptoKey> = new EncryptionPair<
|
||||
string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
map,
|
||||
mergeMap,
|
||||
Observable,
|
||||
share,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
@@ -66,6 +67,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
|
||||
map((notification) => [notification, activeAccountId] as const),
|
||||
);
|
||||
}),
|
||||
share(), // Multiple subscribers should only create a single connection to the server
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Subscription } from "rxjs";
|
||||
import { Observable, Subject, Subscription } from "rxjs";
|
||||
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { NotificationsService } from "../notifications.service";
|
||||
|
||||
export class NoopNotificationsService implements NotificationsService {
|
||||
notifications$: Observable<readonly [NotificationResponse, UserId]> = new Subject();
|
||||
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
startListening(): Subscription {
|
||||
|
||||
@@ -23,6 +23,11 @@ export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResp
|
||||
|
||||
export type SignalRNotification = Heartbeat | ReceiveMessage;
|
||||
|
||||
export type TimeoutManager = {
|
||||
setTimeout: (handler: TimerHandler, timeout: number) => number;
|
||||
clearTimeout: (timeoutId: number) => void;
|
||||
};
|
||||
|
||||
class SignalRLogger implements ILogger {
|
||||
constructor(private readonly logService: LogService) {}
|
||||
|
||||
@@ -51,11 +56,14 @@ export class SignalRConnectionService {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly logService: LogService,
|
||||
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
|
||||
new HubConnectionBuilder(),
|
||||
private readonly timeoutManager: TimeoutManager = globalThis,
|
||||
) {}
|
||||
|
||||
connect$(userId: UserId, notificationsUrl: string) {
|
||||
return new Observable<SignalRNotification>((subsciber) => {
|
||||
const connection = new HubConnectionBuilder()
|
||||
const connection = this.hubConnectionBuilderFactory()
|
||||
.withUrl(notificationsUrl + "/hub", {
|
||||
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
|
||||
skipNegotiation: true,
|
||||
@@ -76,48 +84,60 @@ export class SignalRConnectionService {
|
||||
let reconnectSubscription: Subscription | null = null;
|
||||
|
||||
// Create schedule reconnect function
|
||||
const scheduleReconnect = (): Subscription => {
|
||||
const scheduleReconnect = () => {
|
||||
if (
|
||||
connection == null ||
|
||||
connection.state !== HubConnectionState.Disconnected ||
|
||||
(reconnectSubscription != null && !reconnectSubscription.closed)
|
||||
) {
|
||||
return Subscription.EMPTY;
|
||||
// Skip scheduling a new reconnect, either the connection isn't disconnected
|
||||
// or an active reconnect is already scheduled.
|
||||
return;
|
||||
}
|
||||
|
||||
const randomTime = this.random();
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
// If we've somehow gotten here while the subscriber is closed,
|
||||
// we do not want to reconnect. So leave.
|
||||
if (subsciber.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const randomTime = this.randomReconnectTime();
|
||||
const timeoutHandler = this.timeoutManager.setTimeout(() => {
|
||||
connection
|
||||
.start()
|
||||
.then(() => (reconnectSubscription = null))
|
||||
.then(() => {
|
||||
reconnectSubscription = null;
|
||||
})
|
||||
.catch(() => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
scheduleReconnect();
|
||||
});
|
||||
}, randomTime);
|
||||
|
||||
return new Subscription(() => clearTimeout(timeoutHandler));
|
||||
reconnectSubscription = new Subscription(() =>
|
||||
this.timeoutManager.clearTimeout(timeoutHandler),
|
||||
);
|
||||
};
|
||||
|
||||
connection.onclose((error) => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
// Start connection
|
||||
connection.start().catch(() => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cancel any possible scheduled reconnects
|
||||
reconnectSubscription?.unsubscribe();
|
||||
connection?.stop().catch((error) => {
|
||||
this.logService.error("Error while stopping SignalR connection", error);
|
||||
// TODO: Does calling stop call `onclose`?
|
||||
reconnectSubscription?.unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private random() {
|
||||
private randomReconnectTime() {
|
||||
return (
|
||||
Math.floor(Math.random() * (MAX_RECONNECT_TIME - MIN_RECONNECT_TIME + 1)) + MIN_RECONNECT_TIME
|
||||
);
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { Subscription } from "rxjs";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Needed to link to API
|
||||
import type { DefaultNotificationsService } from "./internal";
|
||||
|
||||
/**
|
||||
* A service offering abilities to interact with push notifications from the server.
|
||||
*/
|
||||
export abstract class NotificationsService {
|
||||
/**
|
||||
* @deprecated This method should not be consumed, an observable to listen to server
|
||||
* notifications will be available one day but it is not ready to be consumed generally.
|
||||
* Please add code reacting to notifications in {@link DefaultNotificationsService.processNotification}
|
||||
*/
|
||||
abstract notifications$: Observable<readonly [NotificationResponse, UserId]>;
|
||||
/**
|
||||
* Starts automatic listening and processing of notifications, should only be called once per application,
|
||||
* or you will risk notifications being processed multiple times.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ScheduledTaskName } from "./scheduled-task-name.enum";
|
||||
* in the future but the task that is ran is NOT the remainder of your RXJS pipeline. The
|
||||
* task you want ran must instead be registered in a location reachable on a service worker
|
||||
* startup (on browser). An example of an acceptible location is the constructor of a service
|
||||
* you know is created in `MainBackground`. Uses of this API is other clients _can_ have the
|
||||
* you know is created in `MainBackground`. Uses of this API in other clients _can_ have the
|
||||
* `registerTaskHandler` call in more places, but in order to have it work across clients
|
||||
* it is recommended to register it according to the rules of browser.
|
||||
*
|
||||
|
||||
@@ -17,11 +17,7 @@ import { SemVer } from "semver";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import {
|
||||
DefaultFeatureFlagValue,
|
||||
FeatureFlag,
|
||||
FeatureFlagValueType,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
@@ -123,26 +119,13 @@ export class DefaultConfigService implements ConfigService {
|
||||
}
|
||||
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => this.getFeatureFlagValue(serverConfig, key)),
|
||||
);
|
||||
}
|
||||
|
||||
private getFeatureFlagValue<Flag extends FeatureFlag>(
|
||||
serverConfig: ServerConfig | null,
|
||||
flag: Flag,
|
||||
) {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
|
||||
return DefaultFeatureFlagValue[flag];
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
|
||||
return this.serverConfig$.pipe(map((serverConfig) => getFeatureFlagValue(serverConfig, key)));
|
||||
}
|
||||
|
||||
userCachedFeatureFlag$<Flag extends FeatureFlag>(key: Flag, userId: UserId) {
|
||||
return this.stateProvider
|
||||
.getUser(userId, USER_SERVER_CONFIG)
|
||||
.state$.pipe(map((config) => this.getFeatureFlagValue(config, key)));
|
||||
.state$.pipe(map((config) => getFeatureFlagValue(config, key)));
|
||||
}
|
||||
|
||||
async getFeatureFlag<Flag extends FeatureFlag>(key: Flag) {
|
||||
|
||||
@@ -222,45 +222,6 @@ export class StateService<
|
||||
await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use UserKeyAuto instead
|
||||
*/
|
||||
async setCryptoMasterKeyAuto(value: string | null, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(
|
||||
this.reconcileOptions(options, { keySuffix: "auto" }),
|
||||
await this.defaultSecureStorageOptions(),
|
||||
);
|
||||
if (options?.userId == null) {
|
||||
return;
|
||||
}
|
||||
await this.saveSecureStorageKey(partialKeys.autoKey, value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated I don't see where this is even used
|
||||
*/
|
||||
async getCryptoMasterKeyB64(options?: StorageOptions): Promise<string> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
return null;
|
||||
}
|
||||
return await this.secureStorageService.get<string>(
|
||||
`${options?.userId}${partialKeys.masterKey}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated I don't see where this is even used
|
||||
*/
|
||||
async setCryptoMasterKeyB64(value: string, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
return;
|
||||
}
|
||||
await this.saveSecureStorageKey(partialKeys.masterKey, value, options);
|
||||
}
|
||||
|
||||
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
@@ -619,8 +580,6 @@ export class StateService<
|
||||
|
||||
await this.setUserKeyAutoUnlock(null, { userId: userId });
|
||||
await this.setUserKeyBiometric(null, { userId: userId });
|
||||
await this.setCryptoMasterKeyAuto(null, { userId: userId });
|
||||
await this.setCryptoMasterKeyB64(null, { userId: userId });
|
||||
}
|
||||
|
||||
protected async removeAccountFromMemory(userId: string = null): Promise<void> {
|
||||
|
||||
@@ -206,3 +206,4 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
|
||||
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
||||
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
||||
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
|
||||
export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk");
|
||||
|
||||
@@ -51,7 +51,6 @@ import { FolderResponse } from "../../vault/models/response/folder.response";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { MessageSender } from "../messaging";
|
||||
import { sequentialize } from "../misc/sequentialize";
|
||||
import { StateProvider } from "../state";
|
||||
|
||||
import { CoreSyncService } from "./core-sync.service";
|
||||
@@ -103,7 +102,6 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
@sequentialize(() => "fullSync")
|
||||
override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise<boolean> {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
|
||||
this.syncStarted();
|
||||
|
||||
@@ -147,6 +147,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexingStartTime = new Date().getTime();
|
||||
await this.setIsIndexing(userId, true);
|
||||
await this.setIndexedEntityIdForSearch(userId, indexedEntityId as IndexedEntityId);
|
||||
const builder = new lunr.Builder();
|
||||
@@ -187,8 +188,11 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
await this.setIndexForSearch(userId, index.toJSON() as SerializedLunrIndex);
|
||||
|
||||
await this.setIsIndexing(userId, false);
|
||||
|
||||
this.logService.info("Finished search indexing");
|
||||
this.logService.info(
|
||||
`[SearchService] Building search index of ${ciphers.length} ciphers took ${
|
||||
new Date().getTime() - indexingStartTime
|
||||
}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
async searchCiphers(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { VendorId } from "../extension";
|
||||
|
||||
import { IntegrationContext } from "./integration-context";
|
||||
import { IntegrationId } from "./integration-id";
|
||||
@@ -8,7 +9,7 @@ import { IntegrationMetadata } from "./integration-metadata";
|
||||
|
||||
const EXAMPLE_META = Object.freeze({
|
||||
// arbitrary
|
||||
id: "simplelogin" as IntegrationId,
|
||||
id: "simplelogin" as IntegrationId & VendorId,
|
||||
name: "Example",
|
||||
// arbitrary
|
||||
extends: ["forwarder"],
|
||||
@@ -34,7 +35,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("throws when the baseurl isn't defined in metadata", () => {
|
||||
const noBaseUrl: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
selfHost: "maybe",
|
||||
@@ -56,7 +57,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("ignores settings when selfhost is 'never'", () => {
|
||||
const selfHostNever: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
@@ -71,7 +72,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("always reads the settings when selfhost is 'always'", () => {
|
||||
const selfHostAlways: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
@@ -86,7 +87,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("fails when the settings are empty and selfhost is 'always'", () => {
|
||||
const selfHostAlways: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
@@ -101,7 +102,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("reads from the metadata by default when selfhost is 'maybe'", () => {
|
||||
const selfHostMaybe: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
@@ -117,7 +118,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("overrides the metadata when selfhost is 'maybe'", () => {
|
||||
const selfHostMaybe: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { VendorId } from "../extension";
|
||||
|
||||
import { ExtensionPointId } from "./extension-point-id";
|
||||
import { IntegrationId } from "./integration-id";
|
||||
|
||||
/** The capabilities and descriptive content for an integration */
|
||||
export type IntegrationMetadata = {
|
||||
/** Uniquely identifies the integrator. */
|
||||
id: IntegrationId;
|
||||
id: IntegrationId & VendorId;
|
||||
|
||||
/** Brand name of the integrator. */
|
||||
name: string;
|
||||
|
||||
@@ -12,7 +12,11 @@ export class DisabledSemanticLogger implements SemanticLogger {
|
||||
|
||||
error<T>(_content: Jsonify<T>, _message?: string): void {}
|
||||
|
||||
panic<T>(_content: Jsonify<T>, message?: string): never {
|
||||
throw new Error(message);
|
||||
panic<T>(content: Jsonify<T>, message?: string): never {
|
||||
if (typeof content === "string" && !message) {
|
||||
throw new Error(content);
|
||||
} else {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SemanticLogger {
|
||||
*/
|
||||
debug(message: string): void;
|
||||
|
||||
// FIXME: replace Jsonify<T> parameter with structural logging schema type
|
||||
/** Logs the content at debug priority.
|
||||
* Debug messages are used for diagnostics, and are typically disabled
|
||||
* in production builds.
|
||||
|
||||
@@ -12,3 +12,4 @@ export type SendId = Opaque<string, "SendId">;
|
||||
export type IndexedEntityId = Opaque<string, "IndexedEntityId">;
|
||||
export type SecurityTaskId = Opaque<string, "SecurityTaskId">;
|
||||
export type NotificationId = Opaque<string, "NotificationId">;
|
||||
export type EmergencyAccessId = Opaque<string, "EmergencyAccessId">;
|
||||
|
||||
@@ -31,7 +31,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
*
|
||||
* An empty array indicates that all ciphers were successfully decrypted.
|
||||
*/
|
||||
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[]>;
|
||||
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[] | null>;
|
||||
abstract clearCache(userId: UserId): Promise<void>;
|
||||
abstract encrypt(
|
||||
model: CipherView,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, map, of } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
@@ -121,6 +122,7 @@ describe("Cipher Service", () => {
|
||||
const bulkEncryptService = mock<BulkEncryptService>();
|
||||
const configService = mock<ConfigService>();
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
const logService = mock<LogService>();
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
const userId = "TestUserId" as UserId;
|
||||
@@ -148,6 +150,7 @@ describe("Cipher Service", () => {
|
||||
configService,
|
||||
stateProvider,
|
||||
accountService,
|
||||
logService,
|
||||
);
|
||||
|
||||
cipherObj = new Cipher(cipherData);
|
||||
@@ -305,51 +308,86 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
describe("cipher.key", () => {
|
||||
it("is null when feature flag is false", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
beforeEach(() => {
|
||||
keyService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
});
|
||||
|
||||
it("is null when feature flag is false", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
const cipher = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipher.key).toBeNull();
|
||||
});
|
||||
|
||||
it("is defined when feature flag flag is true", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
describe("when feature flag is true", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
const cipher = await cipherService.encrypt(cipherView, userId);
|
||||
it("is null when the cipher is not viewPassword", async () => {
|
||||
cipherView.viewPassword = false;
|
||||
|
||||
expect(cipher.key).toBeDefined();
|
||||
const cipher = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipher.key).toBeNull();
|
||||
});
|
||||
|
||||
it("is defined when the cipher is viewPassword", async () => {
|
||||
cipherView.viewPassword = true;
|
||||
|
||||
const cipher = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipher.key).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptWithCipherKey", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn<any, string>(cipherService, "encryptCipherWithCipherKey");
|
||||
keyService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
});
|
||||
|
||||
it("is not called when feature flag is false", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
keyService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
|
||||
await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is called when feature flag is true", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
keyService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
describe("when feature flag is true", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
await cipherService.encrypt(cipherView, userId);
|
||||
it("is called when cipher viewPassword is true", async () => {
|
||||
cipherView.viewPassword = true;
|
||||
|
||||
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
|
||||
await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is not called when cipher viewPassword is false and original cipher has no key", async () => {
|
||||
cipherView.viewPassword = false;
|
||||
|
||||
await cipherService.encrypt(cipherView, userId, undefined, undefined, new Cipher());
|
||||
|
||||
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is called when cipher viewPassword is false and original cipher has a key", async () => {
|
||||
cipherView.viewPassword = false;
|
||||
|
||||
await cipherService.encrypt(cipherView, userId, undefined, undefined, cipherObj);
|
||||
|
||||
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
@@ -31,7 +32,6 @@ import { View } from "../../models/view/view";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { sequentialize } from "../../platform/misc/sequentialize";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import Domain from "../../platform/models/domain/domain-base";
|
||||
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
|
||||
@@ -111,6 +111,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
private configService: ConfigService,
|
||||
private stateProvider: StateProvider,
|
||||
private accountService: AccountService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
localData$(userId: UserId): Observable<Record<CipherId, LocalData>> {
|
||||
@@ -223,7 +224,11 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
cipher.reprompt = model.reprompt;
|
||||
cipher.edit = model.edit;
|
||||
|
||||
if (await this.getCipherKeyEncryptionEnabled()) {
|
||||
if (
|
||||
// prevent unprivileged users from migrating to cipher key encryption
|
||||
(model.viewPassword || originalCipher?.key) &&
|
||||
(await this.getCipherKeyEncryptionEnabled())
|
||||
) {
|
||||
cipher.key = originalCipher?.key ?? null;
|
||||
const userOrOrgKey = await this.getKeyForCipherKeyDecryption(cipher, userId);
|
||||
// The keyForEncryption is only used for encrypting the cipher key, not the cipher itself, since cipher key encryption is enabled.
|
||||
@@ -390,7 +395,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
* cached, the cached ciphers are returned.
|
||||
* @deprecated Use `cipherViews$` observable instead
|
||||
*/
|
||||
@sequentialize(() => "getAllDecrypted")
|
||||
async getAllDecrypted(userId: UserId): Promise<CipherView[]> {
|
||||
const decCiphers = await this.getDecryptedCiphers(userId);
|
||||
if (decCiphers != null && decCiphers.length !== 0) {
|
||||
@@ -443,6 +447,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
{} as Record<string, Cipher[]>,
|
||||
);
|
||||
|
||||
const decryptStartTime = new Date().getTime();
|
||||
const allCipherViews = (
|
||||
await Promise.all(
|
||||
Object.entries(grouped).map(async ([orgId, groupedCiphers]) => {
|
||||
@@ -462,6 +467,9 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
)
|
||||
.flat()
|
||||
.sort(this.getLocaleSortingFunction());
|
||||
this.logService.info(
|
||||
`[CipherService] Decrypting ${allCipherViews.length} ciphers took ${new Date().getTime() - decryptStartTime}ms`,
|
||||
);
|
||||
|
||||
// Split ciphers into two arrays, one for successfully decrypted ciphers and one for ciphers that failed to decrypt
|
||||
return allCipherViews.reduce(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Observable, map } from "rxjs";
|
||||
import { Observable, map, shareReplay } from "rxjs";
|
||||
|
||||
import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state";
|
||||
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service";
|
||||
@@ -46,7 +46,10 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
|
||||
* {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$}
|
||||
*/
|
||||
readonly clickItemsToAutofillVaultView$: Observable<boolean> =
|
||||
this.clickItemsToAutofillVaultViewState.state$.pipe(map((x) => x ?? false));
|
||||
this.clickItemsToAutofillVaultViewState.state$.pipe(
|
||||
map((x) => x ?? false),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { SecurityTask } from "@bitwarden/vault";
|
||||
|
||||
import { SecurityTask } from "../models";
|
||||
|
||||
export abstract class TaskService {
|
||||
/**
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user