diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 48ecca540e8..72b60da97a1 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -33,6 +33,10 @@ on: description: "Custom SDK branch" required: false type: string + testflight_distribute: + description: "Force distribute to TestFlight regardless of branch (useful for QA testing on feature branches)" + type: boolean + default: true defaults: run: @@ -1208,21 +1212,45 @@ jobs: path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg if-no-files-found: error + - name: Create secrets for Fastlane + if: | + github.event_name != 'pull_request_target' + && (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') + run: | + brew install gsed + + KEY_WITHOUT_NEWLINES=$(gsed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g' ~/private_keys/AuthKey_6TV9MKN3GP.p8) + + cat << EOF > ~/secrets/appstoreconnect-fastlane.json + { + "issuer_id": "${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}", + "key_id": "6TV9MKN3GP", + "key": "$KEY_WITHOUT_NEWLINES" + } + EOF + - name: Deploy to TestFlight id: testflight-deploy if: | github.event_name != 'pull_request_target' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') + && (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') env: APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP + BRANCH: ${{ github.ref }} run: | - xcrun altool \ - --upload-app \ - --type macos \ - --file "$(find ./dist/mas-universal/Bitwarden*.pkg)" \ - --apiKey $APP_STORE_CONNECT_AUTH_KEY \ - --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER + + GIT_CHANGE="$(git show -s --format=%s)" + + BRANCH=$(echo $BRANCH | sed 's/refs\/heads\///') + + CHANGELOG="$BRANCH: $GIT_CHANGE" + + fastlane pilot upload \ + --app_identifier "com.bitwarden.desktop" \ + --changelog "$CHANGELOG" \ + --api_key_path $HOME/secrets/appstoreconnect-fastlane.json \ + --pkg "$(find ./dist/mas-universal/Bitwarden*.pkg)" - name: Post message to a Slack channel id: slack-message diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 75b59b8efdc..66e5b0bb214 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -233,11 +233,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { .pipe( switchMap(async () => { const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id); - const biometricSettingAvailable = - !(await BrowserApi.permissionsGranted(["nativeMessaging"])) || - (status !== BiometricsStatus.DesktopDisconnected && - status !== BiometricsStatus.NotEnabledInConnectedDesktopApp) || - (await this.vaultTimeoutSettingsService.isBiometricLockSet()); + const biometricSettingAvailable = await this.biometricsService.canEnableBiometricUnlock(); if (!biometricSettingAvailable) { this.form.controls.biometric.disable({ emitEvent: false }); } else { @@ -256,6 +252,13 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { "biometricsStatusHelptextNotEnabledInDesktop", activeAccount.email, ); + } else if ( + status === BiometricsStatus.HardwareUnavailable && + !biometricSettingAvailable + ) { + this.biometricUnavailabilityReason = this.i18nService.t( + "biometricsStatusHelptextHardwareUnavailable", + ); } else { this.biometricUnavailabilityReason = ""; } diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index c690eb3d2ca..90a536ad25d 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -154,121 +154,115 @@ - -

{{ "enableAutoFillOnPageLoadSectionTitle" | i18n }}

-
- - - {{ "enableAutoFillOnPageLoadDesc" | i18n }} - {{ "warningCapitalized" | i18n }}: {{ "experimentalFeature" | i18n }} - - {{ "learnMoreAboutAutofillOnPageLoadLinkText" | i18n }} - - - - - {{ "enableAutoFillOnPageLoad" | i18n }} - {{ - "enterprisePolicyRequirementsApplied" | i18n - }} - - - {{ "defaultAutoFillOnPageLoad" | i18n }} - - - {{ "defaultAutoFillOnPageLoadDesc" | i18n }} +
+ + +

{{ "enableAutoFillOnPageLoadSectionTitle" | i18n }}

+
+
+ + + {{ "enableAutoFillOnPageLoadDesc" | i18n }} + {{ "warningCapitalized" | i18n }}: {{ "experimentalFeature" | i18n }} + + {{ "learnMoreAboutAutofillOnPageLoadLinkText" | i18n }} + - - + + + {{ "enableAutoFillOnPageLoad" | i18n }} + {{ + "enterprisePolicyRequirementsApplied" | i18n + }} + + + {{ "defaultAutoFillOnPageLoad" | i18n }} + + + + + + {{ "defaultAutoFillOnPageLoadDesc" | i18n }} + + + +
- -

{{ "additionalOptions" | i18n }}

-
- - - - {{ "enableContextMenuItem" | i18n }} - - - - {{ "enableAutoTotpCopy" | i18n }} - - - {{ "clearClipboard" | i18n }} - - - {{ "clearClipboardDesc" | i18n }} - - - - {{ "defaultUriMatchDetection" | i18n }} - - - {{ "defaultUriMatchDetectionDesc" | i18n }} - - - +
+ +

{{ "additionalOptions" | i18n }}

+
+ + + + {{ "enableContextMenuItem" | i18n }} + + + + {{ "enableAutoTotpCopy" | i18n }} + + + {{ "clearClipboard" | i18n }} + + + + + {{ "clearClipboardDesc" | i18n }} + + + + {{ "defaultUriMatchDetection" | i18n }} + + + + + {{ "defaultUriMatchDetectionDesc" | i18n }} + + + +
diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index 7bd6c93bb64..eb18ea9f23e 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -1,8 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; -import { FormsModule } from "@angular/forms"; +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + FormsModule, + ReactiveFormsModule, + FormBuilder, + FormGroup, + FormControl, +} from "@angular/forms"; import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; @@ -73,6 +80,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co SectionHeaderComponent, SelectModule, TypographyModule, + ReactiveFormsModule, ], }) export class AutofillComponent implements OnInit { @@ -94,6 +102,18 @@ export class AutofillComponent implements OnInit { protected autofillOnPageLoadFromPolicy$ = this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$; + protected autofillOnPageLoadForm = new FormGroup({ + autofillOnPageLoad: new FormControl(), + defaultAutofill: new FormControl(), + }); + + protected additionalOptionsForm = new FormGroup({ + enableContextMenuItem: new FormControl(), + enableAutoTotpCopy: new FormControl(), + clearClipboard: new FormControl(), + defaultUriMatch: new FormControl(), + }); + enableAutofillOnPageLoad: boolean = false; enableInlineMenu: boolean = false; enableInlineMenuOnIconSelect: boolean = false; @@ -121,10 +141,12 @@ export class AutofillComponent implements OnInit { private messagingService: MessagingService, private vaultSettingsService: VaultSettingsService, private configService: ConfigService, + private formBuilder: FormBuilder, + private destroyRef: DestroyRef, ) { this.autofillOnPageLoadOptions = [ - { name: i18nService.t("autoFillOnPageLoadYes"), value: true }, - { name: i18nService.t("autoFillOnPageLoadNo"), value: false }, + { name: this.i18nService.t("autoFillOnPageLoadYes"), value: true }, + { name: this.i18nService.t("autoFillOnPageLoadNo"), value: false }, ]; this.clearClipboardOptions = [ { name: i18nService.t("never"), value: ClearClipboardDelay.Never }, @@ -181,27 +203,106 @@ export class AutofillComponent implements OnInit { this.inlineMenuVisibility === AutofillOverlayVisibility.OnFieldFocus || this.enableInlineMenuOnIconSelect; + this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + value + ? this.autofillOnPageLoadForm.controls.autofillOnPageLoad.disable({ emitEvent: false }) + : this.autofillOnPageLoadForm.controls.autofillOnPageLoad.enable({ emitEvent: false }); + }); + this.enableAutofillOnPageLoad = await firstValueFrom( this.autofillSettingsService.autofillOnPageLoad$, ); + this.autofillOnPageLoadForm.controls.autofillOnPageLoad.patchValue( + this.enableAutofillOnPageLoad, + { emitEvent: false }, + ); + this.autofillOnPageLoadDefault = await firstValueFrom( this.autofillSettingsService.autofillOnPageLoadDefault$, ); + if (this.enableAutofillOnPageLoad === false) { + this.autofillOnPageLoadForm.controls.defaultAutofill.disable(); + } + + this.autofillOnPageLoadForm.controls.defaultAutofill.patchValue( + this.autofillOnPageLoadDefault, + { emitEvent: false }, + ); + + this.autofillOnPageLoadForm.controls.autofillOnPageLoad.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + void this.autofillSettingsService.setAutofillOnPageLoad(value); + this.enableDefaultAutofillControl(value); + }); + + this.autofillOnPageLoadForm.controls.defaultAutofill.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + void this.autofillSettingsService.setAutofillOnPageLoadDefault(value); + }); + + /** Additional options form */ + this.enableContextMenuItem = await firstValueFrom( this.autofillSettingsService.enableContextMenu$, ); + this.additionalOptionsForm.controls.enableContextMenuItem.patchValue( + this.enableContextMenuItem, + { emitEvent: false }, + ); + this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$); + this.additionalOptionsForm.controls.enableAutoTotpCopy.patchValue(this.enableAutoTotpCopy, { + emitEvent: false, + }); + this.clearClipboard = await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$); + this.additionalOptionsForm.controls.clearClipboard.patchValue(this.clearClipboard, { + emitEvent: false, + }); + const defaultUriMatch = await firstValueFrom( this.domainSettingsService.defaultUriMatchStrategy$, ); this.defaultUriMatch = defaultUriMatch == null ? UriMatchStrategy.Domain : defaultUriMatch; + this.additionalOptionsForm.controls.defaultUriMatch.patchValue(this.defaultUriMatch, { + emitEvent: false, + }); + + this.additionalOptionsForm.controls.enableContextMenuItem.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + void this.autofillSettingsService.setEnableContextMenu(value); + this.messagingService.send("bgUpdateContextMenu"); + }); + + this.additionalOptionsForm.controls.enableAutoTotpCopy.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + void this.autofillSettingsService.setAutoCopyTotp(value); + }); + + this.additionalOptionsForm.controls.clearClipboard.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + void this.autofillSettingsService.setClearClipboardDelay(value); + }); + + this.additionalOptionsForm.controls.defaultUriMatch.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + void this.domainSettingsService.setDefaultUriMatchStrategy(value); + }); + const command = await this.platformUtilsService.getAutofillKeyboardShortcut(); await this.setAutofillKeyboardHelperText(command); @@ -230,17 +331,16 @@ export class AutofillComponent implements OnInit { await this.requestPrivacyPermission(); } } - - async updateAutofillOnPageLoad() { - await this.autofillSettingsService.setAutofillOnPageLoad(this.enableAutofillOnPageLoad); + async getAutofillOnPageLoadFromPolicy() { + await firstValueFrom(this.autofillOnPageLoadFromPolicy$); } - async updateAutofillOnPageLoadDefault() { - await this.autofillSettingsService.setAutofillOnPageLoadDefault(this.autofillOnPageLoadDefault); - } - - async saveDefaultUriMatch() { - await this.domainSettingsService.setDefaultUriMatchStrategy(this.defaultUriMatch); + enableDefaultAutofillControl(enable: boolean = true) { + if (enable) { + this.autofillOnPageLoadForm.controls.defaultAutofill.enable(); + } else { + this.autofillOnPageLoadForm.controls.defaultAutofill.disable(); + } } private async setAutofillKeyboardHelperText(command: string) { @@ -388,19 +488,6 @@ export class AutofillComponent implements OnInit { return await BrowserApi.permissionsGranted(["privacy"]); } - async updateContextMenuItem() { - await this.autofillSettingsService.setEnableContextMenu(this.enableContextMenuItem); - this.messagingService.send("bgUpdateContextMenu"); - } - - async updateAutoTotpCopy() { - await this.autofillSettingsService.setAutoCopyTotp(this.enableAutoTotpCopy); - } - - async saveClearClipboard() { - await this.autofillSettingsService.setClearClipboardDelay(this.clearClipboard); - } - async updateShowCardsCurrentTab() { await this.vaultSettingsService.setShowCardsCurrentTab(this.showCardsCurrentTab); } diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts index f3047947c82..6bedb939c30 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts @@ -17,7 +17,6 @@ describe("InlineMenuFieldQualificationService", () => { fields: [], }); inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); - inlineMenuFieldQualificationService["inlineMenuFieldQualificationFlagSet"] = true; }); describe("isFieldForLoginForm", () => { diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 046381d956c..9b16a0cfbdd 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -150,7 +150,6 @@ export class InlineMenuFieldQualificationService this.identityPostalCodeAutocompleteValue, ]); private totpFieldAutocompleteValue = "one-time-code"; - private inlineMenuFieldQualificationFlagSet = false; private premiumEnabled = false; constructor() { @@ -158,7 +157,6 @@ export class InlineMenuFieldQualificationService sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"), sendExtensionMessage("getUserPremiumStatus"), ]).then(([fieldQualificationFlag, premiumStatus]) => { - this.inlineMenuFieldQualificationFlagSet = !!fieldQualificationFlag?.result; this.premiumEnabled = !!premiumStatus?.result; }); } @@ -170,10 +168,6 @@ export class InlineMenuFieldQualificationService * @param pageDetails - The details of the page that the field is on. */ isFieldForLoginForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean { - if (!this.inlineMenuFieldQualificationFlagSet) { - return this.isFieldForLoginFormFallback(field); - } - /** * Totp inline menu is available only for premium users. */ @@ -1223,18 +1217,4 @@ export class InlineMenuFieldQualificationService return false; } - - /** - * This method represents the previous rudimentary approach to qualifying fields for login forms. - * - * @param field - The field to validate - * @deprecated - This method will only be used when the fallback flag is set to true. - */ - private isFieldForLoginFormFallback(field: AutofillField): boolean { - if (field.type === "password") { - return true; - } - - return this.isUsernameField(field); - } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cae554c872c..5cc964c2c2d 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -655,9 +655,7 @@ export default class MainBackground { this.kdfConfigService, this.keyGenerationService, this.logService, - this.masterPasswordService, this.stateProvider, - this.stateService, ); this.keyService = new DefaultKeyService( @@ -674,14 +672,6 @@ export default class MainBackground { this.kdfConfigService, ); - this.biometricsService = new BackgroundBrowserBiometricsService( - runtimeNativeMessagingBackground, - this.logService, - this.keyService, - this.biometricStateService, - this.messagingService, - ); - this.appIdService = new AppIdService(this.storageService, this.logService); this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); @@ -701,6 +691,15 @@ export default class MainBackground { VaultTimeoutStringType.OnRestart, // default vault timeout ); + this.biometricsService = new BackgroundBrowserBiometricsService( + runtimeNativeMessagingBackground, + this.logService, + this.keyService, + this.biometricStateService, + this.messagingService, + this.vaultTimeoutSettingsService, + ); + this.apiService = new ApiService( this.tokenService, this.platformUtilsService, diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 8100ff3cffa..69521228bc5 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -120,9 +120,15 @@ export class NativeMessagingBackground { this.connecting = true; const connectedCallback = () => { - this.logService.info( - "[Native Messaging IPC] Connection to Bitwarden Desktop app established!", - ); + if (!this.platformUtilsService.isSafari()) { + this.logService.info( + "[Native Messaging IPC] Connection to Bitwarden Desktop app established!", + ); + } else { + this.logService.info( + "[Native Messaging IPC] Connection to Safari swift module established!", + ); + } this.connected = true; this.connecting = false; resolve(); @@ -131,6 +137,7 @@ export class NativeMessagingBackground { // Safari has a bundled native component which is always available, no need to // check if the desktop app is running. if (this.platformUtilsService.isSafari()) { + this.isConnectedToOutdatedDesktopClient = false; connectedCallback(); } @@ -428,7 +435,9 @@ export class NativeMessagingBackground { } if (this.callbacks.has(messageId)) { - this.callbacks.get(messageId)!.resolver(message); + const callback = this.callbacks!.get(messageId)!; + this.callbacks.delete(messageId); + callback.resolver(message); } else { this.logService.info("[Native Messaging IPC] Received message without a callback", message); } diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 7db72f38139..c1719abbb3a 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -78,8 +78,8 @@ export default class RuntimeBackground { BiometricsCommands.GetBiometricsStatus, BiometricsCommands.UnlockWithBiometricsForUser, BiometricsCommands.GetBiometricsStatusForUser, + BiometricsCommands.CanEnableBiometricUnlock, "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag", - "getInlineMenuFieldQualificationFeatureFlag", "getUserPremiumStatus", ]; @@ -201,14 +201,14 @@ export default class RuntimeBackground { case BiometricsCommands.GetBiometricsStatusForUser: { return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId); } + case BiometricsCommands.CanEnableBiometricUnlock: { + return await this.main.biometricsService.canEnableBiometricUnlock(); + } case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": { return await this.configService.getFeatureFlag( FeatureFlag.UseTreeWalkerApiForPageDetailsCollection, ); } - case "getInlineMenuFieldQualificationFeatureFlag": { - return await this.configService.getFeatureFlag(FeatureFlag.InlineMenuFieldQualification); - } case "getUserPremiumStatus": { const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts new file mode 100644 index 00000000000..4017953ee28 --- /dev/null +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts @@ -0,0 +1,61 @@ +import { mock } from "jest-mock-extended"; + +import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; + +import { NativeMessagingBackground } from "../../background/nativeMessaging.background"; + +import { BackgroundBrowserBiometricsService } from "./background-browser-biometrics.service"; + +describe("background browser biometrics service tests", function () { + let service: BackgroundBrowserBiometricsService; + + const nativeMessagingBackground = mock(); + const logService = mock(); + const keyService = mock(); + const biometricStateService = mock(); + const messagingService = mock(); + const vaultTimeoutSettingsService = mock(); + + beforeEach(() => { + jest.resetAllMocks(); + service = new BackgroundBrowserBiometricsService( + () => nativeMessagingBackground, + logService, + keyService, + biometricStateService, + messagingService, + vaultTimeoutSettingsService, + ); + }); + + describe("canEnableBiometricUnlock", () => { + const table: [BiometricsStatus, boolean, boolean][] = [ + // status, already enabled, expected + + // if the setting is not already on, it should only be possible to enable it if biometrics are available + [BiometricsStatus.Available, false, true], + [BiometricsStatus.HardwareUnavailable, false, false], + [BiometricsStatus.NotEnabledInConnectedDesktopApp, false, false], + [BiometricsStatus.DesktopDisconnected, false, false], + + // if the setting is already on, it should always be possible to disable it + [BiometricsStatus.Available, true, true], + [BiometricsStatus.HardwareUnavailable, true, true], + [BiometricsStatus.NotEnabledInConnectedDesktopApp, true, true], + [BiometricsStatus.DesktopDisconnected, true, true], + ]; + test.each(table)( + "status: %s, already enabled: %s, expected: %s", + async (status, alreadyEnabled, expected) => { + service.getBiometricsStatus = jest.fn().mockResolvedValue(status); + vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(alreadyEnabled); + const result = await service.canEnableBiometricUnlock(); + + expect(result).toBe(expected); + }, + ); + }); +}); diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts index 3031134dc34..a8a89d45274 100644 --- a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts @@ -1,5 +1,6 @@ import { Injectable } from "@angular/core"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -25,6 +26,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { private keyService: KeyService, private biometricStateService: BiometricStateService, private messagingService: MessagingService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, ) { super(); } @@ -169,4 +171,14 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { } async setShouldAutopromptNow(value: boolean): Promise {} + async canEnableBiometricUnlock(): Promise { + const status = await this.getBiometricsStatus(); + const isBiometricsAlreadyEnabled = await this.vaultTimeoutSettingsService.isBiometricLockSet(); + const statusAllowsBiometric = + status !== BiometricsStatus.DesktopDisconnected && + status !== BiometricsStatus.NotEnabledInConnectedDesktopApp && + status !== BiometricsStatus.HardwareUnavailable; + + return statusAllowsBiometric || isBiometricsAlreadyEnabled; + } } diff --git a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.spec.ts b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.spec.ts new file mode 100644 index 00000000000..672eec8c1fc --- /dev/null +++ b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.spec.ts @@ -0,0 +1,60 @@ +import { mock } from "jest-mock-extended"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { BrowserApi } from "../../platform/browser/browser-api"; + +import { ForegroundBrowserBiometricsService } from "./foreground-browser-biometrics"; + +jest.mock("../../platform/browser/browser-api", () => ({ + BrowserApi: { + sendMessageWithResponse: jest.fn(), + permissionsGranted: jest.fn(), + }, +})); + +describe("foreground browser biometrics service tests", function () { + const platformUtilsService = mock(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("canEnableBiometricUnlock", () => { + const table: [boolean, boolean, boolean, boolean][] = [ + // canEnableBiometricUnlock from background, native permission granted, isSafari, expected + + // needs permission prompt; always allowed + [true, false, false, true], + [false, false, false, true], + + // is safari; depends on the status that the background service reports + [false, false, true, false], + [true, false, true, true], + + // native permissions granted; depends on the status that the background service reports + [false, true, false, false], + [true, true, false, true], + + // should never happen since safari does not use the permissions + [false, true, true, false], + [true, true, true, true], + ]; + test.each(table)( + "canEnableBiometric: %s, native permission granted: %s, isSafari: %s, expected: %s", + async (canEnableBiometricUnlockBackground, granted, isSafari, expected) => { + const service = new ForegroundBrowserBiometricsService(platformUtilsService); + + (BrowserApi.permissionsGranted as jest.Mock).mockResolvedValue(granted); + (BrowserApi.sendMessageWithResponse as jest.Mock).mockResolvedValue({ + result: canEnableBiometricUnlockBackground, + }); + platformUtilsService.isSafari.mockReturnValue(isSafari); + + const result = await service.canEnableBiometricUnlock(); + + expect(result).toBe(expected); + }, + ); + }); +}); diff --git a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts index d248a630cc6..b6e84fee31a 100644 --- a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts +++ b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts @@ -1,3 +1,4 @@ +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; @@ -8,6 +9,10 @@ import { BrowserApi } from "../../platform/browser/browser-api"; export class ForegroundBrowserBiometricsService extends BiometricsService { shouldAutopromptNow = true; + constructor(private platformUtilsService: PlatformUtilsService) { + super(); + } + async authenticateWithBiometrics(): Promise { const response = await BrowserApi.sendMessageWithResponse<{ result: boolean; @@ -52,4 +57,19 @@ export class ForegroundBrowserBiometricsService extends BiometricsService { async setShouldAutopromptNow(value: boolean): Promise { this.shouldAutopromptNow = value; } + + async canEnableBiometricUnlock(): Promise { + const needsPermissionPrompt = + !(await BrowserApi.permissionsGranted(["nativeMessaging"])) && + !this.platformUtilsService.isSafari(); + return ( + needsPermissionPrompt || + ( + await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>(BiometricsCommands.CanEnableBiometricUnlock) + ).result + ); + } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 9d34c102c9b..02ecdb8acc2 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -318,10 +318,8 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: BiometricsService, - useFactory: () => { - return new ForegroundBrowserBiometricsService(); - }, - deps: [], + useClass: ForegroundBrowserBiometricsService, + deps: [PlatformUtilsService], }), safeProvider({ provide: SyncService, diff --git a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift index d4ce360c32a..54e91611325 100644 --- a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift +++ b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift @@ -152,7 +152,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { response.userInfo = [ SFExtensionMessageKey: [ "message": [ - "command": "biometricUnlock", + "command": "unlockWithBiometricsForUser", "response": false, "timestamp": Int64(NSDate().timeIntervalSince1970 * 1000), "messageId": messageId, @@ -177,7 +177,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { response.userInfo = [ SFExtensionMessageKey: [ "message": [ - "command": "biometricUnlock", + "command": "unlockWithBiometricsForUser", "response": false, "timestamp": Int64(NSDate().timeIntervalSince1970 * 1000), "messageId": messageId, @@ -209,7 +209,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { response.userInfo = [ SFExtensionMessageKey: [ "message": [ - "command": "biometricUnlock", + "command": "unlockWithBiometricsForUser", "response": true, "timestamp": Int64(NSDate().timeIntervalSince1970 * 1000), "userKeyB64": result!.replacingOccurrences(of: "\"", with: ""), @@ -220,7 +220,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { response.userInfo = [ SFExtensionMessageKey: [ "message": [ - "command": "biometricUnlock", + "command": "unlockWithBiometricsForUser", "response": true, "timestamp": Int64(NSDate().timeIntervalSince1970 * 1000), "messageId": messageId, diff --git a/apps/cli/src/key-management/cli-biometrics-service.ts b/apps/cli/src/key-management/cli-biometrics-service.ts index bda8fe82895..b4f802eb053 100644 --- a/apps/cli/src/key-management/cli-biometrics-service.ts +++ b/apps/cli/src/key-management/cli-biometrics-service.ts @@ -24,4 +24,7 @@ export class CliBiometricsService extends BiometricsService { } async setShouldAutopromptNow(value: boolean): Promise {} + async canEnableBiometricUnlock(): Promise { + return false; + } } diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 5bc07f63c32..6a4651bcd5a 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -436,9 +436,7 @@ export class ServiceContainer { this.kdfConfigService, this.keyGenerationService, this.logService, - this.masterPasswordService, this.stateProvider, - this.stateService, ); this.keyService = new KeyService( diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index d29147c1823..b05d90b7e1c 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -248,6 +248,7 @@ describe("SettingsComponent", () => { describe("biometrics enabled", () => { beforeEach(() => { desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available); + desktopBiometricsService.canEnableBiometricUnlock.mockResolvedValue(true); vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true); }); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 20b6d509f4d..a592e542d58 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -388,24 +388,12 @@ export class SettingsComponent implements OnInit, OnDestroy { } }); - this.supportsBiometric = this.shouldAllowBiometricSetup( - await this.biometricsService.getBiometricsStatus(), - ); + this.supportsBiometric = await this.biometricsService.canEnableBiometricUnlock(); this.timerId = setInterval(async () => { - this.supportsBiometric = this.shouldAllowBiometricSetup( - await this.biometricsService.getBiometricsStatus(), - ); + this.supportsBiometric = await this.biometricsService.canEnableBiometricUnlock(); }, 1000); } - private shouldAllowBiometricSetup(biometricStatus: BiometricsStatus): boolean { - return [ - BiometricsStatus.Available, - BiometricsStatus.AutoSetupNeeded, - BiometricsStatus.ManualSetupNeeded, - ].includes(biometricStatus); - } - async saveVaultTimeout(newValue: VaultTimeout) { if (newValue === VaultTimeoutStringType.Never) { const confirmed = await this.dialogService.openSimpleDialog({ diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts index dd2e15a2fe8..cf80fa5f7f3 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -163,4 +163,8 @@ export class MainBiometricsService extends DesktopBiometricsService { async getShouldAutopromptNow(): Promise { return this.shouldAutoPrompt; } + + async canEnableBiometricUnlock(): Promise { + return true; + } } diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.spec.ts new file mode 100644 index 00000000000..7a3f00c7c44 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.spec.ts @@ -0,0 +1,44 @@ +import { BiometricsStatus } from "@bitwarden/key-management"; + +import { RendererBiometricsService } from "./renderer-biometrics.service"; + +describe("renderer biometrics service tests", function () { + beforeEach(() => { + (global as any).ipc = { + keyManagement: { + biometric: { + authenticateWithBiometrics: jest.fn(), + getBiometricsStatus: jest.fn(), + unlockWithBiometricsForUser: jest.fn(), + getBiometricsStatusForUser: jest.fn(), + deleteBiometricUnlockKeyForUser: jest.fn(), + setupBiometrics: jest.fn(), + setClientKeyHalfForUser: jest.fn(), + getShouldAutoprompt: jest.fn(), + setShouldAutoprompt: jest.fn(), + }, + }, + }; + }); + + describe("canEnableBiometricUnlock", () => { + const table: [BiometricsStatus, boolean][] = [ + [BiometricsStatus.Available, true], + [BiometricsStatus.AutoSetupNeeded, true], + [BiometricsStatus.ManualSetupNeeded, true], + + [BiometricsStatus.UnlockNeeded, false], + [BiometricsStatus.HardwareUnavailable, false], + [BiometricsStatus.PlatformUnsupported, false], + [BiometricsStatus.NotEnabledLocally, false], + ]; + test.each(table)("canEnableBiometricUnlock(%s) === %s", async (status, expected) => { + const service = new RendererBiometricsService(); + (global as any).ipc.keyManagement.biometric.getBiometricsStatus.mockResolvedValue(status); + + const result = await service.canEnableBiometricUnlock(); + + expect(result).toBe(expected); + }); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index 2a0b1282778..db17ee480cb 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -51,4 +51,13 @@ export class RendererBiometricsService extends DesktopBiometricsService { async setShouldAutopromptNow(value: boolean): Promise { return await ipc.keyManagement.biometric.setShouldAutoprompt(value); } + + async canEnableBiometricUnlock(): Promise { + const biometricStatus = await this.getBiometricsStatus(); + return [ + BiometricsStatus.Available, + BiometricsStatus.AutoSetupNeeded, + BiometricsStatus.ManualSetupNeeded, + ].includes(biometricStatus); + } } diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 88746dc708b..1214c0ca411 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -13,6 +13,8 @@ import { Subject, switchMap, takeUntil, + tap, + filter, } from "rxjs"; import { first } from "rxjs/operators"; @@ -189,10 +191,29 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.formGroup.updateValueAndValidity(); } - this.organizationSelected.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((_) => { - this.organizationSelected.markAsTouched(); - this.formGroup.updateValueAndValidity(); - }); + this.organizationSelected.valueChanges + .pipe( + tap((_) => { + if (this.organizationSelected.errors?.cannotCreateCollections) { + this.buttonDisplayName = ButtonType.Upgrade; + } else { + this.buttonDisplayName = ButtonType.Save; + } + }), + filter(() => this.organizationSelected.errors?.cannotCreateCollections), + switchMap((value) => this.findOrganizationById(value)), + takeUntil(this.destroy$), + ) + .subscribe((org) => { + this.orgExceedingCollectionLimit = org; + this.organizationSelected.markAsTouched(); + this.formGroup.updateValueAndValidity(); + }); + } + + async findOrganizationById(orgId: string): Promise { + const organizations = await firstValueFrom(this.organizations$); + return organizations.find((org) => org.id === orgId); } async loadOrg(orgId: string) { diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index dc748e9ee41..a11858f3be8 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -733,6 +733,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { submit = async () => { if (this.taxComponent !== undefined && !this.taxComponent.validate()) { + this.taxComponent.markAllAsTouched(); return; } diff --git a/apps/web/src/app/key-management/key-rotation/request/unlock-data.request.ts b/apps/web/src/app/key-management/key-rotation/request/unlock-data.request.ts index 5cdb56a3e23..6faf0c29401 100644 --- a/apps/web/src/app/key-management/key-rotation/request/unlock-data.request.ts +++ b/apps/web/src/app/key-management/key-rotation/request/unlock-data.request.ts @@ -1,4 +1,5 @@ import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common"; +import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request"; @@ -11,16 +12,19 @@ export class UnlockDataRequest { emergencyAccessUnlockData: EmergencyAccessWithIdRequest[]; organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[]; passkeyUnlockData: WebauthnRotateCredentialRequest[]; + deviceKeyUnlockData: DeviceKeysUpdateRequest[]; constructor( masterPasswordUnlockData: MasterPasswordUnlockDataRequest, emergencyAccessUnlockData: EmergencyAccessWithIdRequest[], organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[], passkeyUnlockData: WebauthnRotateCredentialRequest[], + deviceTrustUnlockData: DeviceKeysUpdateRequest[], ) { this.masterPasswordUnlockData = masterPasswordUnlockData; this.emergencyAccessUnlockData = emergencyAccessUnlockData; this.organizationAccountRecoveryUnlockData = organizationAccountRecoveryUnlockData; this.passkeyUnlockData = passkeyUnlockData; + this.deviceKeyUnlockData = deviceTrustUnlockData; } } diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index c1b7a04d62b..9dc844c0104 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -180,11 +180,19 @@ export class UserKeyRotationService { newUnencryptedUserKey, user.id, ); + + const trustedDeviceUnlockData = await this.deviceTrustService.getRotatedData( + originalUserKey, + newUnencryptedUserKey, + user.id, + ); + const unlockDataRequest = new UnlockDataRequest( masterPasswordUnlockData, emergencyAccessUnlockData, organizationAccountRecoveryUnlockData, passkeyUnlockData, + trustedDeviceUnlockData, ); const request = new RotateUserAccountKeysRequest( @@ -198,14 +206,6 @@ export class UserKeyRotationService { await this.apiService.postUserKeyUpdateV2(request); this.logService.info("[Userkey rotation] Userkey rotation request posted to server"); - // TODO PM-2199: Add device trust rotation support to the user key rotation endpoint - this.logService.info("[Userkey rotation] Rotating device trust..."); - await this.deviceTrustService.rotateDevicesTrust( - user.id, - newUnencryptedUserKey, - newMasterKeyAuthenticationHash, - ); - this.logService.info("[Userkey rotation] Device trust rotation completed"); this.toastService.showToast({ variant: "success", title: this.i18nService.t("rotationCompletedTitle"), diff --git a/apps/web/src/app/key-management/web-biometric.service.ts b/apps/web/src/app/key-management/web-biometric.service.ts index 0c58c0da759..64fa0d243cd 100644 --- a/apps/web/src/app/key-management/web-biometric.service.ts +++ b/apps/web/src/app/key-management/web-biometric.service.ts @@ -24,4 +24,8 @@ export class WebBiometricsService extends BiometricsService { } async setShouldAutopromptNow(value: boolean): Promise {} + + async canEnableBiometricUnlock(): Promise { + return false; + } } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 2fcaa8d74af..138c7514eb6 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -138,6 +138,7 @@ export class VaultItemsComponent { return canRestore$; }), + map((canRestore) => canRestore && this.showBulkTrashOptions), ); } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.html index e6b5ae122f3..668d59b8830 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.html @@ -21,7 +21,7 @@ @@ -31,35 +31,31 @@

{{ "noClientsInList" | i18n }}

- + {{ "name" | i18n }} {{ "numberOfUsers" | i18n }} {{ "billingPlan" | i18n }} - - + {{ row.organizationName }} - + {{ row.userCount }} / {{ row.seats }} - + {{ row.plan }} - - -