Auth/PM-4596 - Extract PIN and Biometrics unlock method logic into re-useable services for user verification (#7107)

* PM-4596 - PinCryptoService first draft

* PM-4596 - PinCryptoService - Refactor pinKeyEncryptedKey retrievals out into own method getPinKeyEncryptedKeys

* PM-4596 - npm ci + npm run prettier to fix lint issues

* PM-4596 - PinCryptoService - Add kdf types

* PM-4596 - PinCryptoService - Refactor pin validation into own helper method.

* PM-4596 - Rename pin-crypto.service.ts to pin-crypto.service.implementation.ts

* PM-4596 - PinCryptoService - add additional logging for error states.

* PM-4596 - JslibServicesModule - register new PinCryptoService and PinCryptoServiceAbstraction

* PM-4596 - PinCryptoService - modify decryptUserKeyWithPin signature to not require email to match MP verification process in user verification service.

* PM-4596 - Lock components - use new PinCryptoService.decryptUserKeyWithPin(...) to get user key + refactor base comp unlock with pin method to improve

* PM-4596 - Lock component - if too many invalid attempts, added toast explaining that we were logging the user out due to excess PIN entry attempts

* PM-4596 - UserVerificationService - (1) Refactor verifyUser(...) to use switch + separate methods for a cleaner parent method + better extensibility for PIN & biometrics which are TBD (2) Add PIN support to validateInput(...)

* PM-4596 - UserVerificationService - add PIN and biometrics functions to verifyUser(...)

* PM-4596 - PinCryptoService Spec - start test file - instantiates properly

* PM-4596 - PinCryptoService tests - WIP

* PM-4596 - PinCryptoService tests - WIP - got success cases working

* PM-4596 - pin-crypto.service.implementation.spec.ts renamed to pin-crypto.service.spec.ts

* PM-4596 - PinCryptoService.getPinKeyEncryptedKeys(...) - add comment + var name change for clarity

* PM-4596 - PinCryptoService tests - test invalid, null return scenarios

* PM-4596 - CLI - bw.ts - update UserVerificationService instantiation to include new pinCryptoService

* PM-4596 - PinCryptoService - import VaultTimeoutSettingsServiceAbstraction instead of implementation for factory creation to get browser building

* PM-4596 - (1) Create pinCryptoServiceFactory for browser background (2) Add it to the existing userVerificationServiceFactory

* PM-4596 - Browser - Main.background.ts - Add pinCryptoService and add to userVerificationService dependencies

* PM-4596 - UserVerificationService - per PR feedback simplify returns of verifyUserByPIN(...) and verifyUserByBiometrics(...)

* PM-4596 - Messages.json on desktop & browser - per PR feedback, adjust tooManyInvalidPinEntryAttemptsLoggingOut translation text to remove "you"

* PM-4596 - VerificationType enum - fix line copy mistake and give BIOMETRICS own, unique value.

* PM-4596 - VerificationType - rename BIOMETRICS to Biometrics to match existing MasterPassword value case.

* PM-4596  - Update verification type to consider whether or not a secret exists as we have added a new verification which doesn't have a type. Add new server and client side verification types.  Update all relevant code to pass compilation checks.

* PM-4596 - More verification type tweaking

* PM-4596 - Verification - verificationHasSecret - tweak logic to be more dynamic and flexible for future verification types

* PM-4596 - UpdateTempPasswordComp - use new MasterPasswordVerification

* PM-4596 - Desktop - DeleteAcctComp - use VerificationWithSecret to solve compile error w/ accessing secret

* PM-4596 - Per discussions with Andreas & Will, move new Pin Crypto services into libs/auth + added @bitwarden/auth path to CLI tsconfig + added new, required index.ts files for exporting service abstractions & implementations

* PM-4596 - Fixed missed import fixes for lock components across clients for pin crypto service after moving into @bitwarden/auth

* PM-4596 - More PinCryptoService import fixes to get browser & desktop building

* PM-4596 - Update desktop lock comp tests to pass by providing new pin crypto service.

* PM-4596 - User verification service -update todo

* PM-4596 - PinCryptoService - per PR feedback, fix auto import wrong paths.

* PM-4596 - PinCryptoService tests - fix imports per PR feedback

* PM-4596 - UserVerificationSvc - rename method to validateSecretInput per PR feedback

* Fix imports

* PM-4596 - PinCryptoService - Refactor naming for clarity and move test cases into describes per PR feedback

* reorg libs/auth; expose only libs/auth/core to cli app

* PM-4596 - UserVerification - Resolve import issue with importing from libs/auth. Can't use @bitwarden/auth for whatever reason.

* PM-4596 - Fix desktop build by fixing import

* PM-4596 - Provide PinCryptoService to UserVerificationService

* PM-4596 - PinCryptoServiceFactory - you cannot import services from @bitwarden/auth in the background b/c it brings along the libs/auth/components and introduces angular into the background context which doesn't have access to angular which causes random test failures. So, we must separate out the core services just like the CLI to only bring along the angular agnostic services from core.

* PM-4596 - Refactor libs/auth to have angular / common + update all imports per discussion with Matt & Will. Introduced circular dep between PinCryptoService + VaultTimeoutSettingsService + UserVerificationService

* PM-4596 - VaultTimeoutSettingsService - Refactor UserVerificationService out of the service and update all service instantiations and tests. The use of the UserVerificationService.hasMasterPassword method no longer needs to be used for backwards compatibility. This resolves the circular dependency between the PinCryptoService, the UserVerificationService, and the VaultTimeoutSettingsService. We will likely refactor the hasMasterPassword method out of the UserVerificationService in the future.

* PM-4596 - Update CL tsconfig.libs.json to add new auth/common and auth/angular paths for jslib-services.module imports of pin crypto service to work and for test code coverage to run successfully.

* PM-4596 - Address PR feedback

* PM-4596 - Update root tsconfig (only used by storybook) to add new libs/auth paths to fix chromatic build pipeline.

* PM-4596 - Actually update tsconfig with proper routes to fix storybook

* PM-4596 - UserVerificationService - verifyUserByBiometrics - add error handling logic to convert failed or cancelled biometrics verification to a usable boolean

* PM-4596 - Add missing await

* PM-4596 - (1) Add log service and log to user verification service biometric flow to ensure errors are at least revealed to the console (2) Fix factory missing PinCryptoServiceInitOptions

* PM-4596 - Use the correct log service abstraction

* PM-4596 - Remove unused types per PR review

---------

Co-authored-by: William Martin <contact@willmartian.com>
This commit is contained in:
Jared Snider 2024-01-16 14:52:06 -05:00 committed by GitHub
parent 38c525b2ab
commit 756c02cec2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 620 additions and 143 deletions

View File

@ -1493,6 +1493,9 @@
"invalidPin": {
"message": "Invalid PIN code."
},
"tooManyInvalidPinEntryAttemptsLoggingOut": {
"message": "Too many invalid PIN entry attempts. Logging out."
},
"unlockWithBiometrics": {
"message": "Unlock with biometrics"
},

View File

@ -0,0 +1,49 @@
import { PinCryptoServiceAbstraction, PinCryptoService } from "@bitwarden/auth/common";
import {
VaultTimeoutSettingsServiceInitOptions,
vaultTimeoutSettingsServiceFactory,
} from "../../../background/service-factories/vault-timeout-settings-service.factory";
import {
CryptoServiceInitOptions,
cryptoServiceFactory,
} from "../../../platform/background/service-factories/crypto-service.factory";
import {
FactoryOptions,
CachedServices,
factory,
} from "../../../platform/background/service-factories/factory-options";
import {
LogServiceInitOptions,
logServiceFactory,
} from "../../../platform/background/service-factories/log-service.factory";
import {
StateServiceInitOptions,
stateServiceFactory,
} from "../../../platform/background/service-factories/state-service.factory";
type PinCryptoServiceFactoryOptions = FactoryOptions;
export type PinCryptoServiceInitOptions = PinCryptoServiceFactoryOptions &
StateServiceInitOptions &
CryptoServiceInitOptions &
VaultTimeoutSettingsServiceInitOptions &
LogServiceInitOptions;
export function pinCryptoServiceFactory(
cache: { pinCryptoService?: PinCryptoServiceAbstraction } & CachedServices,
opts: PinCryptoServiceInitOptions,
): Promise<PinCryptoServiceAbstraction> {
return factory(
cache,
"pinCryptoService",
opts,
async () =>
new PinCryptoService(
await stateServiceFactory(cache, opts),
await cryptoServiceFactory(cache, opts),
await vaultTimeoutSettingsServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
),
);
}

View File

@ -14,11 +14,16 @@ import {
I18nServiceInitOptions,
i18nServiceFactory,
} from "../../../platform/background/service-factories/i18n-service.factory";
import {
LogServiceInitOptions,
logServiceFactory,
} from "../../../platform/background/service-factories/log-service.factory";
import {
StateServiceInitOptions,
stateServiceFactory,
} from "../../../platform/background/service-factories/state-service.factory";
import { PinCryptoServiceInitOptions, pinCryptoServiceFactory } from "./pin-crypto-service.factory";
import {
UserVerificationApiServiceInitOptions,
userVerificationApiServiceFactory,
@ -30,7 +35,9 @@ export type UserVerificationServiceInitOptions = UserVerificationServiceFactoryO
StateServiceInitOptions &
CryptoServiceInitOptions &
I18nServiceInitOptions &
UserVerificationApiServiceInitOptions;
UserVerificationApiServiceInitOptions &
PinCryptoServiceInitOptions &
LogServiceInitOptions;
export function userVerificationServiceFactory(
cache: { userVerificationService?: AbstractUserVerificationService } & CachedServices,
@ -46,6 +53,8 @@ export function userVerificationServiceFactory(
await cryptoServiceFactory(cache, opts),
await i18nServiceFactory(cache, opts),
await userVerificationApiServiceFactory(cache, opts),
await pinCryptoServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
),
);
}

View File

@ -2,6 +2,7 @@ import { Component, NgZone } from "@angular/core";
import { Router } from "@angular/router";
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
@ -56,6 +57,7 @@ export class LockComponent extends BaseLockComponent {
dialogService: DialogService,
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
userVerificationService: UserVerificationService,
pinCryptoService: PinCryptoServiceAbstraction,
private routerService: BrowserRouterService,
) {
super(
@ -77,6 +79,7 @@ export class LockComponent extends BaseLockComponent {
dialogService,
deviceTrustCryptoService,
userVerificationService,
pinCryptoService,
);
this.successRoute = "/tabs/current";
this.isInitialLockScreen = (window as any).previousPopupUrl == null;

View File

@ -1,3 +1,4 @@
import { PinCryptoServiceAbstraction, PinCryptoService } from "@bitwarden/auth/common";
import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@ -246,6 +247,7 @@ export default class MainBackground {
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
accountService: AccountServiceAbstraction;
globalStateProvider: GlobalStateProvider;
pinCryptoService: PinCryptoServiceAbstraction;
singleUserStateProvider: SingleUserStateProvider;
activeUserStateProvider: ActiveUserStateProvider;
derivedStateProvider: DerivedStateProvider;
@ -482,11 +484,20 @@ export default class MainBackground {
this.userVerificationApiService = new UserVerificationApiService(this.apiService);
this.pinCryptoService = new PinCryptoService(
this.stateService,
this.cryptoService,
this.vaultTimeoutSettingsService,
this.logService,
);
this.userVerificationService = new UserVerificationService(
this.stateService,
this.cryptoService,
this.i18nService,
this.userVerificationApiService,
this.pinCryptoService,
this.logService,
);
this.configApiService = new ConfigApiService(this.apiService, this.authService);
@ -524,7 +535,6 @@ export default class MainBackground {
this.tokenService,
this.policyService,
this.stateService,
this.userVerificationService,
);
this.vaultFilterService = new VaultFilterService(

View File

@ -9,10 +9,6 @@ import {
tokenServiceFactory,
TokenServiceInitOptions,
} from "../../auth/background/service-factories/token-service.factory";
import {
userVerificationServiceFactory,
UserVerificationServiceInitOptions,
} from "../../auth/background/service-factories/user-verification-service.factory";
import {
CryptoServiceInitOptions,
cryptoServiceFactory,
@ -33,8 +29,7 @@ export type VaultTimeoutSettingsServiceInitOptions = VaultTimeoutSettingsService
CryptoServiceInitOptions &
TokenServiceInitOptions &
PolicyServiceInitOptions &
StateServiceInitOptions &
UserVerificationServiceInitOptions;
StateServiceInitOptions;
export function vaultTimeoutSettingsServiceFactory(
cache: { vaultTimeoutSettingsService?: AbstractVaultTimeoutSettingsService } & CachedServices,
@ -50,7 +45,6 @@ export function vaultTimeoutSettingsServiceFactory(
await tokenServiceFactory(cache, opts),
await policyServiceFactory(cache, opts),
await stateServiceFactory(cache, opts),
await userVerificationServiceFactory(cache, opts),
),
);
}

View File

@ -17,7 +17,7 @@ import {
} from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent } from "@bitwarden/auth";
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";

View File

@ -12,7 +12,8 @@
"paths": {
"@bitwarden/admin-console": ["../../libs/admin-console/src"],
"@bitwarden/angular/*": ["../../libs/angular/src/*"],
"@bitwarden/auth": ["../../libs/auth/src"],
"@bitwarden/auth/common": ["../../libs/auth/src/common"],
"@bitwarden/auth/angular": ["../../libs/auth/src/angular"],
"@bitwarden/billing": ["../../libs/billing/src"],
"@bitwarden/common/*": ["../../libs/common/src/*"],
"@bitwarden/components": ["../../libs/components/src"],

View File

@ -4,6 +4,7 @@ import * as path from "path";
import * as program from "commander";
import * as jsdom from "jsdom";
import { PinCryptoServiceAbstraction, PinCryptoService } from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@ -160,6 +161,7 @@ export class Main {
cipherFileUploadService: CipherFileUploadService;
keyConnectorService: KeyConnectorService;
userVerificationService: UserVerificationService;
pinCryptoService: PinCryptoServiceAbstraction;
stateService: StateService;
organizationService: OrganizationService;
providerService: ProviderService;
@ -433,11 +435,20 @@ export class Main {
const lockedCallback = async (userId?: string) =>
await this.cryptoService.clearStoredUserKey(KeySuffixOptions.Auto);
this.pinCryptoService = new PinCryptoService(
this.stateService,
this.cryptoService,
this.vaultTimeoutSettingsService,
this.logService,
);
this.userVerificationService = new UserVerificationService(
this.stateService,
this.cryptoService,
this.i18nService,
this.userVerificationApiService,
this.pinCryptoService,
this.logService,
);
this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService(
@ -445,7 +456,6 @@ export class Main {
this.tokenService,
this.policyService,
this.stateService,
this.userVerificationService,
);
this.vaultTimeoutService = new VaultTimeoutService(

View File

@ -13,6 +13,8 @@
"baseUrl": ".",
"paths": {
"@bitwarden/common/spec": ["../../libs/common/spec"],
"@bitwarden/auth/common": ["../../libs/auth/src/common"],
"@bitwarden/auth/angular": ["../../libs/auth/src/angular"],
"@bitwarden/common/*": ["../../libs/common/src/*"],
"@bitwarden/importer/core": ["../../libs/importer/src"],
"@bitwarden/exporter/*": ["../../libs/exporter/src/*"],

View File

@ -15,7 +15,7 @@ import { firstValueFrom, Subject, takeUntil } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent } from "@bitwarden/auth";
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";

View File

@ -4,7 +4,7 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
@ -33,7 +33,7 @@ import { UserVerificationComponent } from "../app/components/user-verification.c
})
export class DeleteAccountComponent {
deleteForm = this.formBuilder.group({
verification: undefined as Verification | undefined,
verification: undefined as VerificationWithSecret | undefined,
});
constructor(

View File

@ -6,6 +6,7 @@ import { of } from "rxjs";
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
@ -133,6 +134,10 @@ describe("LockComponent", () => {
provide: UserVerificationService,
useValue: mock<UserVerificationService>(),
},
{
provide: PinCryptoServiceAbstraction,
useValue: mock<PinCryptoServiceAbstraction>(),
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

View File

@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from "@angular/router";
import { switchMap } from "rxjs";
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
@ -56,6 +57,7 @@ export class LockComponent extends BaseLockComponent {
dialogService: DialogService,
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
userVerificationService: UserVerificationService,
pinCryptoService: PinCryptoServiceAbstraction,
) {
super(
router,
@ -76,6 +78,7 @@ export class LockComponent extends BaseLockComponent {
dialogService,
deviceTrustCryptoService,
userVerificationService,
pinCryptoService,
);
}

View File

@ -1392,6 +1392,9 @@
"invalidPin": {
"message": "Invalid PIN code."
},
"tooManyInvalidPinEntryAttemptsLoggingOut": {
"message": "Too many invalid PIN entry attempts. Logging out."
},
"unlockWithWindowsHello": {
"message": "Unlock with Windows Hello"
},

View File

@ -12,7 +12,8 @@
"paths": {
"@bitwarden/admin-console": ["../../libs/admin-console/src"],
"@bitwarden/angular/*": ["../../libs/angular/src/*"],
"@bitwarden/auth": ["../../libs/auth/src"],
"@bitwarden/auth/common": ["../../libs/auth/src/common"],
"@bitwarden/auth/angular": ["../../libs/auth/src/angular"],
"@bitwarden/billing": ["../../libs/billing/src"],
"@bitwarden/common/*": ["../../libs/common/src/*"],
"@bitwarden/components": ["../../libs/components/src"],

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { PasswordCalloutComponent } from "@bitwarden/auth";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { LooseComponentsModule } from "../../../shared";
import { SharedOrganizationModule } from "../shared";

View File

@ -1,6 +1,6 @@
import { inject, Injectable } from "@angular/core";
import { RotateableKeySet } from "@bitwarden/auth";
import { RotateableKeySet } from "@bitwarden/auth/common";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";

View File

@ -2,7 +2,7 @@ import { randomBytes } from "crypto";
import { mock, MockProxy } from "jest-mock-extended";
import { RotateableKeySet } from "@bitwarden/auth";
import { RotateableKeySet } from "@bitwarden/auth/common";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";

View File

@ -1,7 +1,7 @@
import { Injectable, Optional } from "@angular/core";
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";
import { PrfKeySet } from "@bitwarden/auth";
import { PrfKeySet } from "@bitwarden/auth/common";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view";

View File

@ -2,6 +2,7 @@ import { Component, NgZone } from "@angular/core";
import { Router } from "@angular/router";
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
@ -43,6 +44,7 @@ export class LockComponent extends BaseLockComponent {
dialogService: DialogService,
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
userVerificationService: UserVerificationService,
pinCryptoService: PinCryptoServiceAbstraction,
) {
super(
router,
@ -63,6 +65,7 @@ export class LockComponent extends BaseLockComponent {
dialogService,
deviceTrustCryptoService,
userVerificationService,
pinCryptoService,
);
}

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { PasswordCalloutComponent } from "@bitwarden/auth";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { SharedModule } from "../../shared";

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { PasswordCalloutComponent } from "@bitwarden/auth";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { SharedModule } from "../../shared";
import { EmergencyAccessModule } from "../emergency-access";

View File

@ -3,7 +3,7 @@ import { Component, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom, map, Observable } from "rxjs";
import { PrfKeySet } from "@bitwarden/auth";
import { PrfKeySet } from "@bitwarden/auth/common";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { PasswordCalloutComponent } from "@bitwarden/auth";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { OrganizationSwitcherComponent } from "../admin-console/components/organization-switcher.component";
import { OrganizationLayoutComponent } from "../admin-console/organizations/layouts/organization-layout.component";

View File

@ -7,7 +7,8 @@
"paths": {
"@bitwarden/admin-console": ["../../libs/admin-console/src"],
"@bitwarden/angular/*": ["../../libs/angular/src/*"],
"@bitwarden/auth": ["../../libs/auth/src"],
"@bitwarden/auth/common": ["../../libs/auth/src/common"],
"@bitwarden/auth/angular": ["../../libs/auth/src/angular"],
"@bitwarden/billing": ["../../libs/billing/src"],
"@bitwarden/common/*": ["../../libs/common/src/*"],
"@bitwarden/components": ["../../libs/components/src"],

View File

@ -3,6 +3,7 @@ import { Router } from "@angular/router";
import { firstValueFrom, Subject } from "rxjs";
import { concatMap, take, takeUntil } from "rxjs/operators";
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
@ -23,7 +24,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
@ -73,6 +73,7 @@ export class LockComponent implements OnInit, OnDestroy {
protected dialogService: DialogService,
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
protected userVerificationService: UserVerificationService,
protected pinCryptoService: PinCryptoServiceAbstraction,
) {}
async ngOnInit() {
@ -150,78 +151,41 @@ export class LockComponent implements OnInit, OnDestroy {
}
private async doUnlockWithPin() {
let failed = true;
const MAX_INVALID_PIN_ENTRY_ATTEMPTS = 5;
try {
const kdf = await this.stateService.getKdfType();
const kdfConfig = await this.stateService.getKdfConfig();
let userKeyPin: EncString;
let oldPinKey: EncString;
switch (this.pinStatus) {
case "PERSISTANT": {
userKeyPin = await this.stateService.getPinKeyEncryptedUserKey();
const oldEncryptedPinKey = await this.stateService.getEncryptedPinProtected();
oldPinKey = oldEncryptedPinKey ? new EncString(oldEncryptedPinKey) : undefined;
break;
}
case "TRANSIENT": {
userKeyPin = await this.stateService.getPinKeyEncryptedUserKeyEphemeral();
oldPinKey = await this.stateService.getDecryptedPinProtected();
break;
}
case "DISABLED": {
throw new Error("Pin is disabled");
}
default: {
const _exhaustiveCheck: never = this.pinStatus;
return _exhaustiveCheck;
}
}
const userKey = await this.pinCryptoService.decryptUserKeyWithPin(this.pin);
let userKey: UserKey;
if (oldPinKey) {
userKey = await this.cryptoService.decryptAndMigrateOldPinKey(
this.pinStatus === "TRANSIENT",
this.pin,
this.email,
kdf,
kdfConfig,
oldPinKey,
);
} else {
userKey = await this.cryptoService.decryptUserKeyWithPin(
this.pin,
this.email,
kdf,
kdfConfig,
userKeyPin,
);
}
const protectedPin = await this.stateService.getProtectedPin();
const decryptedPin = await this.cryptoService.decryptToUtf8(
new EncString(protectedPin),
userKey,
);
failed = decryptedPin !== this.pin;
if (!failed) {
if (userKey) {
await this.setUserKeyAndContinue(userKey);
return; // successfully unlocked
}
} catch {
failed = true;
}
if (failed) {
// Failure state: invalid PIN or failed decryption
this.invalidPinAttempts++;
if (this.invalidPinAttempts >= 5) {
// Log user out if they have entered an invalid PIN too many times
if (this.invalidPinAttempts >= MAX_INVALID_PIN_ENTRY_ATTEMPTS) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("tooManyInvalidPinEntryAttemptsLoggingOut"),
);
this.messagingService.send("logout");
return;
}
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("invalidPin"),
);
} catch {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("unexpectedError"),
);
}
}

View File

@ -9,7 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@ -31,7 +31,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent {
enforcedPolicyOptions: MasterPasswordPolicyOptions;
showPassword = false;
reason: ForceSetPasswordReason = ForceSetPasswordReason.None;
verification: Verification = {
verification: MasterPasswordVerification = {
type: VerificationType.MasterPassword,
secret: "",
};

View File

@ -1,5 +1,6 @@
import { LOCALE_ID, NgModule } from "@angular/core";
import { PinCryptoServiceAbstraction, PinCryptoService } from "@bitwarden/auth/common";
import { AvatarUpdateService as AccountUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@ -478,7 +479,6 @@ import { ModalService } from "./modal.service";
TokenServiceAbstraction,
PolicyServiceAbstraction,
StateServiceAbstraction,
UserVerificationServiceAbstraction,
],
},
{
@ -628,6 +628,8 @@ import { ModalService } from "./modal.service";
CryptoServiceAbstraction,
I18nServiceAbstraction,
UserVerificationApiServiceAbstraction,
PinCryptoServiceAbstraction,
LogService,
],
},
{
@ -769,6 +771,17 @@ import { ModalService } from "./modal.service";
useClass: AuthRequestCryptoServiceImplementation,
deps: [CryptoServiceAbstraction],
},
{
provide: PinCryptoServiceAbstraction,
useClass: PinCryptoService,
deps: [
StateServiceAbstraction,
CryptoServiceAbstraction,
VaultTimeoutSettingsServiceAbstraction,
LogService,
],
},
{
provide: WebAuthnLoginPrfCryptoServiceAbstraction,
useClass: WebAuthnLoginPrfCryptoService,

View File

@ -0,0 +1,5 @@
/**
* This barrel file should only contain Angular exports
*/
export * from "./fingerprint-dialog/fingerprint-dialog.component";
export * from "./password-callout/password-callout.component";

View File

@ -0,0 +1 @@
export * from "./pin-crypto.service.abstraction";

View File

@ -0,0 +1,4 @@
import { UserKey } from "../../../../common/src/platform/models/domain/symmetric-crypto-key";
export abstract class PinCryptoServiceAbstraction {
decryptUserKeyWithPin: (pin: string) => Promise<UserKey | null>;
}

View File

@ -0,0 +1,6 @@
/**
* This barrel file should only contain non-Angular exports
*/
export * from "./abstractions";
export * from "./models";
export * from "./services";

View File

@ -0,0 +1 @@
export * from "./pin-crypto/pin-crypto.service.implementation";

View File

@ -0,0 +1,106 @@
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { PinCryptoServiceAbstraction } from "../../abstractions/pin-crypto.service.abstraction";
export class PinCryptoService implements PinCryptoServiceAbstraction {
constructor(
private stateService: StateService,
private cryptoService: CryptoService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private logService: LogService,
) {}
async decryptUserKeyWithPin(pin: string): Promise<UserKey | null> {
try {
const pinLockType: PinLockType = await this.vaultTimeoutSettingsService.isPinLockSet();
const { pinKeyEncryptedUserKey, oldPinKeyEncryptedMasterKey } =
await this.getPinKeyEncryptedKeys(pinLockType);
const kdf: KdfType = await this.stateService.getKdfType();
const kdfConfig: KdfConfig = await this.stateService.getKdfConfig();
let userKey: UserKey;
const email = await this.stateService.getEmail();
if (oldPinKeyEncryptedMasterKey) {
userKey = await this.cryptoService.decryptAndMigrateOldPinKey(
pinLockType === "TRANSIENT",
pin,
email,
kdf,
kdfConfig,
oldPinKeyEncryptedMasterKey,
);
} else {
userKey = await this.cryptoService.decryptUserKeyWithPin(
pin,
email,
kdf,
kdfConfig,
pinKeyEncryptedUserKey,
);
}
if (!userKey) {
this.logService.error(`User key null after pin key decryption.`);
return null;
}
if (!(await this.validatePin(userKey, pin))) {
this.logService.error(`Pin key decryption successful but pin validation failed.`);
return null;
}
return userKey;
} catch (error) {
this.logService.error(`Error decrypting user key with pin: ${error}`);
return null;
}
}
// Note: oldPinKeyEncryptedMasterKey is only used for migrating old pin keys
// and will be null for all migrated accounts
private async getPinKeyEncryptedKeys(
pinLockType: PinLockType,
): Promise<{ pinKeyEncryptedUserKey: EncString; oldPinKeyEncryptedMasterKey?: EncString }> {
switch (pinLockType) {
case "PERSISTANT": {
const pinKeyEncryptedUserKey = await this.stateService.getPinKeyEncryptedUserKey();
const oldPinKeyEncryptedMasterKey = await this.stateService.getEncryptedPinProtected();
return {
pinKeyEncryptedUserKey,
oldPinKeyEncryptedMasterKey: oldPinKeyEncryptedMasterKey
? new EncString(oldPinKeyEncryptedMasterKey)
: undefined,
};
}
case "TRANSIENT": {
const pinKeyEncryptedUserKey = await this.stateService.getPinKeyEncryptedUserKeyEphemeral();
const oldPinKeyEncryptedMasterKey = await this.stateService.getDecryptedPinProtected();
return { pinKeyEncryptedUserKey, oldPinKeyEncryptedMasterKey };
}
case "DISABLED":
throw new Error("Pin is disabled");
default: {
// Compile-time check for exhaustive switch
const _exhaustiveCheck: never = pinLockType;
return _exhaustiveCheck;
}
}
}
private async validatePin(userKey: UserKey, pin: string): Promise<boolean> {
const protectedPin = await this.stateService.getProtectedPin();
const decryptedPin = await this.cryptoService.decryptToUtf8(
new EncString(protectedPin),
userKey,
);
return decryptedPin === pin;
}
}

View File

@ -0,0 +1,191 @@
import { mock } from "jest-mock-extended";
import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import {
SymmetricCryptoKey,
UserKey,
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import {
VaultTimeoutSettingsService,
PinLockType,
} from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { PinCryptoService } from "./pin-crypto.service.implementation";
describe("PinCryptoService", () => {
let pinCryptoService: PinCryptoService;
const stateService = mock<StateService>();
const cryptoService = mock<CryptoService>();
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
const logService = mock<LogService>();
beforeEach(() => {
jest.clearAllMocks();
pinCryptoService = new PinCryptoService(
stateService,
cryptoService,
vaultTimeoutSettingsService,
logService,
);
});
it("instantiates", () => {
expect(pinCryptoService).not.toBeFalsy();
});
describe("decryptUserKeyWithPin(...)", () => {
const mockPin = "1234";
const mockProtectedPin = "protectedPin";
const DEFAULT_PBKDF2_ITERATIONS = 600000;
const mockUserEmail = "user@example.com";
const mockUserKey = new SymmetricCryptoKey(randomBytes(32)) as UserKey;
function setupDecryptUserKeyWithPinMocks(
pinLockType: PinLockType,
migrationStatus: "PRE" | "POST" = "POST",
) {
vaultTimeoutSettingsService.isPinLockSet.mockResolvedValue(pinLockType);
stateService.getKdfConfig.mockResolvedValue(new KdfConfig(DEFAULT_PBKDF2_ITERATIONS));
stateService.getEmail.mockResolvedValue(mockUserEmail);
if (migrationStatus === "PRE") {
cryptoService.decryptAndMigrateOldPinKey.mockResolvedValue(mockUserKey);
} else {
cryptoService.decryptUserKeyWithPin.mockResolvedValue(mockUserKey);
}
mockPinEncryptedKeyDataByPinLockType(pinLockType, migrationStatus);
stateService.getProtectedPin.mockResolvedValue(mockProtectedPin);
cryptoService.decryptToUtf8.mockResolvedValue(mockPin);
}
// Note: both pinKeyEncryptedUserKeys use encryptionType: 2 (AesCbc256_HmacSha256_B64)
const pinKeyEncryptedUserKeyEphemeral = new EncString(
"2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY=",
);
const pinKeyEncryptedUserKeyPersistant = new EncString(
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=",
);
const oldPinKeyEncryptedMasterKeyPostMigration: any = null;
const oldPinKeyEncryptedMasterKeyPreMigrationPersistent =
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=";
const oldPinKeyEncryptedMasterKeyPreMigrationEphemeral = new EncString(
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=",
);
function mockPinEncryptedKeyDataByPinLockType(
pinLockType: PinLockType,
migrationStatus: "PRE" | "POST" = "POST",
) {
switch (pinLockType) {
case "PERSISTANT":
stateService.getPinKeyEncryptedUserKey.mockResolvedValue(
pinKeyEncryptedUserKeyPersistant,
);
if (migrationStatus === "PRE") {
stateService.getEncryptedPinProtected.mockResolvedValue(
oldPinKeyEncryptedMasterKeyPreMigrationPersistent,
);
} else {
stateService.getEncryptedPinProtected.mockResolvedValue(
oldPinKeyEncryptedMasterKeyPostMigration,
);
}
break;
case "TRANSIENT":
stateService.getPinKeyEncryptedUserKeyEphemeral.mockResolvedValue(
pinKeyEncryptedUserKeyEphemeral,
);
if (migrationStatus === "PRE") {
stateService.getDecryptedPinProtected.mockResolvedValue(
oldPinKeyEncryptedMasterKeyPreMigrationEphemeral,
);
} else {
stateService.getDecryptedPinProtected.mockResolvedValue(
oldPinKeyEncryptedMasterKeyPostMigration,
);
}
break;
case "DISABLED":
// no mocking required. Error should be thrown
break;
}
}
const testCases: { pinLockType: PinLockType; migrationStatus: "PRE" | "POST" }[] = [
{ pinLockType: "PERSISTANT", migrationStatus: "PRE" },
{ pinLockType: "PERSISTANT", migrationStatus: "POST" },
{ pinLockType: "TRANSIENT", migrationStatus: "PRE" },
{ pinLockType: "TRANSIENT", migrationStatus: "POST" },
];
testCases.forEach(({ pinLockType, migrationStatus }) => {
describe(`given a ${pinLockType} PIN (${migrationStatus} migration)`, () => {
it(`should successfully decrypt and return user key when using a valid PIN`, async () => {
// Arrange
setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
// Act
const result = await pinCryptoService.decryptUserKeyWithPin(mockPin);
// Assert
expect(result).toEqual(mockUserKey);
});
it(`should return null when PIN is incorrect and user key cannot be decrypted`, async () => {
// Arrange
setupDecryptUserKeyWithPinMocks("PERSISTANT");
cryptoService.decryptUserKeyWithPin.mockResolvedValue(null);
// Act
const result = await pinCryptoService.decryptUserKeyWithPin(mockPin);
// Assert
expect(result).toBeNull();
});
// 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
setupDecryptUserKeyWithPinMocks("PERSISTANT");
// non matching PIN
cryptoService.decryptToUtf8.mockResolvedValue("9999");
// Act
const result = await pinCryptoService.decryptUserKeyWithPin(mockPin);
// Assert
expect(result).toBeNull();
});
});
});
it(`should return null when pin is disabled`, async () => {
// Arrange
setupDecryptUserKeyWithPinMocks("DISABLED");
// Act
const result = await pinCryptoService.decryptUserKeyWithPin(mockPin);
// Assert
expect(result).toBeNull();
});
});
});
// Test helpers
function randomBytes(length: number): Uint8Array {
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
}

View File

@ -1,3 +0,0 @@
export * from "./components/fingerprint-dialog.component";
export * from "./password-callout/password-callout.component";
export * from "./models";

View File

@ -1,4 +1,6 @@
export enum VerificationType {
MasterPassword = 0,
OTP = 1,
PIN = 2,
Biometrics = 3,
}

View File

@ -1,12 +1,24 @@
import { PinCryptoServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin-crypto.service.abstraction";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { KeySuffixOptions } from "../../../platform/enums/key-suffix-options.enum";
import { UserKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
import { UserVerificationService as UserVerificationServiceAbstraction } from "../../abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "../../enums/verification-type";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { VerifyOTPRequest } from "../../models/request/verify-otp.request";
import { Verification } from "../../types/verification";
import {
MasterPasswordVerification,
OtpVerification,
PinVerification,
ServerSideVerification,
Verification,
VerificationWithSecret,
verificationHasSecret,
} from "../../types/verification";
/**
* Used for general-purpose user verification throughout the app.
@ -18,6 +30,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
private cryptoService: CryptoService,
private i18nService: I18nService,
private userVerificationApiService: UserVerificationApiServiceAbstraction,
private pinCryptoService: PinCryptoServiceAbstraction,
private logService: LogService,
) {}
/**
@ -27,11 +41,11 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
* @param alreadyHashed Whether the master password is already hashed
*/
async buildRequest<T extends SecretVerificationRequest>(
verification: Verification,
verification: ServerSideVerification,
requestClass?: new () => T,
alreadyHashed?: boolean,
) {
this.validateInput(verification);
this.validateSecretInput(verification);
const request =
requestClass != null ? new requestClass() : (new SecretVerificationRequest() as T);
@ -57,42 +71,87 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
}
/**
* Used to verify the Master Password client-side, or send the OTP to the server for verification (with no other data)
* Used to verify Master Password, PIN, or biometrics client-side, or send the OTP to the server for verification (with no other data)
* Generally used for client-side verification only.
* @param verification User-supplied verification data (Master Password or OTP)
* @param verification User-supplied verification data (OTP, MP, PIN, or biometrics)
*/
async verifyUser(verification: Verification): Promise<boolean> {
this.validateInput(verification);
if (verificationHasSecret(verification)) {
this.validateSecretInput(verification);
}
if (verification.type === VerificationType.OTP) {
const request = new VerifyOTPRequest(verification.secret);
try {
await this.userVerificationApiService.postAccountVerifyOTP(request);
} catch (e) {
throw new Error(this.i18nService.t("invalidVerificationCode"));
switch (verification.type) {
case VerificationType.OTP:
return this.verifyUserByOTP(verification);
case VerificationType.MasterPassword:
return this.verifyUserByMasterPassword(verification);
case VerificationType.PIN:
return this.verifyUserByPIN(verification);
break;
case VerificationType.Biometrics:
return this.verifyUserByBiometrics();
default: {
// Compile-time check for exhaustive switch
const _exhaustiveCheck: never = verification;
return _exhaustiveCheck;
}
} else {
let masterKey = await this.cryptoService.getMasterKey();
if (!masterKey) {
masterKey = await this.cryptoService.makeMasterKey(
verification.secret,
await this.stateService.getEmail(),
await this.stateService.getKdfType(),
await this.stateService.getKdfConfig(),
);
}
const passwordValid = await this.cryptoService.compareAndUpdateKeyHash(
verification.secret,
masterKey,
);
if (!passwordValid) {
throw new Error(this.i18nService.t("invalidMasterPassword"));
}
this.cryptoService.setMasterKey(masterKey);
}
}
private async verifyUserByOTP(verification: OtpVerification): Promise<boolean> {
const request = new VerifyOTPRequest(verification.secret);
try {
await this.userVerificationApiService.postAccountVerifyOTP(request);
} catch (e) {
throw new Error(this.i18nService.t("invalidVerificationCode"));
}
return true;
}
private async verifyUserByMasterPassword(
verification: MasterPasswordVerification,
): Promise<boolean> {
let masterKey = await this.cryptoService.getMasterKey();
if (!masterKey) {
masterKey = await this.cryptoService.makeMasterKey(
verification.secret,
await this.stateService.getEmail(),
await this.stateService.getKdfType(),
await this.stateService.getKdfConfig(),
);
}
const passwordValid = await this.cryptoService.compareAndUpdateKeyHash(
verification.secret,
masterKey,
);
if (!passwordValid) {
throw new Error(this.i18nService.t("invalidMasterPassword"));
}
// TODO: we should re-evaluate later on if user verification should have the side effect of modifying state. Probably not.
await this.cryptoService.setMasterKey(masterKey);
return true;
}
private async verifyUserByPIN(verification: PinVerification): Promise<boolean> {
const userKey = await this.pinCryptoService.decryptUserKeyWithPin(verification.secret);
return userKey != null;
}
private async verifyUserByBiometrics(): Promise<boolean> {
let userKey: UserKey;
// Biometrics crashes and doesn't return a value if the user cancels the prompt
try {
userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
} catch (e) {
this.logService.error(`Biometrics User Verification failed: ${e.message}`);
// So, any failures should be treated as a failed verification
return false;
}
return userKey != null;
}
async requestOTP() {
await this.userVerificationApiService.postAccountRequestOTP();
}
@ -121,12 +180,15 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
);
}
private validateInput(verification: Verification) {
private validateSecretInput(verification: VerificationWithSecret) {
if (verification?.secret == null || verification.secret === "") {
if (verification.type === VerificationType.OTP) {
throw new Error(this.i18nService.t("verificationCodeRequired"));
} else {
throw new Error(this.i18nService.t("masterPasswordRequired"));
switch (verification.type) {
case VerificationType.OTP:
throw new Error(this.i18nService.t("verificationCodeRequired"));
case VerificationType.MasterPassword:
throw new Error(this.i18nService.t("masterPasswordRequired"));
case VerificationType.PIN:
throw new Error(this.i18nService.t("pinRequired"));
}
}
}

View File

@ -1,6 +1,19 @@
import { VerificationType } from "../enums/verification-type";
export type Verification = {
type: VerificationType;
secret: string;
};
export type OtpVerification = { type: VerificationType.OTP; secret: string };
export type MasterPasswordVerification = { type: VerificationType.MasterPassword; secret: string };
export type PinVerification = { type: VerificationType.PIN; secret: string };
export type BiometricsVerification = { type: VerificationType.Biometrics };
export type VerificationWithSecret = OtpVerification | MasterPasswordVerification | PinVerification;
export type VerificationWithoutSecret = BiometricsVerification;
export type Verification = VerificationWithSecret | VerificationWithoutSecret;
export function verificationHasSecret(
verification: Verification,
): verification is VerificationWithSecret {
return "secret" in verification;
}
export type ServerSideVerification = OtpVerification | MasterPasswordVerification;

View File

@ -4,10 +4,10 @@ import { firstValueFrom } from "rxjs";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "../../admin-console/models/domain/policy";
import { TokenService } from "../../auth/abstractions/token.service";
import { UserVerificationService } from "../../auth/abstractions/user-verification/user-verification.service.abstraction";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { StateService } from "../../platform/abstractions/state.service";
import { AccountDecryptionOptions } from "../../platform/models/domain/account";
import { EncString } from "../../platform/models/domain/enc-string";
import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service";
@ -17,7 +17,6 @@ describe("VaultTimeoutSettingsService", () => {
let tokenService: MockProxy<TokenService>;
let policyService: MockProxy<PolicyService>;
let stateService: MockProxy<StateService>;
let userVerificationService: MockProxy<UserVerificationService>;
let service: VaultTimeoutSettingsService;
beforeEach(() => {
@ -25,13 +24,11 @@ describe("VaultTimeoutSettingsService", () => {
tokenService = mock<TokenService>();
policyService = mock<PolicyService>();
stateService = mock<StateService>();
userVerificationService = mock<UserVerificationService>();
service = new VaultTimeoutSettingsService(
cryptoService,
tokenService,
policyService,
stateService,
userVerificationService,
);
});
@ -43,7 +40,9 @@ describe("VaultTimeoutSettingsService", () => {
});
it("contains Lock when the user has a master password", async () => {
userVerificationService.hasMasterPassword.mockResolvedValue(true);
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: true }),
);
const result = await firstValueFrom(service.availableVaultTimeoutActions$());
@ -75,7 +74,9 @@ describe("VaultTimeoutSettingsService", () => {
});
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
userVerificationService.hasMasterPassword.mockResolvedValue(false);
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: false }),
);
stateService.getPinKeyEncryptedUserKey.mockResolvedValue(null);
stateService.getProtectedPin.mockResolvedValue(null);
stateService.getBiometricUnlock.mockResolvedValue(false);
@ -97,7 +98,9 @@ describe("VaultTimeoutSettingsService", () => {
`(
"returns $expected when policy is $policy, and user preference is $userPreference",
async ({ policy, userPreference, expected }) => {
userVerificationService.hasMasterPassword.mockResolvedValue(true);
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: true }),
);
policyService.policyAppliesToUser.mockResolvedValue(policy === null ? false : true);
policyService.getAll.mockResolvedValue(
policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[]),
@ -125,7 +128,9 @@ describe("VaultTimeoutSettingsService", () => {
"returns $expected when policy is $policy, has unlock method is $unlockMethod, and user preference is $userPreference",
async ({ unlockMethod, policy, userPreference, expected }) => {
stateService.getBiometricUnlock.mockResolvedValue(unlockMethod);
userVerificationService.hasMasterPassword.mockResolvedValue(false);
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: false }),
);
policyService.policyAppliesToUser.mockResolvedValue(policy === null ? false : true);
policyService.getAll.mockResolvedValue(
policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[]),

View File

@ -4,7 +4,6 @@ import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction }
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../admin-console/enums";
import { TokenService } from "../../auth/abstractions/token.service";
import { UserVerificationService } from "../../auth/abstractions/user-verification/user-verification.service.abstraction";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { StateService } from "../../platform/abstractions/state.service";
@ -22,7 +21,6 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private tokenService: TokenService,
private policyService: PolicyService,
private stateService: StateService,
private userVerificationService: UserVerificationService,
) {}
async setVaultTimeoutOptions(timeout: number, action: VaultTimeoutAction): Promise<void> {
@ -134,7 +132,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
if (vaultTimeoutAction == null) {
// Depends on whether or not the user has a master password
const defaultValue = (await this.userVerificationService.hasMasterPassword())
const defaultValue = (await this.userHasMasterPassword(userId))
? VaultTimeoutAction.Lock
: VaultTimeoutAction.LogOut;
// We really shouldn't need to set the value here, but multiple services relies on this value being correct.
@ -151,7 +149,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
const availableActions = [VaultTimeoutAction.LogOut];
const canLock =
(await this.userVerificationService.hasMasterPassword(userId)) ||
(await this.userHasMasterPassword(userId)) ||
(await this.isPinLockSet(userId)) !== "DISABLED" ||
(await this.isBiometricLockSet(userId));
@ -166,4 +164,14 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
await this.stateService.setEverBeenUnlocked(false, { userId: userId });
await this.cryptoService.clearPinKeys(userId);
}
private async userHasMasterPassword(userId: string): Promise<boolean> {
const acctDecryptionOpts = await this.stateService.getAccountDecryptionOptions({
userId: userId,
});
if (acctDecryptionOpts?.hasMasterPassword != undefined) {
return acctDecryptionOpts.hasMasterPassword;
}
}
}

View File

@ -4,7 +4,8 @@
"paths": {
"@bitwarden/admin-console": ["../admin-console/src"],
"@bitwarden/angular/*": ["../angular/src/*"],
"@bitwarden/auth": ["../auth/src"],
"@bitwarden/auth/common": ["../auth/src/common"],
"@bitwarden/auth/angular": ["../auth/src/angular"],
"@bitwarden/billing": ["../billing/src"],
"@bitwarden/common/*": ["../common/src/*"],
"@bitwarden/components": ["../components/src"],

View File

@ -17,7 +17,8 @@
"paths": {
"@bitwarden/admin-console": ["./libs/admin-console/src"],
"@bitwarden/angular/*": ["./libs/angular/src/*"],
"@bitwarden/auth": ["./libs/auth/src"],
"@bitwarden/auth/common": ["./libs/auth/src/common"],
"@bitwarden/auth/angular": ["./libs/auth/src/angular"],
"@bitwarden/billing": ["./libs/billing/src"],
"@bitwarden/common/*": ["./libs/common/src/*"],
"@bitwarden/components": ["./libs/components/src"],