mirror of
https://github.com/bitwarden/browser
synced 2025-12-30 15:13:32 +00:00
Merge branch 'main' into auth/pm-8111/browser-refresh-login-component
This commit is contained in:
@@ -11,11 +11,14 @@ import {
|
||||
unauthGuardFn,
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect";
|
||||
import {
|
||||
AnonLayoutWrapperComponent,
|
||||
AnonLayoutWrapperData,
|
||||
LoginComponent,
|
||||
LoginSecondaryContentComponent,
|
||||
LockIcon,
|
||||
LockV2Component,
|
||||
PasswordHintComponent,
|
||||
RegistrationFinishComponent,
|
||||
RegistrationStartComponent,
|
||||
@@ -64,6 +67,7 @@ const routes: Routes = [
|
||||
path: "lock",
|
||||
component: LockComponent,
|
||||
canActivate: [lockGuard()],
|
||||
canMatch: [extensionRefreshRedirect("/lockV2")],
|
||||
},
|
||||
{
|
||||
path: "login-with-device",
|
||||
@@ -221,6 +225,21 @@ const routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "lockV2",
|
||||
canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()],
|
||||
data: {
|
||||
pageIcon: LockIcon,
|
||||
pageTitle: "yourVaultIsLockedV2",
|
||||
showReadonlyHostname: true,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: LockV2Component,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "set-password-jit",
|
||||
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
CLIENT_TYPE,
|
||||
} from "@bitwarden/angular/services/injection-tokens";
|
||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||
import { LoginComponentService, SetPasswordJitService } from "@bitwarden/auth/angular";
|
||||
import {
|
||||
LoginComponentService,
|
||||
SetPasswordJitService,
|
||||
LockComponentService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LoginEmailService,
|
||||
@@ -96,6 +100,7 @@ import { ElectronRendererStorageService } from "../../platform/services/electron
|
||||
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
||||
import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging";
|
||||
import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme";
|
||||
import { DesktopLockComponentService } from "../../services/desktop-lock-component.service";
|
||||
import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service";
|
||||
import { NativeMessageHandlerService } from "../../services/native-message-handler.service";
|
||||
import { NativeMessagingService } from "../../services/native-messaging.service";
|
||||
@@ -287,6 +292,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: NativeMessagingManifestService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LockComponentService,
|
||||
useClass: DesktopLockComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CLIENT_TYPE,
|
||||
useValue: ClientType.Desktop,
|
||||
@@ -297,6 +307,7 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [
|
||||
ApiService,
|
||||
CryptoService,
|
||||
EncryptService,
|
||||
I18nServiceAbstraction,
|
||||
KdfConfigService,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
attr.aria-hidden="{{ showingModal }}"
|
||||
>
|
||||
<div id="content" class="content" style="padding-top: 50px">
|
||||
<img class="logo-image" alt="Bitwarden" />
|
||||
<a (click)="invalidateEmail()" class="tw-cursor-pointer">
|
||||
<img class="logo-image" alt="Bitwarden" />
|
||||
</a>
|
||||
<p class="lead">{{ "loginOrCreateNewAccount" | i18n }}</p>
|
||||
<!-- start email -->
|
||||
<ng-container *ngIf="!validatedEmail; else loginPage">
|
||||
|
||||
@@ -227,4 +227,11 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the validatedEmail flag to false, which will show the login page.
|
||||
*/
|
||||
invalidateEmail() {
|
||||
this.validatedEmail = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import { NativeMessagingProxy } from "./proxy/native-messaging-proxy";
|
||||
import { spawn } from "child_process";
|
||||
import * as path from "path";
|
||||
|
||||
// We need to import the other dependencies using `require` since `import` will
|
||||
// generate `Error: Cannot find module 'electron'`. The cause of this error is
|
||||
// due to native messaging setting the ELECTRON_RUN_AS_NODE env flag on windows
|
||||
// which removes the electron module. This flag is needed for stdin/out to work
|
||||
// properly on Windows.
|
||||
import { app } from "electron";
|
||||
|
||||
if (
|
||||
process.platform === "darwin" &&
|
||||
process.argv.some((arg) => arg.indexOf("chrome-extension://") !== -1 || arg.indexOf("{") !== -1)
|
||||
) {
|
||||
if (process.platform === "darwin") {
|
||||
// eslint-disable-next-line
|
||||
const app = require("electron").app;
|
||||
// If we're on MacOS, we need to support DuckDuckGo's IPC communication,
|
||||
// which for the moment is launching the Bitwarden process.
|
||||
// Ideally the browser would instead startup the desktop_proxy process
|
||||
// when available, but for now we'll just launch it here.
|
||||
|
||||
app.on("ready", () => {
|
||||
app.dock.hide();
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.on("error", (e) => {
|
||||
if (e.code === "EPIPE") {
|
||||
process.exit(0);
|
||||
}
|
||||
app.on("ready", () => {
|
||||
app.dock.hide();
|
||||
});
|
||||
|
||||
const proxy = new NativeMessagingProxy();
|
||||
proxy.run();
|
||||
const proc = spawn(path.join(process.execPath, "..", "desktop_proxy"), process.argv.slice(1), {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
|
||||
proc.on("exit", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
proc.on("error", () => {
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line
|
||||
const Main = require("./main").Main;
|
||||
|
||||
@@ -939,6 +939,18 @@
|
||||
"yourVaultIsLocked": {
|
||||
"message": "Your vault is locked. Verify your identity to continue."
|
||||
},
|
||||
"yourAccountIsLocked": {
|
||||
"message": "Your account is locked"
|
||||
},
|
||||
"or": {
|
||||
"message": "or"
|
||||
},
|
||||
"unlockWithBiometrics": {
|
||||
"message": "Unlock with biometrics"
|
||||
},
|
||||
"unlockWithMasterPassword": {
|
||||
"message": "Unlock with master password"
|
||||
},
|
||||
"unlock": {
|
||||
"message": "Unlock"
|
||||
},
|
||||
@@ -2277,6 +2289,9 @@
|
||||
"locked": {
|
||||
"message": "Locked"
|
||||
},
|
||||
"yourVaultIsLockedV2": {
|
||||
"message": "Your vault is locked"
|
||||
},
|
||||
"unlocked": {
|
||||
"message": "Unlocked"
|
||||
},
|
||||
@@ -2629,6 +2644,9 @@
|
||||
"important": {
|
||||
"message": "Important:"
|
||||
},
|
||||
"accessing": {
|
||||
"message": "Accessing"
|
||||
},
|
||||
"accessTokenUnableToBeDecrypted": {
|
||||
"message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue."
|
||||
},
|
||||
|
||||
@@ -227,6 +227,7 @@ export class Main {
|
||||
this.windowMain,
|
||||
app.getPath("userData"),
|
||||
app.getPath("exe"),
|
||||
app.getAppPath(),
|
||||
);
|
||||
|
||||
this.desktopAutofillSettingsService = new DesktopAutofillSettingsService(stateProvider);
|
||||
@@ -273,13 +274,21 @@ export class Main {
|
||||
if (browserIntegrationEnabled || ddgIntegrationEnabled) {
|
||||
// Re-register the native messaging host integrations on startup, in case they are not present
|
||||
if (browserIntegrationEnabled) {
|
||||
this.nativeMessagingMain.generateManifests().catch(this.logService.error);
|
||||
this.nativeMessagingMain
|
||||
.generateManifests()
|
||||
.catch((err) => this.logService.error("Error while generating manifests", err));
|
||||
}
|
||||
if (ddgIntegrationEnabled) {
|
||||
this.nativeMessagingMain.generateDdgManifests().catch(this.logService.error);
|
||||
this.nativeMessagingMain
|
||||
.generateDdgManifests()
|
||||
.catch((err) => this.logService.error("Error while generating DDG manifests", err));
|
||||
}
|
||||
|
||||
this.nativeMessagingMain.listen();
|
||||
this.nativeMessagingMain
|
||||
.listen()
|
||||
.catch((err) =>
|
||||
this.logService.error("Error while starting native message listener", err),
|
||||
);
|
||||
}
|
||||
|
||||
app.removeAsDefaultProtocolClient("bitwarden");
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { existsSync, promises as fs } from "fs";
|
||||
import { Socket } from "net";
|
||||
import { homedir, userInfo } from "os";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
|
||||
import { ipcMain } from "electron";
|
||||
import * as ipc from "node-ipc";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ipc } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { getIpcSocketRoot } from "../proxy/ipc";
|
||||
import { isDev } from "../utils";
|
||||
|
||||
import { WindowMain } from "./window.main";
|
||||
|
||||
export class NativeMessagingMain {
|
||||
private connected: Socket[] = [];
|
||||
private socket: any;
|
||||
private ipcServer: ipc.IpcServer | null;
|
||||
private connected: number[] = [];
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private windowMain: WindowMain,
|
||||
private userPath: string,
|
||||
private exePath: string,
|
||||
private appPath: string,
|
||||
) {
|
||||
ipcMain.handle(
|
||||
"nativeMessaging.manifests",
|
||||
async (_event: any, options: { create: boolean }) => {
|
||||
if (options.create) {
|
||||
this.listen();
|
||||
try {
|
||||
await this.listen();
|
||||
await this.generateManifests();
|
||||
} catch (e) {
|
||||
this.logService.error("Error generating manifests: " + e);
|
||||
@@ -51,8 +51,8 @@ export class NativeMessagingMain {
|
||||
"nativeMessaging.ddgManifests",
|
||||
async (_event: any, options: { create: boolean }) => {
|
||||
if (options.create) {
|
||||
this.listen();
|
||||
try {
|
||||
await this.listen();
|
||||
await this.generateDdgManifests();
|
||||
} catch (e) {
|
||||
this.logService.error("Error generating duckduckgo manifests: " + e);
|
||||
@@ -72,56 +72,46 @@ export class NativeMessagingMain {
|
||||
);
|
||||
}
|
||||
|
||||
listen() {
|
||||
ipc.config.id = "bitwarden";
|
||||
ipc.config.retry = 1500;
|
||||
const ipcSocketRoot = getIpcSocketRoot();
|
||||
if (ipcSocketRoot != null) {
|
||||
ipc.config.socketRoot = ipcSocketRoot;
|
||||
async listen() {
|
||||
if (this.ipcServer) {
|
||||
this.ipcServer.stop();
|
||||
}
|
||||
|
||||
ipc.serve(() => {
|
||||
ipc.server.on("message", (data: any, socket: any) => {
|
||||
this.socket = socket;
|
||||
this.windowMain.win.webContents.send("nativeMessaging", data);
|
||||
});
|
||||
|
||||
ipcMain.on("nativeMessagingReply", (event, msg) => {
|
||||
if (this.socket != null && msg != null) {
|
||||
this.send(msg, this.socket);
|
||||
this.ipcServer = await ipc.IpcServer.listen("bitwarden", (error, msg) => {
|
||||
switch (msg.kind) {
|
||||
case ipc.IpcMessageType.Connected: {
|
||||
this.connected.push(msg.clientId);
|
||||
this.logService.info("Native messaging client " + msg.clientId + " has connected");
|
||||
break;
|
||||
}
|
||||
});
|
||||
case ipc.IpcMessageType.Disconnected: {
|
||||
const index = this.connected.indexOf(msg.clientId);
|
||||
if (index > -1) {
|
||||
this.connected.splice(index, 1);
|
||||
}
|
||||
|
||||
ipc.server.on("connect", (socket: Socket) => {
|
||||
this.connected.push(socket);
|
||||
});
|
||||
|
||||
ipc.server.on("socket.disconnected", (socket, destroyedSocketID) => {
|
||||
const index = this.connected.indexOf(socket);
|
||||
if (index > -1) {
|
||||
this.connected.splice(index, 1);
|
||||
this.logService.info("Native messaging client " + msg.clientId + " has disconnected");
|
||||
break;
|
||||
}
|
||||
|
||||
this.socket = null;
|
||||
ipc.log("client " + destroyedSocketID + " has disconnected!");
|
||||
});
|
||||
case ipc.IpcMessageType.Message:
|
||||
this.windowMain.win.webContents.send("nativeMessaging", JSON.parse(msg.message));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ipc.server.start();
|
||||
}
|
||||
|
||||
stop() {
|
||||
ipc.server.stop();
|
||||
// Kill all existing connections
|
||||
this.connected.forEach((socket) => {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
ipcMain.on("nativeMessagingReply", (event, msg) => {
|
||||
if (msg != null) {
|
||||
this.send(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(message: object, socket: any) {
|
||||
ipc.server.emit(socket, "message", message);
|
||||
stop() {
|
||||
this.ipcServer?.stop();
|
||||
}
|
||||
|
||||
send(message: object) {
|
||||
this.ipcServer?.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
async generateManifests() {
|
||||
@@ -211,6 +201,13 @@ export class NativeMessagingMain {
|
||||
chromeJson,
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(`${this.homedir()}/.config/chromium/`)) {
|
||||
await this.writeManifest(
|
||||
`${this.homedir()}/.config/chromium/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
chromeJson,
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -331,11 +328,20 @@ export class NativeMessagingMain {
|
||||
}
|
||||
|
||||
private binaryPath() {
|
||||
if (process.platform === "win32") {
|
||||
return path.join(path.dirname(this.exePath), "resources", "native-messaging.bat");
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
|
||||
if (isDev()) {
|
||||
return path.join(
|
||||
this.appPath,
|
||||
"..",
|
||||
"desktop_native",
|
||||
"target",
|
||||
"debug",
|
||||
`desktop_proxy${ext}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.exePath;
|
||||
return path.join(path.dirname(this.exePath), `desktop_proxy${ext}`);
|
||||
}
|
||||
|
||||
private getRegeditInstance() {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
import { createHash } from "crypto";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join as path_join } from "path";
|
||||
|
||||
import * as ipc from "node-ipc";
|
||||
|
||||
export function getIpcSocketRoot(): string | null {
|
||||
let socketRoot = null;
|
||||
|
||||
switch (process.platform) {
|
||||
case "darwin": {
|
||||
const ipcSocketRootDir = path_join(homedir(), "tmp");
|
||||
if (!existsSync(ipcSocketRootDir)) {
|
||||
mkdirSync(ipcSocketRootDir);
|
||||
}
|
||||
socketRoot = ipcSocketRootDir + "/";
|
||||
break;
|
||||
}
|
||||
case "win32": {
|
||||
// Let node-ipc use a unique IPC pipe //./pipe/xxxxxxxxxxxxxxxxx.app.bitwarden per user.
|
||||
// Hashing prevents problems with reserved characters and file length limitations.
|
||||
socketRoot = createHash("sha1").update(homedir()).digest("hex") + ".";
|
||||
}
|
||||
}
|
||||
return socketRoot;
|
||||
}
|
||||
|
||||
ipc.config.id = "proxy";
|
||||
ipc.config.retry = 1500;
|
||||
ipc.config.logger = console.warn; // Stdout is used for native messaging
|
||||
const ipcSocketRoot = getIpcSocketRoot();
|
||||
if (ipcSocketRoot != null) {
|
||||
ipc.config.socketRoot = ipcSocketRoot;
|
||||
}
|
||||
|
||||
export default class IPC {
|
||||
onMessage: (message: object) => void;
|
||||
|
||||
private connected = false;
|
||||
|
||||
connect() {
|
||||
ipc.connectTo("bitwarden", () => {
|
||||
ipc.of.bitwarden.on("connect", () => {
|
||||
this.connected = true;
|
||||
console.error("## connected to bitwarden desktop ##");
|
||||
|
||||
// Notify browser extension, connection is established to desktop application.
|
||||
this.onMessage({ command: "connected" });
|
||||
});
|
||||
|
||||
ipc.of.bitwarden.on("disconnect", () => {
|
||||
this.connected = false;
|
||||
console.error("disconnected from world");
|
||||
|
||||
// Notify browser extension, no connection to desktop application.
|
||||
this.onMessage({ command: "disconnected" });
|
||||
});
|
||||
|
||||
ipc.of.bitwarden.on("message", (message: any) => {
|
||||
this.onMessage(message);
|
||||
});
|
||||
|
||||
ipc.of.bitwarden.on("error", (err: any) => {
|
||||
console.error("error", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
send(json: object) {
|
||||
ipc.of.bitwarden.emit("message", json);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import IPC from "./ipc";
|
||||
import NativeMessage from "./nativemessage";
|
||||
|
||||
// Proxy is a lightweight application which provides bi-directional communication
|
||||
// between the browser extension and a running desktop application.
|
||||
//
|
||||
// Browser extension <-[native messaging]-> proxy <-[ipc]-> desktop
|
||||
export class NativeMessagingProxy {
|
||||
private ipc: IPC;
|
||||
private nativeMessage: NativeMessage;
|
||||
|
||||
constructor() {
|
||||
this.ipc = new IPC();
|
||||
this.nativeMessage = new NativeMessage(this.ipc);
|
||||
}
|
||||
|
||||
run() {
|
||||
this.ipc.connect();
|
||||
this.nativeMessage.listen();
|
||||
|
||||
this.ipc.onMessage = this.nativeMessage.send;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
import IPC from "./ipc";
|
||||
|
||||
// Mostly based on the example from MDN,
|
||||
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging
|
||||
export default class NativeMessage {
|
||||
ipc: IPC;
|
||||
|
||||
constructor(ipc: IPC) {
|
||||
this.ipc = ipc;
|
||||
}
|
||||
|
||||
send(message: object) {
|
||||
const messageBuffer = Buffer.from(JSON.stringify(message));
|
||||
|
||||
const headerBuffer = Buffer.alloc(4);
|
||||
headerBuffer.writeUInt32LE(messageBuffer.length, 0);
|
||||
|
||||
process.stdout.write(Buffer.concat([headerBuffer, messageBuffer]));
|
||||
}
|
||||
|
||||
listen() {
|
||||
let payloadSize: number = null;
|
||||
|
||||
// A queue to store the chunks as we read them from stdin.
|
||||
// This queue can be flushed when `payloadSize` data has been read
|
||||
const chunks: any = [];
|
||||
|
||||
// Only read the size once for each payload
|
||||
const sizeHasBeenRead = () => Boolean(payloadSize);
|
||||
|
||||
// All the data has been read, reset everything for the next message
|
||||
const flushChunksQueue = () => {
|
||||
payloadSize = null;
|
||||
chunks.splice(0);
|
||||
};
|
||||
|
||||
const processData = () => {
|
||||
// Create one big buffer with all all the chunks
|
||||
const stringData = Buffer.concat(chunks);
|
||||
console.error(stringData);
|
||||
|
||||
// The browser will emit the size as a header of the payload,
|
||||
// if it hasn't been read yet, do it.
|
||||
// The next time we'll need to read the payload size is when all of the data
|
||||
// of the current payload has been read (ie. data.length >= payloadSize + 4)
|
||||
if (!sizeHasBeenRead()) {
|
||||
try {
|
||||
payloadSize = stringData.readUInt32LE(0);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If the data we have read so far is >= to the size advertised in the header,
|
||||
// it means we have all of the data sent.
|
||||
// We add 4 here because that's the size of the bytes that old the payloadSize
|
||||
if (stringData.length >= payloadSize + 4) {
|
||||
// Remove the header
|
||||
const contentWithoutSize = stringData.slice(4, payloadSize + 4).toString();
|
||||
|
||||
// Reset the read size and the queued chunks
|
||||
flushChunksQueue();
|
||||
|
||||
const json = JSON.parse(contentWithoutSize);
|
||||
|
||||
// Forward to desktop application
|
||||
this.ipc.send(json);
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on("readable", () => {
|
||||
// A temporary variable holding the nodejs.Buffer of each
|
||||
// chunk of data read off stdin
|
||||
let chunk = null;
|
||||
|
||||
// Read all of the available data
|
||||
// tslint:disable-next-line:no-conditional-assignment
|
||||
while ((chunk = process.stdin.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
try {
|
||||
processData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
377
apps/desktop/src/services/desktop-lock-component.service.spec.ts
Normal file
377
apps/desktop/src/services/desktop-lock-component.service.spec.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/auth/angular";
|
||||
import {
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopLockComponentService } from "./desktop-lock-component.service";
|
||||
|
||||
// ipc mock global
|
||||
const isWindowVisibleMock = jest.fn();
|
||||
const biometricEnabledMock = jest.fn();
|
||||
(global as any).ipc = {
|
||||
keyManagement: {
|
||||
biometric: {
|
||||
enabled: biometricEnabledMock,
|
||||
},
|
||||
},
|
||||
platform: {
|
||||
isWindowVisible: isWindowVisibleMock,
|
||||
},
|
||||
};
|
||||
|
||||
describe("DesktopLockComponentService", () => {
|
||||
let service: DesktopLockComponentService;
|
||||
|
||||
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let biometricsService: MockProxy<BiometricsService>;
|
||||
let pinService: MockProxy<PinServiceAbstraction>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
|
||||
beforeEach(() => {
|
||||
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
biometricsService = mock<BiometricsService>();
|
||||
pinService = mock<PinServiceAbstraction>();
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
cryptoService = mock<CryptoService>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DesktopLockComponentService,
|
||||
{
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
useValue: userDecryptionOptionsService,
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: platformUtilsService,
|
||||
},
|
||||
{
|
||||
provide: BiometricsService,
|
||||
useValue: biometricsService,
|
||||
},
|
||||
{
|
||||
provide: PinServiceAbstraction,
|
||||
useValue: pinService,
|
||||
},
|
||||
{
|
||||
provide: VaultTimeoutSettingsService,
|
||||
useValue: vaultTimeoutSettingsService,
|
||||
},
|
||||
{
|
||||
provide: CryptoService,
|
||||
useValue: cryptoService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(DesktopLockComponentService);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(service).not.toBeFalsy();
|
||||
});
|
||||
|
||||
// getBiometricsError
|
||||
describe("getBiometricsError", () => {
|
||||
it("returns null when given null", () => {
|
||||
const result = service.getBiometricsError(null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when given an unknown error", () => {
|
||||
const result = service.getBiometricsError({ message: "unknown" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPreviousUrl", () => {
|
||||
it("returns null", () => {
|
||||
const result = service.getPreviousUrl();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWindowVisible", () => {
|
||||
it("returns the window visibility", async () => {
|
||||
isWindowVisibleMock.mockReturnValue(true);
|
||||
const result = await service.isWindowVisible();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBiometricsUnlockBtnText", () => {
|
||||
it("returns the correct text for Mac OS", () => {
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.MacOsDesktop);
|
||||
const result = service.getBiometricsUnlockBtnText();
|
||||
expect(result).toBe("unlockWithTouchId");
|
||||
});
|
||||
|
||||
it("returns the correct text for Windows", () => {
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
|
||||
const result = service.getBiometricsUnlockBtnText();
|
||||
expect(result).toBe("unlockWithWindowsHello");
|
||||
});
|
||||
|
||||
it("returns the correct text for Linux", () => {
|
||||
platformUtilsService.getDevice.mockReturnValue(DeviceType.LinuxDesktop);
|
||||
const result = service.getBiometricsUnlockBtnText();
|
||||
expect(result).toBe("unlockWithPolkit");
|
||||
});
|
||||
|
||||
it("throws an error for an unsupported platform", () => {
|
||||
platformUtilsService.getDevice.mockReturnValue("unsupported" as any);
|
||||
expect(() => service.getBiometricsUnlockBtnText()).toThrowError("Unsupported platform");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableUnlockOptions$", () => {
|
||||
interface MockInputs {
|
||||
hasMasterPassword: boolean;
|
||||
osSupportsBiometric: boolean;
|
||||
biometricLockSet: boolean;
|
||||
biometricReady: boolean;
|
||||
hasBiometricEncryptedUserKeyStored: boolean;
|
||||
platformSupportsSecureStorage: boolean;
|
||||
pinDecryptionAvailable: boolean;
|
||||
}
|
||||
|
||||
const table: [MockInputs, UnlockOptions][] = [
|
||||
[
|
||||
// MP + PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: true,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: true,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
pin: {
|
||||
enabled: true,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: true,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: true,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics available: user key stored with no secure storage
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: false,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics available: no user key stored with no secure storage
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: false,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics not available: biometric not ready
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: false,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.SystemBiometricsUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics not available: biometric lock not set
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: false,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics not available: user key not stored
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics not available: OS doesn't support
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: false,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
test.each(table)("returns unlock options", async (mockInputs, expectedOutput) => {
|
||||
const userId = "userId" as UserId;
|
||||
const userDecryptionOptions = {
|
||||
hasMasterPassword: mockInputs.hasMasterPassword,
|
||||
};
|
||||
|
||||
// MP
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
of(userDecryptionOptions),
|
||||
);
|
||||
|
||||
// Biometrics
|
||||
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
|
||||
cryptoService.hasUserKeyStored.mockResolvedValue(
|
||||
mockInputs.hasBiometricEncryptedUserKeyStored,
|
||||
);
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(
|
||||
mockInputs.platformSupportsSecureStorage,
|
||||
);
|
||||
biometricEnabledMock.mockResolvedValue(mockInputs.biometricReady);
|
||||
|
||||
// PIN
|
||||
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);
|
||||
|
||||
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
|
||||
|
||||
expect(unlockOptions).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/desktop/src/services/desktop-lock-component.service.ts
Normal file
129
apps/desktop/src/services/desktop-lock-component.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { combineLatest, defer, map, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
BiometricsDisableReason,
|
||||
LockComponentService,
|
||||
UnlockOptions,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
export class DesktopLockComponentService implements LockComponentService {
|
||||
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
|
||||
private readonly platformUtilsService = inject(PlatformUtilsService);
|
||||
private readonly biometricsService = inject(BiometricsService);
|
||||
private readonly pinService = inject(PinServiceAbstraction);
|
||||
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
private readonly cryptoService = inject(CryptoService);
|
||||
|
||||
constructor() {}
|
||||
|
||||
getBiometricsError(error: any): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPreviousUrl(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async isWindowVisible(): Promise<boolean> {
|
||||
return ipc.platform.isWindowVisible();
|
||||
}
|
||||
|
||||
getBiometricsUnlockBtnText(): string {
|
||||
switch (this.platformUtilsService.getDevice()) {
|
||||
case DeviceType.MacOsDesktop:
|
||||
return "unlockWithTouchId";
|
||||
case DeviceType.WindowsDesktop:
|
||||
return "unlockWithWindowsHello";
|
||||
case DeviceType.LinuxDesktop:
|
||||
return "unlockWithPolkit";
|
||||
default:
|
||||
throw new Error("Unsupported platform");
|
||||
}
|
||||
}
|
||||
|
||||
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
|
||||
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
|
||||
const hasBiometricEncryptedUserKeyStored = await this.cryptoService.hasUserKeyStored(
|
||||
KeySuffixOptions.Biometric,
|
||||
userId,
|
||||
);
|
||||
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
|
||||
|
||||
return (
|
||||
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
|
||||
);
|
||||
}
|
||||
|
||||
private async isBiometricsSupportedAndReady(
|
||||
userId: UserId,
|
||||
): Promise<{ supportsBiometric: boolean; biometricReady: boolean }> {
|
||||
const supportsBiometric = await this.biometricsService.supportsBiometric();
|
||||
const biometricReady = await ipc.keyManagement.biometric.enabled(userId);
|
||||
return { supportsBiometric, biometricReady };
|
||||
}
|
||||
|
||||
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
|
||||
return combineLatest([
|
||||
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
|
||||
defer(() => this.isBiometricsSupportedAndReady(userId)),
|
||||
defer(() => this.isBiometricLockSet(userId)),
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
|
||||
]).pipe(
|
||||
map(
|
||||
([biometricsData, isBiometricsLockSet, userDecryptionOptions, pinDecryptionAvailable]) => {
|
||||
const disableReason = this.getBiometricsDisabledReason(
|
||||
biometricsData.supportsBiometric,
|
||||
isBiometricsLockSet,
|
||||
biometricsData.biometricReady,
|
||||
);
|
||||
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled:
|
||||
biometricsData.supportsBiometric &&
|
||||
isBiometricsLockSet &&
|
||||
biometricsData.biometricReady,
|
||||
disableReason: disableReason,
|
||||
},
|
||||
};
|
||||
|
||||
return unlockOpts;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private getBiometricsDisabledReason(
|
||||
osSupportsBiometric: boolean,
|
||||
biometricLockSet: boolean,
|
||||
biometricReady: boolean,
|
||||
): BiometricsDisableReason | null {
|
||||
if (!osSupportsBiometric) {
|
||||
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
|
||||
} else if (!biometricLockSet) {
|
||||
return BiometricsDisableReason.EncryptedKeysUnavailable;
|
||||
} else if (!biometricReady) {
|
||||
return BiometricsDisableReason.SystemBiometricsUnavailable;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user