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 }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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";