mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-11 14:48:46 +01:00
Merge branch 'main' into vault/pm-5273
This commit is contained in:
commit
b17239595d
@ -9,7 +9,10 @@ module.exports = {
|
||||
...sharedConfig,
|
||||
preset: "jest-preset-angular",
|
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/",
|
||||
}),
|
||||
moduleNameMapper: pathsToModuleNameMapper(
|
||||
{ "@bitwarden/common/spec": ["../../libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
|
||||
{
|
||||
prefix: "<rootDir>/",
|
||||
},
|
||||
),
|
||||
};
|
||||
|
@ -2708,11 +2708,11 @@
|
||||
"launchDuoAndFollowStepsToFinishLoggingIn": {
|
||||
"message": "Launch Duo and follow the steps to finish logging in."
|
||||
},
|
||||
"duoRequiredByOrgForAccount": {
|
||||
"duoRequiredForAccount": {
|
||||
"message": "Duo two-step login is required for your account."
|
||||
},
|
||||
"openExtensionInNewWindowToCompleteLogin": {
|
||||
"message": "Open the extension in a new window to complete login"
|
||||
"popoutTheExtensionToCompleteLogin": {
|
||||
"message": "Popout the extension to complete login."
|
||||
},
|
||||
"popoutExtension": {
|
||||
"message": "Popout extension"
|
||||
|
@ -112,15 +112,12 @@
|
||||
<!-- Duo -->
|
||||
<ng-container *ngIf="isDuoProvider">
|
||||
<div *ngIf="duoFrameless" class="tw-my-4">
|
||||
<p
|
||||
*ngIf="selectedProviderType === providerType.OrganizationDuo"
|
||||
class="tw-mb-0 tw-text-center"
|
||||
>
|
||||
{{ "duoRequiredByOrgForAccount" | i18n }}
|
||||
<p class="tw-mb-0 tw-text-center">
|
||||
{{ "duoRequiredForAccount" | i18n }}
|
||||
</p>
|
||||
|
||||
<p class="tw-text-center" *ngIf="!inPopout">
|
||||
{{ "openExtensionInNewWindowToCompleteLogin" | i18n }}
|
||||
{{ "popoutTheExtensionToCompleteLogin" | i18n }}
|
||||
</p>
|
||||
|
||||
<ng-container *ngIf="inPopout">
|
||||
|
@ -208,7 +208,19 @@ describe("AutofillInit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the isCurrentlyFilling properties of the overlay and focus the recent field after filling", async () => {
|
||||
it("removes the overlay when filling the form", async () => {
|
||||
const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay");
|
||||
sendExtensionRuntimeMessage({
|
||||
command: "fillForm",
|
||||
fillScript,
|
||||
pageDetailsUrl: window.location.href,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(blurAndRemoveOverlaySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling");
|
||||
jest
|
||||
@ -228,9 +240,6 @@ describe("AutofillInit", () => {
|
||||
fillScript,
|
||||
);
|
||||
expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false);
|
||||
expect(
|
||||
autofillInit["autofillOverlayContentService"].focusMostRecentOverlayField,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => {
|
||||
|
@ -98,6 +98,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
return;
|
||||
}
|
||||
|
||||
this.blurAndRemoveOverlay();
|
||||
this.updateOverlayIsCurrentlyFilling(true);
|
||||
await this.insertAutofillContentService.fillForm(fillScript);
|
||||
|
||||
@ -105,10 +106,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.updateOverlayIsCurrentlyFilling(false);
|
||||
this.autofillOverlayContentService.focusMostRecentOverlayField();
|
||||
}, 250);
|
||||
setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -757,7 +757,7 @@ export default class MainBackground {
|
||||
this.platformUtilsService.isSafari() ||
|
||||
this.platformUtilsService.isFirefox() ||
|
||||
this.platformUtilsService.isOpera();
|
||||
BrowserApi.reloadExtension(forceWindowReload ? window : null);
|
||||
BrowserApi.reloadExtension(forceWindowReload ? self : null);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
|
@ -185,6 +185,31 @@ describe("BrowserApi", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadExtension", () => {
|
||||
it("reloads the window location if the passed globalContext is for the window", () => {
|
||||
const windowMock = mock<Window>({
|
||||
location: { reload: jest.fn() },
|
||||
}) as unknown as Window & typeof globalThis;
|
||||
|
||||
BrowserApi.reloadExtension(windowMock);
|
||||
|
||||
expect(windowMock.location.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads the extension runtime if the passed globalContext is not for the window", () => {
|
||||
const globalMock = mock<typeof globalThis>({}) as any;
|
||||
BrowserApi.reloadExtension(globalMock);
|
||||
|
||||
expect(chrome.runtime.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads the extension runtime if a null value is passed as the globalContext", () => {
|
||||
BrowserApi.reloadExtension(null);
|
||||
|
||||
expect(chrome.runtime.reload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadOpenWindows", () => {
|
||||
const href = window.location.href;
|
||||
const reload = window.location.reload;
|
||||
|
@ -381,12 +381,20 @@ export class BrowserApi {
|
||||
return chrome.i18n.getUILanguage();
|
||||
}
|
||||
|
||||
static reloadExtension(win: Window) {
|
||||
if (win != null) {
|
||||
return (win.location as any).reload(true);
|
||||
} else {
|
||||
return chrome.runtime.reload();
|
||||
/**
|
||||
* Handles reloading the extension, either by calling the window location
|
||||
* to reload or by calling the extension's runtime to reload.
|
||||
*
|
||||
* @param globalContext - The global context to use for the reload.
|
||||
*/
|
||||
static reloadExtension(globalContext: (Window & typeof globalThis) | null) {
|
||||
// The passed globalContext might be a ServiceWorkerGlobalScope, as a result
|
||||
// we need to check if the location object exists before calling reload on it.
|
||||
if (typeof globalContext?.location?.reload === "function") {
|
||||
return (globalContext as any).location.reload(true);
|
||||
}
|
||||
|
||||
return chrome.runtime.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CryptoService } from "@bitwarden/common/platform/services/crypto.service";
|
||||
import { USER_KEY } from "@bitwarden/common/platform/services/key-state/user-key.state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
@ -29,9 +30,9 @@ export class BrowserCryptoService extends CryptoService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userKey = await this.stateService.getUserKey({ userId: userId });
|
||||
const userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
|
||||
if (userKey) {
|
||||
return new SymmetricCryptoKey(Utils.fromB64ToArray(userKey.keyB64)) as UserKey;
|
||||
return userKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
@ -11,8 +10,10 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import { State } from "@bitwarden/common/platform/models/domain/state";
|
||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Account } from "../../models/account";
|
||||
import { BrowserComponentState } from "../../models/browserComponentState";
|
||||
@ -30,12 +31,12 @@ describe("Browser State Service", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateFactory: MockProxy<StateFactory<GlobalState, Account>>;
|
||||
let useAccountCache: boolean;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let migrationRunner: MockProxy<MigrationRunner>;
|
||||
|
||||
let state: State<GlobalState, Account>;
|
||||
const userId = "userId";
|
||||
const userId = "userId" as UserId;
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
|
||||
let sut: BrowserStateService;
|
||||
|
||||
@ -44,7 +45,6 @@ describe("Browser State Service", () => {
|
||||
diskStorageService = mock();
|
||||
logService = mock();
|
||||
stateFactory = mock();
|
||||
accountService = mock();
|
||||
environmentService = mock();
|
||||
migrationRunner = mock();
|
||||
// turn off account cache for tests
|
||||
@ -57,6 +57,10 @@ describe("Browser State Service", () => {
|
||||
state.activeUserId = userId;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("state methods", () => {
|
||||
let memoryStorageService: MockProxy<AbstractMemoryStorageService>;
|
||||
|
||||
|
@ -34,8 +34,6 @@ export class BrowserStateService
|
||||
protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>;
|
||||
@sessionSync({ initializer: (s: string) => s })
|
||||
protected activeAccountSubject: BehaviorSubject<string>;
|
||||
@sessionSync({ initializer: (b: boolean) => b })
|
||||
protected activeAccountUnlockedSubject: BehaviorSubject<boolean>;
|
||||
|
||||
protected accountDeserializer = Account.fromJSON;
|
||||
|
||||
|
@ -73,6 +73,7 @@ import { ConfigService } from "@bitwarden/common/platform/services/config/config
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||
import { DerivedStateProvider, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SearchService } from "@bitwarden/common/services/search.service";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
|
||||
@ -109,6 +110,7 @@ import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
|
||||
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
|
||||
import { BrowserConfigService } from "../../platform/services/browser-config.service";
|
||||
import { BrowserCryptoService } from "../../platform/services/browser-crypto.service";
|
||||
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
|
||||
import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service";
|
||||
import { BrowserI18nService } from "../../platform/services/browser-i18n.service";
|
||||
@ -210,7 +212,7 @@ function getBgService<T>(service: keyof MainBackground) {
|
||||
{ provide: CipherService, useFactory: getBgService<CipherService>("cipherService"), deps: [] },
|
||||
{
|
||||
provide: CryptoFunctionService,
|
||||
useFactory: getBgService<CryptoFunctionService>("cryptoFunctionService"),
|
||||
useFactory: () => new WebCryptoFunctionService(window),
|
||||
deps: [],
|
||||
},
|
||||
{
|
||||
@ -258,12 +260,36 @@ function getBgService<T>(service: keyof MainBackground) {
|
||||
},
|
||||
{
|
||||
provide: CryptoService,
|
||||
useFactory: (encryptService: EncryptService) => {
|
||||
const cryptoService = getBgService<CryptoService>("cryptoService")();
|
||||
useFactory: (
|
||||
cryptoFunctionService: CryptoFunctionService,
|
||||
encryptService: EncryptService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
logService: LogServiceAbstraction,
|
||||
stateService: StateServiceAbstraction,
|
||||
accountService: AccountServiceAbstraction,
|
||||
stateProvider: StateProvider,
|
||||
) => {
|
||||
const cryptoService = new BrowserCryptoService(
|
||||
cryptoFunctionService,
|
||||
encryptService,
|
||||
platformUtilsService,
|
||||
logService,
|
||||
stateService,
|
||||
accountService,
|
||||
stateProvider,
|
||||
);
|
||||
new ContainerService(cryptoService, encryptService).attachToGlobal(self);
|
||||
return cryptoService;
|
||||
},
|
||||
deps: [EncryptService],
|
||||
deps: [
|
||||
CryptoFunctionService,
|
||||
EncryptService,
|
||||
PlatformUtilsService,
|
||||
LogServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
AccountServiceAbstraction,
|
||||
StateProvider,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: AuthRequestCryptoServiceAbstraction,
|
||||
|
@ -30,6 +30,7 @@ const runtime = {
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
},
|
||||
reload: jest.fn(),
|
||||
};
|
||||
|
||||
const contextMenus = {
|
||||
|
@ -76,7 +76,17 @@
|
||||
{{ i.amount | currency: "$" }}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}
|
||||
<ng-container
|
||||
*ngIf="
|
||||
sub?.customerDiscount?.appliesTo?.includes(i.productId);
|
||||
else calculateElse
|
||||
"
|
||||
>
|
||||
{{ "freeForOneYear" | i18n }}
|
||||
</ng-container>
|
||||
<ng-template #calculateElse>
|
||||
{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}
|
||||
</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
|
@ -111,6 +111,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
|
||||
})
|
||||
.sort(sortSubscriptionItems);
|
||||
}
|
||||
|
||||
if (this.sub?.customerDiscount?.percentOff == 100) {
|
||||
this.lineItems.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeyResponse = await this.organizationApiService.getApiKeyInformation(
|
||||
@ -152,6 +156,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
|
||||
sponsoredSubscriptionItem: lineItem.sponsoredSubscriptionItem,
|
||||
addonSubscriptionItem: lineItem.addonSubscriptionItem,
|
||||
productName: lineItem.productName,
|
||||
productId: lineItem.productId,
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -1,19 +1,7 @@
|
||||
import { AfterContentInit, Directive, HostListener, Input } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
|
||||
import { SsoComponent } from "@bitwarden/angular/auth/components/sso.component";
|
||||
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
|
||||
|
||||
@Directive({
|
||||
selector: "[app-link-sso]",
|
||||
@ -21,6 +9,8 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/ge
|
||||
export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
|
||||
@Input() organization: Organization;
|
||||
returnUri = "/settings/organizations";
|
||||
redirectUri = window.location.origin + "/sso-connector.html";
|
||||
clientId = "web";
|
||||
|
||||
@HostListener("click", ["$event"])
|
||||
async onClick($event: MouseEvent) {
|
||||
@ -28,42 +18,6 @@ export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
|
||||
await this.submit(this.returnUri, true);
|
||||
}
|
||||
|
||||
constructor(
|
||||
ssoLoginService: SsoLoginServiceAbstraction,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
i18nService: I18nService,
|
||||
apiService: ApiService,
|
||||
loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
router: Router,
|
||||
route: ActivatedRoute,
|
||||
cryptoFunctionService: CryptoFunctionService,
|
||||
passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
stateService: StateService,
|
||||
environmentService: EnvironmentService,
|
||||
logService: LogService,
|
||||
configService: ConfigServiceAbstraction,
|
||||
) {
|
||||
super(
|
||||
ssoLoginService,
|
||||
loginStrategyService,
|
||||
router,
|
||||
i18nService,
|
||||
route,
|
||||
stateService,
|
||||
platformUtilsService,
|
||||
apiService,
|
||||
cryptoFunctionService,
|
||||
environmentService,
|
||||
passwordGenerationService,
|
||||
logService,
|
||||
configService,
|
||||
);
|
||||
|
||||
this.returnUri = "/settings/organizations";
|
||||
this.redirectUri = window.location.origin + "/sso-connector.html";
|
||||
this.clientId = "web";
|
||||
}
|
||||
|
||||
async ngAfterContentInit() {
|
||||
this.identifier = this.organization.identifier;
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="140" height="121" fill="none">
|
||||
const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="10 -10 120 140" fill="none">
|
||||
<rect class="tw-stroke-secondary-500" width="134" height="86" x="3" y="31.485" stroke-width="6" rx="11"/>
|
||||
<path class="tw-fill-secondary-500" d="M123.987 20.15H14.779a3.114 3.114 0 0 1-2.083-.95 3.036 3.036 0 0 1 0-4.208 3.125 3.125 0 0 1 2.083-.951h109.208c.792.043 1.536.38 2.083.95a3.035 3.035 0 0 1 0 4.208 3.115 3.115 0 0 1-2.083.95Zm-6.649-14.041h-95.91a3.114 3.114 0 0 1-2.082-.95 3.036 3.036 0 0 1-.848-2.105c0-.782.306-1.538.848-2.104A3.125 3.125 0 0 1 21.43 0h95.909c.791.043 1.535.38 2.082.95.547.57.849 1.322.849 2.104a3.05 3.05 0 0 1-.849 2.104 3.115 3.115 0 0 1-2.082.95ZM95.132 74.407A42.317 42.317 0 0 0 83.59 65.43l8.799-8.657a1.59 1.59 0 0 0 .004-2.27 1.641 1.641 0 0 0-2.298-.004l-9.64 9.479a28.017 28.017 0 0 0-10.483-2.13c-14.323 0-24.814 12.342-25.298 12.89a2.431 2.431 0 0 0-.675 1.64c-.01.612.215 1.203.626 1.66a43.981 43.981 0 0 0 11.873 9.485l-8.806 8.658a1.601 1.601 0 0 0-.499 1.138 1.602 1.602 0 0 0 1.008 1.5 1.651 1.651 0 0 0 1.255-.009c.199-.085.379-.205.528-.359l9.634-9.443a27.16 27.16 0 0 0 10.359 2.158c14.323 0 24.753-12.086 25.23-12.63a2.983 2.983 0 0 0-.078-4.128h.002ZM49.204 77.82a1.82 1.82 0 0 1-.43-.6 1.767 1.767 0 0 1-.152-.72 1.778 1.778 0 0 1 .582-1.32c3.857-3.564 11.782-9.686 20.77-9.676 2.564.037 5.105.508 7.508 1.395l-3.291 3.235a7.793 7.793 0 0 0-5.02-1.226 7.746 7.746 0 0 0-4.676 2.18 7.528 7.528 0 0 0-1 9.563l-4.199 4.143a43.135 43.135 0 0 1-10.092-6.974Zm26.059-1.318a5.19 5.19 0 0 1-1.557 3.68 5.326 5.326 0 0 1-3.733 1.521c-.82-.005-1.63-.2-2.359-.57l7.067-6.952c.377.718.575 1.513.582 2.321Zm-10.58 0a5.136 5.136 0 0 1 .673-2.555 5.204 5.204 0 0 1 1.862-1.897 5.302 5.302 0 0 1 5.172-.146l-7.096 6.977a5.06 5.06 0 0 1-.61-2.379Zm26.053 1.331c-3.857 3.56-11.779 9.677-20.763 9.677a22.723 22.723 0 0 1-7.454-1.369l3.292-3.226a7.793 7.793 0 0 0 4.995 1.192 7.734 7.734 0 0 0 4.642-2.176 7.524 7.524 0 0 0 1.033-9.506l4.224-4.168a43.258 43.258 0 0 1 10.02 6.945 1.788 1.788 0 0 1 .585 1.313 1.788 1.788 0 0 1-.577 1.318h.003Z"/>
|
||||
</svg>`;
|
||||
|
@ -7584,5 +7584,8 @@
|
||||
"tooExpensive": {
|
||||
"message": "Too expensive",
|
||||
"description": "An option for the offboarding survey shown when a user cancels their subscription."
|
||||
},
|
||||
"freeForOneYear": {
|
||||
"message": "Free for 1 year"
|
||||
}
|
||||
}
|
||||
|
@ -65,6 +65,10 @@ export class FakeAccountService implements AccountService {
|
||||
await this.mock.setAccountStatus(userId, status);
|
||||
}
|
||||
|
||||
async setMaxAccountStatus(userId: UserId, maxStatus: AuthenticationStatus): Promise<void> {
|
||||
await this.mock.setMaxAccountStatus(userId, maxStatus);
|
||||
}
|
||||
|
||||
async switchAccount(userId: UserId): Promise<void> {
|
||||
await this.mock.switchAccount(userId);
|
||||
}
|
||||
|
@ -140,7 +140,9 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
|
||||
}
|
||||
|
||||
export class FakeStateProvider implements StateProvider {
|
||||
mock = mock<StateProvider>();
|
||||
getUserState$<T>(keyDefinition: KeyDefinition<T>, userId?: UserId): Observable<T> {
|
||||
this.mock.getUserState$(keyDefinition, userId);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, keyDefinition).state$;
|
||||
}
|
||||
@ -152,6 +154,7 @@ export class FakeStateProvider implements StateProvider {
|
||||
value: T,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T]> {
|
||||
await this.mock.setUserState(keyDefinition, value, userId);
|
||||
if (userId) {
|
||||
return [userId, await this.getUser(userId, keyDefinition).update(() => value)];
|
||||
} else {
|
||||
|
@ -47,6 +47,17 @@ export abstract class AccountService {
|
||||
* @param status
|
||||
*/
|
||||
abstract setAccountStatus(userId: UserId, status: AuthenticationStatus): Promise<void>;
|
||||
/**
|
||||
* Updates the `accounts$` observable with the new account status if the current status is higher than the `maxStatus`.
|
||||
*
|
||||
* This method only downgrades status to the maximum value sent in, it will not increase authentication status.
|
||||
*
|
||||
* @example An account is transitioning from unlocked to logged out. If callbacks that set the status to locked occur
|
||||
* after it is updated to logged out, the account will be in the incorrect state.
|
||||
* @param userId The user id of the account to be updated.
|
||||
* @param maxStatus The new status of the account.
|
||||
*/
|
||||
abstract setMaxAccountStatus(userId: UserId, maxStatus: AuthenticationStatus): Promise<void>;
|
||||
/**
|
||||
* Updates the `activeAccount$` observable with the new active account.
|
||||
* @param userId
|
||||
|
@ -207,4 +207,26 @@ describe("accountService", () => {
|
||||
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMaxAccountStatus", () => {
|
||||
it("should update the account", async () => {
|
||||
accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||
await sut.setMaxAccountStatus(userId, AuthenticationStatus.Locked);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: userInfo(AuthenticationStatus.Locked),
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update if the new max status is higher than the current", async () => {
|
||||
accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.LoggedOut) });
|
||||
await sut.setMaxAccountStatus(userId, AuthenticationStatus.Locked);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: userInfo(AuthenticationStatus.LoggedOut),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -84,6 +84,24 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
async setMaxAccountStatus(userId: UserId, maxStatus: AuthenticationStatus): Promise<void> {
|
||||
await this.accountsState.update(
|
||||
(accounts) => {
|
||||
accounts[userId].status = maxStatus;
|
||||
return accounts;
|
||||
},
|
||||
{
|
||||
shouldUpdate: (accounts) => {
|
||||
if (accounts?.[userId] == null) {
|
||||
throw new Error("Account does not exist");
|
||||
}
|
||||
|
||||
return accounts[userId].status > maxStatus;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async switchAccount(userId: UserId): Promise<void> {
|
||||
await this.activeAccountIdState.update(
|
||||
(_, accounts) => {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
@ -108,7 +110,7 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
|
||||
}
|
||||
|
||||
// At this point of rotating their keys, they should still have their old user key in state
|
||||
const oldUserKey = await this.stateService.getUserKey();
|
||||
const oldUserKey = await firstValueFrom(this.cryptoService.activeUserKey$);
|
||||
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
const secretVerificationRequest = new SecretVerificationRequest();
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { matches, mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { DeviceType } from "../../enums";
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
@ -19,6 +20,7 @@ import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trus
|
||||
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
|
||||
|
||||
import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation";
|
||||
|
||||
describe("deviceTrustCryptoService", () => {
|
||||
let deviceTrustCryptoService: DeviceTrustCryptoService;
|
||||
|
||||
@ -495,6 +497,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
const fakeNewUserKeyData = new Uint8Array(64);
|
||||
fakeNewUserKeyData.fill(FakeNewUserKeyMarker, 0, 1);
|
||||
fakeNewUserKey = new SymmetricCryptoKey(fakeNewUserKeyData) as UserKey;
|
||||
cryptoService.activeUserKey$ = of(fakeNewUserKey);
|
||||
});
|
||||
|
||||
it("does an early exit when the current device is not a trusted device", async () => {
|
||||
@ -521,9 +524,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
fakeOldUserKeyData.fill(FakeOldUserKeyMarker, 0, 1);
|
||||
|
||||
// Mock the retrieval of a user key that differs from the new one passed into the method
|
||||
stateService.getUserKey.mockResolvedValue(
|
||||
new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey,
|
||||
);
|
||||
cryptoService.activeUserKey$ = of(new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey);
|
||||
|
||||
appIdService.getAppId.mockResolvedValue("test_device_identifier");
|
||||
|
||||
|
@ -12,10 +12,13 @@ import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class CryptoService {
|
||||
activeUserKey$: Observable<UserKey>;
|
||||
/**
|
||||
* Sets the provided user key and stores
|
||||
* any other necessary versions (such as auto, biometrics,
|
||||
* or pin)
|
||||
*
|
||||
* @throws when key is null. Use {@link clearUserKey} instead
|
||||
* @param key The user key to set
|
||||
* @param userId The desired user
|
||||
*/
|
||||
|
@ -16,7 +16,7 @@ import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, MasterKey, UserKey } from "../../types/key";
|
||||
import { DeviceKey, MasterKey } from "../../types/key";
|
||||
import { UriMatchType } from "../../vault/enums";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
import { LocalData } from "../../vault/models/data/local.data";
|
||||
@ -50,6 +50,9 @@ export type InitOptions = {
|
||||
export abstract class StateService<T extends Account = Account> {
|
||||
accounts$: Observable<{ [userId: string]: T }>;
|
||||
activeAccount$: Observable<string>;
|
||||
/**
|
||||
* @deprecated use accountService.activeAccount$ instead
|
||||
*/
|
||||
activeAccountUnlocked$: Observable<boolean>;
|
||||
|
||||
addAccount: (account: T) => Promise<void>;
|
||||
@ -82,14 +85,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setClearClipboard: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
|
||||
setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* gets the user key
|
||||
*/
|
||||
getUserKey: (options?: StorageOptions) => Promise<UserKey>;
|
||||
/**
|
||||
* Sets the user key
|
||||
*/
|
||||
setUserKey: (value: UserKey, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* Gets the user's master key
|
||||
*/
|
||||
@ -150,10 +145,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
* @deprecated For migration purposes only, use getUserKeyMasterKey instead
|
||||
*/
|
||||
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
|
||||
/**
|
||||
* @deprecated For migration purposes only, use setUserKeyMasterKey instead
|
||||
*/
|
||||
setEncryptedCryptoSymmetricKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated For legacy purposes only, use getMasterKey instead
|
||||
*/
|
||||
|
@ -19,7 +19,7 @@ import { UsernameGeneratorOptions } from "../../../tools/generator/username/user
|
||||
import { SendData } from "../../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../../tools/send/models/view/send.view";
|
||||
import { DeepJsonify } from "../../../types/deep-jsonify";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { MasterKey } from "../../../types/key";
|
||||
import { UriMatchType } from "../../../vault/enums";
|
||||
import { CipherData } from "../../../vault/models/data/cipher.data";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
@ -113,7 +113,6 @@ export class AccountData {
|
||||
}
|
||||
|
||||
export class AccountKeys {
|
||||
userKey?: UserKey;
|
||||
masterKey?: MasterKey;
|
||||
masterKeyEncryptedUserKey?: string;
|
||||
deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>;
|
||||
@ -146,7 +145,6 @@ export class AccountKeys {
|
||||
return null;
|
||||
}
|
||||
return Object.assign(new AccountKeys(), obj, {
|
||||
userKey: SymmetricCryptoKey.fromJSON(obj?.userKey),
|
||||
masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey),
|
||||
deviceKey: obj?.deviceKey,
|
||||
cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey),
|
||||
|
@ -4,6 +4,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserKey, MasterKey, PinKey } from "../../types/key";
|
||||
@ -17,7 +18,7 @@ import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { CryptoService } from "../services/crypto.service";
|
||||
|
||||
import { USER_EVER_HAD_USER_KEY } from "./key-state/user-key.state";
|
||||
import { USER_EVER_HAD_USER_KEY, USER_KEY } from "./key-state/user-key.state";
|
||||
|
||||
describe("cryptoService", () => {
|
||||
let cryptoService: CryptoService;
|
||||
@ -57,42 +58,50 @@ describe("cryptoService", () => {
|
||||
|
||||
describe("getUserKey", () => {
|
||||
let mockUserKey: UserKey;
|
||||
let stateSvcGetUserKey: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
});
|
||||
|
||||
stateSvcGetUserKey = jest.spyOn(stateService, "getUserKey");
|
||||
it("retrieves the key state of the requested user", async () => {
|
||||
await cryptoService.getUserKey(mockUserId);
|
||||
|
||||
expect(stateProvider.mock.getUserState$).toHaveBeenCalledWith(USER_KEY, mockUserId);
|
||||
});
|
||||
|
||||
it("returns the User Key if available", async () => {
|
||||
stateSvcGetUserKey.mockResolvedValue(mockUserKey);
|
||||
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey);
|
||||
|
||||
const userKey = await cryptoService.getUserKey(mockUserId);
|
||||
|
||||
expect(stateSvcGetUserKey).toHaveBeenCalledWith({ userId: mockUserId });
|
||||
expect(userKey).toEqual(mockUserKey);
|
||||
});
|
||||
|
||||
it("sets the Auto key if the User Key if not set", async () => {
|
||||
it("sets from the Auto key if the User Key if not set", async () => {
|
||||
const autoKeyB64 =
|
||||
"IT5cA1i5Hncd953pb00E58D2FqJX+fWTj4AvoI67qkGHSQPgulAqKv+LaKRAo9Bg0xzP9Nw00wk4TqjMmGSM+g==";
|
||||
stateService.getUserKeyAutoUnlock.mockResolvedValue(autoKeyB64);
|
||||
const setKeySpy = jest.spyOn(cryptoService, "setUserKey");
|
||||
|
||||
const userKey = await cryptoService.getUserKey(mockUserId);
|
||||
|
||||
expect(stateService.setUserKey).toHaveBeenCalledWith(expect.any(SymmetricCryptoKey), {
|
||||
userId: mockUserId,
|
||||
});
|
||||
expect(setKeySpy).toHaveBeenCalledWith(expect.any(SymmetricCryptoKey), mockUserId);
|
||||
expect(setKeySpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(userKey.keyB64).toEqual(autoKeyB64);
|
||||
});
|
||||
|
||||
it("returns nullish if there is no auto key and the user key is not set", async () => {
|
||||
const userKey = await cryptoService.getUserKey(mockUserId);
|
||||
|
||||
expect(userKey).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserKeyWithLegacySupport", () => {
|
||||
let mockUserKey: UserKey;
|
||||
let mockMasterKey: MasterKey;
|
||||
let stateSvcGetUserKey: jest.SpyInstance;
|
||||
let stateSvcGetMasterKey: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -100,23 +109,22 @@ describe("cryptoService", () => {
|
||||
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
|
||||
|
||||
stateSvcGetUserKey = jest.spyOn(stateService, "getUserKey");
|
||||
stateSvcGetMasterKey = jest.spyOn(stateService, "getMasterKey");
|
||||
});
|
||||
|
||||
it("returns the User Key if available", async () => {
|
||||
stateSvcGetUserKey.mockResolvedValue(mockUserKey);
|
||||
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey);
|
||||
const getKeySpy = jest.spyOn(cryptoService, "getUserKey");
|
||||
|
||||
const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId);
|
||||
|
||||
expect(stateSvcGetUserKey).toHaveBeenCalledWith({ userId: mockUserId });
|
||||
expect(getKeySpy).toHaveBeenCalledWith(mockUserId);
|
||||
expect(stateSvcGetMasterKey).not.toHaveBeenCalled();
|
||||
|
||||
expect(userKey).toEqual(mockUserKey);
|
||||
});
|
||||
|
||||
it("returns the user's master key when User Key is not available", async () => {
|
||||
stateSvcGetUserKey.mockResolvedValue(null);
|
||||
stateSvcGetMasterKey.mockResolvedValue(mockMasterKey);
|
||||
|
||||
const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId);
|
||||
@ -201,6 +209,19 @@ describe("cryptoService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("throws if key is null", async () => {
|
||||
await expect(cryptoService.setUserKey(null, mockUserId)).rejects.toThrow("No key provided.");
|
||||
});
|
||||
|
||||
it("should update the user's lock state", async () => {
|
||||
await cryptoService.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
expect(accountService.mock.setAccountStatus).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
AuthenticationStatus.Unlocked,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Pin Key refresh", () => {
|
||||
let cryptoSvcMakePinKey: jest.SpyInstance;
|
||||
const protectedPin =
|
||||
@ -259,4 +280,36 @@ describe("cryptoService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearUserKey", () => {
|
||||
it.each([mockUserId, null])("should clear the User Key for id %2", async (userId) => {
|
||||
await cryptoService.clearUserKey(false, userId);
|
||||
|
||||
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, userId);
|
||||
});
|
||||
|
||||
it("should update status to locked", async () => {
|
||||
await cryptoService.clearUserKey(false, mockUserId);
|
||||
|
||||
expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
AuthenticationStatus.Locked,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"should clear stored user keys if clearAll is true (%s)",
|
||||
async (clear) => {
|
||||
const clearSpy = (cryptoService["clearAllStoredUserKeys"] = jest.fn());
|
||||
await cryptoService.clearUserKey(clear, mockUserId);
|
||||
|
||||
if (clear) {
|
||||
expect(clearSpy).toHaveBeenCalledWith(mockUserId);
|
||||
expect(clearSpy).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
expect(clearSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -6,6 +6,7 @@ import { ProfileOrganizationResponse } from "../../admin-console/models/response
|
||||
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
|
||||
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { OrganizationId, ProviderId, UserId } from "../../types/guid";
|
||||
@ -52,9 +53,11 @@ import {
|
||||
USER_EVER_HAD_USER_KEY,
|
||||
USER_PRIVATE_KEY,
|
||||
USER_PUBLIC_KEY,
|
||||
USER_KEY,
|
||||
} from "./key-state/user-key.state";
|
||||
|
||||
export class CryptoService implements CryptoServiceAbstraction {
|
||||
private readonly activeUserKeyState: ActiveUserState<UserKey>;
|
||||
private readonly activeUserEverHadUserKey: ActiveUserState<boolean>;
|
||||
private readonly activeUserEncryptedOrgKeysState: ActiveUserState<
|
||||
Record<OrganizationId, EncryptedOrganizationKeyData>
|
||||
@ -68,6 +71,8 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
private readonly activeUserPrivateKeyState: DerivedState<UserPrivateKey>;
|
||||
private readonly activeUserPublicKeyState: DerivedState<UserPublicKey>;
|
||||
|
||||
readonly activeUserKey$: Observable<UserKey>;
|
||||
|
||||
readonly activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>;
|
||||
readonly activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>;
|
||||
readonly activeUserPrivateKey$: Observable<UserPrivateKey>;
|
||||
@ -84,6 +89,8 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
protected stateProvider: StateProvider,
|
||||
) {
|
||||
// User Key
|
||||
this.activeUserKeyState = stateProvider.getActive(USER_KEY);
|
||||
this.activeUserKey$ = this.activeUserKeyState.state$;
|
||||
this.activeUserEverHadUserKey = stateProvider.getActive(USER_EVER_HAD_USER_KEY);
|
||||
this.everHadUserKey$ = this.activeUserEverHadUserKey.state$.pipe(map((x) => x ?? false));
|
||||
|
||||
@ -131,13 +138,15 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
}
|
||||
|
||||
async setUserKey(key: UserKey, userId?: UserId): Promise<void> {
|
||||
// TODO: make this non-nullable in signature
|
||||
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (key != null) {
|
||||
// Key should never be null anyway
|
||||
await this.stateProvider.getUser(userId, USER_EVER_HAD_USER_KEY).update(() => true);
|
||||
if (key == null) {
|
||||
throw new Error("No key provided. Use ClearUserKey to clear the key");
|
||||
}
|
||||
await this.stateService.setUserKey(key, { userId: userId });
|
||||
// Set userId to ensure we have one for the account status update
|
||||
[userId, key] = await this.stateProvider.setUserState(USER_KEY, key, userId);
|
||||
await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, true, userId);
|
||||
|
||||
await this.accountService.setAccountStatus(userId, AuthenticationStatus.Unlocked);
|
||||
|
||||
await this.storeAdditionalKeys(key, userId);
|
||||
}
|
||||
|
||||
@ -147,7 +156,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
}
|
||||
|
||||
async getUserKey(userId?: UserId): Promise<UserKey> {
|
||||
let userKey = await this.stateService.getUserKey({ userId: userId });
|
||||
let userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
|
||||
if (userKey) {
|
||||
return userKey;
|
||||
}
|
||||
@ -197,7 +206,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
}
|
||||
|
||||
async hasUserKeyInMemory(userId?: UserId): Promise<boolean> {
|
||||
return (await this.stateService.getUserKey({ userId: userId })) != null;
|
||||
return (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId))) != null;
|
||||
}
|
||||
|
||||
async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
|
||||
@ -215,7 +224,9 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
}
|
||||
|
||||
async clearUserKey(clearStoredKeys = true, userId?: UserId): Promise<void> {
|
||||
await this.stateService.setUserKey(null, { userId: userId });
|
||||
// Set userId to ensure we have one for the account status update
|
||||
[userId] = await this.stateProvider.setUserState(USER_KEY, null, userId);
|
||||
await this.accountService.setMaxAccountStatus(userId, AuthenticationStatus.Locked);
|
||||
if (clearStoredKeys) {
|
||||
await this.clearAllStoredUserKeys(userId);
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { UserPrivateKey, UserPublicKey } from "../../../types/key";
|
||||
import { UserPrivateKey, UserPublicKey, UserKey } from "../../../types/key";
|
||||
import { CryptoFunctionService } from "../../abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { EncString, EncryptedString } from "../../models/domain/enc-string";
|
||||
import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
import { KeyDefinition, CRYPTO_DISK, DeriveDefinition, CRYPTO_MEMORY } from "../../state";
|
||||
import { CryptoService } from "../crypto.service";
|
||||
|
||||
export const USER_EVER_HAD_USER_KEY = new KeyDefinition<boolean>(CRYPTO_DISK, "everHadUserKey", {
|
||||
@ -57,3 +58,6 @@ export const USER_PUBLIC_KEY = DeriveDefinition.from<
|
||||
return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey;
|
||||
},
|
||||
});
|
||||
export const USER_KEY = new KeyDefinition<UserKey>(CRYPTO_MEMORY, "userKey", {
|
||||
deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey,
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { BehaviorSubject, concatMap } from "rxjs";
|
||||
import { BehaviorSubject, Observable, map } from "rxjs";
|
||||
import { Jsonify, JsonValue } from "type-fest";
|
||||
|
||||
import { OrganizationData } from "../../admin-console/models/data/organization.data";
|
||||
@ -20,7 +20,7 @@ import { UsernameGeneratorOptions } from "../../tools/generator/username";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, MasterKey, UserKey } from "../../types/key";
|
||||
import { DeviceKey, MasterKey } from "../../types/key";
|
||||
import { UriMatchType } from "../../vault/enums";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
import { LocalData } from "../../vault/models/data/local.data";
|
||||
@ -87,8 +87,7 @@ export class StateService<
|
||||
protected activeAccountSubject = new BehaviorSubject<string | null>(null);
|
||||
activeAccount$ = this.activeAccountSubject.asObservable();
|
||||
|
||||
protected activeAccountUnlockedSubject = new BehaviorSubject<boolean>(false);
|
||||
activeAccountUnlocked$ = this.activeAccountUnlockedSubject.asObservable();
|
||||
activeAccountUnlocked$: Observable<boolean>;
|
||||
|
||||
private hasBeenInited = false;
|
||||
protected isRecoveredSession = false;
|
||||
@ -109,22 +108,11 @@ export class StateService<
|
||||
private migrationRunner: MigrationRunner,
|
||||
protected useAccountCache: boolean = true,
|
||||
) {
|
||||
// If the account gets changed, verify the new account is unlocked
|
||||
this.activeAccountSubject
|
||||
.pipe(
|
||||
concatMap(async (userId) => {
|
||||
if (userId == null && this.activeAccountUnlockedSubject.getValue() == false) {
|
||||
return;
|
||||
} else if (userId == null) {
|
||||
this.activeAccountUnlockedSubject.next(false);
|
||||
}
|
||||
// FIXME: This should be refactored into AuthService or a similar service,
|
||||
// as checking for the existence of the crypto key is a low level
|
||||
// implementation detail.
|
||||
this.activeAccountUnlockedSubject.next((await this.getUserKey()) != null);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
this.activeAccountUnlocked$ = this.accountService.activeAccount$.pipe(
|
||||
map((a) => {
|
||||
return a?.status === AuthenticationStatus.Unlocked;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async init(initOptions: InitOptions = {}): Promise<void> {
|
||||
@ -522,68 +510,6 @@ export class StateService<
|
||||
return account?.keys?.cryptoMasterKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do not save the Master Key. Use the User Symmetric Key instead
|
||||
*/
|
||||
async setCryptoMasterKey(value: SymmetricCryptoKey, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
account.keys.cryptoMasterKey = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
|
||||
const nextStatus = value != null ? AuthenticationStatus.Unlocked : AuthenticationStatus.Locked;
|
||||
await this.accountService.setAccountStatus(options.userId as UserId, nextStatus);
|
||||
|
||||
if (options.userId == this.activeAccountSubject.getValue()) {
|
||||
const nextValue = value != null;
|
||||
|
||||
// Avoid emitting if we are already unlocked
|
||||
if (this.activeAccountUnlockedSubject.getValue() != nextValue) {
|
||||
this.activeAccountUnlockedSubject.next(nextValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* user key used to encrypt/decrypt data
|
||||
*/
|
||||
async getUserKey(options?: StorageOptions): Promise<UserKey> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
return account?.keys?.userKey as UserKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* user key used to encrypt/decrypt data
|
||||
*/
|
||||
async setUserKey(value: UserKey, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
account.keys.userKey = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
|
||||
const nextStatus = value != null ? AuthenticationStatus.Unlocked : AuthenticationStatus.Locked;
|
||||
await this.accountService.setAccountStatus(options.userId as UserId, nextStatus);
|
||||
|
||||
if (options?.userId == this.activeAccountSubject.getValue()) {
|
||||
const nextValue = value != null;
|
||||
|
||||
// Avoid emitting if we are already unlocked
|
||||
if (this.activeAccountUnlockedSubject.getValue() != nextValue) {
|
||||
this.activeAccountUnlockedSubject.next(nextValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User's master key derived from MP, saved only if we decrypted with MP
|
||||
*/
|
||||
@ -885,33 +811,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use UserKey instead
|
||||
*/
|
||||
async getDecryptedCryptoSymmetricKey(options?: StorageOptions): Promise<SymmetricCryptoKey> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
return account?.keys?.cryptoSymmetricKey?.decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use UserKey instead
|
||||
*/
|
||||
async setDecryptedCryptoSymmetricKey(
|
||||
value: SymmetricCryptoKey,
|
||||
options?: StorageOptions,
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
account.keys.cryptoSymmetricKey.decrypted = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForArrayMembers(GeneratedPasswordHistory)
|
||||
async getDecryptedPasswordGenerationHistory(
|
||||
options?: StorageOptions,
|
||||
@ -1565,20 +1464,6 @@ export class StateService<
|
||||
)?.keys.cryptoSymmetricKey.encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use UserKey instead
|
||||
*/
|
||||
async setEncryptedCryptoSymmetricKey(value: string, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.keys.cryptoSymmetricKey.encrypted = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForArrayMembers(GeneratedPasswordHistory)
|
||||
async getEncryptedPasswordGenerationHistory(
|
||||
options?: StorageOptions,
|
||||
|
@ -22,6 +22,7 @@ export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk");
|
||||
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
||||
|
||||
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||
|
||||
|
@ -40,6 +40,18 @@ export class MigrationHelper {
|
||||
return this.storageService.save(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value in the storage service at the given key.
|
||||
*
|
||||
* This is a brute force method to just remove a value in the storage service. If you can use {@link removeFromGlobal} or {@link removeFromUser}, you should.
|
||||
* @param key location
|
||||
* @returns void
|
||||
*/
|
||||
remove(key: string): Promise<void> {
|
||||
this.logService.info(`Removing ${key}`);
|
||||
return this.storageService.remove(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a globally scoped value from a location derived through the key definition
|
||||
*
|
||||
@ -65,6 +77,18 @@ export class MigrationHelper {
|
||||
return this.set(this.getGlobalKey(keyDefinition), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a globally scoped location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link remove} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns void
|
||||
*/
|
||||
removeFromGlobal(keyDefinition: KeyDefinitionLike): Promise<void> {
|
||||
return this.remove(this.getGlobalKey(keyDefinition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user scoped value from a location derived through the user id and key definition
|
||||
*
|
||||
@ -92,6 +116,18 @@ export class MigrationHelper {
|
||||
return this.set(this.getUserKey(userId, keyDefinition), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user scoped location derived through the key definition
|
||||
*
|
||||
* This is for use with the state providers framework, DO NOT use for values stored with {@link StateService},
|
||||
* use {@link remove} for those.
|
||||
* @param keyDefinition unique key definition
|
||||
* @returns void
|
||||
*/
|
||||
removeFromUser(userId: string, keyDefinition: KeyDefinitionLike): Promise<void> {
|
||||
return this.remove(this.getUserKey(userId, keyDefinition));
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.logService.info(message);
|
||||
}
|
||||
|
@ -230,10 +230,8 @@ describe("PSONO JSON Importer", () => {
|
||||
const result = await importer.parse(FoldersTestDataJson);
|
||||
expect(result != null).toBe(true);
|
||||
|
||||
const folders = result.folders;
|
||||
// // Check that ciphers have a folder assigned to them
|
||||
expect(result.ciphers.filter((c) => c.folderId === folders[0].id).length).toBeGreaterThan(0);
|
||||
expect(result.ciphers.filter((c) => c.folderId === folders[1].id).length).toBeGreaterThan(0);
|
||||
expect(result.ciphers.length).toEqual(result.folderRelationships.length);
|
||||
});
|
||||
|
||||
it("should create collections if part of an organization", async () => {
|
||||
|
@ -23,6 +23,24 @@ export const FoldersTestData: PsonoJsonExport = {
|
||||
callback_user: "callbackUser",
|
||||
callback_pass: "callbackPassword",
|
||||
},
|
||||
{
|
||||
type: "website_password",
|
||||
name: "TestEntry1.2",
|
||||
autosubmit: true,
|
||||
urlfilter: "filter",
|
||||
website_password_title: "TestEntry1.2",
|
||||
website_password_url: "bitwarden.com",
|
||||
website_password_username: "testUser",
|
||||
website_password_password: "testPassword",
|
||||
website_password_notes: "some notes",
|
||||
website_password_auto_submit: true,
|
||||
website_password_url_filter: "filter",
|
||||
create_date: "2022-12-13T19:24:09.810266Z",
|
||||
write_date: "2022-12-13T19:24:09.810292Z",
|
||||
callback_url: "callback",
|
||||
callback_user: "callbackUser",
|
||||
callback_pass: "callbackPassword",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -61,20 +61,22 @@ export class PsonoJsonImporter extends BaseImporter implements Importer {
|
||||
this.parseFolders(result, folder.folders, folder.name);
|
||||
}
|
||||
|
||||
this.processFolder(result, folder.name, folderHasItems);
|
||||
|
||||
this.handleItemParsing(result, folder.items);
|
||||
if (!folderHasItems) {
|
||||
this.processFolder(result, folder.name, folderHasItems);
|
||||
} else {
|
||||
this.handleItemParsing(result, folder.items, folder.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleItemParsing(result: ImportResult, items?: PsonoItemTypes[]) {
|
||||
private handleItemParsing(result: ImportResult, items?: PsonoItemTypes[], folderName?: string) {
|
||||
if (items == null || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((record) => {
|
||||
const cipher = this.parsePsonoItem(record);
|
||||
|
||||
this.processFolder(result, folderName, true);
|
||||
this.cleanupCipher(cipher);
|
||||
result.ciphers.push(cipher);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user