diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 61a6d73e52c..ca19964619d 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2161,6 +2161,9 @@ "tooManyInvalidPinEntryAttemptsLoggingOut": { "message": "Too many invalid PIN entry attempts. Logging out." }, + "syncUnlockWithDesktop": { + "message": "Synchronize unlock state with desktop app." + }, "unlockWithBiometrics": { "message": "Unlock with biometrics" }, diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts index 78bee121afb..d575c48deaf 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts @@ -8,6 +8,7 @@ import { LockService } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; import { VaultTimeoutAction, VaultTimeoutService, @@ -70,6 +71,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private authService: AuthService, private lockService: LockService, + private foregroundSyncedUnlockService: SyncedUnlockService, ) {} get accountLimit() { @@ -120,6 +122,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { async lock(userId: string) { this.loading = true; + await this.foregroundSyncedUnlockService.lock(userId as UserId); await this.vaultTimeoutService.lock(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/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index ebf79af644c..da3d27b4468 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -11,7 +11,21 @@

{{ "unlockMethods" | i18n }}

- + + + + {{ "syncUnlockWithDesktop" | i18n }} + + + {{ "unlockWithBiometrics" | i18n @@ -23,7 +37,7 @@ {{ "unlockWithPin" | i18n }} @@ -45,7 +61,11 @@ - + {{ "vaultTimeoutAction1" | i18n }} - + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} + + The desktop app's vault timeout settings will be used to lock the vault on this device. + 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 ede044b21de..a9b044c5b3b 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -9,6 +9,7 @@ import { combineLatest, concatMap, distinctUntilChanged, + filter, firstValueFrom, map, Observable, @@ -30,6 +31,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; import { VaultTimeout, VaultTimeoutAction, @@ -63,6 +65,7 @@ import { BiometricsService, BiometricStateService, BiometricsStatus, + SyncedUnlockStateServiceAbstraction, } from "@bitwarden/key-management"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; @@ -110,6 +113,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { biometricUnavailabilityReason: string; showChangeMasterPass = true; pinEnabled$: Observable = of(true); + isConnected = false; form = this.formBuilder.group({ vaultTimeout: [null as VaultTimeout | null], @@ -118,6 +122,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { pinLockWithMasterPassword: false, biometric: false, enableAutoBiometricsPrompt: true, + syncUnlockWithDesktop: false, }); private refreshTimeoutSettings$ = new BehaviorSubject(undefined); @@ -142,6 +147,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { private biometricStateService: BiometricStateService, private toastService: ToastService, private biometricsService: BiometricsService, + private syncedUnlockService: SyncedUnlockService, + private syncedUnlockStateService: SyncedUnlockStateServiceAbstraction, ) {} async ngOnInit() { @@ -219,11 +226,34 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { enableAutoBiometricsPrompt: await firstValueFrom( this.biometricStateService.promptAutomatically$, ), + syncUnlockWithDesktop: await firstValueFrom( + this.syncedUnlockStateService.syncedUnlockEnabled$, + ), }; this.form.patchValue(initialValues, { emitEvent: false }); + this.form.controls.syncUnlockWithDesktop.valueChanges + .pipe( + concatMap(async (enabled) => { + if (enabled) { + const granted = await BrowserApi.requestPermission({ + permissions: ["nativeMessaging"], + }); + if (!granted) { + this.form.controls.syncUnlockWithDesktop.setValue(false); + return; + } + } + + await this.syncedUnlockStateService.setSyncedUnlockEnabled(enabled); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + timer(0, 1000) .pipe( + filter(() => !this.form.controls.syncUnlockWithDesktop.value), switchMap(async () => { const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id); const biometricSettingAvailable = await this.biometricsService.canEnableBiometricUnlock(); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a724f857cd1..25310821146 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -84,6 +84,7 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; import { DefaultVaultTimeoutSettingsService, VaultTimeoutSettingsService, @@ -225,6 +226,7 @@ import { DefaultBiometricStateService, DefaultKdfConfigService, DefaultKeyService, + DefaultSyncedUnlockStateService, KdfConfigService, KeyService as KeyServiceAbstraction, } from "@bitwarden/key-management"; @@ -261,6 +263,7 @@ import AutofillService from "../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service"; import { SafariApp } from "../browser/safariApp"; import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service"; +import { BackgroundSyncedUnlockService } from "../key-management/synced-unlock/background-synced-unlock.service"; import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service"; import { BrowserApi } from "../platform/browser/browser-api"; import { flagEnabled } from "../platform/flags"; @@ -388,6 +391,7 @@ export default class MainBackground { vaultSettingsService: VaultSettingsServiceAbstraction; biometricStateService: BiometricStateService; biometricsService: BiometricsService; + syncedUnlockService: SyncedUnlockService; stateEventRunnerService: StateEventRunnerService; ssoLoginService: SsoLoginServiceAbstraction; billingAccountProfileStateService: BillingAccountProfileStateService; @@ -914,6 +918,8 @@ export default class MainBackground { this.vaultSettingsService = new VaultSettingsService(this.stateProvider); + const syncedUnlockStateService = new DefaultSyncedUnlockStateService(this.stateProvider); + this.vaultTimeoutService = new VaultTimeoutService( this.accountService, this.masterPasswordService, @@ -930,9 +936,20 @@ export default class MainBackground { this.taskSchedulerService, this.logService, this.biometricsService, + syncedUnlockStateService, lockedCallback, logoutCallback, ); + + this.syncedUnlockService = new BackgroundSyncedUnlockService( + runtimeNativeMessagingBackground, + this.logService, + this.keyService, + this.accountService, + this.authService, + this.vaultTimeoutService, + ); + this.containerService = new ContainerService(this.keyService, this.encryptService); this.sendStateProvider = new SendStateProvider(this.stateProvider); diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 7172b98d727..2f869bcc799 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -1,4 +1,4 @@ -import { delay, filter, firstValueFrom, from, map, race, timer } from "rxjs"; +import { BehaviorSubject, concatMap, firstValueFrom, timer } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -11,7 +11,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl 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 { KeyService, BiometricStateService, BiometricsCommands } from "@bitwarden/key-management"; +import { KeyService, BiometricStateService } from "@bitwarden/key-management"; import { BrowserApi } from "../platform/browser/browser-api"; @@ -74,6 +74,7 @@ type SecureChannel = { export class NativeMessagingBackground { connected = false; private connecting: boolean = false; + private connected$ = new BehaviorSubject(false); private port?: browser.runtime.Port | chrome.runtime.Port; private appId?: string; @@ -82,8 +83,6 @@ export class NativeMessagingBackground { private messageId = 0; private callbacks = new Map(); - isConnectedToOutdatedDesktopClient = true; - constructor( private keyService: KeyService, private encryptService: EncryptService, @@ -105,6 +104,22 @@ export class NativeMessagingBackground { } }); } + + timer(0, 5000) + .pipe( + concatMap(async () => { + if (!this.connected) { + if (await this.hasPermission()) { + await this.connect(); + } + } + }), + ) + .subscribe(); + } + + async hasPermission(): Promise { + return await BrowserApi.permissionsGranted(["nativeMessaging"]); } async connect() { @@ -130,6 +145,7 @@ export class NativeMessagingBackground { ); } this.connected = true; + this.connected$.next(true); this.connecting = false; resolve(); }; @@ -137,7 +153,6 @@ 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(); } @@ -153,6 +168,7 @@ export class NativeMessagingBackground { reject(new Error("startDesktop")); } this.connected = false; + this.connected$.next(false); port.disconnect(); // reject all for (const callback of this.callbacks.values()) { @@ -188,15 +204,6 @@ export class NativeMessagingBackground { this.secureChannel.sharedSecret = new SymmetricCryptoKey(decrypted); this.logService.info("[Native Messaging IPC] Secure channel established"); - - if ("messageId" in message) { - this.logService.info("[Native Messaging IPC] Non-legacy desktop client"); - this.isConnectedToOutdatedDesktopClient = false; - } else { - this.logService.info("[Native Messaging IPC] Legacy desktop client"); - this.isConnectedToOutdatedDesktopClient = true; - } - this.secureChannel.setupResolve(); break; } @@ -211,6 +218,7 @@ export class NativeMessagingBackground { this.secureChannel = undefined; this.connected = false; + this.connected$.next(false); if (message.messageId != null) { if (this.callbacks.has(message.messageId)) { @@ -274,6 +282,7 @@ export class NativeMessagingBackground { this.secureChannel = undefined; this.connected = false; + this.connected$.next(false); this.logService.error("NativeMessaging port disconnected because of error: " + error); @@ -285,30 +294,6 @@ export class NativeMessagingBackground { async callCommand(message: Message): Promise { const messageId = this.messageId++; - - if ( - message.command == BiometricsCommands.Unlock || - message.command == BiometricsCommands.IsAvailable - ) { - // TODO remove after 2025.3 - // wait until there is no other callbacks, or timeout - const call = await firstValueFrom( - race( - from([false]).pipe(delay(5000)), - timer(0, 100).pipe( - filter(() => this.callbacks.size === 0), - map(() => true), - ), - ), - ); - if (!call) { - this.logService.info( - `[Native Messaging IPC] Message of type ${message.command} did not get a response before timing out`, - ); - return; - } - } - const callback = new Promise((resolver, rejecter) => { this.callbacks.set(messageId, { resolver, rejecter }); }); @@ -417,22 +402,6 @@ export class NativeMessagingBackground { const messageId = message.messageId; - if ( - message.command == BiometricsCommands.Unlock || - message.command == BiometricsCommands.IsAvailable - ) { - this.logService.info( - `[Native Messaging IPC] Received legacy message of type ${message.command}`, - ); - const messageId: number | undefined = this.callbacks.keys().next().value; - if (messageId != null) { - const resolver = this.callbacks.get(messageId); - this.callbacks.delete(messageId); - resolver!.resolver(message); - } - return; - } - if (this.callbacks.has(messageId)) { const callback = this.callbacks!.get(messageId)!; this.callbacks.delete(messageId); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index ec8ff7376e0..b62bb1106c9 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -18,7 +18,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { CipherType } from "@bitwarden/common/vault/enums"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; -import { BiometricsCommands } from "@bitwarden/key-management"; +import { BiometricsCommands, SyncedUnlockStateCommands } from "@bitwarden/key-management"; import { closeUnlockPopout, @@ -80,6 +80,11 @@ export default class RuntimeBackground { BiometricsCommands.UnlockWithBiometricsForUser, BiometricsCommands.GetBiometricsStatusForUser, BiometricsCommands.CanEnableBiometricUnlock, + SyncedUnlockStateCommands.IsConnected, + SyncedUnlockStateCommands.SendLockToDesktop, + SyncedUnlockStateCommands.GetUserKeyFromDesktop, + SyncedUnlockStateCommands.GetUserStatusFromDesktop, + SyncedUnlockStateCommands.FocusDesktopApp, "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag", "getUserPremiumStatus", ]; @@ -205,6 +210,21 @@ export default class RuntimeBackground { case BiometricsCommands.CanEnableBiometricUnlock: { return await this.main.biometricsService.canEnableBiometricUnlock(); } + case SyncedUnlockStateCommands.IsConnected: { + return await this.main.syncedUnlockService.isConnected(); + } + case SyncedUnlockStateCommands.SendLockToDesktop: { + return await this.main.syncedUnlockService.lock(msg.userId); + } + case SyncedUnlockStateCommands.GetUserKeyFromDesktop: { + return await this.main.syncedUnlockService.getUserKeyFromDesktop(msg.userId); + } + case SyncedUnlockStateCommands.GetUserStatusFromDesktop: { + return await this.main.syncedUnlockService.getUserStatusFromDesktop(msg.userId); + } + case SyncedUnlockStateCommands.FocusDesktopApp: { + return await this.main.syncedUnlockService.focusDesktopApp(); + } case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": { return await this.configService.getFeatureFlag( FeatureFlag.UseTreeWalkerApiForPageDetailsCollection, 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 a8a89d45274..2f99bb1d155 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 @@ -35,17 +35,10 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { try { await this.ensureConnected(); - if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) { - const response = await this.nativeMessagingBackground().callCommand({ - command: BiometricsCommands.Unlock, - }); - return response.response == "unlocked"; - } else { - const response = await this.nativeMessagingBackground().callCommand({ - command: BiometricsCommands.AuthenticateWithBiometrics, - }); - return response.response; - } + const response = await this.nativeMessagingBackground().callCommand({ + command: BiometricsCommands.AuthenticateWithBiometrics, + }); + return response.response; } catch (e) { this.logService.info("Biometric authentication failed", e); return false; @@ -60,23 +53,12 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { try { await this.ensureConnected(); - if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) { - const response = await this.nativeMessagingBackground().callCommand({ - command: BiometricsCommands.IsAvailable, - }); - const resp = - response.response == "available" - ? BiometricsStatus.Available - : BiometricsStatus.HardwareUnavailable; - return resp; - } else { - const response = await this.nativeMessagingBackground().callCommand({ - command: BiometricsCommands.GetBiometricsStatus, - }); + const response = await this.nativeMessagingBackground().callCommand({ + command: BiometricsCommands.GetBiometricsStatus, + }); - if (response.response) { - return response.response; - } + if (response.response) { + return response.response; } return BiometricsStatus.Available; // FIXME: Remove when updating file. Eslint update @@ -90,43 +72,23 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { try { await this.ensureConnected(); - // todo remove after 2025.3 - if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) { - const response = await this.nativeMessagingBackground().callCommand({ - command: BiometricsCommands.Unlock, - }); - if (response.response == "unlocked") { - const decodedUserkey = Utils.fromB64ToArray(response.userKeyB64); - const userKey = new SymmetricCryptoKey(decodedUserkey) as UserKey; - if (await this.keyService.validateUserKey(userKey, userId)) { - await this.biometricStateService.setBiometricUnlockEnabled(true); - await this.keyService.setUserKey(userKey, userId); - // to update badge and other things - this.messagingService.send("switchAccount", { userId }); - return userKey; - } - } else { - return null; + const response = await this.nativeMessagingBackground().callCommand({ + command: BiometricsCommands.UnlockWithBiometricsForUser, + userId: userId, + }); + if (response.response) { + // In case the requesting foreground context dies (popup), the userkey should still be set, so the user is unlocked / the setting should be enabled + const decodedUserkey = Utils.fromB64ToArray(response.userKeyB64); + const userKey = new SymmetricCryptoKey(decodedUserkey) as UserKey; + if (await this.keyService.validateUserKey(userKey, userId)) { + await this.biometricStateService.setBiometricUnlockEnabled(true); + await this.keyService.setUserKey(userKey, userId); + // to update badge and other things + this.messagingService.send("switchAccount", { userId }); + return userKey; } } else { - const response = await this.nativeMessagingBackground().callCommand({ - command: BiometricsCommands.UnlockWithBiometricsForUser, - userId: userId, - }); - if (response.response) { - // In case the requesting foreground context dies (popup), the userkey should still be set, so the user is unlocked / the setting should be enabled - const decodedUserkey = Utils.fromB64ToArray(response.userKeyB64); - const userKey = new SymmetricCryptoKey(decodedUserkey) as UserKey; - if (await this.keyService.validateUserKey(userKey, userId)) { - await this.biometricStateService.setBiometricUnlockEnabled(true); - await this.keyService.setUserKey(userKey, userId); - // to update badge and other things - this.messagingService.send("switchAccount", { userId }); - return userKey; - } - } else { - return null; - } + return null; } } catch (e) { this.logService.info("Biometric unlock for user failed", e); @@ -140,10 +102,6 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { try { await this.ensureConnected(); - if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) { - return await this.getBiometricsStatus(); - } - return ( await this.nativeMessagingBackground().callCommand({ command: BiometricsCommands.GetBiometricsStatusForUser, diff --git a/apps/browser/src/key-management/synced-unlock/background-synced-unlock.service.ts b/apps/browser/src/key-management/synced-unlock/background-synced-unlock.service.ts new file mode 100644 index 00000000000..65d8b21898c --- /dev/null +++ b/apps/browser/src/key-management/synced-unlock/background-synced-unlock.service.ts @@ -0,0 +1,99 @@ +import { Injectable } from "@angular/core"; +import { concatMap, firstValueFrom, timer } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; +import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.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"; +import { KeyService, SyncedUnlockStateCommands } from "@bitwarden/key-management"; + +import { NativeMessagingBackground } from "../../background/nativeMessaging.background"; + +@Injectable() +export class BackgroundSyncedUnlockService extends SyncedUnlockService { + constructor( + private nativeMessagingBackground: () => NativeMessagingBackground, + private logService: LogService, + private keyService: KeyService, + private accountService: AccountService, + private authService: AuthService, + private vaultTimeoutService: VaultTimeoutService, + ) { + super(); + timer(0, 1000) + .pipe( + concatMap(async () => { + if (this.nativeMessagingBackground().connected) { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (activeAccount) { + const desktopAccountStatus = await this.getUserStatusFromDesktop(activeAccount.id); + const localAccountStatus = await firstValueFrom( + this.authService.authStatusFor$(activeAccount.id), + ); + if ( + desktopAccountStatus === AuthenticationStatus.Locked && + localAccountStatus === AuthenticationStatus.Unlocked + ) { + await this.vaultTimeoutService.lock(activeAccount.id); + } + if ( + desktopAccountStatus === AuthenticationStatus.Unlocked && + localAccountStatus === AuthenticationStatus.Locked + ) { + const userKey = await this.getUserKeyFromDesktop(activeAccount.id); + if (userKey) { + await this.keyService.setUserKey(userKey, activeAccount.id); + } + } + } + } + }), + ) + .subscribe(); + } + + async isConnected(): Promise { + this.logService.info("abc"); + const a = this.nativeMessagingBackground().connected; + this.logService.info("aaaaa", a); + return a; + } + + async lock(userId: UserId): Promise { + await this.nativeMessagingBackground().callCommand({ + command: SyncedUnlockStateCommands.SendLockToDesktop, + userId, + }); + } + + async getUserKeyFromDesktop(userId: UserId): Promise { + const res = await this.nativeMessagingBackground().callCommand({ + command: SyncedUnlockStateCommands.GetUserKeyFromDesktop, + userId, + }); + if (res == null) { + return null; + } else { + return SymmetricCryptoKey.fromString(res.response) as UserKey; + } + } + + async getUserStatusFromDesktop(userId: UserId): Promise { + const res = await this.nativeMessagingBackground().callCommand({ + command: SyncedUnlockStateCommands.GetUserStatusFromDesktop, + userId, + }); + return res.response; + } + + async focusDesktopApp(): Promise { + await this.nativeMessagingBackground().callCommand({ + command: SyncedUnlockStateCommands.FocusDesktopApp, + }); + } +} diff --git a/apps/browser/src/key-management/synced-unlock/foreground-synced-unlock.service.ts b/apps/browser/src/key-management/synced-unlock/foreground-synced-unlock.service.ts new file mode 100644 index 00000000000..232f29135ab --- /dev/null +++ b/apps/browser/src/key-management/synced-unlock/foreground-synced-unlock.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from "@angular/core"; + +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; +import { SyncedUnlockStateCommands } from "@bitwarden/key-management"; + +import { BrowserApi } from "../../platform/browser/browser-api"; + +@Injectable() +export class ForegroundSyncedUnlockService extends SyncedUnlockService { + constructor(private logService: LogService) { + super(); + } + + async isConnected(): Promise { + const response = await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>(SyncedUnlockStateCommands.IsConnected); + if (response.result == null) { + throw response.error; + } + return response.result; + } + + async lock(userId: UserId): Promise { + try { + await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>(SyncedUnlockStateCommands.SendLockToDesktop, { userId }); + } catch (e) { + this.logService.error("Failed to send lock to desktop", e); + throw e; + } + } + + async getUserStatusFromDesktop(userId: UserId): Promise { + const response = await BrowserApi.sendMessageWithResponse<{ + result: AuthenticationStatus; + error: string; + }>(SyncedUnlockStateCommands.GetUserStatusFromDesktop, { userId }); + if (!response.result) { + throw response.error; + } + return response.result; + } + + async getUserKeyFromDesktop(userId: UserId): Promise { + const response = await BrowserApi.sendMessageWithResponse<{ + result: UserKey; + error: string; + }>(SyncedUnlockStateCommands.GetUserKeyFromDesktop, { userId }); + if (!response.result) { + return null; + } + return response.result; + } + + async focusDesktopApp(): Promise { + const response = await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>(SyncedUnlockStateCommands.FocusDesktopApp); + if (!response.result) { + throw response.error; + } + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 6ede88dfc13..e48ade2a914 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -67,6 +67,7 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; import { VaultTimeoutService, VaultTimeoutStringType, @@ -151,6 +152,7 @@ import AutofillService from "../../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service"; import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics"; import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service"; +import { ForegroundSyncedUnlockService } from "../../key-management/synced-unlock/foreground-synced-unlock.service"; import { ForegroundVaultTimeoutService } from "../../key-management/vault-timeout/foreground-vault-timeout.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { runInsideAngular } from "../../platform/browser/run-inside-angular.operator"; @@ -321,6 +323,11 @@ const safeProviders: SafeProvider[] = [ useClass: ForegroundBrowserBiometricsService, deps: [PlatformUtilsService], }), + safeProvider({ + provide: SyncedUnlockService, + useClass: ForegroundSyncedUnlockService, + deps: [LogService], + }), safeProvider({ provide: SyncService, useClass: ForegroundSyncService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index cdf6c4bbfda..7246962b41f 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -167,6 +167,7 @@ import { DefaultKeyService as KeyService, BiometricStateService, DefaultBiometricStateService, + DefaultSyncedUnlockStateService, } from "@bitwarden/key-management"; import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-function.service"; import { @@ -733,6 +734,7 @@ export class ServiceContainer { ); const biometricService = new CliBiometricsService(); + const syncedUnlockStateService = new DefaultSyncedUnlockStateService(this.stateProvider); this.vaultTimeoutService = new DefaultVaultTimeoutService( this.accountService, @@ -750,6 +752,7 @@ export class ServiceContainer { this.taskSchedulerService, this.logService, biometricService, + syncedUnlockStateService, lockedCallback, undefined, ); diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index a08764fc9d8..a943eb6eae8 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -889,7 +889,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "sysinfo", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tokio-stream", "tokio-util", @@ -931,7 +931,7 @@ dependencies = [ "cc", "core-foundation", "glob", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", ] @@ -2480,7 +2480,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.15", "libredox", - "thiserror 2.0.11", + "thiserror 2.0.12", ] [[package]] @@ -2971,11 +2971,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl 2.0.11", + "thiserror-impl 2.0.12", ] [[package]] @@ -2991,9 +2991,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index cfab600505e..826bf55b0b5 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -56,6 +56,8 @@ import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitw import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; +import { NoopSyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/noop-synced-unlock.service"; import { VaultTimeoutSettingsService, VaultTimeoutStringType, @@ -160,6 +162,11 @@ const safeProviders: SafeProvider[] = [ useClass: RendererBiometricsService, deps: [], }), + safeProvider({ + provide: SyncedUnlockService, + useClass: NoopSyncedUnlockService, + deps: [], + }), safeProvider(NativeMessagingService), safeProvider(BiometricMessageHandlerService), safeProvider(SearchBarService), diff --git a/apps/desktop/src/main/updater.main.ts b/apps/desktop/src/main/updater.main.ts index 51d5073911e..625802d55be 100644 --- a/apps/desktop/src/main/updater.main.ts +++ b/apps/desktop/src/main/updater.main.ts @@ -1,6 +1,6 @@ import { dialog, shell } from "electron"; import log from "electron-log"; -import { autoUpdater, UpdateDownloadedEvent, VerifyUpdateSupport } from "electron-updater"; +import { autoUpdater, UpdateDownloadedEvent } from "electron-updater"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -16,7 +16,7 @@ export class UpdaterMain { private doingUpdateCheckWithFeedback = false; private canUpdate = false; private updateDownloaded: UpdateDownloadedEvent = null; - private originalRolloutFunction: VerifyUpdateSupport = null; + //private originalRolloutFunction: VerifyUpdateSupport = null; constructor( private i18nService: I18nService, @@ -24,7 +24,7 @@ export class UpdaterMain { ) { autoUpdater.logger = log; - this.originalRolloutFunction = autoUpdater.isUserWithinRollout; + //this.originalRolloutFunction = autoUpdater.isUserWithinRollout; const linuxCanUpdate = process.platform === "linux" && isAppImage(); const windowsCanUpdate = @@ -130,7 +130,7 @@ export class UpdaterMain { // If the user has explicitly checked for updates, we want to bypass // the current staging rollout percentage - autoUpdater.isUserWithinRollout = (info) => true; + //autoUpdater.isUserWithinRollout = (info) => true; } await autoUpdater.checkForUpdates(); @@ -139,7 +139,7 @@ export class UpdaterMain { private reset() { autoUpdater.autoDownload = true; // Reset the rollout check to the default behavior - autoUpdater.isUserWithinRollout = this.originalRolloutFunction; + //autoUpdater.isUserWithinRollout = this.originalRolloutFunction; this.doingUpdateCheck = false; this.updateDownloaded = null; } diff --git a/apps/desktop/src/services/biometric-message-handler.service.spec.ts b/apps/desktop/src/services/biometric-message-handler.service.spec.ts index af18828b59d..6ba8eb39e53 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.spec.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.spec.ts @@ -107,6 +107,7 @@ describe("BiometricMessageHandlerService", () => { authService, ngZone, i18nService, + null, ); }); @@ -165,6 +166,7 @@ describe("BiometricMessageHandlerService", () => { authService, ngZone, i18nService, + null, ); }); diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 42d7b8aae5f..5f96e012bf9 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -1,11 +1,12 @@ import { Injectable, NgZone } from "@angular/core"; -import { combineLatest, concatMap, firstValueFrom, map } from "rxjs"; +import { combineLatest, concatMap, firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; 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"; @@ -20,6 +21,7 @@ import { BiometricsService, BiometricsStatus, KeyService, + SyncedUnlockStateCommands, } from "@bitwarden/key-management"; import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; @@ -89,6 +91,7 @@ export class BiometricMessageHandlerService { private authService: AuthService, private ngZone: NgZone, private i18nService: I18nService, + private vaultTimeoutService: VaultTimeoutService, ) { combineLatest([ this.desktopSettingService.browserIntegrationEnabled$, @@ -255,102 +258,61 @@ export class BiometricMessageHandlerService { appId, ); } - // TODO: legacy, remove after 2025.3 - case BiometricsCommands.IsAvailable: { - const available = - (await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available; - return this.send( + case SyncedUnlockStateCommands.SendLockToDesktop: { + const userId = message.userId as UserId; + await this.send( { - command: BiometricsCommands.IsAvailable, - response: available ? "available" : "not available", + command: SyncedUnlockStateCommands.SendLockToDesktop, + messageId, + response: true, }, appId, ); + if (userId != null) { + await this.vaultTimeoutService.lock(userId); + } + break; } - // TODO: legacy, remove after 2025.3 - case BiometricsCommands.Unlock: { - if ( - await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$) - ) { - await this.send({ command: "biometricUnlock", response: "not available" }, appId); - await this.dialogService.openSimpleDialog({ - title: this.i18nService.t("updateBrowserOrDisableFingerprintDialogTitle"), - content: this.i18nService.t("updateBrowserOrDisableFingerprintDialogMessage"), - type: "warning", - }); - return; + case SyncedUnlockStateCommands.GetUserKeyFromDesktop: { + if (!(await this.validateFingerprint(appId))) { + this.logService.info("[Native Messaging IPC] Fingerprint validation failed."); } - const isTemporarilyDisabled = - (await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) && - !((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available); - if (isTemporarilyDisabled) { - return this.send({ command: "biometricUnlock", response: "not available" }, appId); - } - - if (!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available)) { - return this.send({ command: "biometricUnlock", response: "not supported" }, appId); - } - - const userId = - (message.userId as UserId) ?? - (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)))); - - if (userId == null) { - return this.send({ command: "biometricUnlock", response: "not unlocked" }, appId); - } - - const biometricUnlock = - message.userId == null - ? await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) - : await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId); - if (!biometricUnlock) { - await this.send({ command: "biometricUnlock", response: "not enabled" }, appId); - - return this.ngZone.run(() => - this.dialogService.openSimpleDialog({ - type: "warning", - title: { key: "biometricsNotEnabledTitle" }, - content: { key: "biometricsNotEnabledDesc" }, - cancelButtonText: null, - acceptButtonText: { key: "cancel" }, - }), - ); - } - - try { - const userKey = await this.biometricsService.unlockWithBiometricsForUser(userId); - - if (userKey != null) { - await this.send( + const userId = message.userId as UserId; + if (userId != null) { + const key = await this.keyService.getUserKey(userId); + if (key != null) { + return await this.send( { - command: "biometricUnlock", - response: "unlocked", - userKeyB64: userKey.keyB64, + command: SyncedUnlockStateCommands.GetUserKeyFromDesktop, + messageId, + response: key.keyB64, }, appId, ); - - const currentlyActiveAccountId = ( - await firstValueFrom(this.accountService.activeAccount$) - )?.id; - const isCurrentlyActiveAccountUnlocked = - (await this.authService.getAuthStatus(userId)) == AuthenticationStatus.Unlocked; - - // prevent proc reloading an active account, when it is the same as the browser - if (currentlyActiveAccountId != message.userId || !isCurrentlyActiveAccountUnlocked) { - ipc.platform.reloadProcess(); - } - } else { - await this.send({ command: "biometricUnlock", response: "canceled" }, appId); } - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - await this.send({ command: "biometricUnlock", response: "canceled" }, appId); } break; } + case SyncedUnlockStateCommands.GetUserStatusFromDesktop: { + const userId = message.userId as UserId; + if (userId != null) { + const status = await firstValueFrom(this.authService.authStatusFor$(userId)); + return await this.send( + { + command: SyncedUnlockStateCommands.GetUserStatusFromDesktop, + messageId, + response: status, + }, + appId, + ); + } + break; + } + case SyncedUnlockStateCommands.FocusDesktopApp: { + this.messagingService.send("setFocus"); + break; + } default: this.logService.error("NativeMessage, got unknown command: " + message.command); break; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 48e884f252c..30d43afc9a4 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -56,6 +56,8 @@ import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-managemen import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; +import { NoopSyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/noop-synced-unlock.service"; import { VaultTimeout, VaultTimeoutStringType, @@ -103,6 +105,8 @@ import { KdfConfigService, KeyService as KeyServiceAbstraction, BiometricsService, + SyncedUnlockStateServiceAbstraction, + DefaultSyncedUnlockStateService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; @@ -231,6 +235,16 @@ const safeProviders: SafeProvider[] = [ useClass: WebBiometricsService, deps: [], }), + safeProvider({ + provide: SyncedUnlockStateServiceAbstraction, + useClass: DefaultSyncedUnlockStateService, + deps: [StateProvider], + }), + safeProvider({ + provide: SyncedUnlockService, + useClass: NoopSyncedUnlockService, + deps: [], + }), safeProvider({ provide: ThemeStateService, useFactory: (globalStateProvider: GlobalStateProvider) => diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 920d35a1017..e6a7c5222c4 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -306,10 +306,12 @@ import { DefaultBiometricStateService, DefaultKdfConfigService, DefaultKeyService, + DefaultSyncedUnlockStateService, DefaultUserAsymmetricKeysRegenerationApiService, DefaultUserAsymmetricKeysRegenerationService, KdfConfigService, KeyService, + SyncedUnlockStateServiceAbstraction, UserAsymmetricKeysRegenerationApiService, UserAsymmetricKeysRegenerationService, } from "@bitwarden/key-management"; @@ -822,6 +824,7 @@ const safeProviders: SafeProvider[] = [ TaskSchedulerService, LogService, BiometricsService, + SyncedUnlockStateServiceAbstraction, LOCKED_CALLBACK, LOGOUT_CALLBACK, ], @@ -1295,6 +1298,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultBiometricStateService, deps: [StateProvider], }), + safeProvider({ + provide: SyncedUnlockStateServiceAbstraction, + useClass: DefaultSyncedUnlockStateService, + deps: [StateProvider], + }), safeProvider({ provide: VaultSettingsServiceAbstraction, useClass: VaultSettingsService, diff --git a/libs/common/src/key-management/synced-unlock/abstractions/synced-unlock.service.ts b/libs/common/src/key-management/synced-unlock/abstractions/synced-unlock.service.ts new file mode 100644 index 00000000000..90491a640c8 --- /dev/null +++ b/libs/common/src/key-management/synced-unlock/abstractions/synced-unlock.service.ts @@ -0,0 +1,11 @@ +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { UserId } from "../../../types/guid"; +import { UserKey } from "../../../types/key"; + +export abstract class SyncedUnlockService { + abstract isConnected(): Promise; + abstract lock(userId: UserId): Promise; + abstract getUserStatusFromDesktop(userId: UserId): Promise; + abstract getUserKeyFromDesktop(userId: UserId): Promise; + abstract focusDesktopApp(): Promise; +} diff --git a/libs/common/src/key-management/synced-unlock/noop-synced-unlock.service.ts b/libs/common/src/key-management/synced-unlock/noop-synced-unlock.service.ts new file mode 100644 index 00000000000..0028c45b7b9 --- /dev/null +++ b/libs/common/src/key-management/synced-unlock/noop-synced-unlock.service.ts @@ -0,0 +1,27 @@ +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; + +import { SyncedUnlockService } from "./abstractions/synced-unlock.service"; + +export class NoopSyncedUnlockService extends SyncedUnlockService { + isConnected(): Promise { + return Promise.resolve(false); + } + + lock(userId: UserId): Promise { + return Promise.resolve(); + } + + getUserStatusFromDesktop(userId: UserId): Promise { + return Promise.resolve(AuthenticationStatus.LoggedOut); + } + + getUserKeyFromDesktop(userId: UserId): Promise { + return Promise.resolve(null); + } + + focusDesktopApp(): Promise { + return Promise.resolve(); + } +} diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts index d71b8972727..1bf07fbc944 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts @@ -4,7 +4,8 @@ import { combineLatest, concatMap, filter, firstValueFrom, map, timeout } from " import { CollectionService } from "@bitwarden/admin-console/common"; import { LogoutReason } from "@bitwarden/auth/common"; -import { BiometricsService } from "@bitwarden/key-management"; +import { ClientType } from "@bitwarden/common/enums"; +import { BiometricsService, SyncedUnlockStateServiceAbstraction } from "@bitwarden/key-management"; import { SearchService } from "../../../abstractions/search.service"; import { AccountService } from "../../../auth/abstractions/account.service"; @@ -43,6 +44,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { private taskSchedulerService: TaskSchedulerService, protected logService: LogService, private biometricService: BiometricsService, + private syncedUnlockService: SyncedUnlockStateServiceAbstraction, private lockedCallback: (userId?: string) => Promise = null, private loggedOutCallback: ( logoutReason: LogoutReason, @@ -75,6 +77,13 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { } async checkVaultTimeout(): Promise { + if ( + (await firstValueFrom(this.syncedUnlockService.syncedUnlockEnabled$)) && + this.platformUtilsService.getClientType() === ClientType.Browser + ) { + return; + } + // Get whether or not the view is open a single time so it can be compared for each user const isViewOpen = await this.platformUtilsService.isViewOpen(); diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index d7a5b4795e5..7d6b40dc451 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -114,6 +114,7 @@ export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", web: "disk-local", }); export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk"); +export const SYNCED_UNLOCK_SETTINGS_DISK = new StateDefinition("syncedUnlock", "disk"); export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CONFIG_DISK = new StateDefinition("config", "disk", { web: "disk-local", diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index efc7fb26a2f..46817e2168a 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -5,175 +5,45 @@ - - - - -
-

{{ "or" | i18n }}

- - - - - - - - - - -
-
- - - -
- - {{ "pin" | i18n }} - - - - + +
+ This user's account is synchronized with the desktop app. - -

{{ "or" | i18n }}

- - - - - - - - - -
+ + + This account has unlock synchronization enabled, but the desktop app is not running. + - - -
- - {{ "masterPass" | i18n }} - - - - - + + +
- - -

{{ "or" | i18n }}

- - - - +

{{ "or" | i18n }}

+ + +
- +
+ + + +
+ + {{ "pin" | i18n }} + + + + +
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+
+
+ + + +
+ + {{ "masterPass" | i18n }} + + + + + + +
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+
+
diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 3cb0dbaca52..bcf9ebc25e7 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -32,6 +32,7 @@ import { import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { SyncedUnlockService } from "@bitwarden/common/key-management/synced-unlock/abstractions/synced-unlock.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -54,6 +55,7 @@ import { BiometricsService, BiometricsStatus, UserAsymmetricKeysRegenerationService, + SyncedUnlockStateServiceAbstraction, } from "@bitwarden/key-management"; import { @@ -134,6 +136,11 @@ export class LockComponent implements OnInit, OnDestroy { unlockingViaBiometrics = false; + unlockViaDesktop = false; + isDesktopOpen = false; + showLocalUnlockOptions = false; + desktopUnlockFormGroup: FormGroup = new FormGroup({}); + constructor( private accountService: AccountService, private pinService: PinServiceAbstraction, @@ -157,6 +164,8 @@ export class LockComponent implements OnInit, OnDestroy { private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService, private biometricService: BiometricsService, + private syncedUnlockStateService: SyncedUnlockStateServiceAbstraction, + private syncedUnlockService: SyncedUnlockService, private lockComponentService: LockComponentService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, @@ -191,6 +200,35 @@ export class LockComponent implements OnInit, OnDestroy { this.unlockOptions = await firstValueFrom( this.lockComponentService.getAvailableUnlockOptions$(this.activeAccount.id), ); + const userKey = await this.keyService.getUserKey(this.activeAccount.id); + if (userKey != null) { + await this.doContinue(false); + } + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + interval(500) + .pipe( + switchMap(async () => { + try { + this.isDesktopOpen = await this.syncedUnlockService.isConnected(); + this.showLocalUnlockOptions = !(this.isDesktopOpen && this.unlockViaDesktop); + } catch (e) { + this.logService.error(e); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.syncedUnlockStateService.syncedUnlockEnabled$ + .pipe( + tap((enabled) => { + if (enabled) { + this.unlockViaDesktop = true; + } else { + this.unlockViaDesktop = false; } }), takeUntil(this.destroy$), @@ -338,11 +376,18 @@ export class LockComponent implements OnInit, OnDestroy { // Note: this submit method is only used for unlock methods that require a form and user input. // For biometrics unlock, the method is called directly. submit = async (): Promise => { - if (this.activeUnlockOption === UnlockOption.Pin) { - return await this.unlockViaPin(); - } + if ( + this.platformUtilsService.getClientType() === ClientType.Browser && + !this.showLocalUnlockOptions + ) { + await this.syncedUnlockService.focusDesktopApp(); + } else { + if (this.activeUnlockOption === UnlockOption.Pin) { + return await this.unlockViaPin(); + } - await this.unlockViaMasterPassword(); + await this.unlockViaMasterPassword(); + } }; async logOut() { diff --git a/libs/key-management/src/biometrics/synced-unlock-commands.ts b/libs/key-management/src/biometrics/synced-unlock-commands.ts new file mode 100644 index 00000000000..48763e3f35d --- /dev/null +++ b/libs/key-management/src/biometrics/synced-unlock-commands.ts @@ -0,0 +1,9 @@ +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum SyncedUnlockStateCommands { + IsConnected = "isConnected", + SendLockToDesktop = "sendLockToDesktop", + GetUserKeyFromDesktop = "getUserKeyFromDesktop", + GetUserStatusFromDesktop = "getUserStatusFromDesktop", + FocusDesktopApp = "focusDesktopApp", +} diff --git a/libs/key-management/src/biometrics/synced-unlock-state.service.ts b/libs/key-management/src/biometrics/synced-unlock-state.service.ts new file mode 100644 index 00000000000..a42bb3b4a7d --- /dev/null +++ b/libs/key-management/src/biometrics/synced-unlock-state.service.ts @@ -0,0 +1,31 @@ +import { Observable, map } from "rxjs"; + +import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state"; + +import { SYNCED_UNLOCK_ENABLED } from "./synced-unlock.state"; + +export abstract class SyncedUnlockStateServiceAbstraction { + /** + * syncedUnlockEnabled$ is an observable that emits the current state of the synced unlock feature. + */ + abstract syncedUnlockEnabled$: Observable; + /** + * Updates whether the unlock state should be synced with the desktop client. + * @param enabled the value to save + */ + abstract setSyncedUnlockEnabled(enabled: boolean): Promise; +} + +export class DefaultSyncedUnlockStateService implements SyncedUnlockStateServiceAbstraction { + private syncedUnlockEnabledState: ActiveUserState; + syncedUnlockEnabled$: Observable; + + constructor(private stateProvider: StateProvider) { + this.syncedUnlockEnabledState = this.stateProvider.getActive(SYNCED_UNLOCK_ENABLED); + this.syncedUnlockEnabled$ = this.syncedUnlockEnabledState.state$.pipe(map(Boolean)); + } + + async setSyncedUnlockEnabled(enabled: boolean): Promise { + await this.syncedUnlockEnabledState.update(() => enabled); + } +} diff --git a/libs/key-management/src/biometrics/synced-unlock.state.ts b/libs/key-management/src/biometrics/synced-unlock.state.ts new file mode 100644 index 00000000000..a6e902de509 --- /dev/null +++ b/libs/key-management/src/biometrics/synced-unlock.state.ts @@ -0,0 +1,13 @@ +import { UserKeyDefinition, SYNCED_UNLOCK_SETTINGS_DISK } from "@bitwarden/common/platform/state"; + +/** + * Indicates whether the user elected to store a biometric key to unlock their vault. + */ +export const SYNCED_UNLOCK_ENABLED = new UserKeyDefinition( + SYNCED_UNLOCK_SETTINGS_DISK, + "syncedUnlockEnabled", + { + deserializer: (obj: any) => obj, + clearOn: [], + }, +); diff --git a/libs/key-management/src/index.ts b/libs/key-management/src/index.ts index d21b79540e0..ce693e28a8c 100644 --- a/libs/key-management/src/index.ts +++ b/libs/key-management/src/index.ts @@ -2,6 +2,10 @@ export { BiometricStateService, DefaultBiometricStateService, } from "./biometrics/biometric-state.service"; +export { + SyncedUnlockStateServiceAbstraction, + DefaultSyncedUnlockStateService, +} from "./biometrics/synced-unlock-state.service"; export { BiometricsStatus } from "./biometrics/biometrics-status"; export { BiometricsCommands } from "./biometrics/biometrics-commands"; export { BiometricsService } from "./biometrics/biometric.service"; @@ -20,5 +24,6 @@ export { export { KdfConfigService } from "./abstractions/kdf-config.service"; export { DefaultKdfConfigService } from "./kdf-config.service"; export { KdfType } from "./enums/kdf-type.enum"; +export { SyncedUnlockStateCommands } from "./biometrics/synced-unlock-commands"; export * from "./user-asymmetric-key-regeneration";