diff --git a/apps/browser/package.json b/apps/browser/package.json index d06eadf58d4..ee6d100572d 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.3.1", + "version": "2024.4.1", "scripts": { "build": "webpack", "build:mv3": "cross-env MANIFEST_VERSION=3 webpack", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d802d277001..9d444ce40ef 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3005,5 +3005,8 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index e574d8e2e71..2d8286307be 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -95,10 +95,10 @@ "message": "Auto-fill login" }, "autoFillCard": { - "message": "Auto-fill card" + "message": "Auto-bete txartela" }, "autoFillIdentity": { - "message": "Auto-fill identity" + "message": "Auto-bete nortasuna" }, "generatePasswordCopied": { "message": "Sortu pasahitza (kopiatuta)" @@ -110,19 +110,19 @@ "message": "Bat datozen saio-hasierarik gabe" }, "noCards": { - "message": "No cards" + "message": "Txartelik ez" }, "noIdentities": { - "message": "No identities" + "message": "Nortasunik ez" }, "addLoginMenu": { "message": "Add login" }, "addCardMenu": { - "message": "Add card" + "message": "Gehitu txartela" }, "addIdentityMenu": { - "message": "Add identity" + "message": "Gehitu nortasuna" }, "unlockVaultMenu": { "message": "Desblokeatu kutxa gotorra" @@ -223,10 +223,10 @@ "message": "Bitwarden Laguntza zentroa" }, "communityForums": { - "message": "Explore Bitwarden community forums" + "message": "Esploratu Bitwarden komunitatearen foroak" }, "contactSupport": { - "message": "Contact Bitwarden support" + "message": "Jarri harremanetan Bitwardeneko laguntza taldearekin" }, "sync": { "message": "Sinkronizatu" @@ -269,7 +269,7 @@ "message": "Luzera" }, "passwordMinLength": { - "message": "Minimum password length" + "message": "Pasahitzaren gutxieneko luzera" }, "uppercase": { "message": "Letra larria (A-Z)" @@ -1064,7 +1064,7 @@ "message": "Edit browser settings." }, "autofillOverlayVisibilityOff": { - "message": "Off", + "message": "Itzalita", "description": "Overlay setting select option for disabling autofill overlay" }, "autofillOverlayVisibilityOnFieldFocus": { @@ -1592,10 +1592,10 @@ "message": "Ezarri pasahitz nagusia" }, "currentMasterPass": { - "message": "Current master password" + "message": "Oraingo pasahitz nagusia" }, "newMasterPass": { - "message": "New master password" + "message": "Pasahitz nagusi berria" }, "confirmNewMasterPass": { "message": "Confirm new master password" @@ -2266,10 +2266,10 @@ "message": "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device." }, "resendNotification": { - "message": "Resend notification" + "message": "Berbidali jakinarazpena" }, "viewAllLoginOptions": { - "message": "View all log in options" + "message": "Ikusi erregistro guztiak ezarpenetan" }, "notificationSentDevice": { "message": "A notification has been sent to your device." @@ -2293,13 +2293,13 @@ "message": "Check known data breaches for this password" }, "important": { - "message": "Important:" + "message": "Garrantzitsua:" }, "masterPasswordHint": { "message": "Your master password cannot be recovered if you forget it!" }, "characterMinimum": { - "message": "$LENGTH$ character minimum", + "message": "$LENGTH$ karaktere gutxienez", "placeholders": { "length": { "content": "$1", @@ -2326,7 +2326,7 @@ "message": "Select an item from this screen, or explore other options in settings." }, "gotIt": { - "message": "Got it" + "message": "Ulertuta" }, "autofillSettings": { "message": "Auto-fill settings" @@ -2359,25 +2359,25 @@ "message": "Logging in on" }, "opensInANewWindow": { - "message": "Opens in a new window" + "message": "Leiho berri batean irekitzen da" }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Gogoratu gailu hau" }, "uncheckIfPublicDevice": { "message": "Uncheck if using a public device" }, "approveFromYourOtherDevice": { - "message": "Approve from your other device" + "message": "Onartu zure beste gailutik" }, "requestAdminApproval": { - "message": "Request admin approval" + "message": "Eskatu administratzailearen onarpena" }, "approveWithMasterPassword": { - "message": "Approve with master password" + "message": "Onartu pasahitz nagusiarekin" }, "ssoIdentifierRequired": { "message": "Organization SSO identifier is required." @@ -2390,31 +2390,31 @@ "message": "Access denied. You do not have permission to view this page." }, "general": { - "message": "General" + "message": "Orokorra" }, "display": { - "message": "Display" + "message": "Bistaratzea" }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Kontua zuzen sortu da!" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "Administratzailearen onarpena eskatuta" }, "adminApprovalRequestSentToAdmins": { - "message": "Your request has been sent to your admin." + "message": "Zure eskaera zure administratzaileari bidali zaio." }, "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." + "message": "Jakinaraziko zaizu onartzen denean." }, "troubleLoggingIn": { - "message": "Trouble logging in?" + "message": "Arazoak saioa hasterakoan?" }, "loginApproved": { "message": "Login approved" }, "userEmailMissing": { - "message": "User email missing" + "message": "Erabiltzailearen emaila falta da" }, "deviceTrusted": { "message": "Device trusted" @@ -2540,19 +2540,19 @@ "description": "Notification button text for starting a fileless import." }, "importing": { - "message": "Importing...", + "message": "Inportatzen...", "description": "Notification message for when an import is in progress." }, "dataSuccessfullyImported": { - "message": "Data successfully imported!", + "message": "Datuak zuzen inportatu dira!", "description": "Notification message for when an import has completed successfully." }, "dataImportFailed": { - "message": "Error importing. Check console for details.", + "message": "Errorea gertatu da inportatzean. Begiratu xehetasunak kontsolan.", "description": "Notification message for when an import has failed." }, "importNetworkError": { - "message": "Network error encountered during import.", + "message": "Sareko errorea gertatu da inportatzerakoan.", "description": "Notification message for when an import has failed due to a network error." }, "aliasDomain": { @@ -2602,11 +2602,11 @@ "description": "Screen reader text for when a login item is focused where a partial username is displayed. SR will announce this phrase before reading the text of the partial username" }, "noItemsToShow": { - "message": "No items to show", + "message": "Ez dago elementurik erakusteko", "description": "Text to show in overlay if there are no matching items" }, "newItem": { - "message": "New item", + "message": "Elementu berria", "description": "Button text to display in overlay when there are no matching items" }, "addNewVaultItem": { @@ -2618,32 +2618,32 @@ "description": "Screen reader text for announcing when the overlay opens on the page" }, "turnOn": { - "message": "Turn on" + "message": "Piztu" }, "ignore": { - "message": "Ignore" + "message": "Ezikusi" }, "importData": { - "message": "Import data", + "message": "Inportatu datuak", "description": "Used for the header of the import dialog, the import button and within the file-password-prompt" }, "importError": { - "message": "Import error" + "message": "Errorea inportatzerakoan" }, "importErrorDesc": { - "message": "There was a problem with the data you tried to import. Please resolve the errors listed below in your source file and try again." + "message": "Inportatzen saiatu zaren datuekin arazo bat egon da. Mesedez, konpondu ondoren adierazten diren akatsak eta saiatu berriro." }, "resolveTheErrorsBelowAndTryAgain": { - "message": "Resolve the errors below and try again." + "message": "Konpondu beheko akatsak eta saiatu berriro." }, "description": { - "message": "Description" + "message": "Deskribapena" }, "importSuccess": { - "message": "Data successfully imported" + "message": "Datuak zuzen inportatu dira" }, "importSuccessNumberOfItems": { - "message": "A total of $AMOUNT$ items were imported.", + "message": "Guztira $AMOUNT$ elementu inportatu dira.", "placeholders": { "amount": { "content": "$1", @@ -2652,7 +2652,7 @@ } }, "tryAgain": { - "message": "Try again" + "message": "Saiatu berriro" }, "verificationRequiredForActionSetPinToContinue": { "message": "Verification required for this action. Set a PIN to continue." @@ -2661,10 +2661,10 @@ "message": "Set PIN" }, "verifyWithBiometrics": { - "message": "Verify with biometrics" + "message": "Egiaztatu biometria erabiliz" }, "awaitingConfirmation": { - "message": "Awaiting confirmation" + "message": "Baieztapenaren zain" }, "couldNotCompleteBiometrics": { "message": "Could not complete biometrics." @@ -2673,13 +2673,13 @@ "message": "Need a different method?" }, "useMasterPassword": { - "message": "Use master password" + "message": "Erabili pasahitz nagusia" }, "usePin": { - "message": "Use PIN" + "message": "Erabili PIN kodea" }, "useBiometrics": { - "message": "Use biometrics" + "message": "Erabili biometria" }, "enterVerificationCodeSentToEmail": { "message": "Enter the verification code that was sent to your email." @@ -2688,10 +2688,10 @@ "message": "Resend code" }, "total": { - "message": "Total" + "message": "Guztira" }, "importWarning": { - "message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?", + "message": "$ORGANIZATION$(e)ra datuak inportatzen ari zara. Zure datuak erakunde horretako kideekin parteka daitezke. Jarraitu nahi duzu?", "placeholders": { "organization": { "content": "$1", @@ -2810,7 +2810,7 @@ "message": "You do not have a matching login for this site." }, "confirm": { - "message": "Confirm" + "message": "Berretsi" }, "savePasskey": { "message": "Save passkey" diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 591f12421c2..13c6d119f9f 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -11,7 +11,7 @@ "description": "Extension description" }, "loginOrCreateNewAccount": { - "message": "Käytä salattua holviasi kirjautumalla sisään tai tai luo uusi tili." + "message": "Käytä salattua holviasi kirjautumalla sisään tai luo uusi tili." }, "createAccount": { "message": "Luo tili" @@ -802,7 +802,7 @@ "message": "Lue lisää" }, "authenticatorKeyTotp": { - "message": "Todennusaavain (TOTP)" + "message": "Todennusavain (TOTP)" }, "verificationCodeTotp": { "message": "Todennuskoodi (TOTP)" diff --git a/apps/browser/src/auth/background/service-factories/auth-request-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-request-service.factory.ts index bd96a211ba4..295fedbadd0 100644 --- a/apps/browser/src/auth/background/service-factories/auth-request-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/auth-request-service.factory.ts @@ -17,18 +17,21 @@ import { FactoryOptions, factory, } from "../../../platform/background/service-factories/factory-options"; + +import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory"; import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "./master-password-service.factory"; type AuthRequestServiceFactoryOptions = FactoryOptions; export type AuthRequestServiceInitOptions = AuthRequestServiceFactoryOptions & AppIdServiceInitOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & CryptoServiceInitOptions & - ApiServiceInitOptions & - StateServiceInitOptions; + ApiServiceInitOptions; export function authRequestServiceFactory( cache: { authRequestService?: AuthRequestServiceAbstraction } & CachedServices, @@ -41,9 +44,10 @@ export function authRequestServiceFactory( async () => new AuthRequestService( await appIdServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts index 4a0dd07b322..c602acadaed 100644 --- a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts @@ -31,6 +31,11 @@ import { StateProviderInitOptions, } from "../../../platform/background/service-factories/state-provider.factory"; +import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "./master-password-service.factory"; import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.factory"; type KeyConnectorServiceFactoryOptions = FactoryOptions & { @@ -40,6 +45,8 @@ type KeyConnectorServiceFactoryOptions = FactoryOptions & { }; export type KeyConnectorServiceInitOptions = KeyConnectorServiceFactoryOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & TokenServiceInitOptions & @@ -58,6 +65,8 @@ export function keyConnectorServiceFactory( opts, async () => new KeyConnectorService( + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await tokenServiceFactory(cache, opts), diff --git a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts index 2cc4692ca9c..f184072cce5 100644 --- a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts @@ -59,6 +59,7 @@ import { PasswordStrengthServiceInitOptions, } from "../../../tools/background/service_factories/password-strength-service.factory"; +import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory"; import { authRequestServiceFactory, AuthRequestServiceInitOptions, @@ -71,6 +72,10 @@ import { keyConnectorServiceFactory, KeyConnectorServiceInitOptions, } from "./key-connector-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "./master-password-service.factory"; import { tokenServiceFactory, TokenServiceInitOptions } from "./token-service.factory"; import { twoFactorServiceFactory, TwoFactorServiceInitOptions } from "./two-factor-service.factory"; import { @@ -81,6 +86,8 @@ import { type LoginStrategyServiceFactoryOptions = FactoryOptions; export type LoginStrategyServiceInitOptions = LoginStrategyServiceFactoryOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & TokenServiceInitOptions & @@ -111,6 +118,8 @@ export function loginStrategyServiceFactory( opts, async () => new LoginStrategyService( + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await tokenServiceFactory(cache, opts), diff --git a/apps/browser/src/auth/background/service-factories/master-password-service.factory.ts b/apps/browser/src/auth/background/service-factories/master-password-service.factory.ts new file mode 100644 index 00000000000..a2f9052a3f2 --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/master-password-service.factory.ts @@ -0,0 +1,42 @@ +import { + InternalMasterPasswordServiceAbstraction, + MasterPasswordServiceAbstraction, +} from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; + +import { + CachedServices, + factory, + FactoryOptions, +} from "../../../platform/background/service-factories/factory-options"; +import { + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; + +type MasterPasswordServiceFactoryOptions = FactoryOptions; + +export type MasterPasswordServiceInitOptions = MasterPasswordServiceFactoryOptions & + StateProviderInitOptions; + +export function internalMasterPasswordServiceFactory( + cache: { masterPasswordService?: InternalMasterPasswordServiceAbstraction } & CachedServices, + opts: MasterPasswordServiceInitOptions, +): Promise { + return factory( + cache, + "masterPasswordService", + opts, + async () => new MasterPasswordService(await stateProviderFactory(cache, opts)), + ); +} + +export async function masterPasswordServiceFactory( + cache: { masterPasswordService?: InternalMasterPasswordServiceAbstraction } & CachedServices, + opts: MasterPasswordServiceInitOptions, +): Promise { + return (await internalMasterPasswordServiceFactory( + cache, + opts, + )) as MasterPasswordServiceAbstraction; +} diff --git a/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts b/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts index e8be9099cac..a8b67b21cac 100644 --- a/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts @@ -31,6 +31,11 @@ import { stateServiceFactory, } from "../../../platform/background/service-factories/state-service.factory"; +import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "./master-password-service.factory"; import { PinCryptoServiceInitOptions, pinCryptoServiceFactory } from "./pin-crypto-service.factory"; import { userDecryptionOptionsServiceFactory, @@ -46,6 +51,8 @@ type UserVerificationServiceFactoryOptions = FactoryOptions; export type UserVerificationServiceInitOptions = UserVerificationServiceFactoryOptions & StateServiceInitOptions & CryptoServiceInitOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & I18nServiceInitOptions & UserVerificationApiServiceInitOptions & UserDecryptionOptionsServiceInitOptions & @@ -66,6 +73,8 @@ export function userVerificationServiceFactory( new UserVerificationService( await stateServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await userVerificationApiServiceFactory(cache, opts), await userDecryptionOptionsServiceFactory(cache, opts), diff --git a/apps/browser/src/auth/popup/lock.component.ts b/apps/browser/src/auth/popup/lock.component.ts index f232eca45a7..16c32337cf5 100644 --- a/apps/browser/src/auth/popup/lock.component.ts +++ b/apps/browser/src/auth/popup/lock.component.ts @@ -12,6 +12,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -41,6 +42,7 @@ export class LockComponent extends BaseLockComponent { fido2PopoutSessionData$ = fido2PopoutSessionData$(); constructor( + masterPasswordService: InternalMasterPasswordServiceAbstraction, router: Router, i18nService: I18nService, platformUtilsService: PlatformUtilsService, @@ -66,6 +68,7 @@ export class LockComponent extends BaseLockComponent { accountService: AccountService, ) { super( + masterPasswordService, router, i18nService, platformUtilsService, diff --git a/apps/browser/src/auth/popup/set-password.component.ts b/apps/browser/src/auth/popup/set-password.component.ts index ea1cacc7ac5..accde2e9a09 100644 --- a/apps/browser/src/auth/popup/set-password.component.ts +++ b/apps/browser/src/auth/popup/set-password.component.ts @@ -1,65 +1,9 @@ import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; -import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService } from "@bitwarden/components"; @Component({ selector: "app-set-password", templateUrl: "set-password.component.html", }) -export class SetPasswordComponent extends BaseSetPasswordComponent { - constructor( - apiService: ApiService, - i18nService: I18nService, - cryptoService: CryptoService, - messagingService: MessagingService, - stateService: StateService, - passwordGenerationService: PasswordGenerationServiceAbstraction, - platformUtilsService: PlatformUtilsService, - policyApiService: PolicyApiServiceAbstraction, - policyService: PolicyService, - router: Router, - syncService: SyncService, - route: ActivatedRoute, - organizationApiService: OrganizationApiServiceAbstraction, - organizationUserService: OrganizationUserService, - userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, - ssoLoginService: SsoLoginServiceAbstraction, - dialogService: DialogService, - ) { - super( - i18nService, - cryptoService, - messagingService, - passwordGenerationService, - platformUtilsService, - policyApiService, - policyService, - router, - apiService, - syncService, - route, - stateService, - organizationApiService, - organizationUserService, - userDecryptionOptionsService, - ssoLoginService, - dialogService, - ); - } -} +export class SetPasswordComponent extends BaseSetPasswordComponent {} diff --git a/apps/browser/src/auth/popup/sso.component.ts b/apps/browser/src/auth/popup/sso.component.ts index 228c7401fda..14df0d17521 100644 --- a/apps/browser/src/auth/popup/sso.component.ts +++ b/apps/browser/src/auth/popup/sso.component.ts @@ -9,7 +9,9 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -45,7 +47,9 @@ export class SsoComponent extends BaseSsoComponent { logService: LogService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, configService: ConfigService, - protected authService: AuthService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, + private authService: AuthService, @Inject(WINDOW) private win: Window, ) { super( @@ -63,6 +67,8 @@ export class SsoComponent extends BaseSsoComponent { logService, userDecryptionOptionsService, configService, + masterPasswordService, + accountService, ); environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => { diff --git a/apps/browser/src/auth/popup/two-factor.component.ts b/apps/browser/src/auth/popup/two-factor.component.ts index 9bac3366957..98363bc93cc 100644 --- a/apps/browser/src/auth/popup/two-factor.component.ts +++ b/apps/browser/src/auth/popup/two-factor.component.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -58,6 +60,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { configService: ConfigService, ssoLoginService: SsoLoginServiceAbstraction, private dialogService: DialogService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, @Inject(WINDOW) protected win: Window, private browserMessagingApi: ZonedMessageListenerService, ) { @@ -78,6 +82,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { userDecryptionOptionsService, ssoLoginService, configService, + masterPasswordService, + accountService, ); super.onSuccessfulLogin = async () => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index 2fe8496b8db..af67d416015 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -8,7 +8,6 @@ import { generateRandomCustomElementName, sendExtensionMessage, setElementStyles, - getFromLocalStorage, setupExtensionDisconnectAction, setupAutofillInitDisconnectAction, } from "./index"; @@ -124,33 +123,6 @@ describe("setElementStyles", () => { }); }); -describe("getFromLocalStorage", () => { - it("returns a promise with the storage object pulled from the extension storage api", async () => { - const localStorage: Record = { - testValue: "test", - another: "another", - }; - jest.spyOn(chrome.storage.local, "get").mockImplementation((keys, callback) => { - const localStorageObject: Record = {}; - - if (typeof keys === "string") { - localStorageObject[keys] = localStorage[keys]; - } else if (Array.isArray(keys)) { - for (const key of keys) { - localStorageObject[key] = localStorage[key]; - } - } - - callback(localStorageObject); - }); - - const returnValue = await getFromLocalStorage("testValue"); - - expect(chrome.storage.local.get).toHaveBeenCalled(); - expect(returnValue).toEqual({ testValue: "test" }); - }); -}); - describe("setupExtensionDisconnectAction", () => { afterEach(() => { jest.clearAllMocks(); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 1d7aa3ae308..8a51aa10378 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -106,18 +106,6 @@ function setElementStyles( } } -/** - * Get data from local storage based on the keys provided. - * - * @param keys - String or array of strings of keys to get from local storage - * @deprecated Do not call this, use state-relevant services instead - */ -async function getFromLocalStorage(keys: string | string[]): Promise> { - return new Promise((resolve) => { - chrome.storage.local.get(keys, (storage: Record) => resolve(storage)); - }); -} - /** * Sets up a long-lived connection with the extension background * and triggers an onDisconnect event if the extension context @@ -279,7 +267,6 @@ export { buildSvgDomElement, sendExtensionMessage, setElementStyles, - getFromLocalStorage, setupExtensionDisconnectAction, setupAutofillInitDisconnectAction, elementIsFillableFormField, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 2affc7a0d6f..54072153f5e 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -10,6 +10,7 @@ import { AuthRequestServiceAbstraction, AuthRequestService, LoginEmailServiceAbstraction, + LoginEmailService, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -32,6 +33,7 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; @@ -46,6 +48,7 @@ import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; +import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; @@ -232,7 +235,7 @@ import RuntimeBackground from "./runtime.background"; export default class MainBackground { messagingService: MessagingServiceAbstraction; - storageService: AbstractStorageService & ObservableStorageService; + storageService: BrowserLocalStorageService; secureStorageService: AbstractStorageService; memoryStorageService: AbstractMemoryStorageService; memoryStorageForStateProviders: AbstractMemoryStorageService & ObservableStorageService; @@ -242,6 +245,7 @@ export default class MainBackground { keyGenerationService: KeyGenerationServiceAbstraction; cryptoService: CryptoServiceAbstraction; cryptoFunctionService: CryptoFunctionServiceAbstraction; + masterPasswordService: InternalMasterPasswordServiceAbstraction; tokenService: TokenServiceAbstraction; appIdService: AppIdServiceAbstraction; apiService: ApiServiceAbstraction; @@ -361,9 +365,10 @@ export default class MainBackground { const logoutCallback = async (expired: boolean, userId?: UserId) => await this.logout(expired, userId); - this.messagingService = this.popupOnlyContext - ? new BrowserMessagingPrivateModeBackgroundService() - : new BrowserMessagingService(); + this.messagingService = + this.isPrivateMode && BrowserApi.isManifestVersion(2) + ? new BrowserMessagingPrivateModeBackgroundService() + : new BrowserMessagingService(); this.logService = new ConsoleLogService(false); this.cryptoFunctionService = new WebCryptoFunctionService(self); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); @@ -405,13 +410,14 @@ export default class MainBackground { storageServiceProvider, ); - this.encryptService = flagEnabled("multithreadDecryption") - ? new MultithreadEncryptServiceImplementation( - this.cryptoFunctionService, - this.logService, - true, - ) - : new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true); + this.encryptService = + flagEnabled("multithreadDecryption") && BrowserApi.isManifestVersion(2) + ? new MultithreadEncryptServiceImplementation( + this.cryptoFunctionService, + this.logService, + true, + ) + : new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true); this.singleUserStateProvider = new DefaultSingleUserStateProvider( storageServiceProvider, @@ -480,8 +486,11 @@ export default class MainBackground { const themeStateService = new DefaultThemeStateService(this.globalStateProvider); + this.masterPasswordService = new MasterPasswordService(this.stateProvider); + this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); this.cryptoService = new BrowserCryptoService( + this.masterPasswordService, this.keyGenerationService, this.cryptoFunctionService, this.encryptService, @@ -508,7 +517,7 @@ export default class MainBackground { this.apiService, this.fileUploadService, ); - this.searchService = new SearchService(this.logService, this.i18nService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.collectionService = new CollectionService( this.cryptoService, @@ -525,6 +534,8 @@ export default class MainBackground { this.badgeSettingsService = new BadgeSettingsService(this.stateProvider); this.policyApiService = new PolicyApiService(this.policyService, this.apiService); this.keyConnectorService = new KeyConnectorService( + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -550,10 +561,13 @@ export default class MainBackground { const backgroundMessagingService = new (class extends MessagingServiceAbstraction { // AuthService should send the messages to the background not popup. send = (subscriber: string, arg: any = {}) => { + if (BrowserApi.isManifestVersion(3)) { + that.messagingService.send(subscriber, arg); + return; + } + const message = Object.assign({}, { command: subscriber }, arg); - // 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 - that.runtimeBackground.processMessage(message, that as any); + void that.runtimeBackground.processMessage(message, that as any); }; })(); @@ -578,9 +592,10 @@ export default class MainBackground { this.authRequestService = new AuthRequestService( this.appIdService, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, - this.stateService, ); this.authService = new AuthService( @@ -596,7 +611,11 @@ export default class MainBackground { this.stateProvider, ); + this.loginEmailService = new LoginEmailService(this.stateProvider); + this.loginStrategyService = new LoginStrategyService( + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -672,6 +691,8 @@ export default class MainBackground { this.userVerificationService = new UserVerificationService( this.stateService, this.cryptoService, + this.accountService, + this.masterPasswordService, this.i18nService, this.userVerificationApiService, this.userDecryptionOptionsService, @@ -694,6 +715,8 @@ export default class MainBackground { this.vaultSettingsService = new VaultSettingsService(this.stateProvider); this.vaultTimeoutService = new VaultTimeoutService( + this.accountService, + this.masterPasswordService, this.cipherService, this.folderService, this.collectionService, @@ -729,6 +752,8 @@ export default class MainBackground { this.providerService = new ProviderService(this.stateProvider); this.syncService = new SyncService( + this.masterPasswordService, + this.accountService, this.apiService, this.domainSettingsService, this.folderService, @@ -878,6 +903,8 @@ export default class MainBackground { this.fido2Service, ); this.nativeMessagingBackground = new NativeMessagingBackground( + this.accountService, + this.masterPasswordService, this.cryptoService, this.cryptoFunctionService, this.runtimeBackground, @@ -1108,7 +1135,7 @@ export default class MainBackground { const status = await this.authService.getAuthStatus(userId); const forcePasswordReset = - (await this.stateService.getForceSetPasswordReason({ userId: userId })) != + (await firstValueFrom(this.masterPasswordService.forceSetPasswordReason$(userId))) != ForceSetPasswordReason.None; await this.systemService.clearPendingClipboard(); @@ -1159,10 +1186,10 @@ export default class MainBackground { const newActiveUser = await this.stateService.clean({ userId: userId }); if (userId == null || userId === currentUserId) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); } - await this.stateEventRunnerService.handleEvent("logout", currentUserId as UserId); + await this.stateEventRunnerService.handleEvent("logout", userId); if (newActiveUser != null) { // we have a new active user, do not continue tearing down application @@ -1237,18 +1264,8 @@ export default class MainBackground { return; } - const getStorage = (): Promise => - new Promise((resolve) => { - chrome.storage.local.get(null, (o: any) => resolve(o)); - }); - - const clearStorage = (): Promise => - new Promise((resolve) => { - chrome.storage.local.clear(() => resolve()); - }); - - const storage = await getStorage(); - await clearStorage(); + const storage = await this.storageService.getAll(); + await this.storageService.clear(); for (const key in storage) { // eslint-disable-next-line diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 240fb1dedea..faf2e6e2cc9 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -1,6 +1,8 @@ import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -71,6 +73,8 @@ export class NativeMessagingBackground { private validatingFingerprint: boolean; constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cryptoService: CryptoService, private cryptoFunctionService: CryptoFunctionService, private runtimeBackground: RuntimeBackground, @@ -336,10 +340,14 @@ export class NativeMessagingBackground { ) as UserKey; await this.cryptoService.setUserKey(userKey); } else if (message.keyB64) { + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; // Backwards compatibility to support cases in which the user hasn't updated their desktop app // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3472) - let encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey(); - encUserKey ||= await this.stateService.getMasterKeyEncryptedUserKey(); + const encUserKeyPrim = await this.stateService.getEncryptedCryptoSymmetricKey(); + const encUserKey = + encUserKeyPrim != null + ? new EncString(encUserKeyPrim) + : await this.masterPasswordService.getMasterKeyEncryptedUserKey(userId); if (!encUserKey) { throw new Error("No encrypted user key found"); } @@ -348,9 +356,9 @@ export class NativeMessagingBackground { ) as MasterKey; const userKey = await this.cryptoService.decryptUserKeyWithMasterKey( masterKey, - new EncString(encUserKey), + encUserKey, ); - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, userId); await this.cryptoService.setUserKey(userKey); } else { throw new Error("No key received"); diff --git a/apps/browser/src/background/service-factories/search-service.factory.ts b/apps/browser/src/background/service-factories/search-service.factory.ts index 38c7620b5aa..aa83d2afd29 100644 --- a/apps/browser/src/background/service-factories/search-service.factory.ts +++ b/apps/browser/src/background/service-factories/search-service.factory.ts @@ -14,12 +14,17 @@ import { logServiceFactory, LogServiceInitOptions, } from "../../platform/background/service-factories/log-service.factory"; +import { + stateProviderFactory, + StateProviderInitOptions, +} from "../../platform/background/service-factories/state-provider.factory"; type SearchServiceFactoryOptions = FactoryOptions; export type SearchServiceInitOptions = SearchServiceFactoryOptions & LogServiceInitOptions & - I18nServiceInitOptions; + I18nServiceInitOptions & + StateProviderInitOptions; export function searchServiceFactory( cache: { searchService?: AbstractSearchService } & CachedServices, @@ -33,6 +38,7 @@ export function searchServiceFactory( new SearchService( await logServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts b/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts index 0e4d1420da5..14f055114bc 100644 --- a/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts +++ b/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts @@ -1,9 +1,17 @@ import { VaultTimeoutService as AbstractVaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; +import { + accountServiceFactory, + AccountServiceInitOptions, +} from "../../auth/background/service-factories/account-service.factory"; import { authServiceFactory, AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "../../auth/background/service-factories/master-password-service.factory"; import { CryptoServiceInitOptions, cryptoServiceFactory, @@ -57,6 +65,8 @@ type VaultTimeoutServiceFactoryOptions = FactoryOptions & { }; export type VaultTimeoutServiceInitOptions = VaultTimeoutServiceFactoryOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & CipherServiceInitOptions & FolderServiceInitOptions & CollectionServiceInitOptions & @@ -79,6 +89,8 @@ export function vaultTimeoutServiceFactory( opts, async () => new VaultTimeoutService( + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await cipherServiceFactory(cache, opts), await folderServiceFactory(cache, opts), await collectionServiceFactory(cache, opts), diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index f98591dcaa4..94dd60f87e3 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.3.1", + "version": "2024.4.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 6f1bcfb7739..c5807df673e 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.3.1", + "version": "2024.4.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts b/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts index 97614660d15..ed4fde162c0 100644 --- a/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts @@ -4,6 +4,10 @@ import { AccountServiceInitOptions, accountServiceFactory, } from "../../../auth/background/service-factories/account-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "../../../auth/background/service-factories/master-password-service.factory"; import { StateServiceInitOptions, stateServiceFactory, @@ -34,6 +38,7 @@ import { StateProviderInitOptions, stateProviderFactory } from "./state-provider type CryptoServiceFactoryOptions = FactoryOptions; export type CryptoServiceInitOptions = CryptoServiceFactoryOptions & + MasterPasswordServiceInitOptions & KeyGenerationServiceInitOptions & CryptoFunctionServiceInitOptions & EncryptServiceInitOptions & @@ -53,6 +58,7 @@ export function cryptoServiceFactory( opts, async () => new BrowserCryptoService( + await internalMasterPasswordServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), await cryptoFunctionServiceFactory(cache, opts), await encryptServiceFactory(cache, opts), diff --git a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts index 692e33bcce5..6561d5074c6 100644 --- a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts @@ -93,6 +93,10 @@ export class SessionSyncer { } async update(serializedValue: any) { + if (!serializedValue) { + return; + } + const unBuiltValue = JSON.parse(serializedValue); if (!BrowserApi.isManifestVersion(3) && BrowserApi.isBackgroundPage(self)) { await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue); @@ -104,6 +108,10 @@ export class SessionSyncer { } private async updateSession(value: any) { + if (!value) { + return; + } + const serializedValue = JSON.stringify(value); if (BrowserApi.isManifestVersion(3) || BrowserApi.isBackgroundPage(self)) { await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue); diff --git a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts index a5681d65c0a..64935ab5919 100644 --- a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts +++ b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts @@ -10,6 +10,8 @@ import { fromChromeEvent } from "../../browser/from-chrome-event"; export const serializationIndicator = "__json__"; +type serializedObject = { [serializationIndicator]: true; value: string }; + export const objToStore = (obj: any) => { if (obj == null) { return null; @@ -61,11 +63,7 @@ export default abstract class AbstractChromeStorageService return new Promise((resolve) => { this.chromeStorageApi.get(key, (obj: any) => { if (obj != null && obj[key] != null) { - let value = obj[key]; - if (value[serializationIndicator] && typeof value.value === "string") { - value = JSON.parse(value.value); - } - resolve(value as T); + resolve(this.processGetObject(obj[key])); return; } resolve(null); @@ -95,4 +93,22 @@ export default abstract class AbstractChromeStorageService }); }); } + + /** Backwards compatible resolution of retrieved object with new serialized storage */ + protected processGetObject(obj: T | serializedObject): T | null { + if (this.isSerialized(obj)) { + obj = JSON.parse(obj.value); + } + return obj as T; + } + + /** Type guard for whether an object is tagged as serialized */ + protected isSerialized(value: T | serializedObject): value is serializedObject { + const asSerialized = value as serializedObject; + return ( + asSerialized != null && + asSerialized[serializationIndicator] && + typeof asSerialized.value === "string" + ); + } } diff --git a/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts b/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts index bb89dc8a6a4..812901879d7 100644 --- a/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts +++ b/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts @@ -66,6 +66,7 @@ describe("ChromeStorageApiService", () => { describe("get", () => { let getMock: jest.Mock; + const key = "key"; beforeEach(() => { // setup get @@ -76,7 +77,6 @@ describe("ChromeStorageApiService", () => { }); it("returns a stored value when it is serialized", async () => { - const key = "key"; const value = { key: "value" }; store[key] = objToStore(value); const result = await service.get(key); @@ -84,7 +84,6 @@ describe("ChromeStorageApiService", () => { }); it("returns a stored value when it is not serialized", async () => { - const key = "key"; const value = "value"; store[key] = value; const result = await service.get(key); @@ -95,5 +94,12 @@ describe("ChromeStorageApiService", () => { const result = await service.get("key"); expect(result).toBeNull(); }); + + it("returns null when the stored object is null", async () => { + store[key] = null; + + const result = await service.get(key); + expect(result).toBeNull(); + }); }); }); diff --git a/apps/browser/src/platform/services/browser-crypto.service.ts b/apps/browser/src/platform/services/browser-crypto.service.ts index 969dbdf7618..d7533a22d6e 100644 --- a/apps/browser/src/platform/services/browser-crypto.service.ts +++ b/apps/browser/src/platform/services/browser-crypto.service.ts @@ -1,6 +1,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -17,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key"; export class BrowserCryptoService extends CryptoService { constructor( + masterPasswordService: InternalMasterPasswordServiceAbstraction, keyGenerationService: KeyGenerationService, cryptoFunctionService: CryptoFunctionService, encryptService: EncryptService, @@ -28,6 +30,7 @@ export class BrowserCryptoService extends CryptoService { private biometricStateService: BiometricStateService, ) { super( + masterPasswordService, keyGenerationService, cryptoFunctionService, encryptService, diff --git a/apps/browser/src/platform/services/browser-local-storage.service.spec.ts b/apps/browser/src/platform/services/browser-local-storage.service.spec.ts new file mode 100644 index 00000000000..37ea37dbf6f --- /dev/null +++ b/apps/browser/src/platform/services/browser-local-storage.service.spec.ts @@ -0,0 +1,89 @@ +import { objToStore } from "./abstractions/abstract-chrome-storage-api.service"; +import BrowserLocalStorageService from "./browser-local-storage.service"; + +describe("BrowserLocalStorageService", () => { + let service: BrowserLocalStorageService; + let store: Record; + + beforeEach(() => { + store = {}; + + service = new BrowserLocalStorageService(); + }); + + describe("clear", () => { + let clearMock: jest.Mock; + + beforeEach(() => { + clearMock = chrome.storage.local.clear as jest.Mock; + }); + + it("uses the api to clear", async () => { + await service.clear(); + + expect(clearMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("getAll", () => { + let getMock: jest.Mock; + + beforeEach(() => { + // setup get + getMock = chrome.storage.local.get as jest.Mock; + getMock.mockImplementation((key, callback) => { + if (key == null) { + callback(store); + } else { + callback({ [key]: store[key] }); + } + }); + }); + + it("returns all values", async () => { + store["key1"] = "string"; + store["key2"] = 0; + const result = await service.getAll(); + + expect(result).toEqual(store); + }); + + it("handles empty stores", async () => { + const result = await service.getAll(); + + expect(result).toEqual({}); + }); + + it("handles stores with null values", async () => { + store["key"] = null; + + const result = await service.getAll(); + expect(result).toEqual(store); + }); + + it("handles values processed for storage", async () => { + const obj = { test: 2 }; + const key = "key"; + store[key] = objToStore(obj); + + const result = await service.getAll(); + + expect(result).toEqual({ + [key]: obj, + }); + }); + + // This is a test of backwards compatibility before local storage was serialized. + it("handles values that were stored without processing for storage", async () => { + const obj = { test: 2 }; + const key = "key"; + store[key] = obj; + + const result = await service.getAll(); + + expect(result).toEqual({ + [key]: obj, + }); + }); + }); +}); diff --git a/apps/browser/src/platform/services/browser-local-storage.service.ts b/apps/browser/src/platform/services/browser-local-storage.service.ts index 2efd03a0468..e1f9f63676f 100644 --- a/apps/browser/src/platform/services/browser-local-storage.service.ts +++ b/apps/browser/src/platform/services/browser-local-storage.service.ts @@ -4,4 +4,32 @@ export default class BrowserLocalStorageService extends AbstractChromeStorageSer constructor() { super(chrome.storage.local); } + + /** + * Clears local storage + */ + async clear() { + await chrome.storage.local.clear(); + } + + /** + * Retrieves all objects stored in local storage. + * + * @remarks This method processes values prior to resolving, do not use `chrome.storage.local` directly + * @returns Promise resolving to keyed object of all stored data + */ + async getAll(): Promise> { + return new Promise((resolve) => { + this.chromeStorageApi.get(null, (allStorage) => { + const resolved = Object.entries(allStorage).reduce( + (agg, [key, value]) => { + agg[key] = this.processGetObject(value); + return agg; + }, + {} as Record, + ); + resolve(resolved); + }); + }); + } } diff --git a/apps/browser/src/popup/services/popup-search.service.ts b/apps/browser/src/popup/services/popup-search.service.ts index bc5e565e6ca..40e6fd2d967 100644 --- a/apps/browser/src/popup/services/popup-search.service.ts +++ b/apps/browser/src/popup/services/popup-search.service.ts @@ -1,17 +1,14 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { SearchService } from "@bitwarden/common/services/search.service"; export class PopupSearchService extends SearchService { - constructor( - private mainSearchService: SearchService, - logService: LogService, - i18nService: I18nService, - ) { - super(logService, i18nService); + constructor(logService: LogService, i18nService: I18nService, stateProvider: StateProvider) { + super(logService, i18nService, stateProvider); } - clearIndex() { + clearIndex(): Promise { throw new Error("Not available."); } @@ -19,7 +16,7 @@ export class PopupSearchService extends SearchService { throw new Error("Not available."); } - getIndexForSearch() { - return this.mainSearchService.getIndexForSearch(); + async getIndexForSearch() { + return await super.getIndexForSearch(); } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 037246d3c42..40daf1b04db 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -62,6 +62,7 @@ import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/p import { AbstractMemoryStorageService, AbstractStorageService, + ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; @@ -74,17 +75,15 @@ import { GlobalStateProvider, StateProvider, } from "@bitwarden/common/platform/state"; -import { SearchService } from "@bitwarden/common/services/search.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { CipherFileUploadService } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderService as FolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService } from "@bitwarden/components"; -import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; import { UnauthGuardService } from "../../auth/popup/services"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; @@ -159,7 +158,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: MessagingService, useFactory: () => { - return needsBackgroundInit + return needsBackgroundInit && BrowserApi.isManifestVersion(2) ? new BrowserMessagingPrivateModePopupService() : new BrowserMessagingService(); }, @@ -187,19 +186,8 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: SearchServiceAbstraction, - useFactory: (logService: LogService, i18nService: I18nServiceAbstraction) => { - return new PopupSearchService( - getBgService("searchService")(), - logService, - i18nService, - ); - }, - deps: [LogService, I18nServiceAbstraction], - }), - safeProvider({ - provide: CipherFileUploadService, - useFactory: getBgService("cipherFileUploadService"), - deps: [], + useClass: PopupSearchService, + deps: [LogService, I18nServiceAbstraction, StateProvider], }), safeProvider({ provide: CipherService, @@ -231,11 +219,6 @@ const safeProviders: SafeProvider[] = [ useClass: BrowserEnvironmentService, deps: [LogService, StateProvider, AccountServiceAbstraction], }), - safeProvider({ - provide: TotpService, - useFactory: getBgService("totpService"), - deps: [], - }), safeProvider({ provide: I18nServiceAbstraction, useFactory: (globalStateProvider: GlobalStateProvider) => { @@ -252,6 +235,11 @@ const safeProviders: SafeProvider[] = [ }, deps: [EncryptService], }), + safeProvider({ + provide: TotpServiceAbstraction, + useClass: TotpService, + deps: [CryptoFunctionService, LogService], + }), safeProvider({ provide: AuthRequestServiceAbstraction, useFactory: getBgService("authRequestService"), @@ -325,7 +313,7 @@ const safeProviders: SafeProvider[] = [ deps: [ CipherService, AutofillSettingsServiceAbstraction, - TotpService, + TotpServiceAbstraction, EventCollectionServiceAbstraction, LogService, DomainSettingsService, @@ -333,11 +321,6 @@ const safeProviders: SafeProvider[] = [ BillingAccountProfileStateService, ], }), - safeProvider({ - provide: VaultExportServiceAbstraction, - useFactory: getBgService("exportService"), - deps: [], - }), safeProvider({ provide: KeyConnectorService, useFactory: getBgService("keyConnectorService"), @@ -387,7 +370,15 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: OBSERVABLE_MEMORY_STORAGE, - useClass: ForegroundMemoryStorageService, + useFactory: () => { + if (BrowserApi.isManifestVersion(2)) { + return new ForegroundMemoryStorageService(); + } + + return getBgService( + "memoryStorageForStateProviders", + )(); + }, deps: [], }), safeProvider({ diff --git a/apps/browser/src/tools/popup/send/send-groupings.component.ts b/apps/browser/src/tools/popup/send/send-groupings.component.ts index 9b3ecc71633..a49773367dc 100644 --- a/apps/browser/src/tools/popup/send/send-groupings.component.ts +++ b/apps/browser/src/tools/popup/send/send-groupings.component.ts @@ -171,9 +171,7 @@ export class SendGroupingsComponent extends BaseSendComponent { } showSearching() { - return ( - this.hasSearched || (!this.searchPending && this.searchService.isSearchable(this.searchText)) - ); + return this.hasSearched || (!this.searchPending && this.isSearchable); } private calculateTypeCounts() { diff --git a/apps/browser/src/vault/popup/components/action-buttons.component.ts b/apps/browser/src/vault/popup/components/action-buttons.component.ts index 624789a5c03..b0e7b318d2e 100644 --- a/apps/browser/src/vault/popup/components/action-buttons.component.ts +++ b/apps/browser/src/vault/popup/components/action-buttons.component.ts @@ -6,7 +6,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -31,7 +31,7 @@ export class ActionButtonsComponent implements OnInit, OnDestroy { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private eventCollectionService: EventCollectionService, - private totpService: TotpService, + private totpService: TotpServiceAbstraction, private passwordRepromptService: PasswordRepromptService, private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts index 81d1b88fd85..323d2ab4f29 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -311,7 +311,7 @@ export class Fido2Component implements OnInit, OnDestroy { } protected async search() { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = await this.searchService.isSearchable(this.searchText); this.searchPending = true; if (this.hasSearched) { this.displayedCiphers = await this.searchService.searchCiphers( diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.html b/apps/browser/src/vault/popup/components/vault/current-tab.component.html index c971f6c9371..1a42f707010 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.html +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.html @@ -36,19 +36,32 @@ - -

{{ autofillCalloutText }}

+ +

+ {{ "unassignedItemsBanner" | i18n }} + {{ "learnMore" | i18n }} +

-

diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index d9cf6550fa5..4d2674fd703 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -1,13 +1,16 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom } from "rxjs"; -import { debounceTime, takeUntil } from "rxjs/operators"; +import { Subject, firstValueFrom, from } from "rxjs"; +import { debounceTime, switchMap, takeUntil } from "rxjs/operators"; +import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -53,6 +56,11 @@ export class CurrentTabComponent implements OnInit, OnDestroy { private totpTimeout: number; private loadedTimeout: number; private searchTimeout: number; + private initPageDetailsTimeout: number; + + protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.UnassignedItemsBanner, + ); constructor( private platformUtilsService: PlatformUtilsService, @@ -70,6 +78,8 @@ export class CurrentTabComponent implements OnInit, OnDestroy { private organizationService: OrganizationService, private vaultFilterService: VaultFilterService, private vaultSettingsService: VaultSettingsService, + private configService: ConfigService, + protected unassignedItemsBannerService: UnassignedItemsBannerService, ) {} async ngOnInit() { @@ -120,8 +130,14 @@ export class CurrentTabComponent implements OnInit, OnDestroy { } this.search$ - .pipe(debounceTime(500), takeUntil(this.destroy$)) - .subscribe(() => this.searchVault()); + .pipe( + debounceTime(500), + switchMap(() => { + return from(this.searchVault()); + }), + takeUntil(this.destroy$), + ) + .subscribe(); const autofillOnPageLoadOrgPolicy = await firstValueFrom( this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$, @@ -232,14 +248,12 @@ export class CurrentTabComponent implements OnInit, OnDestroy { } } - searchVault() { - if (!this.searchService.isSearchable(this.searchText)) { + async searchVault() { + if (!(await this.searchService.isSearchable(this.searchText))) { return; } - // 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(["/tabs/vault"], { queryParams: { searchText: this.searchText } }); + await this.router.navigate(["/tabs/vault"], { queryParams: { searchText: this.searchText } }); } closeOnEsc(e: KeyboardEvent) { @@ -303,18 +317,13 @@ export class CurrentTabComponent implements OnInit, OnDestroy { }); if (this.loginCiphers.length) { - void BrowserApi.tabSendMessage(this.tab, { - command: "collectPageDetails", - tab: this.tab, - sender: BroadcasterSubscriptionId, - }); - this.loginCiphers = this.loginCiphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b), ); } this.isLoading = this.loaded = true; + this.collectTabPageDetails(); } async goToSettings() { @@ -352,4 +361,19 @@ export class CurrentTabComponent implements OnInit, OnDestroy { this.autofillCalloutText = this.i18nService.t("autofillSelectInfoWithoutCommand"); } } + + private collectTabPageDetails() { + void BrowserApi.tabSendMessage(this.tab, { + command: "collectPageDetails", + tab: this.tab, + sender: BroadcasterSubscriptionId, + }); + + window.clearTimeout(this.initPageDetailsTimeout); + this.initPageDetailsTimeout = window.setTimeout(() => { + if (this.pageDetails.length === 0) { + this.collectTabPageDetails(); + } + }, 250); + } } diff --git a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts index 2510e2f966b..deb4434df47 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts @@ -1,8 +1,8 @@ import { Location } from "@angular/common"; import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { BehaviorSubject, Subject, firstValueFrom, from } from "rxjs"; +import { first, switchMap, takeUntil } from "rxjs/operators"; import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -53,7 +53,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { folderCounts = new Map(); collectionCounts = new Map(); typeCounts = new Map(); - searchText: string; state: BrowserGroupingsComponentState; showLeftHeader = true; searchPending = false; @@ -71,6 +70,16 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private hasSearched = false; private hasLoadedAllCiphers = false; private allCiphers: CipherView[] = null; + private destroy$ = new Subject(); + private _searchText$ = new BehaviorSubject(""); + private isSearchable: boolean = false; + + get searchText() { + return this._searchText$.value; + } + set searchText(value: string) { + this._searchText$.next(value); + } constructor( private i18nService: I18nService, @@ -148,6 +157,15 @@ export class VaultFilterComponent implements OnInit, OnDestroy { BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY); } }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); } ngOnDestroy() { @@ -161,6 +179,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.saveState(); this.broadcasterService.unsubscribe(ComponentId); + this.destroy$.next(); + this.destroy$.complete(); } async load() { @@ -181,7 +201,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { async loadCiphers() { this.allCiphers = await this.cipherService.getAllDecrypted(); if (!this.hasLoadedAllCiphers) { - this.hasLoadedAllCiphers = !this.searchService.isSearchable(this.searchText); + this.hasLoadedAllCiphers = !(await this.searchService.isSearchable(this.searchText)); } await this.search(null); this.getCounts(); @@ -210,7 +230,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } const filterDeleted = (c: CipherView) => !c.isDeleted; if (timeout == null) { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.ciphers = await this.searchService.searchCiphers( this.searchText, filterDeleted, @@ -223,7 +243,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } this.searchPending = true; this.searchTimeout = setTimeout(async () => { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; if (!this.hasLoadedAllCiphers && !this.hasSearched) { await this.loadCiphers(); } else { @@ -381,9 +401,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } showSearching() { - return ( - this.hasSearched || (!this.searchPending && this.searchService.isSearchable(this.searchText)) - ); + return this.hasSearched || (!this.searchPending && this.isSearchable); } closeOnEsc(e: KeyboardEvent) { diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index d7ef15afb7b..a225db0c11a 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -20,7 +20,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; @@ -74,7 +74,7 @@ export class ViewComponent extends BaseViewComponent { constructor( cipherService: CipherService, folderService: FolderService, - totpService: TotpService, + totpService: TotpServiceAbstraction, tokenService: TokenService, i18nService: I18nService, cryptoService: CryptoService, diff --git a/apps/cli/src/auth/commands/unlock.command.ts b/apps/cli/src/auth/commands/unlock.command.ts index 98bc926079c..d52468139ae 100644 --- a/apps/cli/src/auth/commands/unlock.command.ts +++ b/apps/cli/src/auth/commands/unlock.command.ts @@ -1,6 +1,10 @@ +import { firstValueFrom } from "rxjs"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -18,6 +22,8 @@ import { CliUtils } from "../../utils"; export class UnlockCommand { constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cryptoService: CryptoService, private stateService: StateService, private cryptoFunctionService: CryptoFunctionService, @@ -45,11 +51,14 @@ export class UnlockCommand { const kdf = await this.stateService.getKdfType(); const kdfConfig = await this.stateService.getKdfConfig(); const masterKey = await this.cryptoService.makeMasterKey(password, email, kdf, kdfConfig); - const storedKeyHash = await this.cryptoService.getMasterKeyHash(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const storedMasterKeyHash = await firstValueFrom( + this.masterPasswordService.masterKeyHash$(userId), + ); let passwordValid = false; if (masterKey != null) { - if (storedKeyHash != null) { + if (storedMasterKeyHash != null) { passwordValid = await this.cryptoService.compareAndUpdateKeyHash(password, masterKey); } else { const serverKeyHash = await this.cryptoService.hashMasterKey( @@ -67,7 +76,7 @@ export class UnlockCommand { masterKey, HashPurpose.LocalAuthorization, ); - await this.cryptoService.setMasterKeyHash(localKeyHash); + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); } catch { // Ignore } @@ -75,7 +84,7 @@ export class UnlockCommand { } if (passwordValid) { - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, userId); const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); await this.cryptoService.setUserKey(userKey); diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 0e6571f7759..fd6552e2f0c 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -28,6 +28,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; @@ -168,6 +169,7 @@ export class Main { organizationUserService: OrganizationUserService; collectionService: CollectionService; vaultTimeoutService: VaultTimeoutService; + masterPasswordService: InternalMasterPasswordServiceAbstraction; vaultTimeoutSettingsService: VaultTimeoutSettingsService; syncService: SyncService; eventCollectionService: EventCollectionServiceAbstraction; @@ -352,6 +354,7 @@ export class Main { ); this.cryptoService = new CryptoService( + this.masterPasswordService, this.keyGenerationService, this.cryptoFunctionService, this.encryptService, @@ -411,7 +414,7 @@ export class Main { this.sendService, ); - this.searchService = new SearchService(this.logService, this.i18nService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.broadcasterService = new BroadcasterService(); @@ -432,6 +435,8 @@ export class Main { this.policyApiService = new PolicyApiService(this.policyService, this.apiService); this.keyConnectorService = new KeyConnectorService( + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -471,9 +476,10 @@ export class Main { this.authRequestService = new AuthRequestService( this.appIdService, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, - this.stateService, ); this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( @@ -481,6 +487,8 @@ export class Main { ); this.loginStrategyService = new LoginStrategyService( + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -568,6 +576,8 @@ export class Main { this.userVerificationService = new UserVerificationService( this.stateService, this.cryptoService, + this.accountService, + this.masterPasswordService, this.i18nService, this.userVerificationApiService, this.userDecryptionOptionsService, @@ -578,6 +588,8 @@ export class Main { ); this.vaultTimeoutService = new VaultTimeoutService( + this.accountService, + this.masterPasswordService, this.cipherService, this.folderService, this.collectionService, @@ -596,6 +608,8 @@ export class Main { this.avatarService = new AvatarService(this.apiService, this.stateProvider); this.syncService = new SyncService( + this.masterPasswordService, + this.accountService, this.apiService, this.domainSettingsService, this.folderService, diff --git a/apps/cli/src/commands/serve.command.ts b/apps/cli/src/commands/serve.command.ts index 4d0d1e5798d..76447f769c1 100644 --- a/apps/cli/src/commands/serve.command.ts +++ b/apps/cli/src/commands/serve.command.ts @@ -122,6 +122,8 @@ export class ServeCommand { this.shareCommand = new ShareCommand(this.main.cipherService); this.lockCommand = new LockCommand(this.main.vaultTimeoutService); this.unlockCommand = new UnlockCommand( + this.main.accountService, + this.main.masterPasswordService, this.main.cryptoService, this.main.stateService, this.main.cryptoFunctionService, diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index a79f3847da1..fa71a88f54e 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -253,6 +253,8 @@ export class Program { if (!cmd.check) { await this.exitIfNotAuthed(); const command = new UnlockCommand( + this.main.accountService, + this.main.masterPasswordService, this.main.cryptoService, this.main.stateService, this.main.cryptoFunctionService, @@ -613,6 +615,8 @@ export class Program { this.processResponse(response, true); } else { const command = new UnlockCommand( + this.main.accountService, + this.main.masterPasswordService, this.main.cryptoService, this.main.stateService, this.main.cryptoFunctionService, diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 7646b630014..b921cab37b2 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -1237,18 +1237,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.51" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f11c217e1416d6f036b870f14e0413d480dbf28edbee1f877abaf0206af43bb7" +checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.51" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" +checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index a1625020e54..4b2bc2e905e 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -24,7 +24,7 @@ rand = "=0.8.5" retry = "=2.0.0" scopeguard = "=1.2.0" sha2 = "=0.10.8" -thiserror = "=1.0.51" +thiserror = "=1.0.58" typenum = "=1.17.0" [build-dependencies] diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 5ce5ef948f6..5fd26f32bab 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -203,7 +203,7 @@ "si", "sk", "sl", - "sr", + "sr-cyrl", "sv", "te", "th", @@ -228,6 +228,7 @@ "artifactName": "${productName}-${version}-${arch}.${ext}" }, "snap": { + "summary": "After installation enable required `password-manager-service` by running `sudo snap connect bitwarden:password-manager-service`.", "autoStart": true, "base": "core22", "confinement": "strict", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 52dd0fafdb2..0dc23b04b11 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2024.3.2", + "version": "2024.4.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 884296ea29c..257921e2ad6 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -26,6 +26,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -120,6 +121,7 @@ export class AppComponent implements OnInit, OnDestroy { private accountCleanUpInProgress: { [userId: string]: boolean } = {}; constructor( + private masterPasswordService: MasterPasswordServiceAbstraction, private broadcasterService: BroadcasterService, private folderService: InternalFolderService, private syncService: SyncService, @@ -408,8 +410,9 @@ export class AppComponent implements OnInit, OnDestroy { (await this.authService.getAuthStatus(message.userId)) === AuthenticationStatus.Locked; const forcedPasswordReset = - (await this.stateService.getForceSetPasswordReason({ userId: message.userId })) != - ForceSetPasswordReason.None; + (await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(message.userId), + )) != ForceSetPasswordReason.None; if (locked) { this.messagingService.send("locked", { userId: message.userId }); } else if (forcedPasswordReset) { @@ -606,7 +609,7 @@ export class AppComponent implements OnInit, OnDestroy { // This must come last otherwise the logout will prematurely trigger // a process reload before all the state service user data can be cleaned up if (userBeingLoggedOut === preLogoutActiveUserId) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); this.authService.logOut(async () => { if (expired) { this.platformUtilsService.showToast( diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 84932ce7d95..8e412d4977e 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -20,6 +20,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -228,6 +229,7 @@ const safeProviders: SafeProvider[] = [ provide: CryptoServiceAbstraction, useClass: ElectronCryptoService, deps: [ + InternalMasterPasswordServiceAbstraction, KeyGenerationServiceAbstraction, CryptoFunctionServiceAbstraction, EncryptService, diff --git a/apps/desktop/src/auth/lock.component.spec.ts b/apps/desktop/src/auth/lock.component.spec.ts index 0339889bf75..c125eba022f 100644 --- a/apps/desktop/src/auth/lock.component.spec.ts +++ b/apps/desktop/src/auth/lock.component.spec.ts @@ -14,7 +14,9 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -52,6 +54,7 @@ describe("LockComponent", () => { let broadcasterServiceMock: MockProxy; let platformUtilsServiceMock: MockProxy; let activatedRouteMock: MockProxy; + let mockMasterPasswordService: FakeMasterPasswordService; const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); @@ -67,6 +70,8 @@ describe("LockComponent", () => { activatedRouteMock = mock(); activatedRouteMock.queryParams = mock(); + mockMasterPasswordService = new FakeMasterPasswordService(); + biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false); biometricStateService.promptAutomatically$ = of(false); biometricStateService.promptCancelled$ = of(false); @@ -74,6 +79,7 @@ describe("LockComponent", () => { await TestBed.configureTestingModule({ declarations: [LockComponent, I18nPipe], providers: [ + { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, { provide: I18nService, useValue: mock(), diff --git a/apps/desktop/src/auth/lock.component.ts b/apps/desktop/src/auth/lock.component.ts index 8b1448c06fc..16b58c5bbed 100644 --- a/apps/desktop/src/auth/lock.component.ts +++ b/apps/desktop/src/auth/lock.component.ts @@ -11,6 +11,7 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { DeviceType } from "@bitwarden/common/enums"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -38,6 +39,7 @@ export class LockComponent extends BaseLockComponent { private autoPromptBiometric = false; constructor( + masterPasswordService: InternalMasterPasswordServiceAbstraction, router: Router, i18nService: I18nService, platformUtilsService: PlatformUtilsService, @@ -63,6 +65,7 @@ export class LockComponent extends BaseLockComponent { accountService: AccountService, ) { super( + masterPasswordService, router, i18nService, platformUtilsService, diff --git a/apps/desktop/src/auth/set-password.component.ts b/apps/desktop/src/auth/set-password.component.ts index a75668a8567..93dfe0abd84 100644 --- a/apps/desktop/src/auth/set-password.component.ts +++ b/apps/desktop/src/auth/set-password.component.ts @@ -8,6 +8,8 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -29,6 +31,8 @@ const BroadcasterSubscriptionId = "SetPasswordComponent"; }) export class SetPasswordComponent extends BaseSetPasswordComponent implements OnDestroy { constructor( + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, apiService: ApiService, i18nService: I18nService, cryptoService: CryptoService, @@ -50,6 +54,8 @@ export class SetPasswordComponent extends BaseSetPasswordComponent implements On dialogService: DialogService, ) { super( + accountService, + masterPasswordService, i18nService, cryptoService, messagingService, diff --git a/apps/desktop/src/auth/sso.component.ts b/apps/desktop/src/auth/sso.component.ts index 210319b9ed2..cc261f1235c 100644 --- a/apps/desktop/src/auth/sso.component.ts +++ b/apps/desktop/src/auth/sso.component.ts @@ -7,6 +7,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -39,6 +41,8 @@ export class SsoComponent extends BaseSsoComponent { logService: LogService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, ) { super( ssoLoginService, @@ -55,6 +59,8 @@ export class SsoComponent extends BaseSsoComponent { logService, userDecryptionOptionsService, configService, + masterPasswordService, + accountService, ); super.onSuccessfulLogin = async () => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/desktop/src/auth/two-factor.component.ts b/apps/desktop/src/auth/two-factor.component.ts index fdbc52b4bf4..d1b84c1fa0e 100644 --- a/apps/desktop/src/auth/two-factor.component.ts +++ b/apps/desktop/src/auth/two-factor.component.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -60,6 +62,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, @Inject(WINDOW) protected win: Window, ) { super( @@ -79,6 +83,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { userDecryptionOptionsService, ssoLoginService, configService, + masterPasswordService, + accountService, ); super.onSuccessfulLogin = async () => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 412e8fc20f3..206588f3c3f 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -494,7 +494,7 @@ "message": "Kansio poistettiin" }, "loginOrCreateNewAccount": { - "message": "Käytä salattua holviasi kirjautumalla sisään tai tai luo uusi tili." + "message": "Käytä salattua holviasi kirjautumalla sisään tai luo uusi tili." }, "createAccount": { "message": "Luo tili" @@ -2689,7 +2689,7 @@ "description": "Label indicating the most common import formats" }, "troubleshooting": { - "message": "Vianmääritys" + "message": "Vianselvitys" }, "disableHardwareAccelerationRestart": { "message": "Poista laitteistokiihdytys käytöstä ja käynnistä sovellus uudelleen" diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index d124fc7d585..c93f2369769 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -404,7 +404,7 @@ "message": "長度" }, "passwordMinLength": { - "message": "Minimum password length" + "message": "最小密碼長度" }, "uppercase": { "message": "大寫 (A-Z)" @@ -561,10 +561,10 @@ "message": "帳戶已建立!現在可以登入了。" }, "youSuccessfullyLoggedIn": { - "message": "You successfully logged in" + "message": "你已成功登入" }, "youMayCloseThisWindow": { - "message": "You may close this window" + "message": "你可以關閉此視窗" }, "masterPassSent": { "message": "已寄出包含您主密碼提示的電子郵件。" @@ -1546,15 +1546,15 @@ "message": "設定主密碼" }, "orgPermissionsUpdatedMustSetPassword": { - "message": "Your organization permissions were updated, requiring you to set a master password.", + "message": "你的組織權限已更新,要求你設定一個主密碼。", "description": "Used as a card title description on the set password page to explain why the user is there" }, "orgRequiresYouToSetPassword": { - "message": "Your organization requires you to set a master password.", + "message": "你的組織要求你設定一個主密碼。", "description": "Used as a card title description on the set password page to explain why the user is there" }, "verificationRequired": { - "message": "Verification required", + "message": "需要驗證", "description": "Default title for the user verification dialog." }, "currentMasterPass": { @@ -1645,10 +1645,10 @@ "message": "在您的桌面和瀏覽器閒建立連綫時,透過要求指紋短語確認,以添加一個額外的安全層。每次建立連綫都需要使用者干預和驗證。" }, "enableHardwareAcceleration": { - "message": "Use hardware acceleration" + "message": "使用硬體加速" }, "enableHardwareAccelerationDesc": { - "message": "By default this setting is ON. Turn OFF only if you experience graphical issues. Restart is required." + "message": "此設定預設為開啟。僅當你遇到圖形問題時才關閉。需要重新啟動。" }, "approve": { "message": "核准" @@ -1889,40 +1889,40 @@ "message": "您的主密碼不符合一個或多個組織原則要求。您必須立即更新您的主密碼才能存取密碼庫。進行此動作將登出您目前的工作階段,需要您重新登入。其他裝置上的工作階段可能繼續長達一小時。" }, "tryAgain": { - "message": "Try again" + "message": "再試一次" }, "verificationRequiredForActionSetPinToContinue": { - "message": "Verification required for this action. Set a PIN to continue." + "message": "此操作需要驗證。設定 PIN 碼以繼續。" }, "setPin": { - "message": "Set PIN" + "message": "設定 PIN 碼" }, "verifyWithBiometrics": { - "message": "Verify with biometrics" + "message": "使用生物辨識進行驗證" }, "awaitingConfirmation": { - "message": "Awaiting confirmation" + "message": "正在等待確認" }, "couldNotCompleteBiometrics": { - "message": "Could not complete biometrics." + "message": "無法完成生物辨識。" }, "needADifferentMethod": { - "message": "Need a different method?" + "message": "需要不同的方法嗎?" }, "useMasterPassword": { - "message": "Use master password" + "message": "使用主密碼" }, "usePin": { - "message": "Use PIN" + "message": "使用 PIN 碼" }, "useBiometrics": { - "message": "Use biometrics" + "message": "使用生物辨識" }, "enterVerificationCodeSentToEmail": { "message": "Enter the verification code that was sent to your email." }, "resendCode": { - "message": "Resend code" + "message": "重新傳送驗證碼" }, "hours": { "message": "小時" @@ -2465,7 +2465,7 @@ "message": "全部清除" }, "plusNMore": { - "message": "+ $QUANTITY$ more", + "message": "+ $QUANTITY$ 個更多", "placeholders": { "quantity": { "content": "$1", @@ -2477,7 +2477,7 @@ "message": "子選單" }, "skipToContent": { - "message": "Skip to content" + "message": "跳至內容" }, "typePasskey": { "message": "密碼金鑰" @@ -2621,13 +2621,13 @@ "message": "使用者名稱或密碼不正確" }, "incorrectPassword": { - "message": "Incorrect password" + "message": "密碼不正確" }, "incorrectCode": { - "message": "Incorrect code" + "message": "驗證碼不正確" }, "incorrectPin": { - "message": "Incorrect PIN" + "message": "PIN 碼不正確" }, "multifactorAuthenticationFailed": { "message": "多因素驗證失敗" @@ -2685,22 +2685,22 @@ "message": "將與您的 LastPass 帳戶關聯的 YubiKey 插入電腦的 USB 連接埠,然後觸摸其按鈕。" }, "commonImportFormats": { - "message": "Common formats", + "message": "常見格式", "description": "Label indicating the most common import formats" }, "troubleshooting": { - "message": "Troubleshooting" + "message": "疑難排解" }, "disableHardwareAccelerationRestart": { - "message": "Disable hardware acceleration and restart" + "message": "停用硬體加速並重新啟動" }, "enableHardwareAccelerationRestart": { - "message": "Enable hardware acceleration and restart" + "message": "啟用硬體加速並重新啟動" }, "removePasskey": { - "message": "Remove passkey" + "message": "移除金鑰" }, "passkeyRemoved": { - "message": "Passkey removed" + "message": "金鑰已移除" } } diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 167c32cc81d..05313451314 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.3.2", + "version": "2024.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.3.2", + "version": "2024.4.1", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-native": "file:../desktop_native", diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index cfc0b9b4e2b..6527c215212 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2024.3.2", + "version": "2024.4.1", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/platform/services/electron-crypto.service.spec.ts b/apps/desktop/src/platform/services/electron-crypto.service.spec.ts index 04adfcac708..3d9171b52ea 100644 --- a/apps/desktop/src/platform/services/electron-crypto.service.spec.ts +++ b/apps/desktop/src/platform/services/electron-crypto.service.spec.ts @@ -1,6 +1,7 @@ import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; import { mock } from "jest-mock-extended"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -30,6 +31,7 @@ describe("electronCryptoService", () => { const platformUtilService = mock(); const logService = mock(); const stateService = mock(); + let masterPasswordService: FakeMasterPasswordService; let accountService: FakeAccountService; let stateProvider: FakeStateProvider; const biometricStateService = mock(); @@ -38,9 +40,11 @@ describe("electronCryptoService", () => { beforeEach(() => { accountService = mockAccountServiceWith("userId" as UserId); + masterPasswordService = new FakeMasterPasswordService(); stateProvider = new FakeStateProvider(accountService); sut = new ElectronCryptoService( + masterPasswordService, keyGenerationService, cryptoFunctionService, encryptService, diff --git a/apps/desktop/src/platform/services/electron-crypto.service.ts b/apps/desktop/src/platform/services/electron-crypto.service.ts index 6b9327a9c4c..d113a18200e 100644 --- a/apps/desktop/src/platform/services/electron-crypto.service.ts +++ b/apps/desktop/src/platform/services/electron-crypto.service.ts @@ -1,6 +1,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -20,6 +21,7 @@ import { UserKey, MasterKey } from "@bitwarden/common/types/key"; export class ElectronCryptoService extends CryptoService { constructor( + masterPasswordService: InternalMasterPasswordServiceAbstraction, keyGenerationService: KeyGenerationService, cryptoFunctionService: CryptoFunctionService, encryptService: EncryptService, @@ -31,6 +33,7 @@ export class ElectronCryptoService extends CryptoService { private biometricStateService: BiometricStateService, ) { super( + masterPasswordService, keyGenerationService, cryptoFunctionService, encryptService, @@ -159,12 +162,16 @@ export class ElectronCryptoService extends CryptoService { const oldBiometricKey = await this.stateService.getCryptoMasterKeyBiometric({ userId }); // decrypt const masterKey = new SymmetricCryptoKey(Utils.fromB64ToArray(oldBiometricKey)) as MasterKey; - let encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey(); - encUserKey = encUserKey ?? (await this.stateService.getMasterKeyEncryptedUserKey()); + userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; + const encUserKeyPrim = await this.stateService.getEncryptedCryptoSymmetricKey(); + const encUserKey = + encUserKeyPrim != null + ? new EncString(encUserKeyPrim) + : await this.masterPasswordService.getMasterKeyEncryptedUserKey(userId); if (!encUserKey) { throw new Error("No user key found during biometric migration"); } - const userKey = await this.decryptUserKeyWithMasterKey(masterKey, new EncString(encUserKey)); + const userKey = await this.decryptUserKeyWithMasterKey(masterKey, encUserKey); // migrate await this.storeBiometricKey(userKey, userId); await this.stateService.setCryptoMasterKeyBiometric(null, { userId }); diff --git a/apps/desktop/src/services/native-messaging.service.ts b/apps/desktop/src/services/native-messaging.service.ts index 148e4f1e893..01d94769777 100644 --- a/apps/desktop/src/services/native-messaging.service.ts +++ b/apps/desktop/src/services/native-messaging.service.ts @@ -1,6 +1,7 @@ import { Injectable, NgZone } from "@angular/core"; import { firstValueFrom } from "rxjs"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -30,6 +31,7 @@ export class NativeMessagingService { private sharedSecrets = new Map(); constructor( + private masterPasswordService: MasterPasswordServiceAbstraction, private cryptoFunctionService: CryptoFunctionService, private cryptoService: CryptoService, private platformUtilService: PlatformUtilsService, @@ -162,7 +164,9 @@ export class NativeMessagingService { KeySuffixOptions.Biometric, message.userId, ); - const masterKey = await this.cryptoService.getMasterKey(message.userId); + const masterKey = await firstValueFrom( + this.masterPasswordService.masterKey$(message.userId as UserId), + ); if (userKey != null) { // we send the master key still for backwards compatibility diff --git a/apps/web/src/app/admin-console/common/base.people.component.ts b/apps/web/src/app/admin-console/common/base.people.component.ts index 0a1f4338ff8..fbb9faf5694 100644 --- a/apps/web/src/app/admin-console/common/base.people.component.ts +++ b/apps/web/src/app/admin-console/common/base.people.component.ts @@ -1,5 +1,5 @@ -import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { Directive, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -32,8 +32,10 @@ const MaxCheckedCount = 500; @Directive() export abstract class BasePeopleComponent< - UserType extends ProviderUserUserDetailsResponse | OrganizationUserView, -> { + UserType extends ProviderUserUserDetailsResponse | OrganizationUserView, + > + implements OnInit, OnDestroy +{ @ViewChild("confirmTemplate", { read: ViewContainerRef, static: true }) confirmModalRef: ViewContainerRef; @@ -88,7 +90,6 @@ export abstract class BasePeopleComponent< status: StatusType; users: UserType[] = []; pagedUsers: UserType[] = []; - searchText: string; actionPromise: Promise; protected allUsers: UserType[] = []; @@ -97,7 +98,19 @@ export abstract class BasePeopleComponent< protected didScroll = false; protected pageSize = 100; + protected destroy$ = new Subject(); + private pagedUsersCount = 0; + private _searchText$ = new BehaviorSubject(""); + private isSearching: boolean = false; + + get searchText() { + return this._searchText$.value; + } + + set searchText(value: string) { + this._searchText$.next(value); + } constructor( protected apiService: ApiService, @@ -122,6 +135,22 @@ export abstract class BasePeopleComponent< abstract reinviteUser(id: string): Promise; abstract confirmUser(user: UserType, publicKey: Uint8Array): Promise; + ngOnInit(): void { + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + async load() { const response = await this.getUsers(); this.statusMap.clear(); @@ -390,12 +419,8 @@ export abstract class BasePeopleComponent< } } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - isPaging() { - const searching = this.isSearching(); + const searching = this.isSearching; if (searching && this.didScroll) { // 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 diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts index a41d57f874d..9ff596181e3 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts @@ -91,15 +91,16 @@ export class GroupsComponent implements OnInit, OnDestroy { private pagedGroupsCount = 0; private pagedGroups: GroupDetailsRow[]; private searchedGroups: GroupDetailsRow[]; - private _searchText: string; + private _searchText$ = new BehaviorSubject(""); private destroy$ = new Subject(); private refreshGroups$ = new BehaviorSubject(null); + private isSearching: boolean = false; get searchText() { - return this._searchText; + return this._searchText$.value; } set searchText(value: string) { - this._searchText = value; + this._searchText$.next(value); // Manually update as we are not using the search pipe in the template this.updateSearchedGroups(); } @@ -114,7 +115,7 @@ export class GroupsComponent implements OnInit, OnDestroy { if (this.isPaging()) { return this.pagedGroups; } - if (this.isSearching()) { + if (this.isSearching) { return this.searchedGroups; } return this.groups; @@ -180,6 +181,15 @@ export class GroupsComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ) .subscribe(); + + this._searchText$ + .pipe( + switchMap((searchText) => this.searchService.isSearchable(searchText)), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); } ngOnDestroy() { @@ -297,10 +307,6 @@ export class GroupsComponent implements OnInit, OnDestroy { this.loadMore(); } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - check(groupRow: GroupDetailsRow) { groupRow.checked = !groupRow.checked; } @@ -310,7 +316,7 @@ export class GroupsComponent implements OnInit, OnDestroy { } isPaging() { - const searching = this.isSearching(); + const searching = this.isSearching; if (searching && this.didScroll) { this.resetPaging(); } @@ -340,7 +346,7 @@ export class GroupsComponent implements OnInit, OnDestroy { } private updateSearchedGroups() { - if (this.searchService.isSearchable(this.searchText)) { + if (this.isSearching) { // Making use of the pipe in the component as we need know which groups where filtered this.searchedGroups = this.searchPipe.transform( this.groups, diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index 0da0ab79f00..6b632dce389 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, ViewChild, ViewContainerRef } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, @@ -9,7 +9,6 @@ import { map, Observable, shareReplay, - Subject, switchMap, takeUntil, } from "rxjs"; @@ -73,10 +72,7 @@ import { ResetPasswordComponent } from "./components/reset-password.component"; selector: "app-org-people", templateUrl: "people.component.html", }) -export class PeopleComponent - extends BasePeopleComponent - implements OnInit, OnDestroy -{ +export class PeopleComponent extends BasePeopleComponent { @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) groupsModalRef: ViewContainerRef; @ViewChild("confirmTemplate", { read: ViewContainerRef, static: true }) @@ -99,7 +95,6 @@ export class PeopleComponent orgResetPasswordPolicyEnabled = false; protected canUseSecretsManager$: Observable; - private destroy$ = new Subject(); constructor( apiService: ApiService, @@ -210,8 +205,7 @@ export class PeopleComponent } ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); + super.ngOnDestroy(); } async load() { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 628875f04a2..7a3b34969a5 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -281,7 +281,7 @@ export class AppComponent implements OnDestroy, OnInit { await this.stateEventRunnerService.handleEvent("logout", userId as UserId); - this.searchService.clearIndex(); + await this.searchService.clearIndex(); this.authService.logOut(async () => { if (expired) { this.platformUtilsService.showToast( diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts index 09c7bf9ace4..0997f188641 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts @@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -9,7 +10,6 @@ import { EncryptionType } from "@bitwarden/common/platform/enums"; 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"; -import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; @@ -22,6 +22,10 @@ import { Folder } from "@bitwarden/common/vault/models/domain/folder"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { + FakeAccountService, + mockAccountServiceWith, +} from "../../../../../../libs/common/spec/fake-account-service"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { StateService } from "../../core"; import { EmergencyAccessService } from "../emergency-access"; @@ -46,8 +50,10 @@ describe("KeyRotationService", () => { const mockUserId = Utils.newGuid() as UserId; const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId); + let mockMasterPasswordService: FakeMasterPasswordService = new FakeMasterPasswordService(); beforeAll(() => { + mockMasterPasswordService = new FakeMasterPasswordService(); mockApiService = mock(); mockCipherService = mock(); mockFolderService = mock(); @@ -61,6 +67,7 @@ describe("KeyRotationService", () => { mockConfigService = mock(); keyRotationService = new UserKeyRotationService( + mockMasterPasswordService, mockApiService, mockCipherService, mockFolderService, @@ -174,7 +181,10 @@ describe("KeyRotationService", () => { it("saves the master key in state after creation", async () => { await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"); - expect(mockCryptoService.setMasterKey).toHaveBeenCalledWith("mockMasterKey" as any); + expect(mockMasterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + "mockMasterKey" as any, + mockUserId, + ); }); it("uses legacy rotation if feature flag is off", async () => { diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts index 03bc604b4d8..f5812d341a5 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -25,6 +26,7 @@ import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; @Injectable() export class UserKeyRotationService { constructor( + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private apiService: UserKeyRotationApiService, private cipherService: CipherService, private folderService: FolderService, @@ -61,7 +63,8 @@ export class UserKeyRotationService { } // Set master key again in case it was lost (could be lost on refresh) - await this.cryptoService.setMasterKey(masterKey); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey(masterKey, userId); const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey); if (!newUserKey || !newEncUserKey) { diff --git a/apps/web/src/app/auth/lock.component.ts b/apps/web/src/app/auth/lock.component.ts index a1d47243969..021bf0f9df4 100644 --- a/apps/web/src/app/auth/lock.component.ts +++ b/apps/web/src/app/auth/lock.component.ts @@ -1,80 +1,12 @@ -import { Component, NgZone } from "@angular/core"; -import { Router } from "@angular/router"; +import { Component } from "@angular/core"; 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"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; -import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; -import { DialogService } from "@bitwarden/components"; @Component({ selector: "app-lock", templateUrl: "lock.component.html", }) export class LockComponent extends BaseLockComponent { - constructor( - router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - messagingService: MessagingService, - cryptoService: CryptoService, - vaultTimeoutService: VaultTimeoutService, - vaultTimeoutSettingsService: VaultTimeoutSettingsService, - environmentService: EnvironmentService, - stateService: StateService, - apiService: ApiService, - logService: LogService, - ngZone: NgZone, - policyApiService: PolicyApiServiceAbstraction, - policyService: InternalPolicyService, - passwordStrengthService: PasswordStrengthServiceAbstraction, - dialogService: DialogService, - deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, - userVerificationService: UserVerificationService, - pinCryptoService: PinCryptoServiceAbstraction, - biometricStateService: BiometricStateService, - accountService: AccountService, - ) { - super( - router, - i18nService, - platformUtilsService, - messagingService, - cryptoService, - vaultTimeoutService, - vaultTimeoutSettingsService, - environmentService, - stateService, - apiService, - logService, - ngZone, - policyApiService, - policyService, - passwordStrengthService, - dialogService, - deviceTrustCryptoService, - userVerificationService, - pinCryptoService, - biometricStateService, - accountService, - ); - } - async ngOnInit() { await super.ngOnInit(); this.onSuccessfulSubmit = async () => { diff --git a/apps/web/src/app/auth/sso.component.ts b/apps/web/src/app/auth/sso.component.ts index cdd979aa898..e120b2749f1 100644 --- a/apps/web/src/app/auth/sso.component.ts +++ b/apps/web/src/app/auth/sso.component.ts @@ -10,6 +10,8 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction"; import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -46,6 +48,8 @@ export class SsoComponent extends BaseSsoComponent { private validationService: ValidationService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, ) { super( ssoLoginService, @@ -62,6 +66,8 @@ export class SsoComponent extends BaseSsoComponent { logService, userDecryptionOptionsService, configService, + masterPasswordService, + accountService, ); this.redirectUri = window.location.origin + "/sso-connector.html"; this.clientId = "web"; diff --git a/apps/web/src/app/auth/two-factor.component.ts b/apps/web/src/app/auth/two-factor.component.ts index 65bf1dba58a..eed84b91f1c 100644 --- a/apps/web/src/app/auth/two-factor.component.ts +++ b/apps/web/src/app/auth/two-factor.component.ts @@ -10,6 +10,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -50,6 +52,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, @Inject(WINDOW) protected win: Window, ) { super( @@ -69,6 +73,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest userDecryptionOptionsService, ssoLoginService, configService, + masterPasswordService, + accountService, ); this.onSuccessfulLoginNavigate = this.goAfterLogIn; } diff --git a/apps/web/src/app/billing/shared/tax-info.component.html b/apps/web/src/app/billing/shared/tax-info.component.html index caf92f41899..30cca550d37 100644 --- a/apps/web/src/app/billing/shared/tax-info.component.html +++ b/apps/web/src/app/billing/shared/tax-info.component.html @@ -279,10 +279,7 @@ />

-
+
-
+
- +
-
+
diff --git a/apps/web/src/app/billing/shared/tax-info.component.ts b/apps/web/src/app/billing/shared/tax-info.component.ts index 2046cf44bef..a704c86eb51 100644 --- a/apps/web/src/app/billing/shared/tax-info.component.ts +++ b/apps/web/src/app/billing/shared/tax-info.component.ts @@ -3,7 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { OrganizationTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/organization-tax-info-update.request"; +import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { TaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/tax-info-update.request"; import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; import { TaxRateResponse } from "@bitwarden/common/billing/models/response/tax-rate.response"; @@ -29,6 +29,7 @@ export class TaxInfoComponent { loading = true; organizationId: string; + providerId: string; taxInfo: TaxInfoView = { taxId: null, line1: null, @@ -61,6 +62,12 @@ export class TaxInfoComponent { ) {} async ngOnInit() { + // Provider setup + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + this.route.queryParams.subscribe((params) => { + this.providerId = params.providerId; + }); + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.parent.parent.params.subscribe(async (params) => { this.organizationId = params.organizationId; @@ -126,9 +133,25 @@ export class TaxInfoComponent { } } + get showTaxIdCheckbox() { + return ( + (this.organizationId || this.providerId) && + this.taxInfo.country !== "US" && + this.countrySupportsTax(this.taxInfo.country) + ); + } + + get showTaxIdFields() { + return ( + (this.organizationId || this.providerId) && + this.taxInfo.includeTaxId && + this.countrySupportsTax(this.taxInfo.country) + ); + } + getTaxInfoRequest(): TaxInfoUpdateRequest { - if (this.organizationId) { - const request = new OrganizationTaxInfoUpdateRequest(); + if (this.organizationId || this.providerId) { + const request = new ExpandedTaxInfoUpdateRequest(); request.country = this.taxInfo.country; request.postalCode = this.taxInfo.postalCode; @@ -164,7 +187,7 @@ export class TaxInfoComponent { return this.organizationId ? this.organizationApiService.updateTaxInfo( this.organizationId, - request as OrganizationTaxInfoUpdateRequest, + request as ExpandedTaxInfoUpdateRequest, ) : this.apiService.putTaxInfo(request); } diff --git a/apps/web/src/app/layouts/header/web-header.component.html b/apps/web/src/app/layouts/header/web-header.component.html index 514e5deebd9..e1cda607c01 100644 --- a/apps/web/src/app/layouts/header/web-header.component.html +++ b/apps/web/src/app/layouts/header/web-header.component.html @@ -1,16 +1,18 @@ - {{ "newWebApp" | i18n }} + {{ "unassignedItemsBanner" | i18n }} {{ "releaseBlog" | i18n }}{{ "learnMore" | i18n }}
; protected selfHosted: boolean; protected hostname = location.hostname; + protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.UnassignedItemsBanner, + ); constructor( private route: ActivatedRoute, @@ -38,7 +42,8 @@ export class WebHeaderComponent { private platformUtilsService: PlatformUtilsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private messagingService: MessagingService, - protected webLayoutMigrationBannerService: WebLayoutMigrationBannerService, + protected unassignedItemsBannerService: UnassignedItemsBannerService, + private configService: ConfigService, ) { this.routeData$ = this.route.data.pipe( map((params) => { diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 2027b0102b8..6fe31f29f4f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -272,7 +272,7 @@ export class VaultComponent implements OnInit, OnDestroy { concatMap(async ([ciphers, filter, searchText]) => { const filterFunction = createFilterFunction(filter); - if (this.searchService.isSearchable(searchText)) { + if (await this.searchService.isSearchable(searchText)) { return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers); } @@ -283,7 +283,7 @@ export class VaultComponent implements OnInit, OnDestroy { const collections$ = combineLatest([nestedCollections$, filter$, this.currentSearchText$]).pipe( filter(([collections, filter]) => collections != undefined && filter != undefined), - map(([collections, filter, searchText]) => { + concatMap(async ([collections, filter, searchText]) => { if (filter.collectionId === undefined || filter.collectionId === Unassigned) { return []; } @@ -303,7 +303,7 @@ export class VaultComponent implements OnInit, OnDestroy { collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; } - if (this.searchService.isSearchable(searchText)) { + if (await this.searchService.isSearchable(searchText)) { collectionsToReturn = this.searchPipe.transform( collectionsToReturn, searchText, diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index cb01951fcc3..50d32161506 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -331,7 +331,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - this.searchService.indexCiphers(ciphers, organization.id); + await this.searchService.indexCiphers(ciphers, organization.id); return ciphers; }), ); @@ -350,7 +350,7 @@ export class VaultComponent implements OnInit, OnDestroy { const collections$ = combineLatest([nestedCollections$, filter$, this.currentSearchText$]).pipe( filter(([collections, filter]) => collections != undefined && filter != undefined), - map(([collections, filter, searchText]) => { + concatMap(async ([collections, filter, searchText]) => { if ( filter.collectionId === Unassigned || (filter.collectionId === undefined && filter.type !== undefined) @@ -369,7 +369,7 @@ export class VaultComponent implements OnInit, OnDestroy { collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; } - if (this.searchService.isSearchable(searchText)) { + if (await this.searchService.isSearchable(searchText)) { collectionsToReturn = this.searchPipe.transform( collectionsToReturn, searchText, @@ -436,7 +436,7 @@ export class VaultComponent implements OnInit, OnDestroy { const filterFunction = createFilterFunction(filter); - if (this.searchService.isSearchable(searchText)) { + if (await this.searchService.isSearchable(searchText)) { return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 307c5be70cb..e8944471cc6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7899,5 +7899,8 @@ }, "machineAccountAccessUpdated": { "message": "Machine account access updated" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in your All Vaults view across devices and are now only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html index fcbfcb74e6a..0d0ca04f926 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html @@ -26,7 +26,7 @@ {{ "domainNameInputHint" | i18n }} - + {{ "dnsTxtRecord" | i18n }} {{ "dnsTxtRecordInputHint" | i18n }} @@ -42,7 +42,7 @@ @@ -51,7 +51,10 @@
+
+ +
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index b3d3112bf5f..ed7b42c9593 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -1,9 +1,11 @@ -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; +import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -12,6 +14,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { TaxInfoComponent } from "@bitwarden/web-vault/app/billing"; @Component({ selector: "provider-setup", @@ -19,6 +22,8 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class SetupComponent implements OnInit { + @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; + loading = true; authed = false; email: string; @@ -34,6 +39,11 @@ export class SetupComponent implements OnInit { false, ); + protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableConsolidatedBilling, + false, + ); + constructor( private router: Router, private platformUtilsService: PlatformUtilsService, @@ -102,6 +112,22 @@ export class SetupComponent implements OnInit { request.token = this.token; request.key = key; + const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); + + if (enableConsolidatedBilling) { + request.taxInfo = new ExpandedTaxInfoUpdateRequest(); + const taxInfoView = this.taxInfoComponent.taxInfo; + request.taxInfo.country = taxInfoView.country; + request.taxInfo.postalCode = taxInfoView.postalCode; + if (taxInfoView.includeTaxId) { + request.taxInfo.taxId = taxInfoView.taxId; + request.taxInfo.line1 = taxInfoView.line1; + request.taxInfo.line2 = taxInfoView.line2; + request.taxInfo.city = taxInfoView.city; + request.taxInfo.state = taxInfoView.state; + } + } + const provider = await this.apiService.postProviderSetup(this.providerId, request); this.platformUtilsService.showToast("success", null, this.i18nService.t("providerSetup")); await this.syncService.fullSync(true); diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts index 79dd25e8912..a9f341be941 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts @@ -1,8 +1,8 @@ import { SelectionModel } from "@angular/cdk/collections"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { BehaviorSubject, Subject, firstValueFrom, from } from "rxjs"; +import { first, switchMap, takeUntil } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -23,12 +23,22 @@ import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-o }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class ManageClientOrganizationsComponent implements OnInit { +export class ManageClientOrganizationsComponent implements OnInit, OnDestroy { providerId: string; loading = true; manageOrganizations = false; + private destroy$ = new Subject(); + private _searchText$ = new BehaviorSubject(""); + private isSearching: boolean = false; + + get searchText() { + return this._searchText$.value; + } + set searchText(search: string) { + this._searchText$.value; + this.selection.clear(); this.dataSource.filter = search; } @@ -67,6 +77,20 @@ export class ManageClientOrganizationsComponent implements OnInit { this.searchText = qParams.search; }); }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } async load() { @@ -80,7 +104,7 @@ export class ManageClientOrganizationsComponent implements OnInit { } isPaging() { - const searching = this.isSearching(); + const searching = this.isSearching; if (searching && this.didScroll) { // 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 @@ -89,10 +113,6 @@ export class ManageClientOrganizationsComponent implements OnInit { return !searching && this.clients && this.clients.length > this.pageSize; } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - async resetPaging() { this.pagedClients = []; this.loadMore(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/sm-onboarding-tasks.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/sm-onboarding-tasks.service.ts index 77a218bdf88..ddca88048f7 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/sm-onboarding-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/sm-onboarding-tasks.service.ts @@ -3,16 +3,21 @@ import { Observable, map } from "rxjs"; import { ActiveUserState, - KeyDefinition, SM_ONBOARDING_DISK, StateProvider, + UserKeyDefinition, } from "@bitwarden/common/platform/state"; export type SMOnboardingTasks = Record>; -const SM_ONBOARDING_TASKS_KEY = new KeyDefinition(SM_ONBOARDING_DISK, "tasks", { - deserializer: (b) => b, -}); +const SM_ONBOARDING_TASKS_KEY = new UserKeyDefinition( + SM_ONBOARDING_DISK, + "tasks", + { + deserializer: (b) => b, + clearOn: [], // Used to track tasks completed by a user, we don't want to reshow if they've locked or logged out and came back to the app + }, +); @Injectable({ providedIn: "root", diff --git a/libs/angular/jest.config.js b/libs/angular/jest.config.js index e294e4ff472..c8e748575c0 100644 --- a/libs/angular/jest.config.js +++ b/libs/angular/jest.config.js @@ -10,7 +10,11 @@ module.exports = { displayName: "libs/angular tests", preset: "jest-preset-angular", setupFilesAfterEnv: ["/test.setup.ts"], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/", - }), + moduleNameMapper: pathsToModuleNameMapper( + // lets us use @bitwarden/common/spec in tests + { "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) }, + { + prefix: "/", + }, + ), }; diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index aa3b801ded5..6602a917c91 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -12,6 +12,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; @@ -56,6 +57,7 @@ export class LockComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); constructor( + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected router: Router, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, @@ -206,6 +208,7 @@ export class LockComponent implements OnInit, OnDestroy { } private async doUnlockWithMasterPassword() { + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; const kdf = await this.stateService.getKdfType(); const kdfConfig = await this.stateService.getKdfConfig(); @@ -215,11 +218,13 @@ export class LockComponent implements OnInit, OnDestroy { kdf, kdfConfig, ); - const storedPasswordHash = await this.cryptoService.getMasterKeyHash(); + const storedMasterKeyHash = await firstValueFrom( + this.masterPasswordService.masterKeyHash$(userId), + ); let passwordValid = false; - if (storedPasswordHash != null) { + if (storedMasterKeyHash != null) { // Offline unlock possible passwordValid = await this.cryptoService.compareAndUpdateKeyHash( this.masterPassword, @@ -244,7 +249,7 @@ export class LockComponent implements OnInit, OnDestroy { masterKey, HashPurpose.LocalAuthorization, ); - await this.cryptoService.setMasterKeyHash(localKeyHash); + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); } catch (e) { this.logService.error(e); } finally { @@ -262,7 +267,7 @@ export class LockComponent implements OnInit, OnDestroy { } const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, userId); await this.setUserKeyAndContinue(userKey, true); } @@ -292,8 +297,10 @@ export class LockComponent implements OnInit, OnDestroy { } if (this.requirePasswordChange()) { - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.WeakMasterPassword, + userId, ); // 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 diff --git a/libs/angular/src/auth/components/set-password.component.ts b/libs/angular/src/auth/components/set-password.component.ts index a7442f711b8..eebf87655b7 100644 --- a/libs/angular/src/auth/components/set-password.component.ts +++ b/libs/angular/src/auth/components/set-password.component.ts @@ -12,6 +12,8 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs 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 { OrganizationAutoEnrollStatusResponse } from "@bitwarden/common/admin-console/models/response/organization-auto-enroll-status.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; @@ -29,6 +31,7 @@ import { import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService } from "@bitwarden/components"; @@ -45,11 +48,14 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { resetPasswordAutoEnroll = false; onSuccessfulChangePassword: () => Promise; successRoute = "vault"; + userId: UserId; forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None; ForceSetPasswordReason = ForceSetPasswordReason; constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, i18nService: I18nService, cryptoService: CryptoService, messagingService: MessagingService, @@ -88,7 +94,11 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { await this.syncService.fullSync(true); this.syncLoading = false; - this.forceSetPasswordReason = await this.stateService.getForceSetPasswordReason(); + this.userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + + this.forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(this.userId), + ); this.route.queryParams .pipe( @@ -176,7 +186,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { if (response == null) { throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); } - const userId = await this.stateService.getUserId(); const publicKey = Utils.fromB64ToArray(response.publicKey); // RSA Encrypt user key with organization public key @@ -189,7 +198,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { return this.organizationUserService.putOrganizationUserResetPasswordEnrollment( this.orgId, - userId, + this.userId, resetRequest, ); }); @@ -226,7 +235,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { keyPair: [string, EncString] | null, ) { // Clear force set password reason to allow navigation back to vault. - await this.stateService.setForceSetPasswordReason(ForceSetPasswordReason.None); + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.None, + this.userId, + ); // User now has a password so update account decryption options in state const userDecryptionOpts = await firstValueFrom( @@ -237,7 +249,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { await this.stateService.setKdfType(this.kdf); await this.stateService.setKdfConfig(this.kdfConfig); - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, this.userId); await this.cryptoService.setUserKey(userKey[0]); // Set private key only for new JIT provisioned users in MP encryption orgs @@ -255,6 +267,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { masterKey, HashPurpose.LocalAuthorization, ); - await this.cryptoService.setMasterKeyHash(localMasterKeyHash); + await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.userId); } } diff --git a/libs/angular/src/auth/components/sso.component.spec.ts b/libs/angular/src/auth/components/sso.component.spec.ts index c5c062d9a7c..269ec51e303 100644 --- a/libs/angular/src/auth/components/sso.component.spec.ts +++ b/libs/angular/src/auth/components/sso.component.spec.ts @@ -12,10 +12,13 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; 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 { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -23,7 +26,9 @@ 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UserId } from "@bitwarden/common/types/guid"; import { SsoComponent } from "./sso.component"; // test component that extends the SsoComponent @@ -48,6 +53,7 @@ describe("SsoComponent", () => { let component: TestSsoComponent; let _component: SsoComponentProtected; let fixture: ComponentFixture; + const userId = "userId" as UserId; // Mock Services let mockLoginStrategyService: MockProxy; @@ -67,6 +73,8 @@ describe("SsoComponent", () => { let mockLogService: MockProxy; let mockUserDecryptionOptionsService: MockProxy; let mockConfigService: MockProxy; + let mockMasterPasswordService: FakeMasterPasswordService; + let mockAccountService: FakeAccountService; // Mock authService.logIn params let code: string; @@ -117,6 +125,8 @@ describe("SsoComponent", () => { mockLogService = mock(); mockUserDecryptionOptionsService = mock(); mockConfigService = mock(); + mockAccountService = mockAccountServiceWith(userId); + mockMasterPasswordService = new FakeMasterPasswordService(); // Mock loginStrategyService.logIn params code = "code"; @@ -199,6 +209,8 @@ describe("SsoComponent", () => { }, { provide: LogService, useValue: mockLogService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, + { provide: AccountService, useValue: mockAccountService }, ], }); @@ -365,8 +377,9 @@ describe("SsoComponent", () => { await _component.logIn(code, codeVerifier, orgIdFromState); expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - expect(mockStateService.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); expect(mockOnSuccessfulLoginTdeNavigate).not.toHaveBeenCalled(); diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index 68d6e72e8d6..30815beef8a 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -66,6 +68,8 @@ export class SsoComponent { protected logService: LogService, protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, protected configService: ConfigService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected accountService: AccountService, ) {} async ngOnInit() { @@ -290,8 +294,10 @@ export class SsoComponent { // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) // Note: we cannot directly navigate 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. - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); } diff --git a/libs/angular/src/auth/components/two-factor.component.spec.ts b/libs/angular/src/auth/components/two-factor.component.spec.ts index bff39188ea9..0eb248f6d9d 100644 --- a/libs/angular/src/auth/components/two-factor.component.spec.ts +++ b/libs/angular/src/auth/components/two-factor.component.spec.ts @@ -15,11 +15,14 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; 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 { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/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"; @@ -27,6 +30,8 @@ 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { TwoFactorComponent } from "./two-factor.component"; @@ -46,6 +51,7 @@ describe("TwoFactorComponent", () => { let _component: TwoFactorComponentProtected; let fixture: ComponentFixture; + const userId = "userId" as UserId; // Mock Services let mockLoginStrategyService: MockProxy; @@ -63,6 +69,8 @@ describe("TwoFactorComponent", () => { let mockUserDecryptionOptionsService: MockProxy; let mockSsoLoginService: MockProxy; let mockConfigService: MockProxy; + let mockMasterPasswordService: FakeMasterPasswordService; + let mockAccountService: FakeAccountService; let mockUserDecryptionOpts: { noMasterPassword: UserDecryptionOptions; @@ -93,6 +101,8 @@ describe("TwoFactorComponent", () => { mockUserDecryptionOptionsService = mock(); mockSsoLoginService = mock(); mockConfigService = mock(); + mockAccountService = mockAccountServiceWith(userId); + mockMasterPasswordService = new FakeMasterPasswordService(); mockUserDecryptionOpts = { noMasterPassword: new UserDecryptionOptions({ @@ -170,6 +180,8 @@ describe("TwoFactorComponent", () => { }, { provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, + { provide: AccountService, useValue: mockAccountService }, ], }); @@ -407,9 +419,9 @@ describe("TwoFactorComponent", () => { await component.doSubmit(); // Assert - - expect(mockStateService.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); expect(mockRouter.navigate).toHaveBeenCalledTimes(1); diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index c306e6cc804..f73f0483be1 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -14,6 +14,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; 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"; @@ -92,6 +94,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, protected ssoLoginService: SsoLoginServiceAbstraction, protected configService: ConfigService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected accountService: AccountService, ) { super(environmentService, i18nService, platformUtilsService); this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); @@ -342,8 +346,10 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI // 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. - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); } diff --git a/libs/angular/src/auth/components/update-temp-password.component.ts b/libs/angular/src/auth/components/update-temp-password.component.ts index 0b4541fe52c..54fdc832399 100644 --- a/libs/angular/src/auth/components/update-temp-password.component.ts +++ b/libs/angular/src/auth/components/update-temp-password.component.ts @@ -1,9 +1,12 @@ import { Directive } from "@angular/core"; import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -56,6 +59,8 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { private userVerificationService: UserVerificationService, protected router: Router, dialogService: DialogService, + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, ) { super( i18nService, @@ -72,7 +77,8 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { async ngOnInit() { await this.syncService.fullSync(true); - this.reason = await this.stateService.getForceSetPasswordReason(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + this.reason = await firstValueFrom(this.masterPasswordService.forceSetPasswordReason$(userId)); // If we somehow end up here without a reason, go back to the home page if (this.reason == ForceSetPasswordReason.None) { @@ -163,7 +169,11 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { this.i18nService.t("updatedMasterPassword"), ); - await this.stateService.setForceSetPasswordReason(ForceSetPasswordReason.None); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.None, + userId, + ); if (this.onSuccessfulChangePassword != null) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts index 29024cfa0bc..b8e37d0af36 100644 --- a/libs/angular/src/auth/guards/auth.guard.ts +++ b/libs/angular/src/auth/guards/auth.guard.ts @@ -1,12 +1,14 @@ import { Injectable } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Injectable() export class AuthGuard implements CanActivate { @@ -15,7 +17,8 @@ export class AuthGuard implements CanActivate { private router: Router, private messagingService: MessagingService, private keyConnectorService: KeyConnectorService, - private stateService: StateService, + private accountService: AccountService, + private masterPasswordService: MasterPasswordServiceAbstraction, ) {} async canActivate(route: ActivatedRouteSnapshot, routerState: RouterStateSnapshot) { @@ -40,7 +43,10 @@ export class AuthGuard implements CanActivate { return this.router.createUrlTree(["/remove-password"]); } - const forceSetPasswordReason = await this.stateService.getForceSetPasswordReason(); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(userId), + ); if ( forceSetPasswordReason === diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 73f2bb4a32f..79bb6714d0e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -60,6 +60,10 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { + InternalMasterPasswordServiceAbstraction, + MasterPasswordServiceAbstraction, +} from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; @@ -78,6 +82,7 @@ import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; +import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; @@ -359,6 +364,8 @@ const safeProviders: SafeProvider[] = [ provide: LoginStrategyServiceAbstraction, useClass: LoginStrategyService, deps: [ + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, TokenServiceAbstraction, @@ -521,6 +528,7 @@ const safeProviders: SafeProvider[] = [ provide: CryptoServiceAbstraction, useClass: CryptoService, deps: [ + InternalMasterPasswordServiceAbstraction, KeyGenerationServiceAbstraction, CryptoFunctionServiceAbstraction, EncryptService, @@ -587,6 +595,8 @@ const safeProviders: SafeProvider[] = [ provide: SyncServiceAbstraction, useClass: SyncService, deps: [ + InternalMasterPasswordServiceAbstraction, + AccountServiceAbstraction, ApiServiceAbstraction, DomainSettingsService, InternalFolderService, @@ -626,6 +636,8 @@ const safeProviders: SafeProvider[] = [ provide: VaultTimeoutService, useClass: VaultTimeoutService, deps: [ + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, CipherServiceAbstraction, FolderServiceAbstraction, CollectionServiceAbstraction, @@ -714,7 +726,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SearchServiceAbstraction, useClass: SearchService, - deps: [LogService, I18nServiceAbstraction], + deps: [LogService, I18nServiceAbstraction, StateProvider], }), safeProvider({ provide: NotificationsServiceAbstraction, @@ -771,10 +783,21 @@ const safeProviders: SafeProvider[] = [ useClass: PolicyApiService, deps: [InternalPolicyService, ApiServiceAbstraction], }), + safeProvider({ + provide: InternalMasterPasswordServiceAbstraction, + useClass: MasterPasswordService, + deps: [StateProvider], + }), + safeProvider({ + provide: MasterPasswordServiceAbstraction, + useExisting: InternalMasterPasswordServiceAbstraction, + }), safeProvider({ provide: KeyConnectorServiceAbstraction, useClass: KeyConnectorService, deps: [ + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, TokenServiceAbstraction, @@ -791,6 +814,8 @@ const safeProviders: SafeProvider[] = [ deps: [ StateServiceAbstraction, CryptoServiceAbstraction, + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, I18nServiceAbstraction, UserVerificationApiServiceAbstraction, UserDecryptionOptionsServiceAbstraction, @@ -934,9 +959,10 @@ const safeProviders: SafeProvider[] = [ useClass: AuthRequestService, deps: [ AppIdServiceAbstraction, + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, - StateServiceAbstraction, ], }), safeProvider({ diff --git a/libs/angular/src/services/unassigned-items-banner.api.service.ts b/libs/angular/src/services/unassigned-items-banner.api.service.ts new file mode 100644 index 00000000000..69b74f8c7fa --- /dev/null +++ b/libs/angular/src/services/unassigned-items-banner.api.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +@Injectable({ providedIn: "root" }) +export class UnassignedItemsBannerApiService { + constructor(private apiService: ApiService) {} + + async getShowUnassignedCiphersBanner(): Promise { + const r = await this.apiService.send( + "GET", + "/ciphers/has-unassigned-ciphers", + null, + true, + true, + ); + return r; + } +} diff --git a/libs/angular/src/services/unassigned-items-banner.service.spec.ts b/libs/angular/src/services/unassigned-items-banner.service.spec.ts new file mode 100644 index 00000000000..9b2ffc1ef9c --- /dev/null +++ b/libs/angular/src/services/unassigned-items-banner.service.spec.ts @@ -0,0 +1,51 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { UnassignedItemsBannerApiService } from "./unassigned-items-banner.api.service"; +import { SHOW_BANNER_KEY, UnassignedItemsBannerService } from "./unassigned-items-banner.service"; + +describe("UnassignedItemsBanner", () => { + let stateProvider: FakeStateProvider; + let apiService: MockProxy; + + const sutFactory = () => new UnassignedItemsBannerService(stateProvider, apiService); + + beforeEach(() => { + const fakeAccountService = mockAccountServiceWith("userId" as UserId); + stateProvider = new FakeStateProvider(fakeAccountService); + apiService = mock(); + }); + + it("shows the banner if showBanner local state is true", async () => { + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(true); + + const sut = sutFactory(); + expect(await firstValueFrom(sut.showBanner$)).toBe(true); + expect(apiService.getShowUnassignedCiphersBanner).not.toHaveBeenCalled(); + }); + + it("does not show the banner if showBanner local state is false", async () => { + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(false); + + const sut = sutFactory(); + expect(await firstValueFrom(sut.showBanner$)).toBe(false); + expect(apiService.getShowUnassignedCiphersBanner).not.toHaveBeenCalled(); + }); + + it("fetches from server if local state has not been set yet", async () => { + apiService.getShowUnassignedCiphersBanner.mockResolvedValue(true); + + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(undefined); + + const sut = sutFactory(); + + expect(await firstValueFrom(sut.showBanner$)).toBe(true); + expect(apiService.getShowUnassignedCiphersBanner).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/angular/src/services/unassigned-items-banner.service.ts b/libs/angular/src/services/unassigned-items-banner.service.ts new file mode 100644 index 00000000000..faa766a18a8 --- /dev/null +++ b/libs/angular/src/services/unassigned-items-banner.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from "@angular/core"; +import { concatMap } from "rxjs"; + +import { + StateProvider, + UNASSIGNED_ITEMS_BANNER_DISK, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; + +import { UnassignedItemsBannerApiService } from "./unassigned-items-banner.api.service"; + +export const SHOW_BANNER_KEY = new UserKeyDefinition( + UNASSIGNED_ITEMS_BANNER_DISK, + "showBanner", + { + deserializer: (b) => b, + clearOn: [], + }, +); + +/** Displays a banner that tells users how to move their unassigned items into a collection. */ +@Injectable({ providedIn: "root" }) +export class UnassignedItemsBannerService { + private _showBanner = this.stateProvider.getActive(SHOW_BANNER_KEY); + + showBanner$ = this._showBanner.state$.pipe( + concatMap(async (showBannerState) => { + // null indicates that the user has not seen or dismissed the banner yet - get the flag from server + if (showBannerState == null) { + const showBannerResponse = await this.apiService.getShowUnassignedCiphersBanner(); + await this._showBanner.update(() => showBannerResponse); + return showBannerResponse; + } + + return showBannerState; + }), + ); + + constructor( + private stateProvider: StateProvider, + private apiService: UnassignedItemsBannerApiService, + ) {} + + async hideBanner() { + await this._showBanner.update(() => false); + } +} diff --git a/libs/angular/src/tools/send/send.component.ts b/libs/angular/src/tools/send/send.component.ts index 90d9b39e8c6..fc51e324164 100644 --- a/libs/angular/src/tools/send/send.component.ts +++ b/libs/angular/src/tools/send/send.component.ts @@ -1,5 +1,13 @@ import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core"; -import { Subject, firstValueFrom, mergeMap, takeUntil } from "rxjs"; +import { + BehaviorSubject, + Subject, + firstValueFrom, + mergeMap, + from, + switchMap, + takeUntil, +} from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -24,7 +32,6 @@ export class SendComponent implements OnInit, OnDestroy { expired = false; type: SendType = null; sends: SendView[] = []; - searchText: string; selectedType: SendType; selectedAll: boolean; filter: (cipher: SendView) => boolean; @@ -39,6 +46,8 @@ export class SendComponent implements OnInit, OnDestroy { private searchTimeout: any; private destroy$ = new Subject(); private _filteredSends: SendView[]; + private _searchText$ = new BehaviorSubject(""); + protected isSearchable: boolean = false; get filteredSends(): SendView[] { return this._filteredSends; @@ -48,6 +57,14 @@ export class SendComponent implements OnInit, OnDestroy { this._filteredSends = filteredSends; } + get searchText() { + return this._searchText$.value; + } + + set searchText(value: string) { + this._searchText$.next(value); + } + constructor( protected sendService: SendService, protected i18nService: I18nService, @@ -68,6 +85,15 @@ export class SendComponent implements OnInit, OnDestroy { .subscribe((policyAppliesToActiveUser) => { this.disableSend = policyAppliesToActiveUser; }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); } ngOnDestroy() { @@ -122,14 +148,14 @@ export class SendComponent implements OnInit, OnDestroy { clearTimeout(this.searchTimeout); } if (timeout == null) { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.filteredSends = this.sends.filter((s) => this.filter == null || this.filter(s)); this.applyTextSearch(); return; } this.searchPending = true; this.searchTimeout = setTimeout(async () => { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.filteredSends = this.sends.filter((s) => this.filter == null || this.filter(s)); this.applyTextSearch(); this.searchPending = false; diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index cdfb1b6299c..458b10865c7 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -1,4 +1,5 @@ -import { Directive, EventEmitter, Input, Output } from "@angular/core"; +import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { BehaviorSubject, Subject, from, switchMap, takeUntil } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -6,7 +7,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @Directive() -export class VaultItemsComponent { +export class VaultItemsComponent implements OnInit, OnDestroy { @Input() activeCipherId: string = null; @Output() onCipherClicked = new EventEmitter(); @Output() onCipherRightClicked = new EventEmitter(); @@ -23,13 +24,15 @@ export class VaultItemsComponent { protected searchPending = false; + private destroy$ = new Subject(); private searchTimeout: any = null; - private _searchText: string = null; + private isSearchable: boolean = false; + private _searchText$ = new BehaviorSubject(""); get searchText() { - return this._searchText; + return this._searchText$.value; } set searchText(value: string) { - this._searchText = value; + this._searchText$.next(value); } constructor( @@ -37,6 +40,21 @@ export class VaultItemsComponent { protected cipherService: CipherService, ) {} + ngOnInit(): void { + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); + } + + ngOnDestroy(): void { + throw new Error("Method not implemented."); + } + async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { this.deleted = deleted ?? false; await this.applyFilter(filter); @@ -90,7 +108,7 @@ export class VaultItemsComponent { } isSearching() { - return !this.searchPending && this.searchService.isSearchable(this.searchText); + return !this.searchPending && this.isSearchable; } protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index 53722cd259c..0ce6c9fed76 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -5,6 +5,7 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -14,7 +15,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; @@ -42,6 +45,10 @@ describe("AuthRequestLoginStrategy", () => { let deviceTrustCryptoService: MockProxy; let billingAccountProfileStateService: MockProxy; + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; + let authRequestLoginStrategy: AuthRequestLoginStrategy; let credentials: AuthRequestLoginCredentials; let tokenResponse: IdentityTokenResponse; @@ -71,12 +78,17 @@ describe("AuthRequestLoginStrategy", () => { deviceTrustCryptoService = mock(); billingAccountProfileStateService = mock(); + accountService = mockAccountServiceWith(mockUserId); + masterPasswordService = new FakeMasterPasswordService(); + tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.mockResolvedValue({}); authRequestLoginStrategy = new AuthRequestLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -108,13 +120,16 @@ describe("AuthRequestLoginStrategy", () => { const masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await authRequestLoginStrategy.logIn(credentials); - expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); - expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(decMasterKeyHash); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(masterKey, mockUserId); + expect(masterPasswordService.mock.setMasterKeyHash).toHaveBeenCalledWith( + decMasterKeyHash, + mockUserId, + ); expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); expect(deviceTrustCryptoService.trustDeviceIfRequired).toHaveBeenCalled(); @@ -136,8 +151,8 @@ describe("AuthRequestLoginStrategy", () => { await authRequestLoginStrategy.logIn(credentials); // setMasterKey and setMasterKeyHash should not be called - expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); - expect(cryptoService.setMasterKeyHash).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setMasterKeyHash).not.toHaveBeenCalled(); // setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index 31a0cebbfee..e47f0f88ee9 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -1,8 +1,10 @@ -import { Observable, map, BehaviorSubject } from "rxjs"; +import { firstValueFrom, Observable, map, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -47,6 +49,8 @@ export class AuthRequestLoginStrategy extends LoginStrategy { constructor( data: AuthRequestLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -61,6 +65,8 @@ export class AuthRequestLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -114,8 +120,15 @@ export class AuthRequestLoginStrategy extends LoginStrategy { authRequestCredentials.decryptedMasterKey && authRequestCredentials.decryptedMasterKeyHash ) { - await this.cryptoService.setMasterKey(authRequestCredentials.decryptedMasterKey); - await this.cryptoService.setMasterKeyHash(authRequestCredentials.decryptedMasterKeyHash); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey( + authRequestCredentials.decryptedMasterKey, + userId, + ); + await this.masterPasswordService.setMasterKeyHash( + authRequestCredentials.decryptedMasterKeyHash, + userId, + ); } } @@ -137,7 +150,8 @@ export class AuthRequestLoginStrategy extends LoginStrategy { } private async trySetUserKeyWithMasterKey(): Promise { - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); await this.cryptoService.setUserKey(userKey); diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 0ac22047c5b..431f736e949 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -14,6 +14,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -31,11 +32,13 @@ import { } from "@bitwarden/common/platform/models/domain/account"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction, PasswordStrengthService, } from "@bitwarden/common/tools/password-strength"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; @@ -56,7 +59,7 @@ const privateKey = "PRIVATE_KEY"; const captchaSiteKey = "CAPTCHA_SITE_KEY"; const kdf = 0; const kdfIterations = 10000; -const userId = Utils.newGuid(); +const userId = Utils.newGuid() as UserId; const masterPasswordHash = "MASTER_PASSWORD_HASH"; const name = "NAME"; const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerResponse = { @@ -98,6 +101,8 @@ export function identityTokenResponseFactory( // TODO: add tests for latest changes to base class for TDE describe("LoginStrategy", () => { let cache: PasswordLoginStrategyData; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let loginStrategyService: MockProxy; let cryptoService: MockProxy; @@ -118,6 +123,9 @@ describe("LoginStrategy", () => { let credentials: PasswordLoginCredentials; beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); + loginStrategyService = mock(); cryptoService = mock(); apiService = mock(); @@ -139,6 +147,8 @@ describe("LoginStrategy", () => { // The base class is abstract so we test it via PasswordLoginStrategy passwordLoginStrategy = new PasswordLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -241,7 +251,7 @@ describe("LoginStrategy", () => { }); apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); const result = await passwordLoginStrategy.logIn(credentials); @@ -260,7 +270,7 @@ describe("LoginStrategy", () => { cryptoService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]); apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await passwordLoginStrategy.logIn(credentials); @@ -382,6 +392,8 @@ describe("LoginStrategy", () => { passwordLoginStrategy = new PasswordLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 4fe99b276cf..df6aa171db4 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -1,6 +1,8 @@ import { BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -60,6 +62,8 @@ export abstract class LoginStrategy { protected abstract cache: BehaviorSubject; constructor( + protected accountService: AccountService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected cryptoService: CryptoService, protected apiService: ApiService, protected tokenService: TokenService, diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 470a4ac713c..b902fff574c 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -9,6 +9,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -19,11 +20,13 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction, PasswordStrengthService, } from "@bitwarden/common/tools/password-strength"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; @@ -42,6 +45,7 @@ const masterKey = new SymmetricCryptoKey( "N2KWjlLpfi5uHjv+YcfUKIpZ1l+W+6HRensmIqD+BFYBf6N/dvFpJfWwYnVBdgFCK2tJTAIMLhqzIQQEUmGFgg==", ), ) as MasterKey; +const userId = Utils.newGuid() as UserId; const deviceId = Utils.newGuid(); const masterPasswordPolicy = new MasterPasswordPolicyResponse({ EnforceOnLogin: true, @@ -50,6 +54,8 @@ const masterPasswordPolicy = new MasterPasswordPolicyResponse({ describe("PasswordLoginStrategy", () => { let cache: PasswordLoginStrategyData; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let loginStrategyService: MockProxy; let cryptoService: MockProxy; @@ -71,6 +77,9 @@ describe("PasswordLoginStrategy", () => { let tokenResponse: IdentityTokenResponse; beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); + loginStrategyService = mock(); cryptoService = mock(); apiService = mock(); @@ -102,6 +111,8 @@ describe("PasswordLoginStrategy", () => { passwordLoginStrategy = new PasswordLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -145,13 +156,16 @@ describe("PasswordLoginStrategy", () => { it("sets keys after a successful authentication", async () => { const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await passwordLoginStrategy.logIn(credentials); - expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); - expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(localHashedPassword); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(masterKey, userId); + expect(masterPasswordService.mock.setMasterKeyHash).toHaveBeenCalledWith( + localHashedPassword, + userId, + ); expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey); @@ -183,8 +197,9 @@ describe("PasswordLoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); expect(policyService.evaluateMasterPassword).toHaveBeenCalled(); - expect(stateService.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.WeakMasterPassword, + userId, ); expect(result.forcePasswordReset).toEqual(ForceSetPasswordReason.WeakMasterPassword); }); @@ -222,8 +237,9 @@ describe("PasswordLoginStrategy", () => { expect(firstResult.forcePasswordReset).toEqual(ForceSetPasswordReason.None); // Second login attempt should save the force password reset options and return in result - expect(stateService.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.WeakMasterPassword, + userId, ); expect(secondResult.forcePasswordReset).toEqual(ForceSetPasswordReason.WeakMasterPassword); }); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index d3de3ea6bac..52c97d5d855 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -1,9 +1,11 @@ -import { BehaviorSubject, map, Observable } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map, Observable } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -70,6 +72,8 @@ export class PasswordLoginStrategy extends LoginStrategy { constructor( data: PasswordLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -86,6 +90,8 @@ export class PasswordLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -157,8 +163,10 @@ export class PasswordLoginStrategy extends LoginStrategy { }); } else { // Authentication was successful, save the force update password options with the state service - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.WeakMasterPassword, + userId, ); authResult.forcePasswordReset = ForceSetPasswordReason.WeakMasterPassword; } @@ -184,7 +192,8 @@ export class PasswordLoginStrategy extends LoginStrategy { !result.requiresCaptcha && forcePasswordResetReason != ForceSetPasswordReason.None ) { - await this.stateService.setForceSetPasswordReason(forcePasswordResetReason); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason(forcePasswordResetReason, userId); result.forcePasswordReset = forcePasswordResetReason; } @@ -193,8 +202,9 @@ export class PasswordLoginStrategy extends LoginStrategy { protected override async setMasterKey(response: IdentityTokenResponse) { const { masterKey, localMasterKeyHash } = this.cache.value; - await this.cryptoService.setMasterKey(masterKey); - await this.cryptoService.setMasterKeyHash(localMasterKeyHash); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey(masterKey, userId); + await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId); } protected override async setUserKey(response: IdentityTokenResponse): Promise { @@ -204,7 +214,8 @@ export class PasswordLoginStrategy extends LoginStrategy { } await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); await this.cryptoService.setUserKey(userKey); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index d4b0b13eafe..bce62681d03 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -9,6 +9,7 @@ import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/a import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -20,7 +21,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { DeviceKey, UserKey, MasterKey } from "@bitwarden/common/types/key"; import { @@ -33,6 +36,9 @@ import { identityTokenResponseFactory } from "./login.strategy.spec"; import { SsoLoginStrategy } from "./sso-login.strategy"; describe("SsoLoginStrategy", () => { + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; + let cryptoService: MockProxy; let apiService: MockProxy; let tokenService: MockProxy; @@ -52,6 +58,7 @@ describe("SsoLoginStrategy", () => { let ssoLoginStrategy: SsoLoginStrategy; let credentials: SsoLoginCredentials; + const userId = Utils.newGuid() as UserId; const deviceId = Utils.newGuid(); const keyConnectorUrl = "KEY_CONNECTOR_URL"; @@ -61,6 +68,9 @@ describe("SsoLoginStrategy", () => { const ssoOrgId = "SSO_ORG_ID"; beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); + cryptoService = mock(); apiService = mock(); tokenService = mock(); @@ -83,6 +93,8 @@ describe("SsoLoginStrategy", () => { ssoLoginStrategy = new SsoLoginStrategy( null, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -130,7 +142,7 @@ describe("SsoLoginStrategy", () => { await ssoLoginStrategy.logIn(credentials); - expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); expect(cryptoService.setUserKey).not.toHaveBeenCalled(); expect(cryptoService.setPrivateKey).not.toHaveBeenCalled(); }); @@ -395,7 +407,7 @@ describe("SsoLoginStrategy", () => { ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); await ssoLoginStrategy.logIn(credentials); @@ -422,7 +434,7 @@ describe("SsoLoginStrategy", () => { ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await ssoLoginStrategy.logIn(credentials); @@ -446,7 +458,7 @@ describe("SsoLoginStrategy", () => { ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); await ssoLoginStrategy.logIn(credentials); @@ -473,7 +485,7 @@ describe("SsoLoginStrategy", () => { ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await ssoLoginStrategy.logIn(credentials); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 7745104bd15..db0228a338e 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -1,9 +1,11 @@ -import { Observable, map, BehaviorSubject } from "rxjs"; +import { firstValueFrom, Observable, map, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -79,6 +81,8 @@ export class SsoLoginStrategy extends LoginStrategy { constructor( data: SsoLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -96,6 +100,8 @@ export class SsoLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -138,7 +144,11 @@ export class SsoLoginStrategy extends LoginStrategy { // Auth guard currently handles redirects for this. if (ssoAuthResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) { - await this.stateService.setForceSetPasswordReason(ssoAuthResult.forcePasswordReset); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( + ssoAuthResult.forcePasswordReset, + userId, + ); } this.cache.next({ @@ -323,7 +333,8 @@ export class SsoLoginStrategy extends LoginStrategy { } private async trySetUserKeyWithMasterKey(): Promise { - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); // There is a scenario in which the master key is not set here. That will occur if the user // has a master password and is using Key Connector. In that case, we cannot set the master key diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 02aed305a46..5e7d7985b11 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -5,6 +5,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -19,7 +20,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; @@ -30,6 +33,8 @@ import { UserApiLoginStrategy, UserApiLoginStrategyData } from "./user-api-login describe("UserApiLoginStrategy", () => { let cache: UserApiLoginStrategyData; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let cryptoService: MockProxy; let apiService: MockProxy; @@ -48,12 +53,16 @@ describe("UserApiLoginStrategy", () => { let apiLogInStrategy: UserApiLoginStrategy; let credentials: UserApiLoginCredentials; + const userId = Utils.newGuid() as UserId; const deviceId = Utils.newGuid(); const keyConnectorUrl = "KEY_CONNECTOR_URL"; const apiClientId = "API_CLIENT_ID"; const apiClientSecret = "API_CLIENT_SECRET"; beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); + cryptoService = mock(); apiService = mock(); tokenService = mock(); @@ -74,6 +83,8 @@ describe("UserApiLoginStrategy", () => { apiLogInStrategy = new UserApiLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -172,7 +183,7 @@ describe("UserApiLoginStrategy", () => { environmentService.environment$ = new BehaviorSubject(env); apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await apiLogInStrategy.logIn(credentials); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index 2af666f95c8..421746b49ce 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -2,7 +2,9 @@ import { firstValueFrom, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; @@ -39,6 +41,8 @@ export class UserApiLoginStrategy extends LoginStrategy { constructor( data: UserApiLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -54,6 +58,8 @@ export class UserApiLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -95,7 +101,8 @@ export class UserApiLoginStrategy extends LoginStrategy { await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); if (response.apiUseKeyConnector) { - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); await this.cryptoService.setUserKey(userKey); diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index edc1441361a..1d96921286f 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -6,6 +6,7 @@ import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -16,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService } from "@bitwarden/common/spec"; import { PrfKey, UserKey } from "@bitwarden/common/types/key"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; @@ -26,6 +28,8 @@ import { WebAuthnLoginStrategy, WebAuthnLoginStrategyData } from "./webauthn-log describe("WebAuthnLoginStrategy", () => { let cache: WebAuthnLoginStrategyData; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let cryptoService!: MockProxy; let apiService!: MockProxy; @@ -63,6 +67,9 @@ describe("WebAuthnLoginStrategy", () => { beforeEach(() => { jest.clearAllMocks(); + accountService = new FakeAccountService(null); + masterPasswordService = new FakeMasterPasswordService(); + cryptoService = mock(); apiService = mock(); tokenService = mock(); @@ -81,6 +88,8 @@ describe("WebAuthnLoginStrategy", () => { webAuthnLoginStrategy = new WebAuthnLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -207,7 +216,7 @@ describe("WebAuthnLoginStrategy", () => { expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey); // Master key and private key should not be set - expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); }); it("does not try to set the user key when prfKey is missing", async () => { diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index a8e67597b82..843978e2a22 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -2,6 +2,8 @@ import { BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -41,6 +43,8 @@ export class WebAuthnLoginStrategy extends LoginStrategy { constructor( data: WebAuthnLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -54,6 +58,8 @@ export class WebAuthnLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts index 80d00b2a01e..f04628ffd94 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts @@ -2,13 +2,15 @@ import { mock } from "jest-mock-extended"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { AuthRequestService } from "./auth-request.service"; @@ -16,17 +18,27 @@ import { AuthRequestService } from "./auth-request.service"; describe("AuthRequestService", () => { let sut: AuthRequestService; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; const appIdService = mock(); const cryptoService = mock(); const apiService = mock(); - const stateService = mock(); let mockPrivateKey: Uint8Array; + const mockUserId = Utils.newGuid() as UserId; beforeEach(() => { jest.clearAllMocks(); + accountService = mockAccountServiceWith(mockUserId); + masterPasswordService = new FakeMasterPasswordService(); - sut = new AuthRequestService(appIdService, cryptoService, apiService, stateService); + sut = new AuthRequestService( + appIdService, + accountService, + masterPasswordService, + cryptoService, + apiService, + ); mockPrivateKey = new Uint8Array(64); }); @@ -67,8 +79,8 @@ describe("AuthRequestService", () => { }); it("should use the master key and hash if they exist", async () => { - cryptoService.getMasterKey.mockResolvedValueOnce({ encKey: new Uint8Array(64) } as MasterKey); - stateService.getKeyHash.mockResolvedValueOnce("KEY_HASH"); + masterPasswordService.masterKeySubject.next({ encKey: new Uint8Array(64) } as MasterKey); + masterPasswordService.masterKeyHashSubject.next("MASTER_KEY_HASH"); await sut.approveOrDenyAuthRequest( true, @@ -130,8 +142,8 @@ describe("AuthRequestService", () => { masterKeyHash: mockDecryptedMasterKeyHash, }); - cryptoService.setMasterKey.mockResolvedValueOnce(undefined); - cryptoService.setMasterKeyHash.mockResolvedValueOnce(undefined); + masterPasswordService.masterKeySubject.next(undefined); + masterPasswordService.masterKeyHashSubject.next(undefined); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValueOnce(mockDecryptedUserKey); cryptoService.setUserKey.mockResolvedValueOnce(undefined); @@ -144,10 +156,18 @@ describe("AuthRequestService", () => { mockAuthReqResponse.masterPasswordHash, mockPrivateKey, ); - expect(cryptoService.setMasterKey).toBeCalledWith(mockDecryptedMasterKey); - expect(cryptoService.setMasterKeyHash).toBeCalledWith(mockDecryptedMasterKeyHash); - expect(cryptoService.decryptUserKeyWithMasterKey).toBeCalledWith(mockDecryptedMasterKey); - expect(cryptoService.setUserKey).toBeCalledWith(mockDecryptedUserKey); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + mockDecryptedMasterKey, + mockUserId, + ); + expect(masterPasswordService.mock.setMasterKeyHash).toHaveBeenCalledWith( + mockDecryptedMasterKeyHash, + mockUserId, + ); + expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockDecryptedMasterKey, + ); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockDecryptedUserKey); }); }); diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.ts b/libs/auth/src/common/services/auth-request/auth-request.service.ts index eb39659f53f..5f8dcfd7290 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.ts @@ -1,12 +1,13 @@ -import { Observable, Subject } from "rxjs"; +import { firstValueFrom, Observable, Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; @@ -19,9 +20,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { constructor( private appIdService: AppIdService, + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cryptoService: CryptoService, private apiService: ApiService, - private stateService: StateService, ) { this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable(); } @@ -38,8 +40,9 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { } const pubKey = Utils.fromB64ToArray(authRequest.publicKey); - const masterKey = await this.cryptoService.getMasterKey(); - const masterKeyHash = await this.stateService.getKeyHash(); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + const masterKeyHash = await firstValueFrom(this.masterPasswordService.masterKeyHash$(userId)); let encryptedMasterKeyHash; let keyToEncrypt; @@ -92,8 +95,9 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); // Set masterKey + masterKeyHash in state after decryption (in case decryption fails) - await this.cryptoService.setMasterKey(masterKey); - await this.cryptoService.setMasterKeyHash(masterKeyHash); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + await this.masterPasswordService.setMasterKey(masterKey, userId); + await this.masterPasswordService.setMasterKeyHash(masterKeyHash, userId); await this.cryptoService.setUserKey(userKey); } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts index 981e4d81ac7..fcc0220d0ab 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -11,6 +11,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -22,8 +23,14 @@ 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 { KdfType } from "@bitwarden/common/platform/enums"; -import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/common/spec"; +import { + FakeAccountService, + FakeGlobalState, + FakeGlobalStateProvider, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { AuthRequestServiceAbstraction, @@ -38,6 +45,8 @@ import { CACHE_EXPIRATION_KEY } from "./login-strategy.state"; describe("LoginStrategyService", () => { let sut: LoginStrategyService; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let cryptoService: MockProxy; let apiService: MockProxy; let tokenService: MockProxy; @@ -61,7 +70,11 @@ describe("LoginStrategyService", () => { let stateProvider: FakeGlobalStateProvider; let loginStrategyCacheExpirationState: FakeGlobalState; + const userId = "USER_ID" as UserId; + beforeEach(() => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); cryptoService = mock(); apiService = mock(); tokenService = mock(); @@ -84,6 +97,8 @@ describe("LoginStrategyService", () => { stateProvider = new FakeGlobalStateProvider(); sut = new LoginStrategyService( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index b55f38af7f6..a8bd7bc2ff2 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -9,8 +9,10 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; @@ -81,6 +83,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { currentAuthType$: Observable; constructor( + protected accountService: AccountService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected cryptoService: CryptoService, protected apiService: ApiService, protected tokenService: TokenService, @@ -257,7 +261,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { ): Promise { const pubKey = Utils.fromB64ToArray(key); - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); let keyToEncrypt; let encryptedMasterKeyHash = null; @@ -266,7 +271,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { // Only encrypt the master password hash if masterKey exists as // we won't have a masterKeyHash without a masterKey - const masterKeyHash = await this.stateService.getKeyHash(); + const masterKeyHash = await firstValueFrom(this.masterPasswordService.masterKeyHash$(userId)); if (masterKeyHash != null) { encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt( Utils.fromUtf8ToArray(masterKeyHash), @@ -333,6 +338,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.Password: return new PasswordLoginStrategy( data?.password, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -351,6 +358,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.Sso: return new SsoLoginStrategy( data?.sso, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -370,6 +379,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.UserApiKey: return new UserApiLoginStrategy( data?.userApiKey, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -387,6 +398,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.AuthRequest: return new AuthRequestLoginStrategy( data?.authRequest, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -403,6 +416,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.WebAuthn: return new WebAuthnLoginStrategy( data?.webAuthn, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index 2f33d9cf023..bd35d901c24 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -32,12 +32,8 @@ export class FakeAccountService implements AccountService { get activeUserId() { return this._activeUserId; } - get accounts$() { - return this.accountsSubject.asObservable(); - } - get activeAccount$() { - return this.activeAccountSubject.asObservable(); - } + accounts$ = this.accountsSubject.asObservable(); + activeAccount$ = this.activeAccountSubject.asObservable(); accountLock$: Observable; accountLogout$: Observable; diff --git a/libs/common/src/abstractions/search.service.ts b/libs/common/src/abstractions/search.service.ts index 97a12c83154..dfcf2c5d078 100644 --- a/libs/common/src/abstractions/search.service.ts +++ b/libs/common/src/abstractions/search.service.ts @@ -1,11 +1,15 @@ +import { Observable } from "rxjs"; + import { SendView } from "../tools/send/models/view/send.view"; +import { IndexedEntityId } from "../types/guid"; import { CipherView } from "../vault/models/view/cipher.view"; export abstract class SearchService { - indexedEntityId?: string = null; - clearIndex: () => void; - isSearchable: (query: string) => boolean; - indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => void; + indexedEntityId$: Observable; + + clearIndex: () => Promise; + isSearchable: (query: string) => Promise; + indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => Promise; searchCiphers: ( query: string, filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[], diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index 7f1a40d1404..66a05cf6133 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -3,9 +3,9 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; +import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; -import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/request/organization-tax-info-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingResponse } from "../../../billing/models/response/billing.response"; @@ -63,7 +63,7 @@ export class OrganizationApiServiceAbstraction { ) => Promise>; rotateApiKey: (id: string, request: OrganizationApiKeyRequest) => Promise; getTaxInfo: (id: string) => Promise; - updateTaxInfo: (id: string, request: OrganizationTaxInfoUpdateRequest) => Promise; + updateTaxInfo: (id: string, request: ExpandedTaxInfoUpdateRequest) => Promise; getKeys: (id: string) => Promise; updateKeys: (id: string, request: OrganizationKeysRequest) => Promise; getSso: (id: string) => Promise; diff --git a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts index 61eb943f1d0..7dc664869c1 100644 --- a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts +++ b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts @@ -1,7 +1,10 @@ +import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request"; + export class ProviderSetupRequest { name: string; businessName: string; billingEmail: string; token: string; key: string; + taxInfo: ExpandedTaxInfoUpdateRequest; } diff --git a/libs/common/src/admin-console/services/organization-domain/requests/organization-domain.request.ts b/libs/common/src/admin-console/services/organization-domain/requests/organization-domain.request.ts index fb515e3cbc9..c316a1d27cd 100644 --- a/libs/common/src/admin-console/services/organization-domain/requests/organization-domain.request.ts +++ b/libs/common/src/admin-console/services/organization-domain/requests/organization-domain.request.ts @@ -1,9 +1,7 @@ export class OrganizationDomainRequest { - txt: string; domainName: string; - constructor(txt: string, domainName: string) { - this.txt = txt; + constructor(domainName: string) { this.domainName = domainName; } } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 883bf352604..262232a9649 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -4,9 +4,9 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; +import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; -import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/request/organization-tax-info-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingResponse } from "../../../billing/models/response/billing.response"; @@ -257,7 +257,7 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new TaxInfoResponse(r); } - async updateTaxInfo(id: string, request: OrganizationTaxInfoUpdateRequest): Promise { + async updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise { // Can't broadcast anything because the response doesn't have content return this.apiService.send("PUT", "/organizations/" + id + "/tax", request, true, false); } diff --git a/libs/common/src/auth/abstractions/master-password.service.abstraction.ts b/libs/common/src/auth/abstractions/master-password.service.abstraction.ts new file mode 100644 index 00000000000..b36c8bfaae1 --- /dev/null +++ b/libs/common/src/auth/abstractions/master-password.service.abstraction.ts @@ -0,0 +1,82 @@ +import { Observable } from "rxjs"; + +import { EncString } from "../../platform/models/domain/enc-string"; +import { UserId } from "../../types/guid"; +import { MasterKey } from "../../types/key"; +import { ForceSetPasswordReason } from "../models/domain/force-set-password-reason"; + +export abstract class MasterPasswordServiceAbstraction { + /** + * An observable that emits if the user is being forced to set a password on login and why. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract forceSetPasswordReason$: (userId: UserId) => Observable; + /** + * An observable that emits the master key for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract masterKey$: (userId: UserId) => Observable; + /** + * An observable that emits the master key hash for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract masterKeyHash$: (userId: UserId) => Observable; + /** + * Returns the master key encrypted user key for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract getMasterKeyEncryptedUserKey: (userId: UserId) => Promise; +} + +export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction { + /** + * Set the master key for the user. + * Note: Use {@link clearMasterKey} to clear the master key. + * @param masterKey The master key. + * @param userId The user ID. + * @throws If the user ID or master key is missing. + */ + abstract setMasterKey: (masterKey: MasterKey, userId: UserId) => Promise; + /** + * Clear the master key for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract clearMasterKey: (userId: UserId) => Promise; + /** + * Set the master key hash for the user. + * Note: Use {@link clearMasterKeyHash} to clear the master key hash. + * @param masterKeyHash The master key hash. + * @param userId The user ID. + * @throws If the user ID or master key hash is missing. + */ + abstract setMasterKeyHash: (masterKeyHash: string, userId: UserId) => Promise; + /** + * Clear the master key hash for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract clearMasterKeyHash: (userId: UserId) => Promise; + + /** + * Set the master key encrypted user key for the user. + * @param encryptedKey The master key encrypted user key. + * @param userId The user ID. + * @throws If the user ID or encrypted key is missing. + */ + abstract setMasterKeyEncryptedUserKey: (encryptedKey: EncString, userId: UserId) => Promise; + /** + * Set the force set password reason for the user. + * @param reason The reason the user is being forced to set a password. + * @param userId The user ID. + * @throws If the user ID or reason is missing. + */ + abstract setForceSetPasswordReason: ( + reason: ForceSetPasswordReason, + userId: UserId, + ) => Promise; +} diff --git a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts index e65c5cd499a..6fb58eab289 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts @@ -14,7 +14,7 @@ import { StorageLocation } from "../../platform/enums"; import { EncString } from "../../platform/models/domain/enc-string"; import { StorageOptions } from "../../platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; -import { DEVICE_TRUST_DISK_LOCAL, KeyDefinition, StateProvider } from "../../platform/state"; +import { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../platform/state"; import { UserId } from "../../types/guid"; import { UserKey, DeviceKey } from "../../types/key"; import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; @@ -27,16 +27,18 @@ import { } from "../models/request/update-devices-trust.request"; /** Uses disk storage so that the device key can persist after log out and tab removal. */ -export const DEVICE_KEY = new KeyDefinition(DEVICE_TRUST_DISK_LOCAL, "deviceKey", { +export const DEVICE_KEY = new UserKeyDefinition(DEVICE_TRUST_DISK_LOCAL, "deviceKey", { deserializer: (deviceKey) => SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey, + clearOn: [], // Device key is needed to log back into device, so we can't clear it automatically during lock or logout }); /** Uses disk storage so that the shouldTrustDevice bool can persist across login. */ -export const SHOULD_TRUST_DEVICE = new KeyDefinition( +export const SHOULD_TRUST_DEVICE = new UserKeyDefinition( DEVICE_TRUST_DISK_LOCAL, "shouldTrustDevice", { deserializer: (shouldTrustDevice) => shouldTrustDevice, + clearOn: [], // Need to preserve the user setting, so we can't clear it automatically during lock or logout }, ); diff --git a/libs/common/src/auth/services/key-connector.service.spec.ts b/libs/common/src/auth/services/key-connector.service.spec.ts index 50fed856f97..e3e5fbdbe7d 100644 --- a/libs/common/src/auth/services/key-connector.service.spec.ts +++ b/libs/common/src/auth/services/key-connector.service.spec.ts @@ -21,6 +21,7 @@ import { CONVERT_ACCOUNT_TO_KEY_CONNECTOR, KeyConnectorService, } from "./key-connector.service"; +import { FakeMasterPasswordService } from "./master-password/fake-master-password.service"; import { TokenService } from "./token.service"; describe("KeyConnectorService", () => { @@ -36,6 +37,7 @@ describe("KeyConnectorService", () => { let stateProvider: FakeStateProvider; let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; const mockUserId = Utils.newGuid() as UserId; const mockOrgId = Utils.newGuid() as OrganizationId; @@ -47,10 +49,13 @@ describe("KeyConnectorService", () => { beforeEach(() => { jest.clearAllMocks(); + masterPasswordService = new FakeMasterPasswordService(); accountService = mockAccountServiceWith(mockUserId); stateProvider = new FakeStateProvider(accountService); keyConnectorService = new KeyConnectorService( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -214,7 +219,10 @@ describe("KeyConnectorService", () => { // Assert expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url); - expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + masterKey, + expect.any(String), + ); }); it("should handle errors thrown during the process", async () => { @@ -241,10 +249,10 @@ describe("KeyConnectorService", () => { // Arrange const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); + masterPasswordService.masterKeySubject.next(masterKey); const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); - jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); // Act @@ -252,7 +260,6 @@ describe("KeyConnectorService", () => { // Assert expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled(); - expect(cryptoService.getMasterKey).toHaveBeenCalled(); expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( organization.keyConnectorUrl, keyConnectorRequest, @@ -268,8 +275,8 @@ describe("KeyConnectorService", () => { const error = new Error("Failed to post user key to key connector"); organizationService.getAll.mockResolvedValue([organization]); + masterPasswordService.masterKeySubject.next(masterKey); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); - jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockRejectedValue(error); jest.spyOn(logService, "error"); @@ -280,7 +287,6 @@ describe("KeyConnectorService", () => { // Assert expect(logService.error).toHaveBeenCalledWith(error); expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled(); - expect(cryptoService.getMasterKey).toHaveBeenCalled(); expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( organization.keyConnectorUrl, keyConnectorRequest, diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index d1502ce06c3..f8e523cce46 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -16,7 +16,9 @@ import { UserKeyDefinition, } from "../../platform/state"; import { MasterKey } from "../../types/key"; +import { AccountService } from "../abstractions/account.service"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; import { TokenService } from "../abstractions/token.service"; import { KdfConfig } from "../models/domain/kdf-config"; import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request"; @@ -45,6 +47,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private usesKeyConnectorState: ActiveUserState; private convertAccountToKeyConnectorState: ActiveUserState; constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cryptoService: CryptoService, private apiService: ApiService, private tokenService: TokenService, @@ -78,7 +82,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { async migrateUser() { const organization = await this.getManagingOrganization(); - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); try { @@ -99,7 +104,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { const masterKeyResponse = await this.apiService.getMasterKeyFromKeyConnector(url); const keyArr = Utils.fromB64ToArray(masterKeyResponse.key); const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; - await this.cryptoService.setMasterKey(masterKey); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey(masterKey, userId); } catch (e) { this.handleKeyConnectorError(e); } @@ -136,7 +142,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { kdfConfig, ); const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); - await this.cryptoService.setMasterKey(masterKey); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey(masterKey, userId); const userKey = await this.cryptoService.makeUserKey(masterKey); await this.cryptoService.setUserKey(userKey[0]); diff --git a/libs/common/src/auth/services/master-password/fake-master-password.service.ts b/libs/common/src/auth/services/master-password/fake-master-password.service.ts new file mode 100644 index 00000000000..dd034ec50be --- /dev/null +++ b/libs/common/src/auth/services/master-password/fake-master-password.service.ts @@ -0,0 +1,64 @@ +import { mock } from "jest-mock-extended"; +import { ReplaySubject, Observable } from "rxjs"; + +import { EncString } from "../../../platform/models/domain/enc-string"; +import { UserId } from "../../../types/guid"; +import { MasterKey } from "../../../types/key"; +import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction"; +import { ForceSetPasswordReason } from "../../models/domain/force-set-password-reason"; + +export class FakeMasterPasswordService implements InternalMasterPasswordServiceAbstraction { + mock = mock(); + + // eslint-disable-next-line rxjs/no-exposed-subjects -- test class + masterKeySubject = new ReplaySubject(1); + // eslint-disable-next-line rxjs/no-exposed-subjects -- test class + masterKeyHashSubject = new ReplaySubject(1); + // eslint-disable-next-line rxjs/no-exposed-subjects -- test class + forceSetPasswordReasonSubject = new ReplaySubject(1); + + constructor(initialMasterKey?: MasterKey, initialMasterKeyHash?: string) { + this.masterKeySubject.next(initialMasterKey); + this.masterKeyHashSubject.next(initialMasterKeyHash); + } + + masterKey$(userId: UserId): Observable { + return this.masterKeySubject.asObservable(); + } + + setMasterKey(masterKey: MasterKey, userId: UserId): Promise { + return this.mock.setMasterKey(masterKey, userId); + } + + clearMasterKey(userId: UserId): Promise { + return this.mock.clearMasterKey(userId); + } + + masterKeyHash$(userId: UserId): Observable { + return this.masterKeyHashSubject.asObservable(); + } + + getMasterKeyEncryptedUserKey(userId: UserId): Promise { + return this.mock.getMasterKeyEncryptedUserKey(userId); + } + + setMasterKeyEncryptedUserKey(encryptedKey: EncString, userId: UserId): Promise { + return this.mock.setMasterKeyEncryptedUserKey(encryptedKey, userId); + } + + setMasterKeyHash(masterKeyHash: string, userId: UserId): Promise { + return this.mock.setMasterKeyHash(masterKeyHash, userId); + } + + clearMasterKeyHash(userId: UserId): Promise { + return this.mock.clearMasterKeyHash(userId); + } + + forceSetPasswordReason$(userId: UserId): Observable { + return this.forceSetPasswordReasonSubject.asObservable(); + } + + setForceSetPasswordReason(reason: ForceSetPasswordReason, userId: UserId): Promise { + return this.mock.setForceSetPasswordReason(reason, userId); + } +} diff --git a/libs/common/src/auth/services/master-password/master-password.service.ts b/libs/common/src/auth/services/master-password/master-password.service.ts new file mode 100644 index 00000000000..fad48abc12b --- /dev/null +++ b/libs/common/src/auth/services/master-password/master-password.service.ts @@ -0,0 +1,140 @@ +import { firstValueFrom, map, Observable } from "rxjs"; + +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { + MASTER_PASSWORD_DISK, + MASTER_PASSWORD_MEMORY, + StateProvider, + UserKeyDefinition, +} from "../../../platform/state"; +import { UserId } from "../../../types/guid"; +import { MasterKey } from "../../../types/key"; +import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction"; +import { ForceSetPasswordReason } from "../../models/domain/force-set-password-reason"; + +/** Memory since master key shouldn't be available on lock */ +const MASTER_KEY = new UserKeyDefinition(MASTER_PASSWORD_MEMORY, "masterKey", { + deserializer: (masterKey) => SymmetricCryptoKey.fromJSON(masterKey) as MasterKey, + clearOn: ["lock", "logout"], +}); + +/** Disk since master key hash is used for unlock */ +const MASTER_KEY_HASH = new UserKeyDefinition(MASTER_PASSWORD_DISK, "masterKeyHash", { + deserializer: (masterKeyHash) => masterKeyHash, + clearOn: ["logout"], +}); + +/** Disk to persist through lock */ +const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( + MASTER_PASSWORD_DISK, + "masterKeyEncryptedUserKey", + { + deserializer: (key) => key, + clearOn: ["logout"], + }, +); + +/** Disk to persist through lock and account switches */ +const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( + MASTER_PASSWORD_DISK, + "forceSetPasswordReason", + { + deserializer: (reason) => reason, + clearOn: ["logout"], + }, +); + +export class MasterPasswordService implements InternalMasterPasswordServiceAbstraction { + constructor(private stateProvider: StateProvider) {} + + masterKey$(userId: UserId): Observable { + if (userId == null) { + throw new Error("User ID is required."); + } + return this.stateProvider.getUser(userId, MASTER_KEY).state$; + } + + masterKeyHash$(userId: UserId): Observable { + if (userId == null) { + throw new Error("User ID is required."); + } + return this.stateProvider.getUser(userId, MASTER_KEY_HASH).state$; + } + + forceSetPasswordReason$(userId: UserId): Observable { + if (userId == null) { + throw new Error("User ID is required."); + } + return this.stateProvider + .getUser(userId, FORCE_SET_PASSWORD_REASON) + .state$.pipe(map((reason) => reason ?? ForceSetPasswordReason.None)); + } + + // TODO: Remove this method and decrypt directly in the service instead + async getMasterKeyEncryptedUserKey(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + const key = await firstValueFrom( + this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$, + ); + return EncString.fromJSON(key); + } + + async setMasterKey(masterKey: MasterKey, userId: UserId): Promise { + if (masterKey == null) { + throw new Error("Master key is required."); + } + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, MASTER_KEY).update((_) => masterKey); + } + + async clearMasterKey(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, MASTER_KEY).update((_) => null); + } + + async setMasterKeyHash(masterKeyHash: string, userId: UserId): Promise { + if (masterKeyHash == null) { + throw new Error("Master key hash is required."); + } + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, MASTER_KEY_HASH).update((_) => masterKeyHash); + } + + async clearMasterKeyHash(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, MASTER_KEY_HASH).update((_) => null); + } + + async setMasterKeyEncryptedUserKey(encryptedKey: EncString, userId: UserId): Promise { + if (encryptedKey == null) { + throw new Error("Encrypted Key is required."); + } + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider + .getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY) + .update((_) => encryptedKey.toJSON() as EncryptedString); + } + + async setForceSetPasswordReason(reason: ForceSetPasswordReason, userId: UserId): Promise { + if (reason == null) { + throw new Error("Reason is required."); + } + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).update((_) => reason); + } +} diff --git a/libs/common/src/auth/services/sso-login.service.ts b/libs/common/src/auth/services/sso-login.service.ts index 99640e1c6c6..3df6ef3540c 100644 --- a/libs/common/src/auth/services/sso-login.service.ts +++ b/libs/common/src/auth/services/sso-login.service.ts @@ -6,6 +6,7 @@ import { KeyDefinition, SSO_DISK, StateProvider, + UserKeyDefinition, } from "../../platform/state"; import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.abstraction"; @@ -26,7 +27,19 @@ const SSO_STATE = new KeyDefinition(SSO_DISK, "ssoState", { /** * Uses disk storage so that the organization sso identifier can be persisted across sso redirects. */ -const ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( +const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition( + SSO_DISK, + "organizationSsoIdentifier", + { + deserializer: (organizationIdentifier) => organizationIdentifier, + clearOn: ["logout"], // Used for login, so not needed past logout + }, +); + +/** + * Uses disk storage so that the organization sso identifier can be persisted across sso redirects. + */ +const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( SSO_DISK, "organizationSsoIdentifier", { @@ -51,10 +64,10 @@ export class SsoLoginService implements SsoLoginServiceAbstraction { constructor(private stateProvider: StateProvider) { this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER); this.ssoState = this.stateProvider.getGlobal(SSO_STATE); - this.orgSsoIdentifierState = this.stateProvider.getGlobal(ORGANIZATION_SSO_IDENTIFIER); + this.orgSsoIdentifierState = this.stateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER); this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL); this.activeUserOrgSsoIdentifierState = this.stateProvider.getActive( - ORGANIZATION_SSO_IDENTIFIER, + USER_ORGANIZATION_SSO_IDENTIFIER, ); } diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index fb13c218705..db399976631 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -15,8 +15,8 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt import { GlobalState, GlobalStateProvider, - KeyDefinition, SingleUserStateProvider, + UserKeyDefinition, } from "../../platform/state"; import { UserId } from "../../types/guid"; import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service"; @@ -863,7 +863,7 @@ export class TokenService implements TokenServiceAbstraction { private async getStateValueByUserIdAndKeyDef( userId: UserId, - storageLocation: KeyDefinition, + storageLocation: UserKeyDefinition, ): Promise { // read from single user state provider return await firstValueFrom(this.singleUserStateProvider.get(userId, storageLocation).state$); diff --git a/libs/common/src/auth/services/token.state.spec.ts b/libs/common/src/auth/services/token.state.spec.ts index 24eddc73f56..55f97b7e00c 100644 --- a/libs/common/src/auth/services/token.state.spec.ts +++ b/libs/common/src/auth/services/token.state.spec.ts @@ -1,4 +1,4 @@ -import { KeyDefinition } from "../../platform/state"; +import { KeyDefinition, UserKeyDefinition } from "../../platform/state"; import { ACCESS_TOKEN_DISK, @@ -28,8 +28,8 @@ describe.each([ "deserializes state key definitions", ( keyDefinition: - | KeyDefinition - | KeyDefinition + | UserKeyDefinition + | UserKeyDefinition | KeyDefinition>, state: string | boolean | Record, ) => { @@ -50,7 +50,10 @@ describe.each([ return typeof value === "object" && value !== null && !Array.isArray(value); } - function testDeserialization(keyDefinition: KeyDefinition, state: T) { + function testDeserialization( + keyDefinition: KeyDefinition | UserKeyDefinition, + state: T, + ) { const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); expect(deserialized).toEqual(state); } diff --git a/libs/common/src/auth/services/token.state.ts b/libs/common/src/auth/services/token.state.ts index 368f3c4ca29..a8c6878fbb6 100644 --- a/libs/common/src/auth/services/token.state.ts +++ b/libs/common/src/auth/services/token.state.ts @@ -1,30 +1,41 @@ -import { KeyDefinition, TOKEN_DISK, TOKEN_DISK_LOCAL, TOKEN_MEMORY } from "../../platform/state"; +import { + KeyDefinition, + TOKEN_DISK, + TOKEN_DISK_LOCAL, + TOKEN_MEMORY, + UserKeyDefinition, +} from "../../platform/state"; // Note: all tokens / API key information must be cleared on logout. // because we are using secure storage, we must manually call to clean up our tokens. // See stateService.deAuthenticateAccount for where we call clearTokens(...) -export const ACCESS_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "accessToken", { +export const ACCESS_TOKEN_DISK = new UserKeyDefinition(TOKEN_DISK, "accessToken", { deserializer: (accessToken) => accessToken, + clearOn: [], // Manually handled }); -export const ACCESS_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "accessToken", { +export const ACCESS_TOKEN_MEMORY = new UserKeyDefinition(TOKEN_MEMORY, "accessToken", { deserializer: (accessToken) => accessToken, + clearOn: [], // Manually handled }); -export const REFRESH_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "refreshToken", { +export const REFRESH_TOKEN_DISK = new UserKeyDefinition(TOKEN_DISK, "refreshToken", { deserializer: (refreshToken) => refreshToken, + clearOn: [], // Manually handled }); -export const REFRESH_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "refreshToken", { +export const REFRESH_TOKEN_MEMORY = new UserKeyDefinition(TOKEN_MEMORY, "refreshToken", { deserializer: (refreshToken) => refreshToken, + clearOn: [], // Manually handled }); -export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition( +export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new UserKeyDefinition( TOKEN_DISK, "refreshTokenMigratedToSecureStorage", { deserializer: (refreshTokenMigratedToSecureStorage) => refreshTokenMigratedToSecureStorage, + clearOn: [], // Don't clear on lock/logout so that we always check the correct place (secure storage) for the refresh token if it's been migrated }, ); @@ -36,26 +47,34 @@ export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL = KeyDefinition.record(TOKEN_DISK, "apiKeyClientId", { +export const API_KEY_CLIENT_ID_DISK = new UserKeyDefinition(TOKEN_DISK, "apiKeyClientId", { deserializer: (apiKeyClientId) => apiKeyClientId, + clearOn: [], // Manually handled }); -export const API_KEY_CLIENT_ID_MEMORY = new KeyDefinition(TOKEN_MEMORY, "apiKeyClientId", { - deserializer: (apiKeyClientId) => apiKeyClientId, -}); +export const API_KEY_CLIENT_ID_MEMORY = new UserKeyDefinition( + TOKEN_MEMORY, + "apiKeyClientId", + { + deserializer: (apiKeyClientId) => apiKeyClientId, + clearOn: [], // Manually handled + }, +); -export const API_KEY_CLIENT_SECRET_DISK = new KeyDefinition( +export const API_KEY_CLIENT_SECRET_DISK = new UserKeyDefinition( TOKEN_DISK, "apiKeyClientSecret", { deserializer: (apiKeyClientSecret) => apiKeyClientSecret, + clearOn: [], // Manually handled }, ); -export const API_KEY_CLIENT_SECRET_MEMORY = new KeyDefinition( +export const API_KEY_CLIENT_SECRET_MEMORY = new UserKeyDefinition( TOKEN_MEMORY, "apiKeyClientSecret", { deserializer: (apiKeyClientSecret) => apiKeyClientSecret, + clearOn: [], // Manually handled }, ); diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.ts b/libs/common/src/auth/services/user-verification/user-verification.service.ts index 0b4cd960993..5a443b784d6 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.ts @@ -10,7 +10,10 @@ import { LogService } from "../../../platform/abstractions/log.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { KeySuffixOptions } from "../../../platform/enums/key-suffix-options.enum"; +import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; +import { AccountService } from "../../abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction"; 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"; @@ -35,6 +38,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti constructor( private stateService: StateService, private cryptoService: CryptoService, + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private i18nService: I18nService, private userVerificationApiService: UserVerificationApiServiceAbstraction, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, @@ -107,7 +112,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti if (verification.type === VerificationType.OTP) { request.otp = verification.secret; } else { - let masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (!masterKey && !alreadyHashed) { masterKey = await this.cryptoService.makeMasterKey( verification.secret, @@ -164,7 +170,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti private async verifyUserByMasterPassword( verification: MasterPasswordVerification, ): Promise { - let masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (!masterKey) { masterKey = await this.cryptoService.makeMasterKey( verification.secret, @@ -181,7 +188,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti 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); + await this.masterPasswordService.setMasterKey(masterKey, userId); return true; } @@ -230,9 +237,10 @@ export class UserVerificationService implements UserVerificationServiceAbstracti } async hasMasterPasswordAndMasterKeyHash(userId?: string): Promise { + userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; return ( (await this.hasMasterPassword(userId)) && - (await this.cryptoService.getMasterKeyHash()) != null + (await firstValueFrom(this.masterPasswordService.masterKeyHash$(userId as UserId))) != null ); } diff --git a/libs/common/src/billing/models/request/organization-tax-info-update.request.ts b/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts similarity index 66% rename from libs/common/src/billing/models/request/organization-tax-info-update.request.ts rename to libs/common/src/billing/models/request/expanded-tax-info-update.request.ts index 0f8ec92160e..6589b9c1df1 100644 --- a/libs/common/src/billing/models/request/organization-tax-info-update.request.ts +++ b/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts @@ -1,6 +1,6 @@ import { TaxInfoUpdateRequest } from "./tax-info-update.request"; -export class OrganizationTaxInfoUpdateRequest extends TaxInfoUpdateRequest { +export class ExpandedTaxInfoUpdateRequest extends TaxInfoUpdateRequest { taxId: string; line1: string; line2: string; diff --git a/libs/common/src/billing/models/request/payment.request.ts b/libs/common/src/billing/models/request/payment.request.ts index d54ca91f62a..e73a10bcea7 100644 --- a/libs/common/src/billing/models/request/payment.request.ts +++ b/libs/common/src/billing/models/request/payment.request.ts @@ -1,8 +1,8 @@ import { PaymentMethodType } from "../../enums"; -import { OrganizationTaxInfoUpdateRequest } from "./organization-tax-info-update.request"; +import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request"; -export class PaymentRequest extends OrganizationTaxInfoUpdateRequest { +export class PaymentRequest extends ExpandedTaxInfoUpdateRequest { paymentMethodType: PaymentMethodType; paymentToken: string; } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9d427034bd5..b937e6c462d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -9,6 +9,7 @@ export enum FeatureFlag { ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners", EnableConsolidatedBilling = "enable-consolidated-billing", AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section", + UnassignedItemsBanner = "unassigned-items-banner", } // Replace this with a type safe lookup of the feature flag values in PM-2282 diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index 85b2bfe82e7..6609a1014e3 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -26,7 +26,7 @@ export abstract class CryptoService { * any other necessary versions (such as auto, biometrics, * or pin) * - * @throws when key is null. Use {@link clearUserKey} instead + * @throws when key is null. Lock the account to clear a key * @param key The user key to set * @param userId The desired user */ @@ -93,13 +93,6 @@ export abstract class CryptoService { * @returns A new user key and the master key protected version of it */ abstract makeUserKey(key: MasterKey): Promise<[UserKey, EncString]>; - /** - * Clears the user key - * @param clearStoredKeys Clears all stored versions of the user keys as well, - * such as the biometrics key - * @param userId The desired user - */ - abstract clearUserKey(clearSecretStorage?: boolean, userId?: string): Promise; /** * Clears the user's stored version of the user key * @param keySuffix The desired version of the key to clear @@ -112,18 +105,6 @@ export abstract class CryptoService { * @param userId The desired user */ abstract setMasterKeyEncryptedUserKey(UserKeyMasterKey: string, userId?: string): Promise; - /** - * Sets the user's master key - * @param key The user's master key to set - * @param userId The desired user - */ - abstract setMasterKey(key: MasterKey, userId?: string): Promise; - /** - * @param userId The desired user - * @returns The user's master key - */ - abstract getMasterKey(userId?: string): Promise; - /** * @param password The user's master password that will be used to derive a master key if one isn't found * @param userId The desired user @@ -143,11 +124,6 @@ export abstract class CryptoService { kdf: KdfType, KdfConfig: KdfConfig, ): Promise; - /** - * Clears the user's master key - * @param userId The desired user - */ - abstract clearMasterKey(userId?: string): Promise; /** * Encrypts the existing (or provided) user key with the * provided master key @@ -185,20 +161,6 @@ export abstract class CryptoService { key: MasterKey, hashPurpose?: HashPurpose, ): Promise; - /** - * Sets the user's master password hash - * @param keyHash The user's master password hash to set - */ - abstract setMasterKeyHash(keyHash: string): Promise; - /** - * @returns The user's master password hash - */ - abstract getMasterKeyHash(): Promise; - /** - * Clears the user's stored master password hash - * @param userId The desired user - */ - abstract clearMasterKeyHash(userId?: string): Promise; /** * Compares the provided master password to the stored password hash and server password hash. * Updates the stored hash if outdated. @@ -238,12 +200,6 @@ export abstract class CryptoService { abstract makeDataEncKey( key: T, ): Promise<[SymmetricCryptoKey, EncString]>; - /** - * Clears the user's stored organization keys - * @param memoryOnly Clear only the in-memory keys - * @param userId The desired user - */ - abstract clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise; /** * Stores the encrypted provider keys and clears any decrypted * provider keys currently in memory @@ -260,11 +216,6 @@ export abstract class CryptoService { * @returns A record of the provider Ids to their symmetric keys */ abstract getProviderKeys(): Promise>; - /** - * @param memoryOnly Clear only the in-memory keys - * @param userId The desired user - */ - abstract clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise; /** * Returns the public key from memory. If not available, extracts it * from the private key and stores it in memory @@ -304,12 +255,6 @@ export abstract class CryptoService { * @returns A new keypair: [publicKey in Base64, encrypted privateKey] */ abstract makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]>; - /** - * Clears the user's key pair - * @param memoryOnly Clear only the in-memory keys - * @param userId The desired user - */ - abstract clearKeyPair(memoryOnly?: boolean, userId?: string): Promise; /** * @param pin The user's pin * @param salt The user's salt diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 49714813819..227cb43879f 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -1,14 +1,12 @@ import { Observable } from "rxjs"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; -import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { UserId } from "../../types/guid"; -import { MasterKey } from "../../types/key"; import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; @@ -17,7 +15,6 @@ import { KdfType } from "../enums"; import { Account } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; import { StorageOptions } from "../models/domain/storage-options"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; /** * Options for customizing the initiation behavior. @@ -48,22 +45,6 @@ export abstract class StateService { getAddEditCipherInfo: (options?: StorageOptions) => Promise; setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise; - /** - * Gets the user's master key - */ - getMasterKey: (options?: StorageOptions) => Promise; - /** - * Sets the user's master key - */ - setMasterKey: (value: MasterKey, options?: StorageOptions) => Promise; - /** - * Gets the user key encrypted by the master key - */ - getMasterKeyEncryptedUserKey: (options?: StorageOptions) => Promise; - /** - * Sets the user key encrypted by the master key - */ - setMasterKeyEncryptedUserKey: (value: string, options?: StorageOptions) => Promise; /** * Gets the user's auto key */ @@ -108,10 +89,6 @@ export abstract class StateService { * @deprecated For migration purposes only, use getUserKeyMasterKey instead */ getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise; - /** - * @deprecated For legacy purposes only, use getMasterKey instead - */ - getCryptoMasterKey: (options?: StorageOptions) => Promise; /** * @deprecated For migration purposes only, use getUserKeyAuto instead */ @@ -189,18 +166,11 @@ export abstract class StateService { setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise; - getForceSetPasswordReason: (options?: StorageOptions) => Promise; - setForceSetPasswordReason: ( - value: ForceSetPasswordReason, - options?: StorageOptions, - ) => Promise; getIsAuthenticated: (options?: StorageOptions) => Promise; getKdfConfig: (options?: StorageOptions) => Promise; setKdfConfig: (kdfConfig: KdfConfig, options?: StorageOptions) => Promise; getKdfType: (options?: StorageOptions) => Promise; setKdfType: (value: KdfType, options?: StorageOptions) => Promise; - getKeyHash: (options?: StorageOptions) => Promise; - setKeyHash: (value: string, options?: StorageOptions) => Promise; getLastActive: (options?: StorageOptions) => Promise; setLastActive: (value: number, options?: StorageOptions) => Promise; getLastSync: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/biometrics/biometric.state.spec.ts b/libs/common/src/platform/biometrics/biometric.state.spec.ts index 420a0fb86e2..7bcccd2ea9b 100644 --- a/libs/common/src/platform/biometrics/biometric.state.spec.ts +++ b/libs/common/src/platform/biometrics/biometric.state.spec.ts @@ -1,5 +1,5 @@ import { EncryptedString } from "../models/domain/enc-string"; -import { KeyDefinition } from "../state"; +import { KeyDefinition, UserKeyDefinition } from "../state"; import { BIOMETRIC_UNLOCK_ENABLED, @@ -22,9 +22,15 @@ describe.each([ ])( "deserializes state %s", ( - ...args: [KeyDefinition, EncryptedString] | [KeyDefinition, boolean] + ...args: + | [UserKeyDefinition, EncryptedString] + | [UserKeyDefinition, boolean] + | [KeyDefinition, boolean] ) => { - function testDeserialization(keyDefinition: KeyDefinition, state: T) { + function testDeserialization( + keyDefinition: UserKeyDefinition | KeyDefinition, + state: T, + ) { const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); expect(deserialized).toEqual(state); } diff --git a/libs/common/src/platform/biometrics/biometric.state.ts b/libs/common/src/platform/biometrics/biometric.state.ts index aa16e14baa1..bcefb7b2158 100644 --- a/libs/common/src/platform/biometrics/biometric.state.ts +++ b/libs/common/src/platform/biometrics/biometric.state.ts @@ -1,15 +1,16 @@ import { UserId } from "../../types/guid"; import { EncryptedString } from "../models/domain/enc-string"; -import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state"; +import { KeyDefinition, BIOMETRIC_SETTINGS_DISK, UserKeyDefinition } from "../state"; /** * Indicates whether the user elected to store a biometric key to unlock their vault. */ -export const BIOMETRIC_UNLOCK_ENABLED = new KeyDefinition( +export const BIOMETRIC_UNLOCK_ENABLED = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "biometricUnlockEnabled", { deserializer: (obj) => obj, + clearOn: [], }, ); @@ -18,11 +19,12 @@ export const BIOMETRIC_UNLOCK_ENABLED = new KeyDefinition( * * A true setting controls whether {@link ENCRYPTED_CLIENT_KEY_HALF} is set. */ -export const REQUIRE_PASSWORD_ON_START = new KeyDefinition( +export const REQUIRE_PASSWORD_ON_START = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "requirePasswordOnStart", { deserializer: (value) => value, + clearOn: [], }, ); @@ -33,11 +35,12 @@ export const REQUIRE_PASSWORD_ON_START = new KeyDefinition( * For operating systems without application-level key storage, this key half is concatenated with a signature * provided by the OS and used to encrypt the biometric key prior to storage. */ -export const ENCRYPTED_CLIENT_KEY_HALF = new KeyDefinition( +export const ENCRYPTED_CLIENT_KEY_HALF = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "clientKeyHalf", { deserializer: (obj) => obj, + clearOn: ["logout"], }, ); @@ -45,11 +48,12 @@ export const ENCRYPTED_CLIENT_KEY_HALF = new KeyDefinition( * Indicates the user has been warned about the security implications of using biometrics and, depending on the OS, * recommended to require a password on first unlock of an application instance. */ -export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition( +export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "dismissedBiometricRequirePasswordOnStartCallout", { deserializer: (obj) => obj, + clearOn: [], }, ); @@ -68,11 +72,12 @@ export const PROMPT_CANCELLED = KeyDefinition.record( /** * Stores whether the user has elected to automatically prompt for biometric unlock on application start. */ -export const PROMPT_AUTOMATICALLY = new KeyDefinition( +export const PROMPT_AUTOMATICALLY = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "promptAutomatically", { deserializer: (obj) => obj, + clearOn: [], }, ); diff --git a/libs/common/src/platform/models/domain/account-keys.spec.ts b/libs/common/src/platform/models/domain/account-keys.spec.ts index 4a96da1b489..6bdb08edd51 100644 --- a/libs/common/src/platform/models/domain/account-keys.spec.ts +++ b/libs/common/src/platform/models/domain/account-keys.spec.ts @@ -2,7 +2,6 @@ import { makeStaticByteArray } from "../../../../spec"; import { Utils } from "../../misc/utils"; import { AccountKeys, EncryptionPair } from "./account"; -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; describe("AccountKeys", () => { describe("toJSON", () => { @@ -32,12 +31,6 @@ describe("AccountKeys", () => { expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello")); }); - it("should deserialize cryptoMasterKey", () => { - const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); - AccountKeys.fromJSON({} as any); - expect(spy).toHaveBeenCalled(); - }); - it("should deserialize privateKey", () => { const spy = jest.spyOn(EncryptionPair, "fromJSON"); AccountKeys.fromJSON({ diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 4ed36fd3897..753b15c09b5 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -1,7 +1,6 @@ import { Jsonify } from "type-fest"; import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable"; -import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { GeneratorOptions } from "../../../tools/generator/generator-options"; import { @@ -10,7 +9,6 @@ import { } from "../../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options"; import { DeepJsonify } from "../../../types/deep-jsonify"; -import { MasterKey } from "../../../types/key"; import { CipherData } from "../../../vault/models/data/cipher.data"; import { CipherView } from "../../../vault/models/view/cipher.view"; import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info"; @@ -90,12 +88,8 @@ export class AccountData { } export class AccountKeys { - masterKey?: MasterKey; - masterKeyEncryptedUserKey?: string; publicKey?: Uint8Array; - /** @deprecated July 2023, left for migration purposes*/ - cryptoMasterKey?: SymmetricCryptoKey; /** @deprecated July 2023, left for migration purposes*/ cryptoMasterKeyAuto?: string; /** @deprecated July 2023, left for migration purposes*/ @@ -120,8 +114,6 @@ export class AccountKeys { return null; } return Object.assign(new AccountKeys(), obj, { - masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey), - cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey), cryptoSymmetricKey: EncryptionPair.fromJSON( obj?.cryptoSymmetricKey, SymmetricCryptoKey.fromJSON, @@ -150,10 +142,8 @@ export class AccountProfile { email?: string; emailVerified?: boolean; everBeenUnlocked?: boolean; - forceSetPasswordReason?: ForceSetPasswordReason; lastSync?: string; userId?: string; - keyHash?: string; kdfIterations?: number; kdfMemory?: number; kdfParallelism?: number; diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts index 9532b903d37..e124deccf8c 100644 --- a/libs/common/src/platform/services/config/default-config.service.ts +++ b/libs/common/src/platform/services/config/default-config.service.ts @@ -32,7 +32,6 @@ export const USER_SERVER_CONFIG = new UserKeyDefinition(CONFIG_DIS clearOn: ["logout"], }); -// TODO MDG: When to clean these up? export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record( CONFIG_DISK, "byServer", diff --git a/libs/common/src/platform/services/crypto.service.spec.ts b/libs/common/src/platform/services/crypto.service.spec.ts index 9160664aa54..6d0fdb14236 100644 --- a/libs/common/src/platform/services/crypto.service.spec.ts +++ b/libs/common/src/platform/services/crypto.service.spec.ts @@ -1,10 +1,11 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; +import { firstValueFrom, of, tap } from "rxjs"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state"; import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service"; import { CsprngArray } from "../../types/csprng"; import { UserId } from "../../types/guid"; import { UserKey, MasterKey, PinKey } from "../../types/key"; @@ -18,6 +19,7 @@ import { Utils } from "../misc/utils"; import { EncString } from "../models/domain/enc-string"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { CryptoService } from "../services/crypto.service"; +import { UserKeyDefinition } from "../state"; import { USER_ENCRYPTED_ORGANIZATION_KEYS } from "./key-state/org-keys.state"; import { USER_ENCRYPTED_PROVIDER_KEYS } from "./key-state/provider-keys.state"; @@ -40,12 +42,15 @@ describe("cryptoService", () => { const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; beforeEach(() => { accountService = mockAccountServiceWith(mockUserId); + masterPasswordService = new FakeMasterPasswordService(); stateProvider = new FakeStateProvider(accountService); cryptoService = new CryptoService( + masterPasswordService, keyGenerationService, cryptoFunctionService, encryptService, @@ -157,14 +162,14 @@ describe("cryptoService", () => { describe("getUserKeyWithLegacySupport", () => { let mockUserKey: UserKey; let mockMasterKey: MasterKey; - let stateSvcGetMasterKey: jest.SpyInstance; + let getMasterKey: jest.SpyInstance; beforeEach(() => { const mockRandomBytes = new Uint8Array(64) as CsprngArray; mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey; - stateSvcGetMasterKey = jest.spyOn(stateService, "getMasterKey"); + getMasterKey = jest.spyOn(masterPasswordService, "masterKey$"); }); it("returns the User Key if available", async () => { @@ -174,17 +179,17 @@ describe("cryptoService", () => { const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId); expect(getKeySpy).toHaveBeenCalledWith(mockUserId); - expect(stateSvcGetMasterKey).not.toHaveBeenCalled(); + expect(getMasterKey).not.toHaveBeenCalled(); expect(userKey).toEqual(mockUserKey); }); it("returns the user's master key when User Key is not available", async () => { - stateSvcGetMasterKey.mockResolvedValue(mockMasterKey); + masterPasswordService.masterKeySubject.next(mockMasterKey); const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId); - expect(stateSvcGetMasterKey).toHaveBeenCalledWith({ userId: mockUserId }); + expect(getMasterKey).toHaveBeenCalledWith(mockUserId); expect(userKey).toEqual(mockMasterKey); }); }); @@ -336,231 +341,20 @@ describe("cryptoService", () => { }); }); - describe("clearUserKey", () => { - it.each([mockUserId, null])("should clear the User Key for id %2", async (userId) => { - await cryptoService.clearUserKey(false, userId); + describe("clearKeys", () => { + it("resolves active user id when called with no user id", async () => { + let callCount = 0; + stateProvider.activeUserId$ = stateProvider.activeUserId$.pipe(tap(() => callCount++)); - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, userId); - }); + await cryptoService.clearKeys(null); + expect(callCount).toBe(1); - it("should update status to locked", async () => { - await cryptoService.clearUserKey(false, mockUserId); - - expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith( - mockUserId, - AuthenticationStatus.Locked, - ); - }); - - it.each([true, false])( - "should clear stored user keys if clearAll is true (%s)", - async (clear) => { - const clearSpy = (cryptoService["clearAllStoredUserKeys"] = jest.fn()); - await cryptoService.clearUserKey(clear, mockUserId); - - if (clear) { - expect(clearSpy).toHaveBeenCalledWith(mockUserId); - expect(clearSpy).toHaveBeenCalledTimes(1); - } else { - expect(clearSpy).not.toHaveBeenCalled(); - } - }, - ); - }); - - describe("clearOrgKeys", () => { - let forceMemorySpy: jest.Mock; - beforeEach(() => { - forceMemorySpy = cryptoService["activeUserOrgKeysState"].forceValue = jest.fn(); - }); - it("clears in memory org keys when called with memoryOnly", async () => { - await cryptoService.clearOrgKeys(true); - - expect(forceMemorySpy).toHaveBeenCalledWith({}); - }); - - it("does not clear memory when called with the non active user and memory only", async () => { - await cryptoService.clearOrgKeys(true, "someOtherUser" as UserId); - - expect(forceMemorySpy).not.toHaveBeenCalled(); - }); - - it("does not write to disk state if called with memory only", async () => { - await cryptoService.clearOrgKeys(true); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled(); - }); - - it("clears disk state when called with diskOnly", async () => { - await cryptoService.clearOrgKeys(false); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_ORGANIZATION_KEYS, - ); - expect( - stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_ORGANIZATION_KEYS).nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("clears another user's disk state when called with diskOnly and that user", async () => { - await cryptoService.clearOrgKeys(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - "someOtherUser" as UserId, - USER_ENCRYPTED_ORGANIZATION_KEYS, - ); - expect( - stateProvider.singleUser.getFake( - "someOtherUser" as UserId, - USER_ENCRYPTED_ORGANIZATION_KEYS, - ).nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("does not clear active user disk state when called with diskOnly and a different specified user", async () => { - await cryptoService.clearOrgKeys(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_ORGANIZATION_KEYS, - ); - }); - }); - - describe("clearProviderKeys", () => { - let forceMemorySpy: jest.Mock; - beforeEach(() => { - forceMemorySpy = cryptoService["activeUserProviderKeysState"].forceValue = jest.fn(); - }); - it("clears in memory org keys when called with memoryOnly", async () => { - await cryptoService.clearProviderKeys(true); - - expect(forceMemorySpy).toHaveBeenCalledWith({}); - }); - - it("does not clear memory when called with the non active user and memory only", async () => { - await cryptoService.clearProviderKeys(true, "someOtherUser" as UserId); - - expect(forceMemorySpy).not.toHaveBeenCalled(); - }); - - it("does not write to disk state if called with memory only", async () => { - await cryptoService.clearProviderKeys(true); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled(); - }); - - it("clears disk state when called with diskOnly", async () => { - await cryptoService.clearProviderKeys(false); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_PROVIDER_KEYS, - ); - expect( - stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PROVIDER_KEYS).nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("clears another user's disk state when called with diskOnly and that user", async () => { - await cryptoService.clearProviderKeys(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - "someOtherUser" as UserId, - USER_ENCRYPTED_PROVIDER_KEYS, - ); - expect( - stateProvider.singleUser.getFake("someOtherUser" as UserId, USER_ENCRYPTED_PROVIDER_KEYS) - .nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("does not clear active user disk state when called with diskOnly and a different specified user", async () => { - await cryptoService.clearProviderKeys(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_PROVIDER_KEYS, - ); - }); - }); - - describe("clearKeyPair", () => { - let forceMemoryPrivateKeySpy: jest.Mock; - let forceMemoryPublicKeySpy: jest.Mock; - beforeEach(() => { - forceMemoryPrivateKeySpy = cryptoService["activeUserPrivateKeyState"].forceValue = jest.fn(); - forceMemoryPublicKeySpy = cryptoService["activeUserPublicKeyState"].forceValue = jest.fn(); - }); - it("clears in memory org keys when called with memoryOnly", async () => { - await cryptoService.clearKeyPair(true); - - expect(forceMemoryPrivateKeySpy).toHaveBeenCalledWith(null); - expect(forceMemoryPublicKeySpy).toHaveBeenCalledWith(null); - }); - - it("does not clear memory when called with the non active user and memory only", async () => { - await cryptoService.clearKeyPair(true, "someOtherUser" as UserId); - - expect(forceMemoryPrivateKeySpy).not.toHaveBeenCalled(); - expect(forceMemoryPublicKeySpy).not.toHaveBeenCalled(); - }); - - it("does not write to disk state if called with memory only", async () => { - await cryptoService.clearKeyPair(true); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled(); - }); - - it("clears disk state when called with diskOnly", async () => { - await cryptoService.clearKeyPair(false); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); - expect( - stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("clears another user's disk state when called with diskOnly and that user", async () => { - await cryptoService.clearKeyPair(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - "someOtherUser" as UserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); - expect( - stateProvider.singleUser.getFake("someOtherUser" as UserId, USER_ENCRYPTED_PRIVATE_KEY) - .nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("does not clear active user disk state when called with diskOnly and a different specified user", async () => { - await cryptoService.clearKeyPair(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); - }); - }); - - describe("clearUserKey", () => { - it("clears the user key for the active user when no userId is specified", async () => { - await cryptoService.clearUserKey(false); - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, undefined); - }); - - it("clears the user key for the specified user when a userId is specified", async () => { - await cryptoService.clearUserKey(false, "someOtherUser" as UserId); - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, "someOtherUser"); + // revert to the original state + accountService.activeAccount$ = accountService.activeAccountSubject.asObservable(); }); it("sets the maximum account status of the active user id to locked when user id is not specified", async () => { - await cryptoService.clearUserKey(false); + await cryptoService.clearKeys(); expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith( mockUserId, AuthenticationStatus.Locked, @@ -568,17 +362,36 @@ describe("cryptoService", () => { }); it("sets the maximum account status of the specified user id to locked when user id is specified", async () => { - await cryptoService.clearUserKey(false, "someOtherUser" as UserId); + const userId = "someOtherUser" as UserId; + await cryptoService.clearKeys(userId); expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith( - "someOtherUser" as UserId, + userId, AuthenticationStatus.Locked, ); }); - it("clears all stored user keys when clearAll is true", async () => { - const clearAllSpy = (cryptoService["clearAllStoredUserKeys"] = jest.fn()); - await cryptoService.clearUserKey(true); - expect(clearAllSpy).toHaveBeenCalledWith(mockUserId); + describe.each([ + USER_ENCRYPTED_ORGANIZATION_KEYS, + USER_ENCRYPTED_PROVIDER_KEYS, + USER_ENCRYPTED_PRIVATE_KEY, + USER_KEY, + ])("key removal", (key: UserKeyDefinition) => { + it(`clears ${key.key} for active user when unspecified`, async () => { + await cryptoService.clearKeys(null); + + const encryptedOrgKeyState = stateProvider.singleUser.getFake(mockUserId, key); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledTimes(1); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledWith(null); + }); + + it(`clears ${key.key} for the specified user when specified`, async () => { + const userId = "someOtherUser" as UserId; + await cryptoService.clearKeys(userId); + + const encryptedOrgKeyState = stateProvider.singleUser.getFake(userId, key); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledTimes(1); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledWith(null); + }); }); }); }); diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index dd3c4974701..ae588cbc314 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -6,6 +6,7 @@ import { ProfileOrganizationResponse } from "../../admin-console/models/response import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; import { AccountService } from "../../auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../auth/abstractions/master-password.service.abstraction"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { Utils } from "../../platform/misc/utils"; @@ -82,6 +83,7 @@ export class CryptoService implements CryptoServiceAbstraction { readonly everHadUserKey$: Observable; constructor( + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected keyGenerationService: KeyGenerationService, protected cryptoFunctionService: CryptoFunctionService, protected encryptService: EncryptService, @@ -144,7 +146,7 @@ export class CryptoService implements CryptoServiceAbstraction { async setUserKey(key: UserKey, userId?: UserId): Promise { if (key == null) { - throw new Error("No key provided. Use ClearUserKey to clear the key"); + throw new Error("No key provided. Lock the user to clear the key"); } // Set userId to ensure we have one for the account status update [userId, key] = await this.stateProvider.setUserState(USER_KEY, key, userId); @@ -181,12 +183,16 @@ export class CryptoService implements CryptoServiceAbstraction { } async isLegacyUser(masterKey?: MasterKey, userId?: UserId): Promise { - return await this.validateUserKey( - (masterKey ?? (await this.getMasterKey(userId))) as unknown as UserKey, - ); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + masterKey ??= await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + + return await this.validateUserKey(masterKey as unknown as UserKey); } + // TODO: legacy support for user key is no longer needed since we require users to migrate on login async getUserKeyWithLegacySupport(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + const userKey = await this.getUserKey(userId); if (userKey) { return userKey; @@ -194,7 +200,8 @@ export class CryptoService implements CryptoServiceAbstraction { // Legacy support: encryption used to be done with the master key (derived from master password). // Users who have not migrated will have a null user key and must use the master key instead. - return (await this.getMasterKey(userId)) as unknown as UserKey; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + return masterKey as unknown as UserKey; } async getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: UserId): Promise { @@ -233,7 +240,10 @@ export class CryptoService implements CryptoServiceAbstraction { } async makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]> { - masterKey ||= await this.getMasterKey(); + if (!masterKey) { + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + } if (masterKey == null) { throw new Error("No Master Key found."); } @@ -242,13 +252,19 @@ export class CryptoService implements CryptoServiceAbstraction { return this.buildProtectedSymmetricKey(masterKey, newUserKey.key); } - async clearUserKey(clearStoredKeys = true, userId?: UserId): Promise { - // Set userId to ensure we have one for the account status update - [userId] = await this.stateProvider.setUserState(USER_KEY, null, userId); - await this.accountService.setMaxAccountStatus(userId, AuthenticationStatus.Locked); - if (clearStoredKeys) { - await this.clearAllStoredUserKeys(userId); + /** + * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key + * @param userId The desired user + */ + async clearUserKey(userId: UserId): Promise { + if (userId == null) { + // nothing to do + return; } + // Set userId to ensure we have one for the account status update + await this.stateProvider.setUserState(USER_KEY, null, userId); + await this.accountService.setMaxAccountStatus(userId, AuthenticationStatus.Locked); + await this.clearAllStoredUserKeys(userId); } async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise { @@ -271,28 +287,17 @@ export class CryptoService implements CryptoServiceAbstraction { } async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: UserId): Promise { - await this.stateService.setMasterKeyEncryptedUserKey(userKeyMasterKey, { userId: userId }); - } - - async setMasterKey(key: MasterKey, userId?: UserId): Promise { - await this.stateService.setMasterKey(key, { userId: userId }); - } - - async getMasterKey(userId?: UserId): Promise { - let masterKey = await this.stateService.getMasterKey({ userId: userId }); - if (!masterKey) { - masterKey = (await this.stateService.getCryptoMasterKey({ userId: userId })) as MasterKey; - // if master key was null/undefined and getCryptoMasterKey also returned null/undefined, - // don't set master key as it is unnecessary - if (masterKey) { - await this.setMasterKey(masterKey, userId); - } - } - return masterKey; + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + await this.masterPasswordService.setMasterKeyEncryptedUserKey( + new EncString(userKeyMasterKey), + userId, + ); } + // TODO: Move to MasterPasswordService async getOrDeriveMasterKey(password: string, userId?: UserId) { - let masterKey = await this.getMasterKey(userId); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); return (masterKey ||= await this.makeMasterKey( password, await this.stateService.getEmail({ userId: userId }), @@ -306,6 +311,7 @@ export class CryptoService implements CryptoServiceAbstraction { * * @remarks * Does not validate the kdf config to ensure it satisfies the minimum requirements for the given kdf type. + * TODO: Move to MasterPasswordService */ async makeMasterKey( password: string, @@ -321,10 +327,6 @@ export class CryptoService implements CryptoServiceAbstraction { )) as MasterKey; } - async clearMasterKey(userId?: UserId): Promise { - await this.stateService.setMasterKey(null, { userId: userId }); - } - async encryptUserKeyWithMasterKey( masterKey: MasterKey, userKey?: UserKey, @@ -333,32 +335,28 @@ export class CryptoService implements CryptoServiceAbstraction { return await this.buildProtectedSymmetricKey(masterKey, userKey.key); } + // TODO: move to master password service async decryptUserKeyWithMasterKey( masterKey: MasterKey, userKey?: EncString, userId?: UserId, ): Promise { - masterKey ||= await this.getMasterKey(userId); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + userKey ??= await this.masterPasswordService.getMasterKeyEncryptedUserKey(userId); + masterKey ??= await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey == null) { throw new Error("No master key found."); } - if (!userKey) { - let masterKeyEncryptedUserKey = await this.stateService.getMasterKeyEncryptedUserKey({ + // 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, }); - - // Try one more way to get the user key if it still wasn't found. - if (masterKeyEncryptedUserKey == null) { - masterKeyEncryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ - userId: userId, - }); - } - - if (masterKeyEncryptedUserKey == null) { + if (deprecatedKey == null) { throw new Error("No encrypted user key found."); } - userKey = new EncString(masterKeyEncryptedUserKey); + userKey = new EncString(deprecatedKey); } let decUserKey: Uint8Array; @@ -377,12 +375,16 @@ export class CryptoService implements CryptoServiceAbstraction { return new SymmetricCryptoKey(decUserKey) as UserKey; } + // TODO: move to MasterPasswordService async hashMasterKey( password: string, key: MasterKey, hashPurpose?: HashPurpose, ): Promise { - key ||= await this.getMasterKey(); + if (!key) { + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + key = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + } if (password == null || key == null) { throw new Error("Invalid parameters."); @@ -393,20 +395,12 @@ export class CryptoService implements CryptoServiceAbstraction { return Utils.fromBufferToB64(hash); } - async setMasterKeyHash(keyHash: string): Promise { - await this.stateService.setKeyHash(keyHash); - } - - async getMasterKeyHash(): Promise { - return await this.stateService.getKeyHash(); - } - - async clearMasterKeyHash(userId?: UserId): Promise { - return await this.stateService.setKeyHash(null, { userId: userId }); - } - + // TODO: move to MasterPasswordService async compareAndUpdateKeyHash(masterPassword: string, masterKey: MasterKey): Promise { - const storedPasswordHash = await this.getMasterKeyHash(); + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + const storedPasswordHash = await firstValueFrom( + this.masterPasswordService.masterKeyHash$(userId), + ); if (masterPassword != null && storedPasswordHash != null) { const localKeyHash = await this.hashMasterKey( masterPassword, @@ -424,7 +418,7 @@ export class CryptoService implements CryptoServiceAbstraction { HashPurpose.ServerAuthorization, ); if (serverKeyHash != null && storedPasswordHash === serverKeyHash) { - await this.setMasterKeyHash(localKeyHash); + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); return true; } } @@ -480,25 +474,12 @@ export class CryptoService implements CryptoServiceAbstraction { return this.buildProtectedSymmetricKey(key, newSymKey.key); } - async clearOrgKeys(memoryOnly?: boolean, userId?: UserId): Promise { - const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - const userIdIsActive = userId == null || userId === activeUserId; - - if (!memoryOnly) { - if (userId == null && activeUserId == null) { - // nothing to do - return; - } - await this.stateProvider - .getUser(userId ?? activeUserId, USER_ENCRYPTED_ORGANIZATION_KEYS) - .update(() => null); + private async clearOrgKeys(userId: UserId): Promise { + if (userId == null) { + // nothing to do return; } - - // org keys are only cached for active users - if (userIdIsActive) { - await this.activeUserOrgKeysState.forceValue({}); - } + await this.stateProvider.setUserState(USER_ENCRYPTED_ORGANIZATION_KEYS, null, userId); } async setProviderKeys(providers: ProfileProviderResponse[]): Promise { @@ -526,25 +507,12 @@ export class CryptoService implements CryptoServiceAbstraction { return await firstValueFrom(this.activeUserProviderKeys$); } - async clearProviderKeys(memoryOnly?: boolean, userId?: UserId): Promise { - const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - const userIdIsActive = userId == null || userId === activeUserId; - - if (!memoryOnly) { - if (userId == null && activeUserId == null) { - // nothing to do - return; - } - await this.stateProvider - .getUser(userId ?? activeUserId, USER_ENCRYPTED_PROVIDER_KEYS) - .update(() => null); + private async clearProviderKeys(userId: UserId): Promise { + if (userId == null) { + // nothing to do return; } - - // provider keys are only cached for active users - if (userIdIsActive) { - await this.activeUserProviderKeysState.forceValue({}); - } + await this.stateProvider.setUserState(USER_ENCRYPTED_PROVIDER_KEYS, null, userId); } async getPublicKey(): Promise { @@ -597,26 +565,17 @@ export class CryptoService implements CryptoServiceAbstraction { return [publicB64, privateEnc]; } - async clearKeyPair(memoryOnly?: boolean, userId?: UserId): Promise { - const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - const userIdIsActive = userId == null || userId === activeUserId; - - if (!memoryOnly) { - if (userId == null && activeUserId == null) { - // nothing to do - return; - } - await this.stateProvider - .getUser(userId ?? activeUserId, USER_ENCRYPTED_PRIVATE_KEY) - .update(() => null); + /** + * Clears the user's key pair + * @param userId The desired user + */ + private async clearKeyPair(userId: UserId): Promise { + if (userId == null) { + // nothing to do return; } - // decrypted key pair is only cached for active users - if (userIdIsActive) { - await this.activeUserPrivateKeyState.forceValue(null); - await this.activeUserPublicKeyState.forceValue(null); - } + await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId); } async makePinKey(pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig): Promise { @@ -681,11 +640,17 @@ export class CryptoService implements CryptoServiceAbstraction { } async clearKeys(userId?: UserId): Promise { - await this.clearUserKey(true, userId); - await this.clearMasterKeyHash(userId); - await this.clearOrgKeys(false, userId); - await this.clearProviderKeys(false, userId); - await this.clearKeyPair(false, userId); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + + if (userId == null) { + throw new Error("Cannot clear keys, no user Id resolved."); + } + + await this.masterPasswordService.clearMasterKeyHash(userId); + await this.clearUserKey(userId); + await this.clearOrgKeys(userId); + await this.clearProviderKeys(userId); + await this.clearKeyPair(userId); await this.clearPinKeys(userId); await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId); } @@ -1037,7 +1002,8 @@ export class CryptoService implements CryptoServiceAbstraction { if (await this.isLegacyUser(masterKey, userId)) { // Legacy users don't have a user key, so no need to migrate. // Instead, set the master key for additional isLegacyUser checks that will log the user out. - await this.setMasterKey(masterKey, userId); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + await this.masterPasswordService.setMasterKey(masterKey, userId); return; } const encryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ diff --git a/libs/common/src/platform/services/default-environment.service.spec.ts b/libs/common/src/platform/services/default-environment.service.spec.ts index 66bc1acfdaa..a70ab3d179b 100644 --- a/libs/common/src/platform/services/default-environment.service.spec.ts +++ b/libs/common/src/platform/services/default-environment.service.spec.ts @@ -7,9 +7,10 @@ import { UserId } from "../../types/guid"; import { CloudRegion, Region } from "../abstractions/environment.service"; import { - ENVIRONMENT_KEY, + GLOBAL_ENVIRONMENT_KEY, DefaultEnvironmentService, EnvironmentUrls, + USER_ENVIRONMENT_KEY, } from "./default-environment.service"; // There are a few main states EnvironmentService could be in when first used @@ -55,7 +56,7 @@ describe("EnvironmentService", () => { }; const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => { - stateProvider.global.getFake(ENVIRONMENT_KEY).stateSubject.next({ + stateProvider.global.getFake(GLOBAL_ENVIRONMENT_KEY).stateSubject.next({ region: region, urls: environmentUrls, }); @@ -66,7 +67,7 @@ describe("EnvironmentService", () => { environmentUrls: EnvironmentUrls, userId: UserId = testUser, ) => { - stateProvider.singleUser.getFake(userId, ENVIRONMENT_KEY).nextState({ + stateProvider.singleUser.getFake(userId, USER_ENVIRONMENT_KEY).nextState({ region: region, urls: environmentUrls, }); diff --git a/libs/common/src/platform/services/default-environment.service.ts b/libs/common/src/platform/services/default-environment.service.ts index d074ff43f80..59956ede7ae 100644 --- a/libs/common/src/platform/services/default-environment.service.ts +++ b/libs/common/src/platform/services/default-environment.service.ts @@ -18,6 +18,7 @@ import { GlobalState, KeyDefinition, StateProvider, + UserKeyDefinition, } from "../state"; export class EnvironmentUrls { @@ -40,7 +41,7 @@ class EnvironmentState { } } -export const ENVIRONMENT_KEY = new KeyDefinition( +export const GLOBAL_ENVIRONMENT_KEY = new KeyDefinition( ENVIRONMENT_DISK, "environment", { @@ -48,9 +49,31 @@ export const ENVIRONMENT_KEY = new KeyDefinition( }, ); -export const CLOUD_REGION_KEY = new KeyDefinition(ENVIRONMENT_MEMORY, "cloudRegion", { - deserializer: (b) => b, -}); +export const USER_ENVIRONMENT_KEY = new UserKeyDefinition( + ENVIRONMENT_DISK, + "environment", + { + deserializer: EnvironmentState.fromJSON, + clearOn: ["logout"], + }, +); + +export const GLOBAL_CLOUD_REGION_KEY = new KeyDefinition( + ENVIRONMENT_MEMORY, + "cloudRegion", + { + deserializer: (b) => b, + }, +); + +export const USER_CLOUD_REGION_KEY = new UserKeyDefinition( + ENVIRONMENT_MEMORY, + "cloudRegion", + { + deserializer: (b) => b, + clearOn: ["logout"], + }, +); /** * The production regions available for selection. @@ -114,8 +137,8 @@ export class DefaultEnvironmentService implements EnvironmentService { private stateProvider: StateProvider, private accountService: AccountService, ) { - this.globalState = this.stateProvider.getGlobal(ENVIRONMENT_KEY); - this.globalCloudRegionState = this.stateProvider.getGlobal(CLOUD_REGION_KEY); + this.globalState = this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY); + this.globalCloudRegionState = this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY); const account$ = this.activeAccountId$.pipe( // Use == here to not trigger on undefined -> null transition @@ -125,8 +148,8 @@ export class DefaultEnvironmentService implements EnvironmentService { this.environment$ = account$.pipe( switchMap((userId) => { const t = userId - ? this.stateProvider.getUser(userId, ENVIRONMENT_KEY).state$ - : this.stateProvider.getGlobal(ENVIRONMENT_KEY).state$; + ? this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$ + : this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY).state$; return t; }), map((state) => { @@ -136,8 +159,8 @@ export class DefaultEnvironmentService implements EnvironmentService { this.cloudWebVaultUrl$ = account$.pipe( switchMap((userId) => { const t = userId - ? this.stateProvider.getUser(userId, CLOUD_REGION_KEY).state$ - : this.stateProvider.getGlobal(CLOUD_REGION_KEY).state$; + ? this.stateProvider.getUser(userId, USER_CLOUD_REGION_KEY).state$ + : this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY).state$; return t; }), map((region) => { @@ -242,7 +265,7 @@ export class DefaultEnvironmentService implements EnvironmentService { if (userId == null) { await this.globalCloudRegionState.update(() => region); } else { - await this.stateProvider.getUser(userId, CLOUD_REGION_KEY).update(() => region); + await this.stateProvider.getUser(userId, USER_CLOUD_REGION_KEY).update(() => region); } } @@ -261,13 +284,13 @@ export class DefaultEnvironmentService implements EnvironmentService { return activeUserId == null ? await firstValueFrom(this.globalState.state$) : await firstValueFrom( - this.stateProvider.getUser(userId ?? activeUserId, ENVIRONMENT_KEY).state$, + this.stateProvider.getUser(userId ?? activeUserId, USER_ENVIRONMENT_KEY).state$, ); } async seedUserEnvironment(userId: UserId) { const global = await firstValueFrom(this.globalState.state$); - await this.stateProvider.getUser(userId, ENVIRONMENT_KEY).update(() => global); + await this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).update(() => global); } } diff --git a/libs/common/src/platform/services/key-state/org-keys.state.ts b/libs/common/src/platform/services/key-state/org-keys.state.ts index b39cc9a82a6..f67e64b6538 100644 --- a/libs/common/src/platform/services/key-state/org-keys.state.ts +++ b/libs/common/src/platform/services/key-state/org-keys.state.ts @@ -4,13 +4,14 @@ import { OrganizationId } from "../../../types/guid"; import { OrgKey } from "../../../types/key"; import { CryptoService } from "../../abstractions/crypto.service"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state"; +import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state"; -export const USER_ENCRYPTED_ORGANIZATION_KEYS = KeyDefinition.record< +export const USER_ENCRYPTED_ORGANIZATION_KEYS = UserKeyDefinition.record< EncryptedOrganizationKeyData, OrganizationId >(CRYPTO_DISK, "organizationKeys", { deserializer: (obj) => obj, + clearOn: ["logout"], }); export const USER_ORGANIZATION_KEYS = DeriveDefinition.from< diff --git a/libs/common/src/platform/services/key-state/provider-keys.state.ts b/libs/common/src/platform/services/key-state/provider-keys.state.ts index c89df34c80c..776fdc77d8b 100644 --- a/libs/common/src/platform/services/key-state/provider-keys.state.ts +++ b/libs/common/src/platform/services/key-state/provider-keys.state.ts @@ -3,14 +3,15 @@ import { ProviderKey } from "../../../types/key"; import { EncryptService } from "../../abstractions/encrypt.service"; import { EncString, EncryptedString } from "../../models/domain/enc-string"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state"; +import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state"; import { CryptoService } from "../crypto.service"; -export const USER_ENCRYPTED_PROVIDER_KEYS = KeyDefinition.record( +export const USER_ENCRYPTED_PROVIDER_KEYS = UserKeyDefinition.record( CRYPTO_DISK, "providerKeys", { deserializer: (obj) => obj, + clearOn: ["logout"], }, ); diff --git a/libs/common/src/platform/services/key-state/user-key.state.ts b/libs/common/src/platform/services/key-state/user-key.state.ts index d0f54c9add7..609525b0aca 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.ts @@ -3,18 +3,24 @@ import { CryptoFunctionService } from "../../abstractions/crypto-function.servic import { EncryptService } from "../../abstractions/encrypt.service"; import { EncString, EncryptedString } from "../../models/domain/enc-string"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { KeyDefinition, CRYPTO_DISK, DeriveDefinition, CRYPTO_MEMORY } from "../../state"; +import { CRYPTO_DISK, DeriveDefinition, CRYPTO_MEMORY, UserKeyDefinition } from "../../state"; import { CryptoService } from "../crypto.service"; -export const USER_EVER_HAD_USER_KEY = new KeyDefinition(CRYPTO_DISK, "everHadUserKey", { - deserializer: (obj) => obj, -}); +export const USER_EVER_HAD_USER_KEY = new UserKeyDefinition( + CRYPTO_DISK, + "everHadUserKey", + { + deserializer: (obj) => obj, + clearOn: ["logout"], + }, +); -export const USER_ENCRYPTED_PRIVATE_KEY = new KeyDefinition( +export const USER_ENCRYPTED_PRIVATE_KEY = new UserKeyDefinition( CRYPTO_DISK, "privateKey", { deserializer: (obj) => obj, + clearOn: ["logout"], }, ); @@ -58,6 +64,7 @@ export const USER_PUBLIC_KEY = DeriveDefinition.from< return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey; }, }); -export const USER_KEY = new KeyDefinition(CRYPTO_MEMORY, "userKey", { +export const USER_KEY = new UserKeyDefinition(CRYPTO_MEMORY, "userKey", { deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, + clearOn: ["logout", "lock"], }); diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index a35659a7ac9..b3e33cf362b 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -5,14 +5,12 @@ import { AccountService } from "../../auth/abstractions/account.service"; import { TokenService } from "../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; -import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { UserId } from "../../types/guid"; -import { MasterKey } from "../../types/key"; import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; @@ -35,7 +33,6 @@ import { EncString } from "../models/domain/enc-string"; import { GlobalState } from "../models/domain/global-state"; import { State } from "../models/domain/state"; import { StorageOptions } from "../models/domain/storage-options"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { MigrationRunner } from "./migration-runner"; @@ -273,65 +270,6 @@ export class StateService< ); } - /** - * @deprecated Do not save the Master Key. Use the User Symmetric Key instead - */ - async getCryptoMasterKey(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - return account?.keys?.cryptoMasterKey; - } - - /** - * User's master key derived from MP, saved only if we decrypted with MP - */ - async getMasterKey(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - return account?.keys?.masterKey; - } - - /** - * User's master key derived from MP, saved only if we decrypted with MP - */ - async setMasterKey(value: MasterKey, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.keys.masterKey = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - - /** - * The master key encrypted User symmetric key, saved on every auth - * so we can unlock with MP offline - */ - async getMasterKeyEncryptedUserKey(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.keys.masterKeyEncryptedUserKey; - } - - /** - * The master key encrypted User symmetric key, saved on every auth - * so we can unlock with MP offline - */ - async setMasterKeyEncryptedUserKey(value: string, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.keys.masterKeyEncryptedUserKey = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - /** * user key when using the "never" option of vault timeout */ @@ -823,30 +761,6 @@ export class StateService< ); } - async getForceSetPasswordReason(options?: StorageOptions): Promise { - return ( - ( - await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ) - )?.profile?.forceSetPasswordReason ?? ForceSetPasswordReason.None - ); - } - - async setForceSetPasswordReason( - value: ForceSetPasswordReason, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - account.profile.forceSetPasswordReason = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - } - async getIsAuthenticated(options?: StorageOptions): Promise { return ( (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && @@ -897,23 +811,6 @@ export class StateService< ); } - async getKeyHash(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.keyHash; - } - - async setKeyHash(value: string, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.keyHash = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getLastActive(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); diff --git a/libs/common/src/platform/state/derive-definition.ts b/libs/common/src/platform/state/derive-definition.ts index 6c514f8869a..8f62d3a342c 100644 --- a/libs/common/src/platform/state/derive-definition.ts +++ b/libs/common/src/platform/state/derive-definition.ts @@ -5,6 +5,7 @@ import { DerivedStateDependencies, StorageKey } from "../../types/state"; import { KeyDefinition } from "./key-definition"; import { StateDefinition } from "./state-definition"; +import { UserKeyDefinition } from "./user-key-definition"; declare const depShapeMarker: unique symbol; /** @@ -129,26 +130,28 @@ export class DeriveDefinition( definition: | KeyDefinition + | UserKeyDefinition | [DeriveDefinition, string], options: DeriveDefinitionOptions, ) { - if (isKeyDefinition(definition)) { - return new DeriveDefinition(definition.stateDefinition, definition.key, options); - } else { + if (isFromDeriveDefinition(definition)) { return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); + } else { + return new DeriveDefinition(definition.stateDefinition, definition.key, options); } } static fromWithUserId( definition: | KeyDefinition + | UserKeyDefinition | [DeriveDefinition, string], options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>, ) { - if (isKeyDefinition(definition)) { - return new DeriveDefinition(definition.stateDefinition, definition.key, options); - } else { + if (isFromDeriveDefinition(definition)) { return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); + } else { + return new DeriveDefinition(definition.stateDefinition, definition.key, options); } } @@ -181,10 +184,11 @@ export class DeriveDefinition + | UserKeyDefinition | [DeriveDefinition, string], -): definition is KeyDefinition { - return Object.prototype.hasOwnProperty.call(definition, "key"); +): definition is [DeriveDefinition, string] { + return Array.isArray(definition); } diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index d9265cf10cb..ed6ef1590d8 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -37,6 +37,8 @@ export const BILLING_DISK = new StateDefinition("billing", "disk"); export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); +export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory"); +export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk"); export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); export const ROUTER_DISK = new StateDefinition("router", "disk"); export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", { @@ -74,6 +76,10 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne web: "disk-local", }); +export const UNASSIGNED_ITEMS_BANNER_DISK = new StateDefinition("unassignedItemsBanner", "disk", { + web: "disk-local", +}); + // Platform export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { @@ -125,3 +131,4 @@ export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", web: "disk-local", }); export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory"); +export const VAULT_SEARCH_MEMORY = new StateDefinition("vaultSearch", "memory"); diff --git a/libs/common/src/services/search.service.ts b/libs/common/src/services/search.service.ts index 773d51297a4..429992b0760 100644 --- a/libs/common/src/services/search.service.ts +++ b/libs/common/src/services/search.service.ts @@ -1,20 +1,91 @@ import * as lunr from "lunr"; +import { Observable, firstValueFrom, map } from "rxjs"; +import { Jsonify } from "type-fest"; import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service"; import { UriMatchStrategy } from "../models/domain/domain-service"; import { I18nService } from "../platform/abstractions/i18n.service"; import { LogService } from "../platform/abstractions/log.service"; +import { + ActiveUserState, + StateProvider, + UserKeyDefinition, + VAULT_SEARCH_MEMORY, +} from "../platform/state"; import { SendView } from "../tools/send/models/view/send.view"; +import { IndexedEntityId } from "../types/guid"; import { FieldType } from "../vault/enums"; import { CipherType } from "../vault/enums/cipher-type"; import { CipherView } from "../vault/models/view/cipher.view"; +export type SerializedLunrIndex = { + version: string; + fields: string[]; + fieldVectors: [string, number[]]; + invertedIndex: any[]; + pipeline: string[]; +}; + +/** + * The `KeyDefinition` for accessing the search index in application state. + * The key definition is configured to clear the index when the user locks the vault. + */ +export const LUNR_SEARCH_INDEX = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "searchIndex", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock"], + }, +); + +/** + * The `KeyDefinition` for accessing the ID of the entity currently indexed by Lunr search. + * The key definition is configured to clear the indexed entity ID when the user locks the vault. + */ +export const LUNR_SEARCH_INDEXED_ENTITY_ID = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "searchIndexedEntityId", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock"], + }, +); + +/** + * The `KeyDefinition` for accessing the state of Lunr search indexing, indicating whether the Lunr search index is currently being built or updating. + * The key definition is configured to clear the indexing state when the user locks the vault. + */ +export const LUNR_SEARCH_INDEXING = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "isIndexing", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock"], + }, +); + export class SearchService implements SearchServiceAbstraction { private static registeredPipeline = false; - indexedEntityId?: string = null; - private indexing = false; - private index: lunr.Index = null; + private searchIndexState: ActiveUserState = + this.stateProvider.getActive(LUNR_SEARCH_INDEX); + private readonly index$: Observable = this.searchIndexState.state$.pipe( + map((searchIndex) => (searchIndex ? lunr.Index.load(searchIndex) : null)), + ); + + private searchIndexEntityIdState: ActiveUserState = this.stateProvider.getActive( + LUNR_SEARCH_INDEXED_ENTITY_ID, + ); + readonly indexedEntityId$: Observable = + this.searchIndexEntityIdState.state$.pipe(map((id) => id)); + + private searchIsIndexingState: ActiveUserState = + this.stateProvider.getActive(LUNR_SEARCH_INDEXING); + private readonly searchIsIndexing$: Observable = this.searchIsIndexingState.state$.pipe( + map((indexing) => indexing ?? false), + ); + private readonly immediateSearchLocales: string[] = ["zh-CN", "zh-TW", "ja", "ko", "vi"]; private readonly defaultSearchableMinLength: number = 2; private searchableMinLength: number = this.defaultSearchableMinLength; @@ -22,6 +93,7 @@ export class SearchService implements SearchServiceAbstraction { constructor( private logService: LogService, private i18nService: I18nService, + private stateProvider: StateProvider, ) { this.i18nService.locale$.subscribe((locale) => { if (this.immediateSearchLocales.indexOf(locale) !== -1) { @@ -40,28 +112,29 @@ export class SearchService implements SearchServiceAbstraction { } } - clearIndex(): void { - this.indexedEntityId = null; - this.index = null; + async clearIndex(): Promise { + await this.searchIndexEntityIdState.update(() => null); + await this.searchIndexState.update(() => null); + await this.searchIsIndexingState.update(() => null); } - isSearchable(query: string): boolean { + async isSearchable(query: string): Promise { query = SearchService.normalizeSearchQuery(query); + const index = await this.getIndexForSearch(); const notSearchable = query == null || - (this.index == null && query.length < this.searchableMinLength) || - (this.index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0); + (index == null && query.length < this.searchableMinLength) || + (index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0); return !notSearchable; } - indexCiphers(ciphers: CipherView[], indexedEntityId?: string): void { - if (this.indexing) { + async indexCiphers(ciphers: CipherView[], indexedEntityId?: string): Promise { + if (await this.getIsIndexing()) { return; } - this.indexing = true; - this.indexedEntityId = indexedEntityId; - this.index = null; + await this.setIsIndexing(true); + await this.setIndexedEntityIdForSearch(indexedEntityId as IndexedEntityId); const builder = new lunr.Builder(); builder.pipeline.add(this.normalizeAccentsPipelineFunction); builder.ref("id"); @@ -95,9 +168,11 @@ export class SearchService implements SearchServiceAbstraction { builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId }); ciphers = ciphers || []; ciphers.forEach((c) => builder.add(c)); - this.index = builder.build(); + const index = builder.build(); - this.indexing = false; + await this.setIndexForSearch(index.toJSON() as SerializedLunrIndex); + + await this.setIsIndexing(false); this.logService.info("Finished search indexing"); } @@ -125,18 +200,18 @@ export class SearchService implements SearchServiceAbstraction { ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean); } - if (!this.isSearchable(query)) { + if (!(await this.isSearchable(query))) { return ciphers; } - if (this.indexing) { + if (await this.getIsIndexing()) { await new Promise((r) => setTimeout(r, 250)); - if (this.indexing) { + if (await this.getIsIndexing()) { await new Promise((r) => setTimeout(r, 500)); } } - const index = this.getIndexForSearch(); + const index = await this.getIndexForSearch(); if (index == null) { // Fall back to basic search if index is not available return this.searchCiphersBasic(ciphers, query); @@ -230,8 +305,24 @@ export class SearchService implements SearchServiceAbstraction { return sendsMatched.concat(lowPriorityMatched); } - getIndexForSearch(): lunr.Index { - return this.index; + async getIndexForSearch(): Promise { + return await firstValueFrom(this.index$); + } + + private async setIndexForSearch(index: SerializedLunrIndex): Promise { + await this.searchIndexState.update(() => index); + } + + private async setIndexedEntityIdForSearch(indexedEntityId: IndexedEntityId): Promise { + await this.searchIndexEntityIdState.update(() => indexedEntityId); + } + + private async setIsIndexing(indexing: boolean): Promise { + await this.searchIsIndexingState.update(() => indexing); + } + + private async getIsIndexing(): Promise { + return await firstValueFrom(this.searchIsIndexing$); } private fieldExtractor(c: CipherView, joined: boolean) { diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts index e48f2fe0a3e..0594de741cc 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts @@ -1,17 +1,21 @@ import { MockProxy, any, mock } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { SearchService } from "../../abstractions/search.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { AuthService } from "../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; import { Account } from "../../platform/models/domain/account"; import { StateEventRunnerService } from "../../platform/state"; +import { UserId } from "../../types/guid"; import { CipherService } from "../../vault/abstractions/cipher.service"; import { CollectionService } from "../../vault/abstractions/collection.service"; import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; @@ -19,6 +23,8 @@ import { FolderService } from "../../vault/abstractions/folder/folder.service.ab import { VaultTimeoutService } from "./vault-timeout.service"; describe("VaultTimeoutService", () => { + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let cipherService: MockProxy; let folderService: MockProxy; let collectionService: MockProxy; @@ -39,7 +45,11 @@ describe("VaultTimeoutService", () => { let vaultTimeoutService: VaultTimeoutService; + const userId = Utils.newGuid() as UserId; + beforeEach(() => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); cipherService = mock(); folderService = mock(); collectionService = mock(); @@ -66,6 +76,8 @@ describe("VaultTimeoutService", () => { availableVaultTimeoutActionsSubject = new BehaviorSubject([]); vaultTimeoutService = new VaultTimeoutService( + accountService, + masterPasswordService, cipherService, folderService, collectionService, @@ -123,6 +135,15 @@ describe("VaultTimeoutService", () => { stateService.activeAccount$ = new BehaviorSubject(globalSetups?.userId); + if (globalSetups?.userId) { + accountService.activeAccountSubject.next({ + id: globalSetups.userId as UserId, + status: accounts[globalSetups.userId]?.authStatus, + email: null, + name: null, + }); + } + platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false); vaultTimeoutSettingsService.vaultTimeoutAction$.mockImplementation((userId) => { @@ -156,8 +177,7 @@ describe("VaultTimeoutService", () => { expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId); expect(stateService.setEverBeenUnlocked).toHaveBeenCalledWith(true, { userId: userId }); expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId }); - expect(cryptoService.clearUserKey).toHaveBeenCalledWith(false, userId); - expect(cryptoService.clearMasterKey).toHaveBeenCalledWith(userId); + expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId); expect(cipherService.clearCache).toHaveBeenCalledWith(userId); expect(lockedCallback).toHaveBeenCalledWith(userId); }; diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index c3270ac2b80..35faf0fceeb 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -3,7 +3,9 @@ import { firstValueFrom, timeout } from "rxjs"; import { SearchService } from "../../abstractions/search.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout.service"; +import { AccountService } from "../../auth/abstractions/account.service"; import { AuthService } from "../../auth/abstractions/auth.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../auth/abstractions/master-password.service.abstraction"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { ClientType } from "../../enums"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; @@ -21,6 +23,8 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { private inited = false; constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cipherService: CipherService, private folderService: FolderService, private collectionService: CollectionService, @@ -84,23 +88,20 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { await this.logOut(userId); } - const currentUserId = await this.stateService.getUserId(); + const currentUserId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userId == null || userId === currentUserId) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); await this.folderService.clearCache(); await this.collectionService.clearActiveUserCache(); } + await this.masterPasswordService.clearMasterKey((userId ?? currentUserId) as UserId); + await this.stateService.setEverBeenUnlocked(true, { userId: userId }); await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); - await this.cryptoService.clearUserKey(false, userId); - await this.cryptoService.clearMasterKey(userId); - await this.cryptoService.clearOrgKeys(true, userId); - await this.cryptoService.clearKeyPair(true, userId); - await this.cipherService.clearCache(userId); await this.stateEventRunnerService.handleEvent("lock", (userId ?? currentUserId) as UserId); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index faccddb0afd..76f0d7fd468 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -51,6 +51,7 @@ import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-t import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version"; import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers"; import { SendMigrator } from "./migrations/54-move-encrypted-sends"; +import { MoveMasterKeyStateToProviderMigrator } from "./migrations/55-move-master-key-state-to-provider"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; @@ -58,7 +59,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 54; +export const CURRENT_VERSION = 55; export type MinVersion = typeof MIN_VERSION; @@ -115,7 +116,8 @@ export function createMigrationBuilder() { .with(RememberedEmailMigrator, 50, 51) .with(DeleteInstalledVersion, 51, 52) .with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53) - .with(SendMigrator, 53, 54); + .with(SendMigrator, 53, 54) + .with(MoveMasterKeyStateToProviderMigrator, 54, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts b/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts new file mode 100644 index 00000000000..bbf0352e952 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts @@ -0,0 +1,210 @@ +import { any, MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + FORCE_SET_PASSWORD_REASON_DEFINITION, + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + MASTER_KEY_HASH_DEFINITION, + MoveMasterKeyStateToProviderMigrator, +} from "./55-move-master-key-state-to-provider"; + +function preMigrationState() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"], + // prettier-ignore + "FirstAccount": { + profile: { + forceSetPasswordReason: "FirstAccount_forceSetPasswordReason", + keyHash: "FirstAccount_keyHash", + otherStuff: "overStuff2", + }, + keys: { + masterKeyEncryptedUserKey: "FirstAccount_masterKeyEncryptedUserKey", + }, + otherStuff: "otherStuff3", + }, + // prettier-ignore + "SecondAccount": { + profile: { + forceSetPasswordReason: "SecondAccount_forceSetPasswordReason", + keyHash: "SecondAccount_keyHash", + otherStuff: "otherStuff4", + }, + keys: { + masterKeyEncryptedUserKey: "SecondAccount_masterKeyEncryptedUserKey", + }, + otherStuff: "otherStuff5", + }, + // prettier-ignore + "ThirdAccount": { + profile: { + otherStuff: "otherStuff6", + }, + }, + }; +} + +function postMigrationState() { + return { + user_FirstAccount_masterPassword_forceSetPasswordReason: "FirstAccount_forceSetPasswordReason", + user_FirstAccount_masterPassword_masterKeyHash: "FirstAccount_keyHash", + user_FirstAccount_masterPassword_masterKeyEncryptedUserKey: + "FirstAccount_masterKeyEncryptedUserKey", + user_SecondAccount_masterPassword_forceSetPasswordReason: + "SecondAccount_forceSetPasswordReason", + user_SecondAccount_masterPassword_masterKeyHash: "SecondAccount_keyHash", + user_SecondAccount_masterPassword_masterKeyEncryptedUserKey: + "SecondAccount_masterKeyEncryptedUserKey", + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount"], + // prettier-ignore + "FirstAccount": { + profile: { + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }, + // prettier-ignore + "SecondAccount": { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + // prettier-ignore + "ThirdAccount": { + profile: { + otherStuff: "otherStuff6", + }, + }, + }; +} + +describe("MoveForceSetPasswordReasonToStateProviderMigrator", () => { + let helper: MockProxy; + let sut: MoveMasterKeyStateToProviderMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(preMigrationState(), 54); + sut = new MoveMasterKeyStateToProviderMigrator(54, 55); + }); + + it("should remove properties from existing accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + profile: { + otherStuff: "overStuff2", + }, + keys: {}, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("SecondAccount", { + profile: { + otherStuff: "otherStuff4", + }, + keys: {}, + otherStuff: "otherStuff5", + }); + }); + + it("should set properties for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + FORCE_SET_PASSWORD_REASON_DEFINITION, + "FirstAccount_forceSetPasswordReason", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + MASTER_KEY_HASH_DEFINITION, + "FirstAccount_keyHash", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + "FirstAccount_masterKeyEncryptedUserKey", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + FORCE_SET_PASSWORD_REASON_DEFINITION, + "SecondAccount_forceSetPasswordReason", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + MASTER_KEY_HASH_DEFINITION, + "SecondAccount_keyHash", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + "SecondAccount_masterKeyEncryptedUserKey", + ); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(postMigrationState(), 55); + sut = new MoveMasterKeyStateToProviderMigrator(54, 55); + }); + + it.each(["FirstAccount", "SecondAccount"])("should null out new values", async (userId) => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + userId, + FORCE_SET_PASSWORD_REASON_DEFINITION, + null, + ); + + expect(helper.setToUser).toHaveBeenCalledWith(userId, MASTER_KEY_HASH_DEFINITION, null); + }); + + it("should add explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + profile: { + forceSetPasswordReason: "FirstAccount_forceSetPasswordReason", + keyHash: "FirstAccount_keyHash", + otherStuff: "overStuff2", + }, + keys: { + masterKeyEncryptedUserKey: "FirstAccount_masterKeyEncryptedUserKey", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("SecondAccount", { + profile: { + forceSetPasswordReason: "SecondAccount_forceSetPasswordReason", + keyHash: "SecondAccount_keyHash", + otherStuff: "otherStuff4", + }, + keys: { + masterKeyEncryptedUserKey: "SecondAccount_masterKeyEncryptedUserKey", + }, + otherStuff: "otherStuff5", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts b/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts new file mode 100644 index 00000000000..99b22b56617 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts @@ -0,0 +1,111 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + keys?: { + masterKeyEncryptedUserKey?: string; + }; + profile?: { + forceSetPasswordReason?: number; + keyHash?: string; + }; +}; + +export const FORCE_SET_PASSWORD_REASON_DEFINITION: KeyDefinitionLike = { + key: "forceSetPasswordReason", + stateDefinition: { + name: "masterPassword", + }, +}; + +export const MASTER_KEY_HASH_DEFINITION: KeyDefinitionLike = { + key: "masterKeyHash", + stateDefinition: { + name: "masterPassword", + }, +}; + +export const MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION: KeyDefinitionLike = { + key: "masterKeyEncryptedUserKey", + stateDefinition: { + name: "masterPassword", + }, +}; + +export class MoveMasterKeyStateToProviderMigrator extends Migrator<54, 55> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + const forceSetPasswordReason = account?.profile?.forceSetPasswordReason; + if (forceSetPasswordReason != null) { + await helper.setToUser( + userId, + FORCE_SET_PASSWORD_REASON_DEFINITION, + forceSetPasswordReason, + ); + + delete account.profile.forceSetPasswordReason; + await helper.set(userId, account); + } + + const masterKeyHash = account?.profile?.keyHash; + if (masterKeyHash != null) { + await helper.setToUser(userId, MASTER_KEY_HASH_DEFINITION, masterKeyHash); + + delete account.profile.keyHash; + await helper.set(userId, account); + } + + const masterKeyEncryptedUserKey = account?.keys?.masterKeyEncryptedUserKey; + if (masterKeyEncryptedUserKey != null) { + await helper.setToUser( + userId, + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + masterKeyEncryptedUserKey, + ); + + delete account.keys.masterKeyEncryptedUserKey; + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + const forceSetPasswordReason = await helper.getFromUser( + userId, + FORCE_SET_PASSWORD_REASON_DEFINITION, + ); + const masterKeyHash = await helper.getFromUser(userId, MASTER_KEY_HASH_DEFINITION); + const masterKeyEncryptedUserKey = await helper.getFromUser( + userId, + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + ); + if (account != null) { + if (forceSetPasswordReason != null) { + account.profile = Object.assign(account.profile ?? {}, { + forceSetPasswordReason, + }); + } + if (masterKeyHash != null) { + account.profile = Object.assign(account.profile ?? {}, { + keyHash: masterKeyHash, + }); + } + if (masterKeyEncryptedUserKey != null) { + account.keys = Object.assign(account.keys ?? {}, { + masterKeyEncryptedUserKey, + }); + } + await helper.set(userId, account); + } + + await helper.setToUser(userId, FORCE_SET_PASSWORD_REASON_DEFINITION, null); + await helper.setToUser(userId, MASTER_KEY_HASH_DEFINITION, null); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index 714f5dffc39..97c87e684e9 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -8,3 +8,4 @@ export type CollectionId = Opaque; export type ProviderId = Opaque; export type PolicyId = Opaque; export type CipherId = Opaque; +export type IndexedEntityId = Opaque; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 4a6e96ead76..7d3772f8c51 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -89,9 +89,9 @@ export class CipherService implements CipherServiceAbstraction { } if (this.searchService != null) { if (value == null) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); } else { - this.searchService.indexCiphers(value); + await this.searchService.indexCiphers(value); } } } @@ -333,9 +333,10 @@ export class CipherService implements CipherServiceAbstraction { private async reindexCiphers() { const userId = await this.stateService.getUserId(); const reindexRequired = - this.searchService != null && (this.searchService.indexedEntityId ?? userId) !== userId; + this.searchService != null && + ((await firstValueFrom(this.searchService.indexedEntityId$)) ?? userId) !== userId; if (reindexRequired) { - this.searchService.indexCiphers(await this.getDecryptedCipherCache(), userId); + await this.searchService.indexCiphers(await this.getDecryptedCipherCache(), userId); } } diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index d4601d96210..ff8e9f1f4f5 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -11,8 +11,10 @@ import { OrganizationData } from "../../../admin-console/models/data/organizatio import { PolicyData } from "../../../admin-console/models/data/policy.data"; import { ProviderData } from "../../../admin-console/models/data/provider.data"; import { PolicyResponse } from "../../../admin-console/models/response/policy.response"; +import { AccountService } from "../../../auth/abstractions/account.service"; import { AvatarService } from "../../../auth/abstractions/avatar.service"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../../auth/abstractions/master-password.service.abstraction"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service"; @@ -49,6 +51,8 @@ export class SyncService implements SyncServiceAbstraction { syncInProgress = false; constructor( + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private accountService: AccountService, private apiService: ApiService, private domainSettingsService: DomainSettingsService, private folderService: InternalFolderService, @@ -352,8 +356,10 @@ export class SyncService implements SyncServiceAbstraction { private async setForceSetPasswordReasonIfNeeded(profileResponse: ProfileResponse) { // The `forcePasswordReset` flag indicates an admin has reset the user's password and must be updated if (profileResponse.forcePasswordReset) { - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.AdminForcePasswordReset, + userId, ); } @@ -387,8 +393,10 @@ export class SyncService implements SyncServiceAbstraction { ) { // TDE user w/out MP went from having no password reset permission to having it. // Must set the force password reset reason so the auth guard will redirect to the set password page. - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); } } diff --git a/package-lock.json b/package-lock.json index 540bc145e44..c399536cca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -168,7 +168,7 @@ "regedit": "^3.0.3", "remark-gfm": "3.0.1", "rimraf": "5.0.5", - "sass": "1.69.5", + "sass": "1.74.1", "sass-loader": "13.3.3", "storybook": "7.6.17", "style-loader": "3.3.4", @@ -193,7 +193,7 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2024.3.1" + "version": "2024.4.1" }, "apps/cli": { "name": "@bitwarden/cli", @@ -233,7 +233,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2024.3.2", + "version": "2024.4.1", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -33467,9 +33467,9 @@ } }, "node_modules/sass": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz", - "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", + "version": "1.74.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz", + "integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", diff --git a/package.json b/package.json index 1c36865cd63..203da2d6259 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "regedit": "^3.0.3", "remark-gfm": "3.0.1", "rimraf": "5.0.5", - "sass": "1.69.5", + "sass": "1.74.1", "sass-loader": "13.3.3", "storybook": "7.6.17", "style-loader": "3.3.4",