mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 09:43:23 +00:00
[PM-5189] Merging main into current branch
This commit is contained in:
@@ -262,14 +262,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.hostname = Utils.getHostname(this.url);
|
this.hostname = Utils.getHostname(this.url);
|
||||||
this.pageDetails = [];
|
this.pageDetails = [];
|
||||||
// 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
|
|
||||||
BrowserApi.tabSendMessage(this.tab, {
|
|
||||||
command: "collectPageDetails",
|
|
||||||
tab: this.tab,
|
|
||||||
sender: BroadcasterSubscriptionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const otherTypes: CipherType[] = [];
|
const otherTypes: CipherType[] = [];
|
||||||
const dontShowCards = !(await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$));
|
const dontShowCards = !(await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$));
|
||||||
const dontShowIdentities = !(await firstValueFrom(
|
const dontShowIdentities = !(await firstValueFrom(
|
||||||
@@ -310,9 +302,18 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.loginCiphers = this.loginCiphers.sort((a, b) =>
|
if (this.loginCiphers.length) {
|
||||||
this.cipherService.sortCiphersByLastUsedThenName(a, b),
|
void BrowserApi.tabSendMessage(this.tab, {
|
||||||
);
|
command: "collectPageDetails",
|
||||||
|
tab: this.tab,
|
||||||
|
sender: BroadcasterSubscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loginCiphers = this.loginCiphers.sort((a, b) =>
|
||||||
|
this.cipherService.sortCiphersByLastUsedThenName(a, b),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.isLoading = this.loaded = true;
|
this.isLoading = this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ export class SettingsComponent implements OnInit {
|
|||||||
private domainSettingsService: DomainSettingsService,
|
private domainSettingsService: DomainSettingsService,
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
private userVerificationService: UserVerificationServiceAbstraction,
|
private userVerificationService: UserVerificationServiceAbstraction,
|
||||||
private biometricStateService: BiometricStateService,
|
|
||||||
private desktopSettingsService: DesktopSettingsService,
|
private desktopSettingsService: DesktopSettingsService,
|
||||||
|
private biometricStateService: BiometricStateService,
|
||||||
) {
|
) {
|
||||||
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||||
|
|
||||||
@@ -253,12 +253,12 @@ export class SettingsComponent implements OnInit {
|
|||||||
clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$),
|
clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$),
|
||||||
minimizeOnCopyToClipboard: await this.stateService.getMinimizeOnCopyToClipboard(),
|
minimizeOnCopyToClipboard: await this.stateService.getMinimizeOnCopyToClipboard(),
|
||||||
enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$),
|
enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$),
|
||||||
enableTray: await this.stateService.getEnableTray(),
|
enableTray: await firstValueFrom(this.desktopSettingsService.trayEnabled$),
|
||||||
enableMinToTray: await this.stateService.getEnableMinimizeToTray(),
|
enableMinToTray: await firstValueFrom(this.desktopSettingsService.minimizeToTray$),
|
||||||
enableCloseToTray: await this.stateService.getEnableCloseToTray(),
|
enableCloseToTray: await firstValueFrom(this.desktopSettingsService.closeToTray$),
|
||||||
startToTray: await this.stateService.getEnableStartToTray(),
|
startToTray: await firstValueFrom(this.desktopSettingsService.startToTray$),
|
||||||
openAtLogin: await this.stateService.getOpenAtLogin(),
|
openAtLogin: await firstValueFrom(this.desktopSettingsService.openAtLogin$),
|
||||||
alwaysShowDock: await this.stateService.getAlwaysShowDock(),
|
alwaysShowDock: await firstValueFrom(this.desktopSettingsService.alwaysShowDock$),
|
||||||
enableBrowserIntegration: await this.stateService.getEnableBrowserIntegration(),
|
enableBrowserIntegration: await this.stateService.getEnableBrowserIntegration(),
|
||||||
enableBrowserIntegrationFingerprint:
|
enableBrowserIntegrationFingerprint:
|
||||||
await this.stateService.getEnableBrowserIntegrationFingerprint(),
|
await this.stateService.getEnableBrowserIntegrationFingerprint(),
|
||||||
@@ -507,16 +507,16 @@ export class SettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveMinToTray() {
|
async saveMinToTray() {
|
||||||
await this.stateService.setEnableMinimizeToTray(this.form.value.enableMinToTray);
|
await this.desktopSettingsService.setMinimizeToTray(this.form.value.enableMinToTray);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveCloseToTray() {
|
async saveCloseToTray() {
|
||||||
if (this.requireEnableTray) {
|
if (this.requireEnableTray) {
|
||||||
this.form.controls.enableTray.setValue(true);
|
this.form.controls.enableTray.setValue(true);
|
||||||
await this.stateService.setEnableTray(this.form.value.enableTray);
|
await this.desktopSettingsService.setTrayEnabled(this.form.value.enableTray);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stateService.setEnableCloseToTray(this.form.value.enableCloseToTray);
|
await this.desktopSettingsService.setCloseToTray(this.form.value.enableCloseToTray);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveTray() {
|
async saveTray() {
|
||||||
@@ -533,9 +533,9 @@ export class SettingsComponent implements OnInit {
|
|||||||
|
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
this.form.controls.startToTray.setValue(false, { emitEvent: false });
|
this.form.controls.startToTray.setValue(false, { emitEvent: false });
|
||||||
await this.stateService.setEnableStartToTray(this.form.value.startToTray);
|
await this.desktopSettingsService.setStartToTray(this.form.value.startToTray);
|
||||||
this.form.controls.enableCloseToTray.setValue(false, { emitEvent: false });
|
this.form.controls.enableCloseToTray.setValue(false, { emitEvent: false });
|
||||||
await this.stateService.setEnableCloseToTray(this.form.value.enableCloseToTray);
|
await this.desktopSettingsService.setCloseToTray(this.form.value.enableCloseToTray);
|
||||||
} else {
|
} else {
|
||||||
this.form.controls.enableTray.setValue(true);
|
this.form.controls.enableTray.setValue(true);
|
||||||
}
|
}
|
||||||
@@ -543,17 +543,18 @@ export class SettingsComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stateService.setEnableTray(this.form.value.enableTray);
|
await this.desktopSettingsService.setTrayEnabled(this.form.value.enableTray);
|
||||||
|
// TODO: Ideally the DesktopSettingsService.trayEnabled$ could be subscribed to instead of using messaging.
|
||||||
this.messagingService.send(this.form.value.enableTray ? "showTray" : "removeTray");
|
this.messagingService.send(this.form.value.enableTray ? "showTray" : "removeTray");
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveStartToTray() {
|
async saveStartToTray() {
|
||||||
if (this.requireEnableTray) {
|
if (this.requireEnableTray) {
|
||||||
this.form.controls.enableTray.setValue(true);
|
this.form.controls.enableTray.setValue(true);
|
||||||
await this.stateService.setEnableTray(this.form.value.enableTray);
|
await this.desktopSettingsService.setTrayEnabled(this.form.value.enableTray);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stateService.setEnableStartToTray(this.form.value.startToTray);
|
await this.desktopSettingsService.setStartToTray(this.form.value.startToTray);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveLocale() {
|
async saveLocale() {
|
||||||
@@ -573,13 +574,12 @@ export class SettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveAlwaysShowDock() {
|
async saveAlwaysShowDock() {
|
||||||
await this.stateService.setAlwaysShowDock(this.form.value.alwaysShowDock);
|
await this.desktopSettingsService.setAlwaysShowDock(this.form.value.alwaysShowDock);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveOpenAtLogin() {
|
async saveOpenAtLogin() {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.desktopSettingsService.setOpenAtLogin(this.form.value.openAtLogin);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// TODO: Ideally DesktopSettingsService.openAtLogin$ could be subscribed to directly rather than sending a message
|
||||||
this.stateService.setOpenAtLogin(this.form.value.openAtLogin);
|
|
||||||
this.messagingService.send(
|
this.messagingService.send(
|
||||||
this.form.value.openAtLogin ? "addOpenAtLogin" : "removeOpenAtLogin",
|
this.form.value.openAtLogin ? "addOpenAtLogin" : "removeOpenAtLogin",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export class Main {
|
|||||||
environmentService: DefaultEnvironmentService;
|
environmentService: DefaultEnvironmentService;
|
||||||
mainCryptoFunctionService: MainCryptoFunctionService;
|
mainCryptoFunctionService: MainCryptoFunctionService;
|
||||||
desktopCredentialStorageListener: DesktopCredentialStorageListener;
|
desktopCredentialStorageListener: DesktopCredentialStorageListener;
|
||||||
migrationRunner: MigrationRunner;
|
|
||||||
desktopSettingsService: DesktopSettingsService;
|
desktopSettingsService: DesktopSettingsService;
|
||||||
|
migrationRunner: MigrationRunner;
|
||||||
tokenService: TokenServiceAbstraction;
|
tokenService: TokenServiceAbstraction;
|
||||||
|
|
||||||
windowMain: WindowMain;
|
windowMain: WindowMain;
|
||||||
@@ -179,6 +179,8 @@ export class Main {
|
|||||||
false, // Do not use disk caching because this will get out of sync with the renderer service
|
false, // Do not use disk caching because this will get out of sync with the renderer service
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.desktopSettingsService = new DesktopSettingsService(stateProvider);
|
||||||
|
|
||||||
const biometricStateService = new DefaultBiometricStateService(stateProvider);
|
const biometricStateService = new DefaultBiometricStateService(stateProvider);
|
||||||
|
|
||||||
this.windowMain = new WindowMain(
|
this.windowMain = new WindowMain(
|
||||||
@@ -186,13 +188,13 @@ export class Main {
|
|||||||
biometricStateService,
|
biometricStateService,
|
||||||
this.logService,
|
this.logService,
|
||||||
this.storageService,
|
this.storageService,
|
||||||
|
this.desktopSettingsService,
|
||||||
(arg) => this.processDeepLink(arg),
|
(arg) => this.processDeepLink(arg),
|
||||||
(win) => this.trayMain.setupWindowListeners(win),
|
(win) => this.trayMain.setupWindowListeners(win),
|
||||||
);
|
);
|
||||||
this.messagingMain = new MessagingMain(this, this.stateService);
|
this.messagingMain = new MessagingMain(this, this.stateService, this.desktopSettingsService);
|
||||||
this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain);
|
this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain);
|
||||||
this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.stateService);
|
this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.desktopSettingsService);
|
||||||
this.desktopSettingsService = new DesktopSettingsService(stateProvider);
|
|
||||||
|
|
||||||
this.messagingService = new ElectronMainMessagingService(this.windowMain, (message) => {
|
this.messagingService = new ElectronMainMessagingService(this.windowMain, (message) => {
|
||||||
this.messagingMain.onMessage(message);
|
this.messagingMain.onMessage(message);
|
||||||
@@ -244,7 +246,7 @@ export class Main {
|
|||||||
await this.toggleHardwareAcceleration();
|
await this.toggleHardwareAcceleration();
|
||||||
await this.windowMain.init();
|
await this.windowMain.init();
|
||||||
await this.i18nService.init();
|
await this.i18nService.init();
|
||||||
this.messagingMain.init();
|
await this.messagingMain.init();
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
this.menuMain.init();
|
this.menuMain.init();
|
||||||
@@ -256,10 +258,8 @@ export class Main {
|
|||||||
click: () => this.messagingService.send("lockVault"),
|
click: () => this.messagingService.send("lockVault"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
if (await this.stateService.getEnableStartToTray()) {
|
if (await firstValueFrom(this.desktopSettingsService.startToTray$)) {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.trayMain.hideToTray();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.trayMain.hideToTray();
|
|
||||||
}
|
}
|
||||||
this.powerMonitorMain.init();
|
this.powerMonitorMain.init();
|
||||||
await this.updaterMain.init();
|
await this.updaterMain.init();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { app, ipcMain } from "electron";
|
|||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||||
|
|
||||||
import { Main } from "../main";
|
import { Main } from "../main";
|
||||||
|
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||||
|
|
||||||
import { MenuUpdateRequest } from "./menu/menu.updater";
|
import { MenuUpdateRequest } from "./menu/menu.updater";
|
||||||
|
|
||||||
@@ -17,19 +18,16 @@ export class MessagingMain {
|
|||||||
constructor(
|
constructor(
|
||||||
private main: Main,
|
private main: Main,
|
||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
|
private desktopSettingsService: DesktopSettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
init() {
|
async init() {
|
||||||
this.scheduleNextSync();
|
this.scheduleNextSync();
|
||||||
if (process.platform === "linux") {
|
if (process.platform === "linux") {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.desktopSettingsService.setOpenAtLogin(fs.existsSync(this.linuxStartupFile()));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.stateService.setOpenAtLogin(fs.existsSync(this.linuxStartupFile()));
|
|
||||||
} else {
|
} else {
|
||||||
const loginSettings = app.getLoginItemSettings();
|
const loginSettings = app.getLoginItemSettings();
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.desktopSettingsService.setOpenAtLogin(loginSettings.openAtLogin);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.stateService.setOpenAtLogin(loginSettings.openAtLogin);
|
|
||||||
}
|
}
|
||||||
ipcMain.on("messagingService", async (event: any, message: any) => this.onMessage(message));
|
ipcMain.on("messagingService", async (event: any, message: any) => this.onMessage(message));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
|
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
|
||||||
|
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||||
|
|
||||||
import { WindowMain } from "./window.main";
|
import { WindowMain } from "./window.main";
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ export class TrayMain {
|
|||||||
constructor(
|
constructor(
|
||||||
private windowMain: WindowMain,
|
private windowMain: WindowMain,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private stateService: StateService,
|
private desktopSettingsService: DesktopSettingsService,
|
||||||
) {
|
) {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
this.icon = path.join(__dirname, "/images/icon.ico");
|
this.icon = path.join(__dirname, "/images/icon.ico");
|
||||||
@@ -54,14 +56,14 @@ export class TrayMain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.contextMenu = Menu.buildFromTemplate(menuItemOptions);
|
this.contextMenu = Menu.buildFromTemplate(menuItemOptions);
|
||||||
if (await this.stateService.getEnableTray()) {
|
if (await firstValueFrom(this.desktopSettingsService.trayEnabled$)) {
|
||||||
this.showTray();
|
this.showTray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupWindowListeners(win: BrowserWindow) {
|
setupWindowListeners(win: BrowserWindow) {
|
||||||
win.on("minimize", async (e: Event) => {
|
win.on("minimize", async (e: Event) => {
|
||||||
if (await this.stateService.getEnableMinimizeToTray()) {
|
if (await firstValueFrom(this.desktopSettingsService.minimizeToTray$)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
@@ -70,7 +72,7 @@ export class TrayMain {
|
|||||||
});
|
});
|
||||||
|
|
||||||
win.on("close", async (e: Event) => {
|
win.on("close", async (e: Event) => {
|
||||||
if (await this.stateService.getEnableCloseToTray()) {
|
if (await firstValueFrom(this.desktopSettingsService.closeToTray$)) {
|
||||||
if (!this.windowMain.isQuitting) {
|
if (!this.windowMain.isQuitting) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
@@ -81,7 +83,7 @@ export class TrayMain {
|
|||||||
});
|
});
|
||||||
|
|
||||||
win.on("show", async () => {
|
win.on("show", async () => {
|
||||||
const enableTray = await this.stateService.getEnableTray();
|
const enableTray = await firstValueFrom(this.desktopSettingsService.trayEnabled$);
|
||||||
if (!enableTray) {
|
if (!enableTray) {
|
||||||
setTimeout(() => this.removeTray(false), 100);
|
setTimeout(() => this.removeTray(false), 100);
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,7 @@ export class TrayMain {
|
|||||||
if (this.windowMain.win != null) {
|
if (this.windowMain.win != null) {
|
||||||
this.windowMain.win.hide();
|
this.windowMain.win.hide();
|
||||||
}
|
}
|
||||||
if (this.isDarwin() && !(await this.stateService.getAlwaysShowDock())) {
|
if (this.isDarwin() && !(await firstValueFrom(this.desktopSettingsService.alwaysShowDock$))) {
|
||||||
this.hideDock();
|
this.hideDock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +178,7 @@ export class TrayMain {
|
|||||||
}
|
}
|
||||||
if (this.windowMain.win.isVisible()) {
|
if (this.windowMain.win.isVisible()) {
|
||||||
this.windowMain.win.hide();
|
this.windowMain.win.hide();
|
||||||
if (this.isDarwin() && !(await this.stateService.getAlwaysShowDock())) {
|
if (this.isDarwin() && !(await firstValueFrom(this.desktopSettingsService.alwaysShowDock$))) {
|
||||||
this.hideDock();
|
this.hideDock();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import * as path from "path";
|
|||||||
import * as url from "url";
|
import * as url from "url";
|
||||||
|
|
||||||
import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron";
|
import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { WindowState } from "@bitwarden/common/models/domain/window-state";
|
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
|
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
|
||||||
|
|
||||||
|
import { WindowState } from "../platform/models/domain/window-state";
|
||||||
|
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||||
import {
|
import {
|
||||||
cleanUserAgent,
|
cleanUserAgent,
|
||||||
isDev,
|
isDev,
|
||||||
@@ -40,6 +42,7 @@ export class WindowMain {
|
|||||||
private biometricStateService: BiometricStateService,
|
private biometricStateService: BiometricStateService,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private storageService: AbstractStorageService,
|
private storageService: AbstractStorageService,
|
||||||
|
private desktopSettingsService: DesktopSettingsService,
|
||||||
private argvCallback: (argv: string[]) => void = null,
|
private argvCallback: (argv: string[]) => void = null,
|
||||||
private createWindowCallback: (win: BrowserWindow) => void,
|
private createWindowCallback: (win: BrowserWindow) => void,
|
||||||
) {}
|
) {}
|
||||||
@@ -94,7 +97,7 @@ export class WindowMain {
|
|||||||
// down the application.
|
// down the application.
|
||||||
app.on("before-quit", async () => {
|
app.on("before-quit", async () => {
|
||||||
// Allow biometric to auto-prompt on reload
|
// Allow biometric to auto-prompt on reload
|
||||||
await this.biometricStateService.resetPromptCancelled();
|
await this.biometricStateService.resetAllPromptCancelled();
|
||||||
this.isQuitting = true;
|
this.isQuitting = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +124,7 @@ export class WindowMain {
|
|||||||
app.on("activate", async () => {
|
app.on("activate", async () => {
|
||||||
// On OS X it's common to re-create a window in the app when the
|
// On OS X it's common to re-create a window in the app when the
|
||||||
// dock icon is clicked and there are no other windows open.
|
// dock icon is clicked and there are no other windows open.
|
||||||
if (this.win === null) {
|
if (this.win == null) {
|
||||||
await this.createWindow();
|
await this.createWindow();
|
||||||
} else {
|
} else {
|
||||||
// Show the window when clicking on Dock icon
|
// Show the window when clicking on Dock icon
|
||||||
@@ -141,7 +144,7 @@ export class WindowMain {
|
|||||||
this.defaultWidth,
|
this.defaultWidth,
|
||||||
this.defaultHeight,
|
this.defaultHeight,
|
||||||
);
|
);
|
||||||
this.enableAlwaysOnTop = await this.stateService.getEnableAlwaysOnTop();
|
this.enableAlwaysOnTop = await firstValueFrom(this.desktopSettingsService.alwaysOnTop$);
|
||||||
|
|
||||||
this.session = session.fromPartition("persist:bitwarden", { cache: false });
|
this.session = session.fromPartition("persist:bitwarden", { cache: false });
|
||||||
|
|
||||||
@@ -265,7 +268,7 @@ export class WindowMain {
|
|||||||
async toggleAlwaysOnTop() {
|
async toggleAlwaysOnTop() {
|
||||||
this.enableAlwaysOnTop = !this.win.isAlwaysOnTop();
|
this.enableAlwaysOnTop = !this.win.isAlwaysOnTop();
|
||||||
this.win.setAlwaysOnTop(this.enableAlwaysOnTop);
|
this.win.setAlwaysOnTop(this.enableAlwaysOnTop);
|
||||||
await this.stateService.setEnableAlwaysOnTop(this.enableAlwaysOnTop);
|
await this.desktopSettingsService.setAlwaysOnTop(this.enableAlwaysOnTop);
|
||||||
}
|
}
|
||||||
|
|
||||||
private windowStateChangeHandler(configKey: string, win: BrowserWindow) {
|
private windowStateChangeHandler(configKey: string, win: BrowserWindow) {
|
||||||
@@ -284,7 +287,7 @@ export class WindowMain {
|
|||||||
const bounds = win.getBounds();
|
const bounds = win.getBounds();
|
||||||
|
|
||||||
if (this.windowStates[configKey] == null) {
|
if (this.windowStates[configKey] == null) {
|
||||||
this.windowStates[configKey] = await this.stateService.getWindow();
|
this.windowStates[configKey] = await firstValueFrom(this.desktopSettingsService.window$);
|
||||||
if (this.windowStates[configKey] == null) {
|
if (this.windowStates[configKey] == null) {
|
||||||
this.windowStates[configKey] = <WindowState>{};
|
this.windowStates[configKey] = <WindowState>{};
|
||||||
}
|
}
|
||||||
@@ -304,14 +307,14 @@ export class WindowMain {
|
|||||||
this.windowStates[configKey].zoomFactor = win.webContents.zoomFactor;
|
this.windowStates[configKey].zoomFactor = win.webContents.zoomFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stateService.setWindow(this.windowStates[configKey]);
|
await this.desktopSettingsService.setWindow(this.windowStates[configKey]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logService.error(e);
|
this.logService.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getWindowState(defaultWidth: number, defaultHeight: number) {
|
private async getWindowState(defaultWidth: number, defaultHeight: number) {
|
||||||
const state = await this.stateService.getWindow();
|
const state = await firstValueFrom(this.desktopSettingsService.window$);
|
||||||
|
|
||||||
const isValid = state != null && (this.stateHasBounds(state) || state.isMaximized);
|
const isValid = state != null && (this.stateHasBounds(state) || state.isMaximized);
|
||||||
let displayBounds: Electron.Rectangle = null;
|
let displayBounds: Electron.Rectangle = null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export class WindowState {
|
|||||||
isMaximized?: boolean;
|
isMaximized?: boolean;
|
||||||
// TODO: displayBounds is an Electron.Rectangle.
|
// TODO: displayBounds is an Electron.Rectangle.
|
||||||
// We need to establish some kind of client-specific global state, similar to the way we already extend a base Account.
|
// We need to establish some kind of client-specific global state, similar to the way we already extend a base Account.
|
||||||
displayBounds: any;
|
displayBounds: Electron.Rectangle;
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
zoomFactor?: number;
|
zoomFactor?: number;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { map } from "rxjs";
|
import { Observable, map } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DESKTOP_SETTINGS_DISK,
|
DESKTOP_SETTINGS_DISK,
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
StateProvider,
|
StateProvider,
|
||||||
} from "@bitwarden/common/platform/state";
|
} from "@bitwarden/common/platform/state";
|
||||||
|
|
||||||
|
import { WindowState } from "../models/domain/window-state";
|
||||||
|
|
||||||
export const HARDWARE_ACCELERATION = new KeyDefinition<boolean>(
|
export const HARDWARE_ACCELERATION = new KeyDefinition<boolean>(
|
||||||
DESKTOP_SETTINGS_DISK,
|
DESKTOP_SETTINGS_DISK,
|
||||||
"hardwareAcceleration",
|
"hardwareAcceleration",
|
||||||
@@ -14,13 +16,165 @@ export const HARDWARE_ACCELERATION = new KeyDefinition<boolean>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const WINDOW_KEY = new KeyDefinition<WindowState | null>(DESKTOP_SETTINGS_DISK, "window", {
|
||||||
|
deserializer: (s) => s,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CLOSE_TO_TRAY_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "closeToTray", {
|
||||||
|
deserializer: (b) => b,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MINIMIZE_TO_TRAY_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "minimizeToTray", {
|
||||||
|
deserializer: (b) => b,
|
||||||
|
});
|
||||||
|
|
||||||
|
const START_TO_TRAY_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "startToTray", {
|
||||||
|
deserializer: (b) => b,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TRAY_ENABLED_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "trayEnabled", {
|
||||||
|
deserializer: (b) => b,
|
||||||
|
});
|
||||||
|
|
||||||
|
const OPEN_AT_LOGIN_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "openAtLogin", {
|
||||||
|
deserializer: (b) => b,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALWAYS_SHOW_DOCK_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "alwaysShowDock", {
|
||||||
|
deserializer: (b) => b,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALWAYS_ON_TOP_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "alwaysOnTop", {
|
||||||
|
deserializer: (b) => b,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Various settings for controlling application behavior specific to the desktop client.
|
||||||
|
*/
|
||||||
export class DesktopSettingsService {
|
export class DesktopSettingsService {
|
||||||
private hwState = this.stateProvider.getGlobal(HARDWARE_ACCELERATION);
|
private hwState = this.stateProvider.getGlobal(HARDWARE_ACCELERATION);
|
||||||
hardwareAcceleration$ = this.hwState.state$.pipe(map((v) => v ?? true));
|
hardwareAcceleration$ = this.hwState.state$.pipe(map((v) => v ?? true));
|
||||||
|
|
||||||
constructor(private stateProvider: StateProvider) {}
|
private readonly windowState = this.stateProvider.getGlobal(WINDOW_KEY);
|
||||||
|
|
||||||
|
private readonly closeToTrayState = this.stateProvider.getGlobal(CLOSE_TO_TRAY_KEY);
|
||||||
|
/**
|
||||||
|
* Tha applications setting for whether or not to close the application into the system tray.
|
||||||
|
*/
|
||||||
|
closeToTray$ = this.closeToTrayState.state$.pipe(map((value) => value ?? false));
|
||||||
|
|
||||||
|
private readonly minimizeToTrayState = this.stateProvider.getGlobal(MINIMIZE_TO_TRAY_KEY);
|
||||||
|
/**
|
||||||
|
* The application setting for whether or not to minimize the applicaiton into the system tray.
|
||||||
|
*/
|
||||||
|
minimizeToTray$ = this.minimizeToTrayState.state$.pipe(map((value) => value ?? false));
|
||||||
|
|
||||||
|
private readonly startToTrayState = this.stateProvider.getGlobal(START_TO_TRAY_KEY);
|
||||||
|
/**
|
||||||
|
* The application setting for whether or not to start the application into the system tray.
|
||||||
|
*/
|
||||||
|
startToTray$ = this.startToTrayState.state$.pipe(map((value) => value ?? false));
|
||||||
|
|
||||||
|
private readonly trayEnabledState = this.stateProvider.getGlobal(TRAY_ENABLED_KEY);
|
||||||
|
/**
|
||||||
|
* Whether or not the system tray has been enabled.
|
||||||
|
*/
|
||||||
|
trayEnabled$ = this.trayEnabledState.state$.pipe(map((value) => value ?? false));
|
||||||
|
|
||||||
|
private readonly openAtLoginState = this.stateProvider.getGlobal(OPEN_AT_LOGIN_KEY);
|
||||||
|
/**
|
||||||
|
* The application setting for whether or not the application should open at system login.
|
||||||
|
*/
|
||||||
|
openAtLogin$ = this.openAtLoginState.state$.pipe(map((value) => value ?? false));
|
||||||
|
|
||||||
|
private readonly alwaysShowDockState = this.stateProvider.getGlobal(ALWAYS_SHOW_DOCK_KEY);
|
||||||
|
/**
|
||||||
|
* The application setting for whether or not the application should show up in the dock.
|
||||||
|
*/
|
||||||
|
alwaysShowDock$ = this.alwaysShowDockState.state$.pipe(map((value) => value ?? false));
|
||||||
|
|
||||||
|
private readonly alwaysOnTopState = this.stateProvider.getGlobal(ALWAYS_ON_TOP_KEY);
|
||||||
|
|
||||||
|
alwaysOnTop$ = this.alwaysOnTopState.state$.pipe(map((value) => value ?? false));
|
||||||
|
|
||||||
|
constructor(private stateProvider: StateProvider) {
|
||||||
|
this.window$ = this.windowState.state$.pipe(
|
||||||
|
map((window) =>
|
||||||
|
window != null && Object.keys(window).length > 0 ? window : new WindowState(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async setHardwareAcceleration(enabled: boolean) {
|
async setHardwareAcceleration(enabled: boolean) {
|
||||||
await this.hwState.update(() => enabled);
|
await this.hwState.update(() => enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The applications current window state.
|
||||||
|
*/
|
||||||
|
window$: Observable<WindowState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the window state of the application so that the application can reopen in the same place as it was closed from.
|
||||||
|
* @param windowState The window state to set.
|
||||||
|
*/
|
||||||
|
async setWindow(windowState: WindowState) {
|
||||||
|
await this.windowState.update(() => windowState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the setting for whether or not the application should go into the system tray when closed.
|
||||||
|
* @param value `true` if the application should go into the system tray when closed, `false` if it should not.
|
||||||
|
*/
|
||||||
|
async setCloseToTray(value: boolean) {
|
||||||
|
await this.closeToTrayState.update(() => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the setting for whether or not the application should go into the tray when minimized.
|
||||||
|
* @param value `true` if the application should minimize into the system tray, `false` if it should not.
|
||||||
|
*/
|
||||||
|
async setMinimizeToTray(value: boolean) {
|
||||||
|
await this.minimizeToTrayState.update(() => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the setting for whether or not the application should be started into the system tray.
|
||||||
|
* @param value `true` if the application should be started to the tray`, `false` if it should not.
|
||||||
|
*/
|
||||||
|
async setStartToTray(value: boolean) {
|
||||||
|
await this.startToTrayState.update(() => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the setting for whether or not the application be shown in the system tray.
|
||||||
|
* @param value `true` if the application should show in the tray, `false` if it should not.
|
||||||
|
*/
|
||||||
|
async setTrayEnabled(value: boolean) {
|
||||||
|
await this.trayEnabledState.update(() => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the setting for whether or not the application should open at login of the computer.
|
||||||
|
* @param value `true` if the application should open at login, `false` if it should not.
|
||||||
|
*/
|
||||||
|
async setOpenAtLogin(value: boolean) {
|
||||||
|
await this.openAtLoginState.update(() => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the setting for whether or not the application should be shown in the dock.
|
||||||
|
* @param value `true` if the application should should in the dock, `false` if it should not.
|
||||||
|
*/
|
||||||
|
async setAlwaysShowDock(value: boolean) {
|
||||||
|
await this.alwaysShowDockState.update(() => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the setting for whether or not the application should stay on top of all other windows.
|
||||||
|
* @param value `true` if the application should stay on top, `false` if it should not.
|
||||||
|
*/
|
||||||
|
async setAlwaysOnTop(value: boolean) {
|
||||||
|
await this.alwaysOnTopState.update(() => value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const SupportedTranslationLocales: string[] = [
|
|||||||
"bs",
|
"bs",
|
||||||
"ca",
|
"ca",
|
||||||
"cs",
|
"cs",
|
||||||
|
"cy",
|
||||||
"da",
|
"da",
|
||||||
"de",
|
"de",
|
||||||
"el",
|
"el",
|
||||||
@@ -19,9 +20,11 @@ export const SupportedTranslationLocales: string[] = [
|
|||||||
"es",
|
"es",
|
||||||
"et",
|
"et",
|
||||||
"eu",
|
"eu",
|
||||||
|
"fa",
|
||||||
"fi",
|
"fi",
|
||||||
"fil",
|
"fil",
|
||||||
"fr",
|
"fr",
|
||||||
|
"gl",
|
||||||
"he",
|
"he",
|
||||||
"hi",
|
"hi",
|
||||||
"hr",
|
"hr",
|
||||||
@@ -35,9 +38,13 @@ export const SupportedTranslationLocales: string[] = [
|
|||||||
"ko",
|
"ko",
|
||||||
"lv",
|
"lv",
|
||||||
"ml",
|
"ml",
|
||||||
|
"mr",
|
||||||
|
"my",
|
||||||
"nb",
|
"nb",
|
||||||
|
"ne",
|
||||||
"nl",
|
"nl",
|
||||||
"nn",
|
"nn",
|
||||||
|
"or",
|
||||||
"pl",
|
"pl",
|
||||||
"pt-PT",
|
"pt-PT",
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
@@ -48,6 +55,8 @@ export const SupportedTranslationLocales: string[] = [
|
|||||||
"sl",
|
"sl",
|
||||||
"sr",
|
"sr",
|
||||||
"sv",
|
"sv",
|
||||||
|
"te",
|
||||||
|
"th",
|
||||||
"tr",
|
"tr",
|
||||||
"uk",
|
"uk",
|
||||||
"vi",
|
"vi",
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.biometricStateService.setPromptCancelled();
|
await this.biometricStateService.setUserPromptCancelled();
|
||||||
const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
|
const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
|
||||||
|
|
||||||
if (userKey) {
|
if (userKey) {
|
||||||
@@ -276,7 +276,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
|
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
|
||||||
await this.stateService.setEverBeenUnlocked(true);
|
await this.stateService.setEverBeenUnlocked(true);
|
||||||
await this.biometricStateService.resetPromptCancelled();
|
await this.biometricStateService.resetUserPromptCancelled();
|
||||||
this.messagingService.send("unlocked");
|
this.messagingService.send("unlocked");
|
||||||
|
|
||||||
if (evaluatePasswordAfterUnlock) {
|
if (evaluatePasswordAfterUnlock) {
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export class FakeAccountService implements AccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async switchAccount(userId: UserId): Promise<void> {
|
async switchAccount(userId: UserId): Promise<void> {
|
||||||
|
const next =
|
||||||
|
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
|
||||||
|
this.activeAccountSubject.next(next);
|
||||||
await this.mock.switchAccount(userId);
|
await this.mock.switchAccount(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re
|
|||||||
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
|
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
|
||||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||||
import { BiometricKey } from "../../auth/types/biometric-key";
|
import { BiometricKey } from "../../auth/types/biometric-key";
|
||||||
import { WindowState } from "../../models/domain/window-state";
|
|
||||||
import { GeneratorOptions } from "../../tools/generator/generator-options";
|
import { GeneratorOptions } from "../../tools/generator/generator-options";
|
||||||
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
|
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
|
||||||
import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
||||||
@@ -52,8 +51,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
|
|
||||||
getAddEditCipherInfo: (options?: StorageOptions) => Promise<AddEditCipherInfo>;
|
getAddEditCipherInfo: (options?: StorageOptions) => Promise<AddEditCipherInfo>;
|
||||||
setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise<void>;
|
setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise<void>;
|
||||||
getAlwaysShowDock: (options?: StorageOptions) => Promise<boolean>;
|
|
||||||
setAlwaysShowDock: (value: boolean, options?: StorageOptions) => Promise<void>;
|
|
||||||
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
|
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
|
||||||
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
|
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||||
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
|
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
|
||||||
@@ -184,8 +181,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
|
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
|
||||||
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
|
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||||
getEnableAlwaysOnTop: (options?: StorageOptions) => Promise<boolean>;
|
|
||||||
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
|
|
||||||
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||||
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||||
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
||||||
@@ -193,19 +188,11 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
value: boolean,
|
value: boolean,
|
||||||
options?: StorageOptions,
|
options?: StorageOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
getEnableCloseToTray: (options?: StorageOptions) => Promise<boolean>;
|
|
||||||
setEnableCloseToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
|
||||||
getEnableDuckDuckGoBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
getEnableDuckDuckGoBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||||
setEnableDuckDuckGoBrowserIntegration: (
|
setEnableDuckDuckGoBrowserIntegration: (
|
||||||
value: boolean,
|
value: boolean,
|
||||||
options?: StorageOptions,
|
options?: StorageOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
getEnableMinimizeToTray: (options?: StorageOptions) => Promise<boolean>;
|
|
||||||
setEnableMinimizeToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
|
||||||
getEnableStartToTray: (options?: StorageOptions) => Promise<boolean>;
|
|
||||||
setEnableStartToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
|
||||||
getEnableTray: (options?: StorageOptions) => Promise<boolean>;
|
|
||||||
setEnableTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
|
||||||
getEncryptedCiphers: (options?: StorageOptions) => Promise<{ [id: string]: CipherData }>;
|
getEncryptedCiphers: (options?: StorageOptions) => Promise<{ [id: string]: CipherData }>;
|
||||||
setEncryptedCiphers: (
|
setEncryptedCiphers: (
|
||||||
value: { [id: string]: CipherData },
|
value: { [id: string]: CipherData },
|
||||||
@@ -259,14 +246,8 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
value: { [cipherId: string]: LocalData },
|
value: { [cipherId: string]: LocalData },
|
||||||
options?: StorageOptions,
|
options?: StorageOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
getLocale: (options?: StorageOptions) => Promise<string>;
|
|
||||||
setLocale: (value: string, options?: StorageOptions) => Promise<void>;
|
|
||||||
getMainWindowSize: (options?: StorageOptions) => Promise<number>;
|
|
||||||
setMainWindowSize: (value: number, options?: StorageOptions) => Promise<void>;
|
|
||||||
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
|
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
|
||||||
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
|
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||||
getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>;
|
|
||||||
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
|
|
||||||
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
|
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
|
||||||
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
|
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
|
||||||
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<PasswordGeneratorOptions>;
|
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<PasswordGeneratorOptions>;
|
||||||
@@ -302,8 +283,6 @@ export abstract class StateService<T extends Account = Account> {
|
|||||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||||
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
|
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
|
||||||
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
|
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||||
getWindow: () => Promise<WindowState>;
|
|
||||||
setWindow: (value: WindowState) => Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Do not call this directly, use ConfigService
|
* @deprecated Do not call this directly, use ConfigService
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { makeEncString } from "../../../spec";
|
import { makeEncString, trackEmissions } from "../../../spec";
|
||||||
import { mockAccountServiceWith } from "../../../spec/fake-account-service";
|
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||||
import { FakeSingleUserState } from "../../../spec/fake-state";
|
import { FakeGlobalState, FakeSingleUserState } from "../../../spec/fake-state";
|
||||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
import { EncryptedString } from "../models/domain/enc-string";
|
import { EncryptedString } from "../models/domain/enc-string";
|
||||||
@@ -23,10 +23,11 @@ describe("BiometricStateService", () => {
|
|||||||
const userId = "userId" as UserId;
|
const userId = "userId" as UserId;
|
||||||
const encClientKeyHalf = makeEncString();
|
const encClientKeyHalf = makeEncString();
|
||||||
const encryptedClientKeyHalf = encClientKeyHalf.encryptedString;
|
const encryptedClientKeyHalf = encClientKeyHalf.encryptedString;
|
||||||
const accountService = mockAccountServiceWith(userId);
|
let accountService: FakeAccountService;
|
||||||
let stateProvider: FakeStateProvider;
|
let stateProvider: FakeStateProvider;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
accountService = mockAccountServiceWith(userId);
|
||||||
stateProvider = new FakeStateProvider(accountService);
|
stateProvider = new FakeStateProvider(accountService);
|
||||||
|
|
||||||
sut = new DefaultBiometricStateService(stateProvider);
|
sut = new DefaultBiometricStateService(stateProvider);
|
||||||
@@ -145,19 +146,89 @@ describe("BiometricStateService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("setPromptCancelled", () => {
|
describe("setPromptCancelled", () => {
|
||||||
|
let existingState: Record<UserId, boolean>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
existingState = { ["otherUser" as UserId]: false };
|
||||||
|
stateProvider.global.getFake(PROMPT_CANCELLED).stateSubject.next(existingState);
|
||||||
|
});
|
||||||
|
|
||||||
test("observable is updated", async () => {
|
test("observable is updated", async () => {
|
||||||
await sut.setPromptCancelled();
|
await sut.setUserPromptCancelled();
|
||||||
|
|
||||||
expect(await firstValueFrom(sut.promptCancelled$)).toBe(true);
|
expect(await firstValueFrom(sut.promptCancelled$)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates state", async () => {
|
it("updates state", async () => {
|
||||||
await sut.setPromptCancelled();
|
await sut.setUserPromptCancelled();
|
||||||
|
|
||||||
const nextMock = stateProvider.activeUser.getFake(PROMPT_CANCELLED).nextMock;
|
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||||
expect(nextMock).toHaveBeenCalledWith([userId, true]);
|
expect(nextMock).toHaveBeenCalledWith({ ...existingState, [userId]: true });
|
||||||
expect(nextMock).toHaveBeenCalledTimes(1);
|
expect(nextMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws when called with no active user", async () => {
|
||||||
|
await accountService.switchAccount(null);
|
||||||
|
await expect(sut.setUserPromptCancelled()).rejects.toThrow(
|
||||||
|
"Cannot update biometric prompt cancelled state without an active user",
|
||||||
|
);
|
||||||
|
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||||
|
expect(nextMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resetAllPromptCancelled", () => {
|
||||||
|
it("deletes all prompt cancelled state", async () => {
|
||||||
|
await sut.resetAllPromptCancelled();
|
||||||
|
|
||||||
|
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||||
|
expect(nextMock).toHaveBeenCalledWith(null);
|
||||||
|
expect(nextMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates observable to false", async () => {
|
||||||
|
const emissions = trackEmissions(sut.promptCancelled$);
|
||||||
|
|
||||||
|
await sut.setUserPromptCancelled();
|
||||||
|
|
||||||
|
await sut.resetAllPromptCancelled();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([false, true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resetUserPromptCancelled", () => {
|
||||||
|
let existingState: Record<UserId, boolean>;
|
||||||
|
let state: FakeGlobalState<Record<UserId, boolean>>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await accountService.switchAccount(userId);
|
||||||
|
existingState = { [userId]: true, ["otherUser" as UserId]: false };
|
||||||
|
state = stateProvider.global.getFake(PROMPT_CANCELLED);
|
||||||
|
state.stateSubject.next(existingState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes specified user prompt cancelled state", async () => {
|
||||||
|
await sut.resetUserPromptCancelled("otherUser" as UserId);
|
||||||
|
|
||||||
|
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: true });
|
||||||
|
expect(state.nextMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes active user when called with no user", async () => {
|
||||||
|
await sut.resetUserPromptCancelled();
|
||||||
|
|
||||||
|
expect(state.nextMock).toHaveBeenCalledWith({ ["otherUser" as UserId]: false });
|
||||||
|
expect(state.nextMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates observable to false", async () => {
|
||||||
|
const emissions = trackEmissions(sut.promptCancelled$);
|
||||||
|
|
||||||
|
await sut.resetUserPromptCancelled();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([true, false]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setPromptAutomatically", () => {
|
describe("setPromptAutomatically", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Observable, firstValueFrom, map } from "rxjs";
|
import { Observable, firstValueFrom, map, combineLatest } from "rxjs";
|
||||||
|
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
import { EncryptedString, EncString } from "../models/domain/enc-string";
|
import { EncryptedString, EncString } from "../models/domain/enc-string";
|
||||||
@@ -81,13 +81,18 @@ export abstract class BiometricStateService {
|
|||||||
*/
|
*/
|
||||||
abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>;
|
abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Updates the active user's state to reflect that they've cancelled the biometric prompt this lock.
|
* Updates the active user's state to reflect that they've cancelled the biometric prompt.
|
||||||
*/
|
*/
|
||||||
abstract setPromptCancelled(): Promise<void>;
|
abstract setUserPromptCancelled(): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Resets the active user's state to reflect that they haven't cancelled the biometric prompt this lock.
|
* Resets the given user's state to reflect that they haven't cancelled the biometric prompt.
|
||||||
|
* @param userId the user to reset the prompt cancelled state for. If not provided, the currently active user will be used.
|
||||||
*/
|
*/
|
||||||
abstract resetPromptCancelled(): Promise<void>;
|
abstract resetUserPromptCancelled(userId?: UserId): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Resets all user's state to reflect that they haven't cancelled the biometric prompt.
|
||||||
|
*/
|
||||||
|
abstract resetAllPromptCancelled(): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Updates the currently active user's setting for auto prompting for biometrics on application start and lock
|
* Updates the currently active user's setting for auto prompting for biometrics on application start and lock
|
||||||
* @param prompt Whether or not to prompt for biometrics on application start.
|
* @param prompt Whether or not to prompt for biometrics on application start.
|
||||||
@@ -107,7 +112,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
|||||||
private requirePasswordOnStartState: ActiveUserState<boolean>;
|
private requirePasswordOnStartState: ActiveUserState<boolean>;
|
||||||
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
|
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
|
||||||
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
|
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
|
||||||
private promptCancelledState: ActiveUserState<boolean>;
|
private promptCancelledState: GlobalState<Record<UserId, boolean>>;
|
||||||
private promptAutomaticallyState: ActiveUserState<boolean>;
|
private promptAutomaticallyState: ActiveUserState<boolean>;
|
||||||
private fingerprintValidatedState: GlobalState<boolean>;
|
private fingerprintValidatedState: GlobalState<boolean>;
|
||||||
biometricUnlockEnabled$: Observable<boolean>;
|
biometricUnlockEnabled$: Observable<boolean>;
|
||||||
@@ -138,8 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
|||||||
this.dismissedRequirePasswordOnStartCallout$ =
|
this.dismissedRequirePasswordOnStartCallout$ =
|
||||||
this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map(Boolean));
|
this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map(Boolean));
|
||||||
|
|
||||||
this.promptCancelledState = this.stateProvider.getActive(PROMPT_CANCELLED);
|
this.promptCancelledState = this.stateProvider.getGlobal(PROMPT_CANCELLED);
|
||||||
this.promptCancelled$ = this.promptCancelledState.state$.pipe(map(Boolean));
|
this.promptCancelled$ = combineLatest([
|
||||||
|
this.stateProvider.activeUserId$,
|
||||||
|
this.promptCancelledState.state$,
|
||||||
|
]).pipe(
|
||||||
|
map(([userId, record]) => {
|
||||||
|
return record?.[userId] ?? false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
|
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
|
||||||
this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean));
|
this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean));
|
||||||
|
|
||||||
@@ -202,7 +214,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
|||||||
|
|
||||||
async logout(userId: UserId): Promise<void> {
|
async logout(userId: UserId): Promise<void> {
|
||||||
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null);
|
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null);
|
||||||
await this.stateProvider.getUser(userId, PROMPT_CANCELLED).update(() => null);
|
await this.resetUserPromptCancelled(userId);
|
||||||
// Persist auto prompt setting through logout
|
// Persist auto prompt setting through logout
|
||||||
// Persist dismissed require password on start callout through logout
|
// Persist dismissed require password on start callout through logout
|
||||||
}
|
}
|
||||||
@@ -211,11 +223,41 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
|||||||
await this.dismissedRequirePasswordOnStartCalloutState.update(() => true);
|
await this.dismissedRequirePasswordOnStartCalloutState.update(() => true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPromptCancelled(): Promise<void> {
|
async resetUserPromptCancelled(userId: UserId): Promise<void> {
|
||||||
await this.promptCancelledState.update(() => true);
|
await this.stateProvider.getGlobal(PROMPT_CANCELLED).update(
|
||||||
|
(data, activeUserId) => {
|
||||||
|
delete data[userId ?? activeUserId];
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combineLatestWith: this.stateProvider.activeUserId$,
|
||||||
|
shouldUpdate: (data, activeUserId) => data?.[userId ?? activeUserId] != null,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPromptCancelled(): Promise<void> {
|
async setUserPromptCancelled(): Promise<void> {
|
||||||
|
await this.promptCancelledState.update(
|
||||||
|
(record, userId) => {
|
||||||
|
record ??= {};
|
||||||
|
record[userId] = true;
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combineLatestWith: this.stateProvider.activeUserId$,
|
||||||
|
shouldUpdate: (_, userId) => {
|
||||||
|
if (userId == null) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot update biometric prompt cancelled state without an active user",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetAllPromptCancelled(): Promise<void> {
|
||||||
await this.promptCancelledState.update(() => null);
|
await this.promptCancelledState.update(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
describe.each([
|
describe.each([
|
||||||
[ENCRYPTED_CLIENT_KEY_HALF, "encryptedClientKeyHalf"],
|
[ENCRYPTED_CLIENT_KEY_HALF, "encryptedClientKeyHalf"],
|
||||||
[DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, true],
|
[DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, true],
|
||||||
[PROMPT_CANCELLED, true],
|
[PROMPT_CANCELLED, { userId1: true, userId2: false }],
|
||||||
[PROMPT_AUTOMATICALLY, true],
|
[PROMPT_AUTOMATICALLY, true],
|
||||||
[REQUIRE_PASSWORD_ON_START, true],
|
[REQUIRE_PASSWORD_ON_START, true],
|
||||||
[BIOMETRIC_UNLOCK_ENABLED, true],
|
[BIOMETRIC_UNLOCK_ENABLED, true],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { UserId } from "../../types/guid";
|
||||||
import { EncryptedString } from "../models/domain/enc-string";
|
import { EncryptedString } from "../models/domain/enc-string";
|
||||||
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state";
|
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state";
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition<boo
|
|||||||
* Stores whether the user has elected to cancel the biometric prompt. This is stored on disk due to process-reload
|
* Stores whether the user has elected to cancel the biometric prompt. This is stored on disk due to process-reload
|
||||||
* wiping memory state. We don't want to prompt the user again if they've elected to cancel.
|
* wiping memory state. We don't want to prompt the user again if they've elected to cancel.
|
||||||
*/
|
*/
|
||||||
export const PROMPT_CANCELLED = new KeyDefinition<boolean>(
|
export const PROMPT_CANCELLED = KeyDefinition.record<boolean, UserId>(
|
||||||
BIOMETRIC_SETTINGS_DISK,
|
BIOMETRIC_SETTINGS_DISK,
|
||||||
"promptCancelled",
|
"promptCancelled",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ export class AccountProfile {
|
|||||||
export class AccountSettings {
|
export class AccountSettings {
|
||||||
defaultUriMatch?: UriMatchStrategySetting;
|
defaultUriMatch?: UriMatchStrategySetting;
|
||||||
disableGa?: boolean;
|
disableGa?: boolean;
|
||||||
enableAlwaysOnTop?: boolean;
|
|
||||||
enableBiometric?: boolean;
|
enableBiometric?: boolean;
|
||||||
minimizeOnCopyToClipboard?: boolean;
|
minimizeOnCopyToClipboard?: boolean;
|
||||||
passwordGenerationOptions?: PasswordGeneratorOptions;
|
passwordGenerationOptions?: PasswordGeneratorOptions;
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import { WindowState } from "../../../models/domain/window-state";
|
|
||||||
import { ThemeType } from "../../enums";
|
import { ThemeType } from "../../enums";
|
||||||
|
|
||||||
export class GlobalState {
|
export class GlobalState {
|
||||||
enableAlwaysOnTop?: boolean;
|
|
||||||
installedVersion?: string;
|
installedVersion?: string;
|
||||||
locale?: string;
|
|
||||||
organizationInvitation?: any;
|
organizationInvitation?: any;
|
||||||
rememberedEmail?: string;
|
rememberedEmail?: string;
|
||||||
theme?: ThemeType = ThemeType.System;
|
theme?: ThemeType = ThemeType.System;
|
||||||
window?: WindowState = new WindowState();
|
|
||||||
twoFactorToken?: string;
|
twoFactorToken?: string;
|
||||||
biometricFingerprintValidated?: boolean;
|
biometricFingerprintValidated?: boolean;
|
||||||
vaultTimeout?: number;
|
vaultTimeout?: number;
|
||||||
vaultTimeoutAction?: string;
|
vaultTimeoutAction?: string;
|
||||||
loginRedirect?: any;
|
loginRedirect?: any;
|
||||||
mainWindowSize?: number;
|
mainWindowSize?: number;
|
||||||
enableTray?: boolean;
|
|
||||||
enableMinimizeToTray?: boolean;
|
|
||||||
enableCloseToTray?: boolean;
|
|
||||||
enableStartToTray?: boolean;
|
|
||||||
openAtLogin?: boolean;
|
|
||||||
alwaysShowDock?: boolean;
|
|
||||||
enableBrowserIntegration?: boolean;
|
enableBrowserIntegration?: boolean;
|
||||||
enableBrowserIntegrationFingerprint?: boolean;
|
enableBrowserIntegrationFingerprint?: boolean;
|
||||||
enableDuckDuckGoBrowserIntegration?: boolean;
|
enableDuckDuckGoBrowserIntegration?: boolean;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re
|
|||||||
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
|
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
|
||||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||||
import { BiometricKey } from "../../auth/types/biometric-key";
|
import { BiometricKey } from "../../auth/types/biometric-key";
|
||||||
import { WindowState } from "../../models/domain/window-state";
|
|
||||||
import { GeneratorOptions } from "../../tools/generator/generator-options";
|
import { GeneratorOptions } from "../../tools/generator/generator-options";
|
||||||
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
|
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
|
||||||
import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
||||||
@@ -277,24 +276,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAlwaysShowDock(options?: StorageOptions): Promise<boolean> {
|
|
||||||
return (
|
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
|
||||||
?.alwaysShowDock ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setAlwaysShowDock(value: boolean, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
globals.alwaysShowDock = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBiometricFingerprintValidated(options?: StorageOptions): Promise<boolean> {
|
async getBiometricFingerprintValidated(options?: StorageOptions): Promise<boolean> {
|
||||||
return (
|
return (
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||||
@@ -847,36 +828,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEnableAlwaysOnTop(options?: StorageOptions): Promise<boolean> {
|
|
||||||
const accountPreference = (
|
|
||||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
|
||||||
)?.settings?.enableAlwaysOnTop;
|
|
||||||
const globalPreference = (
|
|
||||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
|
||||||
)?.enableAlwaysOnTop;
|
|
||||||
return accountPreference ?? globalPreference ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEnableAlwaysOnTop(value: boolean, options?: StorageOptions): Promise<void> {
|
|
||||||
const account = await this.getAccount(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
account.settings.enableAlwaysOnTop = value;
|
|
||||||
await this.saveAccount(
|
|
||||||
account,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
globals.enableAlwaysOnTop = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||||
return (
|
return (
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||||
@@ -916,24 +867,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEnableCloseToTray(options?: StorageOptions): Promise<boolean> {
|
|
||||||
return (
|
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
|
||||||
?.enableCloseToTray ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEnableCloseToTray(value: boolean, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
globals.enableCloseToTray = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEnableDuckDuckGoBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
async getEnableDuckDuckGoBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||||
return (
|
return (
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||||
@@ -955,60 +888,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEnableMinimizeToTray(options?: StorageOptions): Promise<boolean> {
|
|
||||||
return (
|
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
|
||||||
?.enableMinimizeToTray ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEnableMinimizeToTray(value: boolean, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
globals.enableMinimizeToTray = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEnableStartToTray(options?: StorageOptions): Promise<boolean> {
|
|
||||||
return (
|
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
|
||||||
?.enableStartToTray ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEnableStartToTray(value: boolean, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
globals.enableStartToTray = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEnableTray(options?: StorageOptions): Promise<boolean> {
|
|
||||||
return (
|
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
|
||||||
?.enableTray ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEnableTray(value: boolean, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
globals.enableTray = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@withPrototypeForObjectValues(CipherData)
|
@withPrototypeForObjectValues(CipherData)
|
||||||
async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> {
|
async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> {
|
||||||
return (
|
return (
|
||||||
@@ -1292,40 +1171,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLocale(options?: StorageOptions): Promise<string> {
|
|
||||||
return (
|
|
||||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
|
||||||
)?.locale;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setLocale(value: string, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
|
||||||
);
|
|
||||||
globals.locale = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMainWindowSize(options?: StorageOptions): Promise<number> {
|
|
||||||
return (
|
|
||||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
|
||||||
)?.mainWindowSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setMainWindowSize(value: number, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
|
||||||
);
|
|
||||||
globals.mainWindowSize = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise<boolean> {
|
async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise<boolean> {
|
||||||
return (
|
return (
|
||||||
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||||
@@ -1344,24 +1189,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOpenAtLogin(options?: StorageOptions): Promise<boolean> {
|
|
||||||
return (
|
|
||||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
|
||||||
?.openAtLogin ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setOpenAtLogin(value: boolean, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
globals.openAtLogin = value;
|
|
||||||
await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOrganizationInvitation(options?: StorageOptions): Promise<any> {
|
async getOrganizationInvitation(options?: StorageOptions): Promise<any> {
|
||||||
return (
|
return (
|
||||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||||
@@ -1571,24 +1398,6 @@ export class StateService<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWindow(): Promise<WindowState> {
|
|
||||||
const globals = await this.getGlobals(await this.defaultOnDiskOptions());
|
|
||||||
return globals?.window != null && Object.keys(globals.window).length > 0
|
|
||||||
? globals.window
|
|
||||||
: new WindowState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async setWindow(value: WindowState, options?: StorageOptions): Promise<void> {
|
|
||||||
const globals = await this.getGlobals(
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
globals.window = value;
|
|
||||||
return await this.saveGlobals(
|
|
||||||
globals,
|
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setServerConfig(value: ServerConfigData, options?: StorageOptions): Promise<void> {
|
async setServerConfig(value: ServerConfigData, options?: StorageOptions): Promise<void> {
|
||||||
const account = await this.getAccount(
|
const account = await this.getAccount(
|
||||||
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export { StateProvider } from "./state.provider";
|
|||||||
export { GlobalStateProvider } from "./global-state.provider";
|
export { GlobalStateProvider } from "./global-state.provider";
|
||||||
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
|
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
|
||||||
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
|
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
|
||||||
export { KeyDefinition } from "./key-definition";
|
export { KeyDefinition, KeyDefinitionOptions } from "./key-definition";
|
||||||
export { StateUpdateOptions } from "./state-update-options";
|
export { StateUpdateOptions } from "./state-update-options";
|
||||||
export { UserKeyDefinition } from "./user-key-definition";
|
export { UserKeyDefinition } from "./user-key-definition";
|
||||||
export { StateEventRunnerService } from "./state-event-runner.service";
|
export { StateEventRunnerService } from "./state-event-runner.service";
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-do
|
|||||||
import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider";
|
import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider";
|
||||||
import { UserDecryptionOptionsMigrator } from "./migrations/44-move-user-decryption-options-to-state-provider";
|
import { UserDecryptionOptionsMigrator } from "./migrations/44-move-user-decryption-options-to-state-provider";
|
||||||
import { MergeEnvironmentState } from "./migrations/45-merge-environment-state";
|
import { MergeEnvironmentState } from "./migrations/45-merge-environment-state";
|
||||||
|
import { DeleteBiometricPromptCancelledData } from "./migrations/46-delete-orphaned-biometric-prompt-data";
|
||||||
|
import { MoveDesktopSettingsMigrator } from "./migrations/47-move-desktop-settings";
|
||||||
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
||||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||||
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
||||||
@@ -49,8 +51,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
|
|||||||
import { MinVersionMigrator } from "./migrations/min-version";
|
import { MinVersionMigrator } from "./migrations/min-version";
|
||||||
|
|
||||||
export const MIN_VERSION = 3;
|
export const MIN_VERSION = 3;
|
||||||
export const CURRENT_VERSION = 45;
|
export const CURRENT_VERSION = 47;
|
||||||
|
|
||||||
export type MinVersion = typeof MIN_VERSION;
|
export type MinVersion = typeof MIN_VERSION;
|
||||||
|
|
||||||
export function createMigrationBuilder() {
|
export function createMigrationBuilder() {
|
||||||
@@ -97,7 +98,9 @@ export function createMigrationBuilder() {
|
|||||||
.with(EnableFaviconMigrator, 41, 42)
|
.with(EnableFaviconMigrator, 41, 42)
|
||||||
.with(AutoConfirmFingerPrintsMigrator, 42, 43)
|
.with(AutoConfirmFingerPrintsMigrator, 42, 43)
|
||||||
.with(UserDecryptionOptionsMigrator, 43, 44)
|
.with(UserDecryptionOptionsMigrator, 43, 44)
|
||||||
.with(MergeEnvironmentState, 44, CURRENT_VERSION);
|
.with(MergeEnvironmentState, 44, 45)
|
||||||
|
.with(DeleteBiometricPromptCancelledData, 45, 46)
|
||||||
|
.with(MoveDesktopSettingsMigrator, 46, CURRENT_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function currentVersion(
|
export async function currentVersion(
|
||||||
|
|||||||
@@ -178,12 +178,9 @@ export function mockMigrationHelper(
|
|||||||
return mockHelper;
|
return mockHelper;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Use const generic for TUsers in TypeScript 5.0 so consumers don't have to `as const` themselves
|
|
||||||
export type InitialDataHint<TUsers extends readonly string[]> = {
|
export type InitialDataHint<TUsers extends readonly string[]> = {
|
||||||
/**
|
/**
|
||||||
* A string array of the users id who are authenticated
|
* A string array of the users id who are authenticated
|
||||||
*
|
|
||||||
* NOTE: It's recommended to as const this string array so you get type help defining the users data
|
|
||||||
*/
|
*/
|
||||||
authenticatedAccounts?: TUsers;
|
authenticatedAccounts?: TUsers;
|
||||||
/**
|
/**
|
||||||
@@ -282,10 +279,9 @@ function expectInjectedData(
|
|||||||
* @param initalData The data to start with
|
* @param initalData The data to start with
|
||||||
* @returns State after your migration has ran.
|
* @returns State after your migration has ran.
|
||||||
*/
|
*/
|
||||||
// TODO: Use const generic for TUsers in TypeScript 5.0 so consumers don't have to `as const` themselves
|
|
||||||
export async function runMigrator<
|
export async function runMigrator<
|
||||||
TMigrator extends Migrator<number, number>,
|
TMigrator extends Migrator<number, number>,
|
||||||
TUsers extends readonly string[] = string[],
|
const TUsers extends readonly string[],
|
||||||
>(
|
>(
|
||||||
migrator: TMigrator,
|
migrator: TMigrator,
|
||||||
initalData?: InitialDataHint<TUsers>,
|
initalData?: InitialDataHint<TUsers>,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { runMigrator } from "../migration-helper.spec";
|
||||||
|
import { IRREVERSIBLE } from "../migrator";
|
||||||
|
|
||||||
|
import { DeleteBiometricPromptCancelledData } from "./46-delete-orphaned-biometric-prompt-data";
|
||||||
|
|
||||||
|
describe("MoveThemeToStateProviders", () => {
|
||||||
|
const sut = new DeleteBiometricPromptCancelledData(45, 46);
|
||||||
|
|
||||||
|
describe("migrate", () => {
|
||||||
|
it("deletes promptCancelled from all users", async () => {
|
||||||
|
const output = await runMigrator(sut, {
|
||||||
|
authenticatedAccounts: ["user-1", "user-2"],
|
||||||
|
"user_user-1_biometricSettings_promptCancelled": true,
|
||||||
|
"user_user-2_biometricSettings_promptCancelled": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
authenticatedAccounts: ["user-1", "user-2"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rollback", () => {
|
||||||
|
it("is irreversible", async () => {
|
||||||
|
await expect(runMigrator(sut, {}, "rollback")).rejects.toThrow(IRREVERSIBLE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||||
|
import { IRREVERSIBLE, Migrator } from "../migrator";
|
||||||
|
|
||||||
|
export const PROMPT_CANCELLED: KeyDefinitionLike = {
|
||||||
|
key: "promptCancelled",
|
||||||
|
stateDefinition: { name: "biometricSettings" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DeleteBiometricPromptCancelledData extends Migrator<45, 46> {
|
||||||
|
async migrate(helper: MigrationHelper): Promise<void> {
|
||||||
|
await Promise.all(
|
||||||
|
(await helper.getAccounts()).map(async ({ userId }) => {
|
||||||
|
if (helper.getFromUser(userId, PROMPT_CANCELLED) != null) {
|
||||||
|
await helper.removeFromUser(userId, PROMPT_CANCELLED);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollback(helper: MigrationHelper): Promise<void> {
|
||||||
|
throw IRREVERSIBLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { runMigrator } from "../migration-helper.spec";
|
||||||
|
|
||||||
|
import { MoveDesktopSettingsMigrator } from "./47-move-desktop-settings";
|
||||||
|
|
||||||
|
describe("MoveDesktopSettings", () => {
|
||||||
|
const sut = new MoveDesktopSettingsMigrator(46, 47);
|
||||||
|
|
||||||
|
it("can migrate truthy values", async () => {
|
||||||
|
const output = await runMigrator(sut, {
|
||||||
|
authenticatedAccounts: ["user1"],
|
||||||
|
global: {
|
||||||
|
window: {
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
displayBounds: {
|
||||||
|
height: 200,
|
||||||
|
width: 200,
|
||||||
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enableAlwaysOnTop: true,
|
||||||
|
enableCloseToTray: true,
|
||||||
|
enableMinimizeToTray: true,
|
||||||
|
enableStartToTray: true,
|
||||||
|
enableTray: true,
|
||||||
|
openAtLogin: true,
|
||||||
|
alwaysShowDock: true,
|
||||||
|
},
|
||||||
|
user1: {
|
||||||
|
settings: {
|
||||||
|
enableAlwaysOnTop: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
authenticatedAccounts: ["user1"],
|
||||||
|
global: {},
|
||||||
|
global_desktopSettings_window: {
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
displayBounds: {
|
||||||
|
height: 200,
|
||||||
|
width: 200,
|
||||||
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global_desktopSettings_closeToTray: true,
|
||||||
|
global_desktopSettings_minimizeToTray: true,
|
||||||
|
global_desktopSettings_startToTray: true,
|
||||||
|
global_desktopSettings_trayEnabled: true,
|
||||||
|
global_desktopSettings_openAtLogin: true,
|
||||||
|
global_desktopSettings_alwaysShowDock: true,
|
||||||
|
global_desktopSettings_alwaysOnTop: true,
|
||||||
|
user1: {
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can migrate falsey values", async () => {
|
||||||
|
const output = await runMigrator(sut, {
|
||||||
|
authenticatedAccounts: ["user1"],
|
||||||
|
global: {
|
||||||
|
window: null,
|
||||||
|
enableCloseToTray: false,
|
||||||
|
enableMinimizeToTray: false,
|
||||||
|
enableStartToTray: false,
|
||||||
|
enableTray: false,
|
||||||
|
openAtLogin: false,
|
||||||
|
alwaysShowDock: false,
|
||||||
|
enableAlwaysOnTop: false,
|
||||||
|
},
|
||||||
|
user1: {
|
||||||
|
settings: {
|
||||||
|
enableAlwaysOnTop: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
authenticatedAccounts: ["user1"],
|
||||||
|
global: {},
|
||||||
|
global_desktopSettings_window: null,
|
||||||
|
global_desktopSettings_closeToTray: false,
|
||||||
|
global_desktopSettings_minimizeToTray: false,
|
||||||
|
global_desktopSettings_startToTray: false,
|
||||||
|
global_desktopSettings_trayEnabled: false,
|
||||||
|
global_desktopSettings_openAtLogin: false,
|
||||||
|
global_desktopSettings_alwaysShowDock: false,
|
||||||
|
global_desktopSettings_alwaysOnTop: false,
|
||||||
|
user1: {
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can migrate even if none of our values are found", async () => {
|
||||||
|
//
|
||||||
|
const output = await runMigrator(sut, {
|
||||||
|
authenticatedAccounts: ["user1"] as const,
|
||||||
|
global: {
|
||||||
|
anotherSetting: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
authenticatedAccounts: ["user1"] as const,
|
||||||
|
global: {
|
||||||
|
anotherSetting: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
|
||||||
|
import { IRREVERSIBLE, Migrator } from "../migrator";
|
||||||
|
|
||||||
|
type ExpectedGlobalType = {
|
||||||
|
window?: object;
|
||||||
|
enableTray?: boolean;
|
||||||
|
enableMinimizeToTray?: boolean;
|
||||||
|
enableCloseToTray?: boolean;
|
||||||
|
enableStartToTray?: boolean;
|
||||||
|
openAtLogin?: boolean;
|
||||||
|
alwaysShowDock?: boolean;
|
||||||
|
enableAlwaysOnTop?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpectedAccountType = {
|
||||||
|
settings?: {
|
||||||
|
enableAlwaysOnTop?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const DESKTOP_SETTINGS_STATE: StateDefinitionLike = { name: "desktopSettings" };
|
||||||
|
|
||||||
|
const WINDOW_KEY: KeyDefinitionLike = { key: "window", stateDefinition: DESKTOP_SETTINGS_STATE };
|
||||||
|
|
||||||
|
const CLOSE_TO_TRAY_KEY: KeyDefinitionLike = {
|
||||||
|
key: "closeToTray",
|
||||||
|
stateDefinition: DESKTOP_SETTINGS_STATE,
|
||||||
|
};
|
||||||
|
const MINIMIZE_TO_TRAY_KEY: KeyDefinitionLike = {
|
||||||
|
key: "minimizeToTray",
|
||||||
|
stateDefinition: DESKTOP_SETTINGS_STATE,
|
||||||
|
};
|
||||||
|
const START_TO_TRAY_KEY: KeyDefinitionLike = {
|
||||||
|
key: "startToTray",
|
||||||
|
stateDefinition: DESKTOP_SETTINGS_STATE,
|
||||||
|
};
|
||||||
|
const TRAY_ENABLED_KEY: KeyDefinitionLike = {
|
||||||
|
key: "trayEnabled",
|
||||||
|
stateDefinition: DESKTOP_SETTINGS_STATE,
|
||||||
|
};
|
||||||
|
const OPEN_AT_LOGIN_KEY: KeyDefinitionLike = {
|
||||||
|
key: "openAtLogin",
|
||||||
|
stateDefinition: DESKTOP_SETTINGS_STATE,
|
||||||
|
};
|
||||||
|
const ALWAYS_SHOW_DOCK_KEY: KeyDefinitionLike = {
|
||||||
|
key: "alwaysShowDock",
|
||||||
|
stateDefinition: DESKTOP_SETTINGS_STATE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALWAYS_ON_TOP_KEY: KeyDefinitionLike = {
|
||||||
|
key: "alwaysOnTop",
|
||||||
|
stateDefinition: DESKTOP_SETTINGS_STATE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MoveDesktopSettingsMigrator extends Migrator<46, 47> {
|
||||||
|
async migrate(helper: MigrationHelper): Promise<void> {
|
||||||
|
const legacyGlobal = await helper.get<ExpectedGlobalType>("global");
|
||||||
|
|
||||||
|
let updatedGlobal = false;
|
||||||
|
if (legacyGlobal?.window !== undefined) {
|
||||||
|
await helper.setToGlobal(WINDOW_KEY, legacyGlobal.window);
|
||||||
|
updatedGlobal = true;
|
||||||
|
delete legacyGlobal.window;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyGlobal?.enableCloseToTray != null) {
|
||||||
|
await helper.setToGlobal(CLOSE_TO_TRAY_KEY, legacyGlobal.enableCloseToTray);
|
||||||
|
updatedGlobal = true;
|
||||||
|
delete legacyGlobal.enableCloseToTray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyGlobal?.enableMinimizeToTray != null) {
|
||||||
|
await helper.setToGlobal(MINIMIZE_TO_TRAY_KEY, legacyGlobal.enableMinimizeToTray);
|
||||||
|
updatedGlobal = true;
|
||||||
|
delete legacyGlobal.enableMinimizeToTray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyGlobal?.enableStartToTray != null) {
|
||||||
|
await helper.setToGlobal(START_TO_TRAY_KEY, legacyGlobal.enableStartToTray);
|
||||||
|
updatedGlobal = true;
|
||||||
|
delete legacyGlobal.enableStartToTray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyGlobal?.enableTray != null) {
|
||||||
|
await helper.setToGlobal(TRAY_ENABLED_KEY, legacyGlobal.enableTray);
|
||||||
|
updatedGlobal = true;
|
||||||
|
delete legacyGlobal.enableTray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyGlobal?.openAtLogin != null) {
|
||||||
|
await helper.setToGlobal(OPEN_AT_LOGIN_KEY, legacyGlobal.openAtLogin);
|
||||||
|
updatedGlobal = true;
|
||||||
|
delete legacyGlobal.openAtLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyGlobal?.alwaysShowDock != null) {
|
||||||
|
await helper.setToGlobal(ALWAYS_SHOW_DOCK_KEY, legacyGlobal.alwaysShowDock);
|
||||||
|
updatedGlobal = true;
|
||||||
|
delete legacyGlobal.alwaysShowDock;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyGlobal?.enableAlwaysOnTop != null) {
|
||||||
|
await helper.setToGlobal(ALWAYS_ON_TOP_KEY, legacyGlobal.enableAlwaysOnTop);
|
||||||
|
updatedGlobal = true;
|
||||||
|
delete legacyGlobal.enableAlwaysOnTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedGlobal) {
|
||||||
|
await helper.set("global", legacyGlobal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateAccount(userId: string, account: ExpectedAccountType) {
|
||||||
|
// We only migrate the global setting for this, if we find it on the account object
|
||||||
|
// just delete it.
|
||||||
|
if (account?.settings?.enableAlwaysOnTop != null) {
|
||||||
|
delete account.settings.enableAlwaysOnTop;
|
||||||
|
await helper.set(userId, account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||||
|
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback(helper: MigrationHelper): Promise<void> {
|
||||||
|
throw IRREVERSIBLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { GENERATOR_DISK } from "../../../platform/state";
|
||||||
|
|
||||||
|
import { SecretClassifier } from "./secret-classifier";
|
||||||
|
import { SecretKeyDefinition } from "./secret-key-definition";
|
||||||
|
|
||||||
|
describe("SecretKeyDefinition", () => {
|
||||||
|
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
|
||||||
|
const options = { deserializer: (v: any) => v };
|
||||||
|
|
||||||
|
describe("value", () => {
|
||||||
|
it("returns an initialized SecretKeyDefinition", () => {
|
||||||
|
const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
|
||||||
|
expect(definition).toBeInstanceOf(SecretKeyDefinition);
|
||||||
|
expect(definition.stateDefinition).toBe(GENERATOR_DISK);
|
||||||
|
expect(definition.key).toBe("key");
|
||||||
|
expect(definition.classifier).toBe(classifier);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deconstruct returns an array with a single item", () => {
|
||||||
|
const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = { foo: true };
|
||||||
|
|
||||||
|
const result = definition.deconstruct(value);
|
||||||
|
|
||||||
|
expect(result).toEqual([[null, value]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconstruct returns the inner value", () => {
|
||||||
|
const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = { foo: true };
|
||||||
|
|
||||||
|
const result = definition.reconstruct([[null, value]]);
|
||||||
|
|
||||||
|
expect(result).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("array", () => {
|
||||||
|
it("returns an initialized SecretKeyDefinition", () => {
|
||||||
|
const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
|
||||||
|
expect(definition).toBeInstanceOf(SecretKeyDefinition);
|
||||||
|
expect(definition.stateDefinition).toBe(GENERATOR_DISK);
|
||||||
|
expect(definition.key).toBe("key");
|
||||||
|
expect(definition.classifier).toBe(classifier);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deconstruct", () => {
|
||||||
|
it("over a 0-length array returns an empty array", () => {
|
||||||
|
const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value: { foo: boolean }[] = [];
|
||||||
|
|
||||||
|
const result = definition.deconstruct(value);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over a 1-length array returns a pair of indices and values", () => {
|
||||||
|
const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = [{ foo: true }];
|
||||||
|
|
||||||
|
const result = definition.deconstruct(value);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual([[0, value[0]]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over an n-length array returns n pairs of indices and values", () => {
|
||||||
|
const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = [{ foo: true }, { foo: false }];
|
||||||
|
|
||||||
|
const result = definition.deconstruct(value);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual([
|
||||||
|
[0, value[0]],
|
||||||
|
[1, value[1]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deconstruct", () => {
|
||||||
|
it("over a 0-length array of entries returns an empty array", () => {
|
||||||
|
const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
|
||||||
|
const result = definition.reconstruct([]);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over a 1-length array of entries returns a 1-length array", () => {
|
||||||
|
const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = [{ foo: true }];
|
||||||
|
|
||||||
|
const result = definition.reconstruct([[0, value[0]]]);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over an n-length array of entries returns an n-length array", () => {
|
||||||
|
const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = [{ foo: true }, { foo: false }];
|
||||||
|
|
||||||
|
const result = definition.reconstruct([
|
||||||
|
[0, value[0]],
|
||||||
|
[1, value[1]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("record", () => {
|
||||||
|
it("returns an initialized SecretKeyDefinition", () => {
|
||||||
|
const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
|
||||||
|
expect(definition).toBeInstanceOf(SecretKeyDefinition);
|
||||||
|
expect(definition.stateDefinition).toBe(GENERATOR_DISK);
|
||||||
|
expect(definition.key).toBe("key");
|
||||||
|
expect(definition.classifier).toBe(classifier);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deconstruct", () => {
|
||||||
|
it("over a 0-key record returns an empty array", () => {
|
||||||
|
const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value: Record<string, { foo: boolean }> = {};
|
||||||
|
|
||||||
|
const result = definition.deconstruct(value);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over a 1-key record returns a pair of indices and values", () => {
|
||||||
|
const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = { foo: { foo: true } };
|
||||||
|
|
||||||
|
const result = definition.deconstruct(value);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual([["foo", value["foo"]]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over an n-key record returns n pairs of indices and values", () => {
|
||||||
|
const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = { foo: { foo: true }, bar: { foo: false } };
|
||||||
|
|
||||||
|
const result = definition.deconstruct(value);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual([
|
||||||
|
["foo", value["foo"]],
|
||||||
|
["bar", value["bar"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deconstruct", () => {
|
||||||
|
it("over a 0-key record of entries returns an empty array", () => {
|
||||||
|
const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
|
||||||
|
const result = definition.reconstruct([]);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over a 1-key record of entries returns a 1-length record", () => {
|
||||||
|
const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = { foo: { foo: true } };
|
||||||
|
|
||||||
|
const result = definition.reconstruct([["foo", value["foo"]]]);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over an n-key record of entries returns an n-length record", () => {
|
||||||
|
const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options);
|
||||||
|
const value = { foo: { foo: true }, bar: { foo: false } };
|
||||||
|
|
||||||
|
const result = definition.reconstruct([
|
||||||
|
["foo", value["foo"]],
|
||||||
|
["bar", value["bar"]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { KeyDefinitionOptions } from "../../../platform/state";
|
||||||
|
// eslint-disable-next-line -- `StateDefinition` used as an argument
|
||||||
|
import { StateDefinition } from "../../../platform/state/state-definition";
|
||||||
|
import { SecretClassifier } from "./secret-classifier";
|
||||||
|
|
||||||
|
/** Encryption and storage settings for data stored by a `SecretState`.
|
||||||
|
*/
|
||||||
|
export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Secret> {
|
||||||
|
private constructor(
|
||||||
|
readonly stateDefinition: StateDefinition,
|
||||||
|
readonly key: string,
|
||||||
|
readonly classifier: SecretClassifier<Inner, Disclosed, Secret>,
|
||||||
|
readonly options: KeyDefinitionOptions<Inner>,
|
||||||
|
// type erasure is necessary here because typescript doesn't support
|
||||||
|
// higher kinded types that generalize over collections. The invariants
|
||||||
|
// needed to make this typesafe are maintained by the static factories.
|
||||||
|
readonly deconstruct: (value: any) => [Id, any][],
|
||||||
|
readonly reconstruct: ([inners, ids]: (readonly [Id, any])[]) => Outer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a secret state for a single value
|
||||||
|
* @param stateDefinition The domain of the secret's durable state.
|
||||||
|
* @param key Domain key that identifies the stored value. This key must not be reused
|
||||||
|
* in any capacity.
|
||||||
|
* @param classifier Partitions the value into encrypted, discarded, and public data.
|
||||||
|
* @param options Configures the operation of the secret state.
|
||||||
|
*/
|
||||||
|
static value<Value extends object, Disclosed, Secret>(
|
||||||
|
stateDefinition: StateDefinition,
|
||||||
|
key: string,
|
||||||
|
classifier: SecretClassifier<Value, Disclosed, Secret>,
|
||||||
|
options: KeyDefinitionOptions<Value>,
|
||||||
|
) {
|
||||||
|
return new SecretKeyDefinition<Value, void, Value, Disclosed, Secret>(
|
||||||
|
stateDefinition,
|
||||||
|
key,
|
||||||
|
classifier,
|
||||||
|
options,
|
||||||
|
(value) => [[null, value]],
|
||||||
|
([[, inner]]) => inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a secret state for an array of values. Each item is encrypted separately.
|
||||||
|
* @param stateDefinition The domain of the secret's durable state.
|
||||||
|
* @param key Domain key that identifies the stored items. This key must not be reused
|
||||||
|
* in any capacity.
|
||||||
|
* @param classifier Partitions each item into encrypted, discarded, and public data.
|
||||||
|
* @param options Configures the operation of the secret state.
|
||||||
|
*/
|
||||||
|
static array<Item extends object, Disclosed, Secret>(
|
||||||
|
stateDefinition: StateDefinition,
|
||||||
|
key: string,
|
||||||
|
classifier: SecretClassifier<Item, Disclosed, Secret>,
|
||||||
|
options: KeyDefinitionOptions<Item>,
|
||||||
|
) {
|
||||||
|
return new SecretKeyDefinition<Item[], number, Item, Disclosed, Secret>(
|
||||||
|
stateDefinition,
|
||||||
|
key,
|
||||||
|
classifier,
|
||||||
|
options,
|
||||||
|
(value) => value.map((v: any, id: number) => [id, v]),
|
||||||
|
(values) => values.map(([, v]) => v),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a secret state for a record. Each property is encrypted separately.
|
||||||
|
* @param stateDefinition The domain of the secret's durable state.
|
||||||
|
* @param key Domain key that identifies the stored properties. This key must not be reused
|
||||||
|
* in any capacity.
|
||||||
|
* @param classifier Partitions each property into encrypted, discarded, and public data.
|
||||||
|
* @param options Configures the operation of the secret state.
|
||||||
|
*/
|
||||||
|
static record<Item extends object, Disclosed, Secret, Id extends string | number>(
|
||||||
|
stateDefinition: StateDefinition,
|
||||||
|
key: string,
|
||||||
|
classifier: SecretClassifier<Item, Disclosed, Secret>,
|
||||||
|
options: KeyDefinitionOptions<Item>,
|
||||||
|
) {
|
||||||
|
return new SecretKeyDefinition<Record<Id, Item>, Id, Item, Disclosed, Secret>(
|
||||||
|
stateDefinition,
|
||||||
|
key,
|
||||||
|
classifier,
|
||||||
|
options,
|
||||||
|
(value) => Object.entries(value) as [Id, Item][],
|
||||||
|
(values) => Object.fromEntries(values) as Record<Id, Item>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,15 +9,18 @@ import {
|
|||||||
awaitAsync,
|
awaitAsync,
|
||||||
} from "../../../../spec";
|
} from "../../../../spec";
|
||||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||||
import { KeyDefinition, GENERATOR_DISK } from "../../../platform/state";
|
import { GENERATOR_DISK } from "../../../platform/state";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
|
|
||||||
|
import { SecretClassifier } from "./secret-classifier";
|
||||||
|
import { SecretKeyDefinition } from "./secret-key-definition";
|
||||||
import { SecretState } from "./secret-state";
|
import { SecretState } from "./secret-state";
|
||||||
import { UserEncryptor } from "./user-encryptor.abstraction";
|
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||||
|
|
||||||
type FooBar = { foo: boolean; bar: boolean; date?: Date };
|
type FooBar = { foo: boolean; bar: boolean; date?: Date };
|
||||||
const FOOBAR_KEY = new KeyDefinition<FooBar>(GENERATOR_DISK, "fooBar", {
|
const classifier = SecretClassifier.allSecret<FooBar>();
|
||||||
deserializer: (fb) => {
|
const options: any = {
|
||||||
|
deserializer: (fb: FooBar) => {
|
||||||
const result: FooBar = { foo: fb.foo, bar: fb.bar };
|
const result: FooBar = { foo: fb.foo, bar: fb.bar };
|
||||||
|
|
||||||
if (fb.date) {
|
if (fb.date) {
|
||||||
@@ -26,23 +29,27 @@ const FOOBAR_KEY = new KeyDefinition<FooBar>(GENERATOR_DISK, "fooBar", {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
const FOOBAR_VALUE = SecretKeyDefinition.value(GENERATOR_DISK, "fooBar", classifier, options);
|
||||||
|
const FOOBAR_ARRAY = SecretKeyDefinition.array(GENERATOR_DISK, "fooBar", classifier, options);
|
||||||
|
const FOOBAR_RECORD = SecretKeyDefinition.record(GENERATOR_DISK, "fooBar", classifier, options);
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
const SomeUser = "some user" as UserId;
|
||||||
|
|
||||||
function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar, Record<string, never>> {
|
function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar> {
|
||||||
// stores "encrypted values" so that they can be "decrypted" later
|
// stores "encrypted values" so that they can be "decrypted" later
|
||||||
// while allowing the operations to be interleaved.
|
// while allowing the operations to be interleaved.
|
||||||
const encrypted = new Map<string, Jsonify<FooBar>>(
|
const encrypted = new Map<string, Jsonify<FooBar>>(
|
||||||
fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const),
|
fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = mock<UserEncryptor<FooBar, Record<string, never>>>({
|
const result = mock<UserEncryptor<FooBar>>({
|
||||||
encrypt(value: FooBar, user: UserId) {
|
encrypt(value: FooBar, user: UserId) {
|
||||||
const encString = toKey(value);
|
const encString = toKey(value);
|
||||||
encrypted.set(encString.encryptedString, toValue(value));
|
encrypted.set(encString.encryptedString, toValue(value));
|
||||||
return Promise.resolve({ secret: encString, disclosed: {} });
|
return Promise.resolve(encString);
|
||||||
},
|
},
|
||||||
decrypt(secret: EncString, disclosed: Record<string, never>, userId: UserId) {
|
decrypt(secret: EncString, userId: UserId) {
|
||||||
const decString = encrypted.get(toValue(secret.encryptedString));
|
const decString = encrypted.get(toValue(secret.encryptedString));
|
||||||
return Promise.resolve(decString);
|
return Promise.resolve(decString);
|
||||||
},
|
},
|
||||||
@@ -59,9 +66,9 @@ function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar, Record<stri
|
|||||||
return JSON.parse(JSON.stringify(value));
|
return JSON.parse(JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
// chromatic pops a false positive about missing `encrypt` and `decrypt`
|
// typescript pops a false positive about missing `encrypt` and `decrypt`
|
||||||
// functions, so assert the type manually.
|
// functions, so assert the type manually.
|
||||||
return result as unknown as UserEncryptor<FooBar, Record<string, never>>;
|
return result as unknown as UserEncryptor<FooBar>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fakeStateProvider() {
|
async function fakeStateProvider() {
|
||||||
@@ -76,7 +83,7 @@ describe("UserEncryptor", () => {
|
|||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
|
|
||||||
const result = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const result = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
|
|
||||||
expect(result).toBeInstanceOf(SecretState);
|
expect(result).toBeInstanceOf(SecretState);
|
||||||
});
|
});
|
||||||
@@ -87,7 +94,7 @@ describe("UserEncryptor", () => {
|
|||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
|
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
|
|
||||||
expect(state.userId).toEqual(SomeUser);
|
expect(state.userId).toEqual(SomeUser);
|
||||||
});
|
});
|
||||||
@@ -95,7 +102,7 @@ describe("UserEncryptor", () => {
|
|||||||
it("state$ gets a set value", async () => {
|
it("state$ gets a set value", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
const value = { foo: true, bar: false };
|
const value = { foo: true, bar: false };
|
||||||
|
|
||||||
await state.update(() => value);
|
await state.update(() => value);
|
||||||
@@ -105,10 +112,55 @@ describe("UserEncryptor", () => {
|
|||||||
expect(result).toEqual(value);
|
expect(result).toEqual(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips json-serializable values", async () => {
|
||||||
|
const provider = await fakeStateProvider();
|
||||||
|
const encryptor = mockEncryptor();
|
||||||
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
|
const value = { foo: true, bar: true, date: new Date(1) };
|
||||||
|
|
||||||
|
await state.update(() => value);
|
||||||
|
await awaitAsync();
|
||||||
|
const result = await firstValueFrom(state.state$);
|
||||||
|
|
||||||
|
expect(result).toEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state$ gets a set array", async () => {
|
||||||
|
const provider = await fakeStateProvider();
|
||||||
|
const encryptor = mockEncryptor();
|
||||||
|
const state = SecretState.from(SomeUser, FOOBAR_ARRAY, provider, encryptor);
|
||||||
|
const array = [
|
||||||
|
{ foo: true, bar: false, date: new Date(1) },
|
||||||
|
{ foo: false, bar: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
await state.update(() => array);
|
||||||
|
await awaitAsync();
|
||||||
|
const result = await firstValueFrom(state.state$);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual(array);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state$ gets a set record", async () => {
|
||||||
|
const provider = await fakeStateProvider();
|
||||||
|
const encryptor = mockEncryptor();
|
||||||
|
const state = SecretState.from(SomeUser, FOOBAR_RECORD, provider, encryptor);
|
||||||
|
const record = {
|
||||||
|
baz: { foo: true, bar: false, date: new Date(1) },
|
||||||
|
biz: { foo: false, bar: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
await state.update(() => record);
|
||||||
|
await awaitAsync();
|
||||||
|
const result = await firstValueFrom(state.state$);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual(record);
|
||||||
|
});
|
||||||
|
|
||||||
it("combinedState$ gets a set value with the userId", async () => {
|
it("combinedState$ gets a set value with the userId", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
const value = { foo: true, bar: false };
|
const value = { foo: true, bar: false };
|
||||||
|
|
||||||
await state.update(() => value);
|
await state.update(() => value);
|
||||||
@@ -119,23 +171,10 @@ describe("UserEncryptor", () => {
|
|||||||
expect(userId).toEqual(SomeUser);
|
expect(userId).toEqual(SomeUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips json-serializable values", async () => {
|
|
||||||
const provider = await fakeStateProvider();
|
|
||||||
const encryptor = mockEncryptor();
|
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
|
||||||
const value = { foo: true, bar: true, date: new Date(1) };
|
|
||||||
|
|
||||||
await state.update(() => value);
|
|
||||||
await awaitAsync();
|
|
||||||
const result = await firstValueFrom(state.state$);
|
|
||||||
|
|
||||||
expect(result).toEqual(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gets the last set value", async () => {
|
it("gets the last set value", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
const initialValue = { foo: true, bar: false };
|
const initialValue = { foo: true, bar: false };
|
||||||
const replacementValue = { foo: false, bar: false };
|
const replacementValue = { foo: false, bar: false };
|
||||||
|
|
||||||
@@ -150,7 +189,7 @@ describe("UserEncryptor", () => {
|
|||||||
it("interprets shouldUpdate option", async () => {
|
it("interprets shouldUpdate option", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
const initialValue = { foo: true, bar: false };
|
const initialValue = { foo: true, bar: false };
|
||||||
const replacementValue = { foo: false, bar: false };
|
const replacementValue = { foo: false, bar: false };
|
||||||
|
|
||||||
@@ -164,7 +203,7 @@ describe("UserEncryptor", () => {
|
|||||||
it("sets the state to `null` when `update` returns `null`", async () => {
|
it("sets the state to `null` when `update` returns `null`", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
const value = { foo: true, bar: false };
|
const value = { foo: true, bar: false };
|
||||||
|
|
||||||
await state.update(() => value);
|
await state.update(() => value);
|
||||||
@@ -178,7 +217,7 @@ describe("UserEncryptor", () => {
|
|||||||
it("sets the state to `null` when `update` returns `undefined`", async () => {
|
it("sets the state to `null` when `update` returns `undefined`", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
const value = { foo: true, bar: false };
|
const value = { foo: true, bar: false };
|
||||||
|
|
||||||
await state.update(() => value);
|
await state.update(() => value);
|
||||||
@@ -192,7 +231,7 @@ describe("UserEncryptor", () => {
|
|||||||
it("sends rxjs observables into the shouldUpdate method", async () => {
|
it("sends rxjs observables into the shouldUpdate method", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
const combinedWith$ = from([1]);
|
const combinedWith$ = from([1]);
|
||||||
let combinedShouldUpdate = 0;
|
let combinedShouldUpdate = 0;
|
||||||
|
|
||||||
@@ -210,7 +249,7 @@ describe("UserEncryptor", () => {
|
|||||||
it("sends rxjs observables into the update method", async () => {
|
it("sends rxjs observables into the update method", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
const encryptor = mockEncryptor();
|
const encryptor = mockEncryptor();
|
||||||
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
|
const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor);
|
||||||
const combinedWith$ = from([1]);
|
const combinedWith$ = from([1]);
|
||||||
let combinedUpdate = 0;
|
let combinedUpdate = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -13,21 +13,27 @@ import {
|
|||||||
} from "../../../platform/state";
|
} from "../../../platform/state";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
|
|
||||||
|
import { SecretKeyDefinition } from "./secret-key-definition";
|
||||||
import { UserEncryptor } from "./user-encryptor.abstraction";
|
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||||
|
|
||||||
/** Describes the structure of data stored by the SecretState's
|
/** Describes the structure of data stored by the SecretState's
|
||||||
* encrypted state. Notably, this interface ensures that `Disclosed`
|
* encrypted state. Notably, this interface ensures that `Disclosed`
|
||||||
* round trips through JSON serialization.
|
* round trips through JSON serialization. It also preserves the
|
||||||
|
* Id.
|
||||||
|
* @remarks Tuple representation chosen because it matches
|
||||||
|
* `Object.entries` format.
|
||||||
*/
|
*/
|
||||||
type ClassifiedFormat<Disclosed> = {
|
type ClassifiedFormat<Id, Disclosed> = {
|
||||||
|
/** Identifies records. `null` when storing a `value` */
|
||||||
|
readonly id: Id | null;
|
||||||
/** Serialized {@link EncString} of the secret state's
|
/** Serialized {@link EncString} of the secret state's
|
||||||
* secret-level classified data.
|
* secret-level classified data.
|
||||||
*/
|
*/
|
||||||
secret: string;
|
readonly secret: string;
|
||||||
/** serialized representation of the secret state's
|
/** serialized representation of the secret state's
|
||||||
* disclosed-level classified data.
|
* disclosed-level classified data.
|
||||||
*/
|
*/
|
||||||
disclosed: Jsonify<Disclosed>;
|
readonly disclosed: Jsonify<Disclosed>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Stores account-specific secrets protected by a UserKeyEncryptor.
|
/** Stores account-specific secrets protected by a UserKeyEncryptor.
|
||||||
@@ -38,15 +44,16 @@ type ClassifiedFormat<Disclosed> = {
|
|||||||
*
|
*
|
||||||
* DO NOT USE THIS for synchronized data.
|
* DO NOT USE THIS for synchronized data.
|
||||||
*/
|
*/
|
||||||
export class SecretState<Plaintext extends object, Disclosed>
|
export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
|
||||||
implements SingleUserState<Plaintext>
|
implements SingleUserState<Outer>
|
||||||
{
|
{
|
||||||
// The constructor is private to avoid creating a circular dependency when
|
// The constructor is private to avoid creating a circular dependency when
|
||||||
// wiring the derived and secret states together.
|
// wiring the derived and secret states together.
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly encryptor: UserEncryptor<Plaintext, Disclosed>,
|
private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>,
|
||||||
private readonly encrypted: SingleUserState<ClassifiedFormat<Disclosed>>,
|
private readonly encryptor: UserEncryptor<Secret>,
|
||||||
private readonly plaintext: DerivedState<Plaintext>,
|
private readonly encrypted: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>,
|
||||||
|
private readonly plaintext: DerivedState<Outer>,
|
||||||
) {
|
) {
|
||||||
this.state$ = plaintext.state$;
|
this.state$ = plaintext.state$;
|
||||||
this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state]));
|
this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state]));
|
||||||
@@ -61,10 +68,10 @@ export class SecretState<Plaintext extends object, Disclosed>
|
|||||||
* updates after the secret has been recorded to state storage.
|
* updates after the secret has been recorded to state storage.
|
||||||
* @returns `undefined` when the account is locked.
|
* @returns `undefined` when the account is locked.
|
||||||
*/
|
*/
|
||||||
readonly state$: Observable<Plaintext>;
|
readonly state$: Observable<Outer>;
|
||||||
|
|
||||||
/** {@link SingleUserState.combinedState$} */
|
/** {@link SingleUserState.combinedState$} */
|
||||||
readonly combinedState$: Observable<CombinedState<Plaintext>>;
|
readonly combinedState$: Observable<CombinedState<Outer>>;
|
||||||
|
|
||||||
/** Creates a secret state bound to an account encryptor. The account must be unlocked
|
/** Creates a secret state bound to an account encryptor. The account must be unlocked
|
||||||
* when this method is called.
|
* when this method is called.
|
||||||
@@ -78,24 +85,28 @@ export class SecretState<Plaintext extends object, Disclosed>
|
|||||||
* encrypted, and stored in a `secret` property. Disclosed-classification data is stored
|
* encrypted, and stored in a `secret` property. Disclosed-classification data is stored
|
||||||
* in a `disclosed` property. Omitted-classification data is not stored.
|
* in a `disclosed` property. Omitted-classification data is not stored.
|
||||||
*/
|
*/
|
||||||
static from<TFrom extends object, Disclosed>(
|
static from<Outer, Id, TFrom extends object, Disclosed, Secret>(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
key: KeyDefinition<TFrom>,
|
key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>,
|
||||||
provider: StateProvider,
|
provider: StateProvider,
|
||||||
encryptor: UserEncryptor<TFrom, Disclosed>,
|
encryptor: UserEncryptor<Secret>,
|
||||||
) {
|
) {
|
||||||
// construct encrypted backing store while avoiding collisions between the derived key and the
|
// construct encrypted backing store while avoiding collisions between the derived key and the
|
||||||
// backing storage key.
|
// backing storage key.
|
||||||
const secretKey = new KeyDefinition<ClassifiedFormat<Disclosed>>(key.stateDefinition, key.key, {
|
const secretKey = new KeyDefinition<ClassifiedFormat<Id, Disclosed>[]>(
|
||||||
cleanupDelayMs: key.cleanupDelayMs,
|
key.stateDefinition,
|
||||||
// FIXME: When the fakes run deserializers and serialization can be guaranteed through
|
key.key,
|
||||||
// state providers, decode `jsonValue.secret` instead of it running in `derive`.
|
{
|
||||||
deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Disclosed>,
|
cleanupDelayMs: key.options.cleanupDelayMs,
|
||||||
});
|
// FIXME: When the fakes run deserializers and serialization can be guaranteed through
|
||||||
|
// state providers, decode `jsonValue.secret` instead of it running in `derive`.
|
||||||
|
deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Id, Disclosed>[],
|
||||||
|
},
|
||||||
|
);
|
||||||
const encryptedState = provider.getUser(userId, secretKey);
|
const encryptedState = provider.getUser(userId, secretKey);
|
||||||
|
|
||||||
// construct plaintext store
|
// construct plaintext store
|
||||||
const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Disclosed>, TFrom>(
|
const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Id, Disclosed>[], Outer>(
|
||||||
secretKey,
|
secretKey,
|
||||||
{
|
{
|
||||||
derive: async (from) => {
|
derive: async (from) => {
|
||||||
@@ -104,23 +115,38 @@ export class SecretState<Plaintext extends object, Disclosed>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise forward the decrypted data to the caller's derive implementation
|
// decrypt each item
|
||||||
const secret = EncString.fromJSON(from.secret);
|
const decryptTasks = from.map(async ({ id, secret, disclosed }) => {
|
||||||
const decrypted = await encryptor.decrypt(secret, from.disclosed, encryptedState.userId);
|
const encrypted = EncString.fromJSON(secret);
|
||||||
const result = key.deserializer(decrypted) as TFrom;
|
const decrypted = await encryptor.decrypt(encrypted, encryptedState.userId);
|
||||||
|
|
||||||
|
const declassified = key.classifier.declassify(disclosed, decrypted);
|
||||||
|
const result = key.options.deserializer(declassified);
|
||||||
|
|
||||||
|
return [id, result] as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
// reconstruct expected type
|
||||||
|
const results = await Promise.all(decryptTasks);
|
||||||
|
const result = key.reconstruct(results);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
// wire in the caller's deserializer for memory serialization
|
// wire in the caller's deserializer for memory serialization
|
||||||
deserializer: key.deserializer,
|
deserializer: (d) => {
|
||||||
|
const items = key.deconstruct(d);
|
||||||
|
const results = items.map(([k, v]) => [k, key.options.deserializer(v)] as const);
|
||||||
|
const result = key.reconstruct(results);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
// cache the decrypted data in memory
|
// cache the decrypted data in memory
|
||||||
cleanupDelayMs: key.cleanupDelayMs,
|
cleanupDelayMs: key.options.cleanupDelayMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null);
|
const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null);
|
||||||
|
|
||||||
// wrap the encrypted and plaintext states in a `SecretState` facade
|
// wrap the encrypted and plaintext states in a `SecretState` facade
|
||||||
const secretState = new SecretState(encryptor, encryptedState, plaintextState);
|
const secretState = new SecretState(key, encryptor, encryptedState, plaintextState);
|
||||||
return secretState;
|
return secretState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +164,9 @@ export class SecretState<Plaintext extends object, Disclosed>
|
|||||||
* they can be lost when the secret state updates its backing store.
|
* they can be lost when the secret state updates its backing store.
|
||||||
*/
|
*/
|
||||||
async update<TCombine>(
|
async update<TCombine>(
|
||||||
configureState: (state: Plaintext, dependencies: TCombine) => Plaintext,
|
configureState: (state: Outer, dependencies: TCombine) => Outer,
|
||||||
options: StateUpdateOptions<Plaintext, TCombine> = null,
|
options: StateUpdateOptions<Outer, TCombine> = null,
|
||||||
): Promise<Plaintext> {
|
): Promise<Outer> {
|
||||||
// reactively grab the latest state from the caller. `zip` requires each
|
// reactively grab the latest state from the caller. `zip` requires each
|
||||||
// observable has a value, so `combined$` provides a default if necessary.
|
// observable has a value, so `combined$` provides a default if necessary.
|
||||||
const combined$ = options?.combineLatestWith ?? of(undefined);
|
const combined$ = options?.combineLatestWith ?? of(undefined);
|
||||||
@@ -155,7 +181,7 @@ export class SecretState<Plaintext extends object, Disclosed>
|
|||||||
);
|
);
|
||||||
|
|
||||||
// update the backing store
|
// update the backing store
|
||||||
let latestValue: Plaintext = null;
|
let latestValue: Outer = null;
|
||||||
await this.encrypted.update((_, [, newStoredState]) => newStoredState, {
|
await this.encrypted.update((_, [, newStoredState]) => newStoredState, {
|
||||||
combineLatestWith: newState$,
|
combineLatestWith: newState$,
|
||||||
shouldUpdate: (_, [shouldUpdate, , newState]) => {
|
shouldUpdate: (_, [shouldUpdate, , newState]) => {
|
||||||
@@ -171,10 +197,10 @@ export class SecretState<Plaintext extends object, Disclosed>
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async prepareCryptoState(
|
private async prepareCryptoState(
|
||||||
currentState: Plaintext,
|
currentState: Outer,
|
||||||
shouldUpdate: () => boolean,
|
shouldUpdate: () => boolean,
|
||||||
configureState: () => Plaintext,
|
configureState: () => Outer,
|
||||||
): Promise<[boolean, ClassifiedFormat<Disclosed>, Plaintext]> {
|
): Promise<[boolean, ClassifiedFormat<Id, Disclosed>[], Outer]> {
|
||||||
// determine whether an update is necessary
|
// determine whether an update is necessary
|
||||||
if (!shouldUpdate()) {
|
if (!shouldUpdate()) {
|
||||||
return [false, undefined, currentState];
|
return [false, undefined, currentState];
|
||||||
@@ -186,18 +212,25 @@ export class SecretState<Plaintext extends object, Disclosed>
|
|||||||
return [true, newState as any, newState];
|
return [true, newState as any, newState];
|
||||||
}
|
}
|
||||||
|
|
||||||
// the encrypt format *is* the storage format, so if the shape of that data changes,
|
// convert the object to a list format so that all encrypt and decrypt
|
||||||
// this needs to map it explicitly for compatibility purposes.
|
// operations are self-similar
|
||||||
const newStoredState = await this.encryptor.encrypt(newState, this.encrypted.userId);
|
const desconstructed = this.key.deconstruct(newState);
|
||||||
|
|
||||||
// the deserializer in the plaintextState's `derive` configuration always runs, but
|
// encrypt each value individually
|
||||||
// `encryptedState` is not guaranteed to serialize the data, so it's necessary to
|
const encryptTasks = desconstructed.map(async ([id, state]) => {
|
||||||
// round-trip it proactively. This will cause some duplicate work in those situations
|
const classified = this.key.classifier.classify(state);
|
||||||
// where the backing store does deserialize the data.
|
const encrypted = await this.encryptor.encrypt(classified.secret, this.encrypted.userId);
|
||||||
//
|
|
||||||
// FIXME: Once there's a backing store configuration setting guaranteeing serialization,
|
// the deserializer in the plaintextState's `derive` configuration always runs, but
|
||||||
// remove this code and configure the backing store as appropriate.
|
// `encryptedState` is not guaranteed to serialize the data, so it's necessary to
|
||||||
const serializedState = JSON.parse(JSON.stringify(newStoredState));
|
// round-trip it proactively. This will cause some duplicate work in those situations
|
||||||
|
// where the backing store does deserialize the data.
|
||||||
|
const serialized = JSON.parse(
|
||||||
|
JSON.stringify({ id, secret: encrypted, disclosed: classified.disclosed }),
|
||||||
|
);
|
||||||
|
return serialized as ClassifiedFormat<Id, Disclosed>;
|
||||||
|
});
|
||||||
|
const serializedState = await Promise.all(encryptTasks);
|
||||||
|
|
||||||
return [true, serializedState, newState];
|
return [true, serializedState, newState];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { UserId } from "../../../types/guid";
|
|||||||
* user-specific information. The specific kind of information is
|
* user-specific information. The specific kind of information is
|
||||||
* determined by the classification strategy.
|
* determined by the classification strategy.
|
||||||
*/
|
*/
|
||||||
export abstract class UserEncryptor<State extends object, Disclosed> {
|
export abstract class UserEncryptor<Secret> {
|
||||||
/** Protects secrets in `value` with a user-specific key.
|
/** Protects secrets in `value` with a user-specific key.
|
||||||
* @param value the object to protect. This object is mutated during encryption.
|
* @param secret the object to protect. This object is mutated during encryption.
|
||||||
* @param userId identifies the user-specific information used to protect
|
* @param userId identifies the user-specific information used to protect
|
||||||
* the secret.
|
* the secret.
|
||||||
* @returns a promise that resolves to a tuple. The tuple's first property contains
|
* @returns a promise that resolves to a tuple. The tuple's first property contains
|
||||||
@@ -17,15 +17,11 @@ export abstract class UserEncryptor<State extends object, Disclosed> {
|
|||||||
* properties.
|
* properties.
|
||||||
* @throws If `value` is `null` or `undefined`, the promise rejects with an error.
|
* @throws If `value` is `null` or `undefined`, the promise rejects with an error.
|
||||||
*/
|
*/
|
||||||
abstract encrypt(
|
abstract encrypt(secret: Secret, userId: UserId): Promise<EncString>;
|
||||||
value: State,
|
|
||||||
userId: UserId,
|
|
||||||
): Promise<{ secret: EncString; disclosed: Disclosed }>;
|
|
||||||
|
|
||||||
/** Combines protected secrets and disclosed data into a type that can be
|
/** Combines protected secrets and disclosed data into a type that can be
|
||||||
* rehydrated into a domain object.
|
* rehydrated into a domain object.
|
||||||
* @param secret an encrypted JSON payload containing State's secrets.
|
* @param secret an encrypted JSON payload containing encrypted secrets.
|
||||||
* @param disclosed a data object containing State's disclosed properties.
|
|
||||||
* @param userId identifies the user-specific information used to protect
|
* @param userId identifies the user-specific information used to protect
|
||||||
* the secret.
|
* the secret.
|
||||||
* @returns a promise that resolves to the raw state. This state *is not* a
|
* @returns a promise that resolves to the raw state. This state *is not* a
|
||||||
@@ -34,9 +30,5 @@ export abstract class UserEncryptor<State extends object, Disclosed> {
|
|||||||
* @throws If `secret` or `disclosed` is `null` or `undefined`, the promise
|
* @throws If `secret` or `disclosed` is `null` or `undefined`, the promise
|
||||||
* rejects with an error.
|
* rejects with an error.
|
||||||
*/
|
*/
|
||||||
abstract decrypt(
|
abstract decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>;
|
||||||
secret: EncString,
|
|
||||||
disclosed: Jsonify<Disclosed>,
|
|
||||||
userId: UserId,
|
|
||||||
): Promise<Jsonify<State>>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { UserId } from "../../../types/guid";
|
|||||||
import { UserKey } from "../../../types/key";
|
import { UserKey } from "../../../types/key";
|
||||||
|
|
||||||
import { DataPacker } from "./data-packer.abstraction";
|
import { DataPacker } from "./data-packer.abstraction";
|
||||||
import { SecretClassifier } from "./secret-classifier";
|
|
||||||
import { UserKeyEncryptor } from "./user-key-encryptor";
|
import { UserKeyEncryptor } from "./user-key-encryptor";
|
||||||
|
|
||||||
describe("UserKeyEncryptor", () => {
|
describe("UserKeyEncryptor", () => {
|
||||||
@@ -38,20 +37,18 @@ describe("UserKeyEncryptor", () => {
|
|||||||
|
|
||||||
describe("encrypt", () => {
|
describe("encrypt", () => {
|
||||||
it("should throw if value was not supplied", async () => {
|
it("should throw if value was not supplied", async () => {
|
||||||
const classifier = SecretClassifier.allSecret<object>();
|
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
|
|
||||||
|
|
||||||
await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow(
|
await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow(
|
||||||
"value cannot be null or undefined",
|
"secret cannot be null or undefined",
|
||||||
);
|
);
|
||||||
await expect(encryptor.encrypt(undefined, anyUserId)).rejects.toThrow(
|
await expect(encryptor.encrypt(undefined, anyUserId)).rejects.toThrow(
|
||||||
"value cannot be null or undefined",
|
"secret cannot be null or undefined",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw if userId was not supplied", async () => {
|
it("should throw if userId was not supplied", async () => {
|
||||||
const classifier = SecretClassifier.allSecret<object>();
|
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
|
|
||||||
|
|
||||||
await expect(encryptor.encrypt({} as any, null)).rejects.toThrow(
|
await expect(encryptor.encrypt({} as any, null)).rejects.toThrow(
|
||||||
"userId cannot be null or undefined",
|
"userId cannot be null or undefined",
|
||||||
@@ -61,80 +58,54 @@ describe("UserKeyEncryptor", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should classify data into a disclosed value and an encrypted packed value using the user's key", async () => {
|
it("should encrypt a packed value using the user's key", async () => {
|
||||||
const classifier = SecretClassifier.allSecret<object>();
|
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
|
||||||
const classifierClassify = jest.spyOn(classifier, "classify");
|
|
||||||
const disclosed = {} as any;
|
|
||||||
const secret = {} as any;
|
|
||||||
classifierClassify.mockReturnValue({ disclosed, secret });
|
|
||||||
|
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
|
|
||||||
const value = { foo: true };
|
const value = { foo: true };
|
||||||
|
|
||||||
const result = await encryptor.encrypt(value, anyUserId);
|
const result = await encryptor.encrypt(value, anyUserId);
|
||||||
|
|
||||||
expect(classifierClassify).toHaveBeenCalledWith(value);
|
// these are data flow expectations; the operations all all pass-through mocks
|
||||||
expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
|
expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
|
||||||
expect(dataPacker.pack).toHaveBeenCalledWith(secret);
|
expect(dataPacker.pack).toHaveBeenCalledWith(value);
|
||||||
expect(encryptService.encrypt).toHaveBeenCalledWith(secret, userKey);
|
expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey);
|
||||||
expect(result.secret).toBe(secret);
|
expect(result).toBe(value);
|
||||||
expect(result.disclosed).toBe(disclosed);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("decrypt", () => {
|
describe("decrypt", () => {
|
||||||
it("should throw if secret was not supplied", async () => {
|
it("should throw if secret was not supplied", async () => {
|
||||||
const classifier = SecretClassifier.allSecret<object>();
|
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
|
|
||||||
|
|
||||||
await expect(encryptor.decrypt(null, {} as any, anyUserId)).rejects.toThrow(
|
await expect(encryptor.decrypt(null, anyUserId)).rejects.toThrow(
|
||||||
"secret cannot be null or undefined",
|
"secret cannot be null or undefined",
|
||||||
);
|
);
|
||||||
await expect(encryptor.decrypt(undefined, {} as any, anyUserId)).rejects.toThrow(
|
await expect(encryptor.decrypt(undefined, anyUserId)).rejects.toThrow(
|
||||||
"secret cannot be null or undefined",
|
"secret cannot be null or undefined",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw if disclosed was not supplied", async () => {
|
|
||||||
const classifier = SecretClassifier.allSecret<object>();
|
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
|
|
||||||
|
|
||||||
await expect(encryptor.decrypt({} as any, null, anyUserId)).rejects.toThrow(
|
|
||||||
"disclosed cannot be null or undefined",
|
|
||||||
);
|
|
||||||
await expect(encryptor.decrypt({} as any, undefined, anyUserId)).rejects.toThrow(
|
|
||||||
"disclosed cannot be null or undefined",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw if userId was not supplied", async () => {
|
it("should throw if userId was not supplied", async () => {
|
||||||
const classifier = SecretClassifier.allSecret<object>();
|
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
|
|
||||||
|
|
||||||
await expect(encryptor.decrypt({} as any, {} as any, null)).rejects.toThrow(
|
await expect(encryptor.decrypt({} as any, null)).rejects.toThrow(
|
||||||
"userId cannot be null or undefined",
|
"userId cannot be null or undefined",
|
||||||
);
|
);
|
||||||
await expect(encryptor.decrypt({} as any, {} as any, undefined)).rejects.toThrow(
|
await expect(encryptor.decrypt({} as any, undefined)).rejects.toThrow(
|
||||||
"userId cannot be null or undefined",
|
"userId cannot be null or undefined",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should declassify a decrypted packed value using the user's key", async () => {
|
it("should declassify a decrypted packed value using the user's key", async () => {
|
||||||
const classifier = SecretClassifier.allSecret<object>();
|
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
|
||||||
const classifierDeclassify = jest.spyOn(classifier, "declassify");
|
|
||||||
const declassified = {} as any;
|
|
||||||
classifierDeclassify.mockReturnValue(declassified);
|
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
|
|
||||||
const secret = "encrypted" as any;
|
const secret = "encrypted" as any;
|
||||||
const disclosed = {} as any;
|
|
||||||
|
|
||||||
const result = await encryptor.decrypt(secret, disclosed, anyUserId);
|
const result = await encryptor.decrypt(secret, anyUserId);
|
||||||
|
|
||||||
|
// these are data flow expectations; the operations all all pass-through mocks
|
||||||
expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
|
expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
|
||||||
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey);
|
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey);
|
||||||
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
|
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
|
||||||
expect(classifierDeclassify).toHaveBeenCalledWith(disclosed, secret);
|
expect(result).toBe(secret);
|
||||||
expect(result).toBe(declassified);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,59 +6,44 @@ import { EncString } from "../../../platform/models/domain/enc-string";
|
|||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
|
|
||||||
import { DataPacker } from "./data-packer.abstraction";
|
import { DataPacker } from "./data-packer.abstraction";
|
||||||
import { SecretClassifier } from "./secret-classifier";
|
|
||||||
import { UserEncryptor } from "./user-encryptor.abstraction";
|
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||||
|
|
||||||
/** A classification strategy that protects a type's secrets by encrypting them
|
/** A classification strategy that protects a type's secrets by encrypting them
|
||||||
* with a `UserKey`
|
* with a `UserKey`
|
||||||
*/
|
*/
|
||||||
export class UserKeyEncryptor<State extends object, Disclosed, Secret> extends UserEncryptor<
|
export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> {
|
||||||
State,
|
|
||||||
Disclosed
|
|
||||||
> {
|
|
||||||
/** Instantiates the encryptor
|
/** Instantiates the encryptor
|
||||||
* @param encryptService protects properties of `Secret`.
|
* @param encryptService protects properties of `Secret`.
|
||||||
* @param keyService looks up the user key when protecting data.
|
* @param keyService looks up the user key when protecting data.
|
||||||
* @param classifier partitions secrets and disclosed information.
|
|
||||||
* @param dataPacker packs and unpacks data classified as secrets.
|
* @param dataPacker packs and unpacks data classified as secrets.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly encryptService: EncryptService,
|
private readonly encryptService: EncryptService,
|
||||||
private readonly keyService: CryptoService,
|
private readonly keyService: CryptoService,
|
||||||
private readonly classifier: SecretClassifier<State, Disclosed, Secret>,
|
|
||||||
private readonly dataPacker: DataPacker,
|
private readonly dataPacker: DataPacker,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@link UserEncryptor.encrypt} */
|
/** {@link UserEncryptor.encrypt} */
|
||||||
async encrypt(
|
async encrypt(secret: Secret, userId: UserId): Promise<EncString> {
|
||||||
value: State,
|
this.assertHasValue("secret", secret);
|
||||||
userId: UserId,
|
|
||||||
): Promise<{ secret: EncString; disclosed: Disclosed }> {
|
|
||||||
this.assertHasValue("value", value);
|
|
||||||
this.assertHasValue("userId", userId);
|
this.assertHasValue("userId", userId);
|
||||||
|
|
||||||
const classified = this.classifier.classify(value);
|
let packed = this.dataPacker.pack(secret);
|
||||||
let packed = this.dataPacker.pack(classified.secret);
|
|
||||||
|
|
||||||
// encrypt the data and drop the key
|
// encrypt the data and drop the key
|
||||||
let key = await this.keyService.getUserKey(userId);
|
let key = await this.keyService.getUserKey(userId);
|
||||||
const secret = await this.encryptService.encrypt(packed, key);
|
const encrypted = await this.encryptService.encrypt(packed, key);
|
||||||
packed = null;
|
packed = null;
|
||||||
key = null;
|
key = null;
|
||||||
|
|
||||||
return { ...classified, secret };
|
return encrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@link UserEncryptor.decrypt} */
|
/** {@link UserEncryptor.decrypt} */
|
||||||
async decrypt(
|
async decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> {
|
||||||
secret: EncString,
|
|
||||||
disclosed: Jsonify<Disclosed>,
|
|
||||||
userId: UserId,
|
|
||||||
): Promise<Jsonify<State>> {
|
|
||||||
this.assertHasValue("secret", secret);
|
this.assertHasValue("secret", secret);
|
||||||
this.assertHasValue("disclosed", disclosed);
|
|
||||||
this.assertHasValue("userId", userId);
|
this.assertHasValue("userId", userId);
|
||||||
|
|
||||||
// decrypt the data and drop the key
|
// decrypt the data and drop the key
|
||||||
@@ -70,9 +55,7 @@ export class UserKeyEncryptor<State extends object, Disclosed, Secret> extends U
|
|||||||
const unpacked = this.dataPacker.unpack<Secret>(decrypted);
|
const unpacked = this.dataPacker.unpack<Secret>(decrypted);
|
||||||
decrypted = null;
|
decrypted = null;
|
||||||
|
|
||||||
const jsonValue = this.classifier.declassify(disclosed, unpacked);
|
return unpacked;
|
||||||
|
|
||||||
return jsonValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertHasValue(name: string, value: any) {
|
private assertHasValue(name: string, value: any) {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { PolicyType } from "../../../admin-console/enums";
|
|||||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||||
import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
||||||
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
||||||
import { KeyDefinition, StateProvider } from "../../../platform/state";
|
import { KeyDefinition, SingleUserState, StateProvider } from "../../../platform/state";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
import { GeneratorStrategy } from "../abstractions";
|
import { GeneratorStrategy } from "../abstractions";
|
||||||
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
|
||||||
import { NoPolicy } from "../no-policy";
|
import { NoPolicy } from "../no-policy";
|
||||||
import { PaddedDataPacker } from "../state/padded-data-packer";
|
import { PaddedDataPacker } from "../state/padded-data-packer";
|
||||||
import { SecretClassifier } from "../state/secret-classifier";
|
import { SecretClassifier } from "../state/secret-classifier";
|
||||||
|
import { SecretKeyDefinition } from "../state/secret-key-definition";
|
||||||
import { SecretState } from "../state/secret-state";
|
import { SecretState } from "../state/secret-state";
|
||||||
import { UserKeyEncryptor } from "../state/user-key-encryptor";
|
import { UserKeyEncryptor } from "../state/user-key-encryptor";
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export abstract class ForwarderGeneratorStrategy<
|
|||||||
this.cache_ms = ONE_MINUTE;
|
this.cache_ms = ONE_MINUTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
private durableStates = new Map<UserId, SecretState<Options, Record<string, never>>>();
|
private durableStates = new Map<UserId, SingleUserState<Options>>();
|
||||||
|
|
||||||
/** {@link GeneratorStrategy.durableState} */
|
/** {@link GeneratorStrategy.durableState} */
|
||||||
durableState = (userId: UserId) => {
|
durableState = (userId: UserId) => {
|
||||||
@@ -47,7 +48,24 @@ export abstract class ForwarderGeneratorStrategy<
|
|||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
const encryptor = this.createEncryptor();
|
const encryptor = this.createEncryptor();
|
||||||
state = SecretState.from(userId, this.key, this.stateProvider, encryptor);
|
// always exclude request properties
|
||||||
|
const classifier = SecretClassifier.allSecret<Options>().exclude("website");
|
||||||
|
|
||||||
|
// Derive the secret key definition
|
||||||
|
const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, {
|
||||||
|
deserializer: (d) => this.key.deserializer(d),
|
||||||
|
cleanupDelayMs: this.key.cleanupDelayMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// the type parameter is explicit because type inference fails for `Omit<Options, "website">`
|
||||||
|
state = SecretState.from<
|
||||||
|
Options,
|
||||||
|
void,
|
||||||
|
Options,
|
||||||
|
Record<keyof Options, never>,
|
||||||
|
Omit<Options, "website">
|
||||||
|
>(userId, key, this.stateProvider, encryptor);
|
||||||
|
|
||||||
this.durableStates.set(userId, state);
|
this.durableStates.set(userId, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +73,9 @@ export abstract class ForwarderGeneratorStrategy<
|
|||||||
};
|
};
|
||||||
|
|
||||||
private createEncryptor() {
|
private createEncryptor() {
|
||||||
// always exclude request properties
|
|
||||||
const classifier = SecretClassifier.allSecret<Options>().exclude("website");
|
|
||||||
|
|
||||||
// construct the encryptor
|
// construct the encryptor
|
||||||
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
|
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
|
||||||
return new UserKeyEncryptor(this.encryptService, this.keyService, classifier, packer);
|
return new UserKeyEncryptor(this.encryptService, this.keyService, packer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Determine where forwarder configuration is stored */
|
/** Determine where forwarder configuration is stored */
|
||||||
|
|||||||
Reference in New Issue
Block a user