1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 04:33:38 +00:00

Implement synced unlock

This commit is contained in:
Bernd Schoolmann
2025-05-16 15:34:02 +02:00
parent e35882afc8
commit 2c682af14f
29 changed files with 754 additions and 384 deletions

View File

@@ -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"
},

View File

@@ -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

View File

@@ -11,7 +11,21 @@
<h2 bitTypography="h6">{{ "unlockMethods" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-control [disableMargin]="!((pinEnabled$ | async) || this.form.value.pin)">
<bit-form-control [disableMargin]="this.form.value.syncUnlockWithDesktop">
<input
bitCheckbox
id="syncUnlockWithDesktop"
type="checkbox"
formControlName="syncUnlockWithDesktop"
/>
<bit-label for="syncUnlockWithDesktop" class="tw-whitespace-normal">
{{ "syncUnlockWithDesktop" | i18n }}
</bit-label>
</bit-form-control>
<bit-form-control
[disableMargin]="!((pinEnabled$ | async) || this.form.value.pin)"
*ngIf="!this.form.value.syncUnlockWithDesktop"
>
<input bitCheckbox id="biometric" type="checkbox" formControlName="biometric" />
<bit-label for="biometric" class="tw-whitespace-normal">{{
"unlockWithBiometrics" | i18n
@@ -23,7 +37,7 @@
<bit-form-control
class="tw-pl-5"
[disableMargin]="!((pinEnabled$ | async) || this.form.value.pin)"
*ngIf="this.form.value.biometric"
*ngIf="this.form.value.biometric && !this.form.value.syncUnlockWithDesktop"
>
<input
bitCheckbox
@@ -37,7 +51,9 @@
</bit-form-control>
<bit-form-control
[disableMargin]="!(this.form.value.pin && showMasterPasswordOnClientRestartOption)"
*ngIf="(pinEnabled$ | async) || this.form.value.pin"
*ngIf="
((pinEnabled$ | async) || this.form.value.pin) && !this.form.value.syncUnlockWithDesktop
"
>
<input bitCheckbox id="pin" type="checkbox" formControlName="pin" />
<bit-label for="pin" class="tw-whitespace-normal">{{ "unlockWithPin" | i18n }}</bit-label>
@@ -45,7 +61,11 @@
<bit-form-control
class="tw-pl-5"
disableMargin
*ngIf="this.form.value.pin && showMasterPasswordOnClientRestartOption"
*ngIf="
this.form.value.pin &&
showMasterPasswordOnClientRestartOption &&
!this.form.value.syncUnlockWithDesktop
"
>
<input
bitCheckbox
@@ -70,10 +90,11 @@
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
*ngIf="!this.form.value.syncUnlockWithDesktop"
>
</auth-vault-timeout-input>
<bit-form-field disableMargin>
<bit-form-field disableMargin *ngIf="!this.form.value.syncUnlockWithDesktop">
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
<bit-option
@@ -89,9 +110,15 @@
</bit-hint>
</bit-form-field>
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
<bit-hint
*ngIf="hasVaultTimeoutPolicy && !this.form.value.syncUnlockWithDesktop"
class="tw-mt-4"
>
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
</bit-hint>
<bit-hint *ngIf="this.form.value.syncUnlockWithDesktop">
The desktop app's vault timeout settings will be used to lock the vault on this device.
</bit-hint>
</bit-card>
</bit-section>

View File

@@ -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<boolean> = 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<void>(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();

View File

@@ -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);

View File

@@ -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<boolean>(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<number, Callback>();
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<boolean> {
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<any> {
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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<boolean> {
this.logService.info("abc");
const a = this.nativeMessagingBackground().connected;
this.logService.info("aaaaa", a);
return a;
}
async lock(userId: UserId): Promise<void> {
await this.nativeMessagingBackground().callCommand({
command: SyncedUnlockStateCommands.SendLockToDesktop,
userId,
});
}
async getUserKeyFromDesktop(userId: UserId): Promise<UserKey | null> {
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<AuthenticationStatus> {
const res = await this.nativeMessagingBackground().callCommand({
command: SyncedUnlockStateCommands.GetUserStatusFromDesktop,
userId,
});
return res.response;
}
async focusDesktopApp(): Promise<void> {
await this.nativeMessagingBackground().callCommand({
command: SyncedUnlockStateCommands.FocusDesktopApp,
});
}
}

View File

@@ -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<boolean> {
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<void> {
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<AuthenticationStatus> {
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<UserKey | null> {
const response = await BrowserApi.sendMessageWithResponse<{
result: UserKey;
error: string;
}>(SyncedUnlockStateCommands.GetUserKeyFromDesktop, { userId });
if (!response.result) {
return null;
}
return response.result;
}
async focusDesktopApp(): Promise<void> {
const response = await BrowserApi.sendMessageWithResponse<{
result: boolean;
error: string;
}>(SyncedUnlockStateCommands.FocusDesktopApp);
if (!response.result) {
throw response.error;
}
}
}

View File

@@ -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,

View File

@@ -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,
);

View File

@@ -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",

View File

@@ -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),

View File

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

View File

@@ -107,6 +107,7 @@ describe("BiometricMessageHandlerService", () => {
authService,
ngZone,
i18nService,
null,
);
});
@@ -165,6 +166,7 @@ describe("BiometricMessageHandlerService", () => {
authService,
ngZone,
i18nService,
null,
);
});

View File

@@ -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;

View File

@@ -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) =>

View File

@@ -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,

View File

@@ -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<boolean>;
abstract lock(userId: UserId): Promise<void>;
abstract getUserStatusFromDesktop(userId: UserId): Promise<AuthenticationStatus>;
abstract getUserKeyFromDesktop(userId: UserId): Promise<UserKey | null>;
abstract focusDesktopApp(): Promise<void>;
}

View File

@@ -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<boolean> {
return Promise.resolve(false);
}
lock(userId: UserId): Promise<void> {
return Promise.resolve();
}
getUserStatusFromDesktop(userId: UserId): Promise<AuthenticationStatus> {
return Promise.resolve(AuthenticationStatus.LoggedOut);
}
getUserKeyFromDesktop(userId: UserId): Promise<UserKey | null> {
return Promise.resolve(null);
}
focusDesktopApp(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -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<void> = null,
private loggedOutCallback: (
logoutReason: LogoutReason,
@@ -75,6 +77,13 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
}
async checkVaultTimeout(): Promise<void> {
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();

View File

@@ -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",

View File

@@ -5,175 +5,45 @@
</ng-template>
<ng-container *ngIf="unlockOptions && !loading; else spinner">
<!-- Biometrics Unlock -->
<ng-container *ngIf="activeUnlockOption === UnlockOption.Biometrics">
<button
type="button"
bitButton
buttonType="primary"
class="tw-mb-3"
[disabled]="unlockingViaBiometrics || !biometricsAvailable"
[loading]="unlockingViaBiometrics"
block
(click)="unlockViaBiometrics()"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
<div class="tw-flex tw-flex-col tw-space-y-3">
<p class="tw-text-center tw-mb-0">{{ "or" | i18n }}</p>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
>
{{ "unlockWithPin" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
<button
type="button"
bitButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.MasterPassword"
>
{{ "unlockWithMasterPassword" | i18n }}
</button>
</ng-container>
<button type="button" bitButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</ng-container>
<!-- PIN Unlock -->
<ng-container *ngIf="unlockOptions.pin.enabled && activeUnlockOption === UnlockOption.Pin">
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "pin" | i18n }}</bit-label>
<input
type="password"
formControlName="pin"
bitInput
appAutofocus
name="pin"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
<ng-container *ngIf="!showLocalUnlockOptions">
<form [bitSubmit]="submit" [formGroup]="desktopUnlockFormGroup">
<div class="tw-flex tw-flex-col tw-space-y-3">
<bit-hint class="tw-text-center"
>This user's account is synchronized with the desktop app.</bit-hint
>
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="showBiometrics">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.MasterPassword"
>
{{ "unlockWithMasterPassword" | i18n }}
</button>
</ng-container>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
Continue in desktop app
</button>
</div>
</form>
</ng-container>
<ng-container *ngIf="showLocalUnlockOptions">
<bit-hint class="tw-text-center" *ngIf="unlockViaDesktop">
This account has unlock synchronization enabled, but the desktop app is not running.
</bit-hint>
<!-- MP Unlock -->
<ng-container
*ngIf="
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
"
>
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<!-- [attr.aria-pressed]="showPassword" -->
</bit-form-field>
<!-- Biometrics Unlock -->
<ng-container *ngIf="activeUnlockOption === UnlockOption.Biometrics">
<button
type="button"
bitButton
buttonType="primary"
class="tw-mb-3"
[disabled]="unlockingViaBiometrics || !biometricsAvailable"
[loading]="unlockingViaBiometrics"
block
(click)="unlockViaBiometrics()"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="showBiometrics">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
<p class="tw-text-center tw-mb-0">{{ "or" | i18n }}</p>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
@@ -182,10 +52,158 @@
</button>
</ng-container>
<button type="button" bitButton bitFormButton block (click)="logOut()">
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
<button
type="button"
bitButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.MasterPassword"
>
{{ "unlockWithMasterPassword" | i18n }}
</button>
</ng-container>
<button type="button" bitButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
<!-- PIN Unlock -->
<ng-container *ngIf="unlockOptions.pin.enabled && activeUnlockOption === UnlockOption.Pin">
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "pin" | i18n }}</bit-label>
<input
type="password"
formControlName="pin"
bitInput
appAutofocus
name="pin"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="showBiometrics">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.masterPassword.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.MasterPassword"
>
{{ "unlockWithMasterPassword" | i18n }}
</button>
</ng-container>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
<!-- MP Unlock -->
<ng-container
*ngIf="
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
"
>
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<!-- [attr.aria-pressed]="showPassword" -->
</bit-form-field>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="showBiometrics">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
>
{{ "unlockWithPin" | i18n }}
</button>
</ng-container>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
</ng-container>
</ng-container>

View File

@@ -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<void> => {
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() {

View File

@@ -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",
}

View File

@@ -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<boolean>;
/**
* Updates whether the unlock state should be synced with the desktop client.
* @param enabled the value to save
*/
abstract setSyncedUnlockEnabled(enabled: boolean): Promise<void>;
}
export class DefaultSyncedUnlockStateService implements SyncedUnlockStateServiceAbstraction {
private syncedUnlockEnabledState: ActiveUserState<boolean>;
syncedUnlockEnabled$: Observable<boolean>;
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<void> {
await this.syncedUnlockEnabledState.update(() => enabled);
}
}

View File

@@ -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<boolean>(
SYNCED_UNLOCK_SETTINGS_DISK,
"syncedUnlockEnabled",
{
deserializer: (obj: any) => obj,
clearOn: [],
},
);

View File

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