1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-04 13:44:00 +01:00

Merge branch 'main' into vault/pm-5273

This commit is contained in:
Carlos Gonçalves 2024-02-23 11:55:56 +00:00
commit b17239595d
No known key found for this signature in database
GPG Key ID: 8147F618E732EF25
37 changed files with 369 additions and 266 deletions

View File

@ -9,7 +9,10 @@ module.exports = {
...sharedConfig, ...sharedConfig,
preset: "jest-preset-angular", preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"], setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { moduleNameMapper: pathsToModuleNameMapper(
prefix: "<rootDir>/", { "@bitwarden/common/spec": ["../../libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
}), {
prefix: "<rootDir>/",
},
),
}; };

View File

@ -2708,11 +2708,11 @@
"launchDuoAndFollowStepsToFinishLoggingIn": { "launchDuoAndFollowStepsToFinishLoggingIn": {
"message": "Launch Duo and follow the steps to finish logging in." "message": "Launch Duo and follow the steps to finish logging in."
}, },
"duoRequiredByOrgForAccount": { "duoRequiredForAccount": {
"message": "Duo two-step login is required for your account." "message": "Duo two-step login is required for your account."
}, },
"openExtensionInNewWindowToCompleteLogin": { "popoutTheExtensionToCompleteLogin": {
"message": "Open the extension in a new window to complete login" "message": "Popout the extension to complete login."
}, },
"popoutExtension": { "popoutExtension": {
"message": "Popout extension" "message": "Popout extension"

View File

@ -112,15 +112,12 @@
<!-- Duo --> <!-- Duo -->
<ng-container *ngIf="isDuoProvider"> <ng-container *ngIf="isDuoProvider">
<div *ngIf="duoFrameless" class="tw-my-4"> <div *ngIf="duoFrameless" class="tw-my-4">
<p <p class="tw-mb-0 tw-text-center">
*ngIf="selectedProviderType === providerType.OrganizationDuo" {{ "duoRequiredForAccount" | i18n }}
class="tw-mb-0 tw-text-center"
>
{{ "duoRequiredByOrgForAccount" | i18n }}
</p> </p>
<p class="tw-text-center" *ngIf="!inPopout"> <p class="tw-text-center" *ngIf="!inPopout">
{{ "openExtensionInNewWindowToCompleteLogin" | i18n }} {{ "popoutTheExtensionToCompleteLogin" | i18n }}
</p> </p>
<ng-container *ngIf="inPopout"> <ng-container *ngIf="inPopout">

View File

@ -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.useFakeTimers();
jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling");
jest jest
@ -228,9 +240,6 @@ describe("AutofillInit", () => {
fillScript, fillScript,
); );
expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false); 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 () => { it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => {

View File

@ -98,6 +98,7 @@ class AutofillInit implements AutofillInitInterface {
return; return;
} }
this.blurAndRemoveOverlay();
this.updateOverlayIsCurrentlyFilling(true); this.updateOverlayIsCurrentlyFilling(true);
await this.insertAutofillContentService.fillForm(fillScript); await this.insertAutofillContentService.fillForm(fillScript);
@ -105,10 +106,7 @@ class AutofillInit implements AutofillInitInterface {
return; return;
} }
setTimeout(() => { setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250);
this.updateOverlayIsCurrentlyFilling(false);
this.autofillOverlayContentService.focusMostRecentOverlayField();
}, 250);
} }
/** /**

View File

@ -757,7 +757,7 @@ export default class MainBackground {
this.platformUtilsService.isSafari() || this.platformUtilsService.isSafari() ||
this.platformUtilsService.isFirefox() || this.platformUtilsService.isFirefox() ||
this.platformUtilsService.isOpera(); this.platformUtilsService.isOpera();
BrowserApi.reloadExtension(forceWindowReload ? window : null); BrowserApi.reloadExtension(forceWindowReload ? self : null);
return Promise.resolve(); return Promise.resolve();
}; };

View File

@ -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", () => { describe("reloadOpenWindows", () => {
const href = window.location.href; const href = window.location.href;
const reload = window.location.reload; const reload = window.location.reload;

View File

@ -381,12 +381,20 @@ export class BrowserApi {
return chrome.i18n.getUILanguage(); return chrome.i18n.getUILanguage();
} }
static reloadExtension(win: Window) { /**
if (win != null) { * Handles reloading the extension, either by calling the window location
return (win.location as any).reload(true); * to reload or by calling the extension's runtime to reload.
} else { *
return chrome.runtime.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();
} }
/** /**

View File

@ -1,7 +1,8 @@
import { firstValueFrom } from "rxjs";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; 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 { 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 { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key"; import { UserKey } from "@bitwarden/common/types/key";
@ -29,9 +30,9 @@ export class BrowserCryptoService extends CryptoService {
return null; return null;
} }
const userKey = await this.stateService.getUserKey({ userId: userId }); const userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
if (userKey) { if (userKey) {
return new SymmetricCryptoKey(Utils.fromB64ToArray(userKey.keyB64)) as UserKey; return userKey;
} }
} }

View File

@ -1,6 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended"; 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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { 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 { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { State } from "@bitwarden/common/platform/models/domain/state"; import { State } from "@bitwarden/common/platform/models/domain/state";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; 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 { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { UserId } from "@bitwarden/common/types/guid";
import { Account } from "../../models/account"; import { Account } from "../../models/account";
import { BrowserComponentState } from "../../models/browserComponentState"; import { BrowserComponentState } from "../../models/browserComponentState";
@ -30,12 +31,12 @@ describe("Browser State Service", () => {
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
let stateFactory: MockProxy<StateFactory<GlobalState, Account>>; let stateFactory: MockProxy<StateFactory<GlobalState, Account>>;
let useAccountCache: boolean; let useAccountCache: boolean;
let accountService: MockProxy<AccountService>;
let environmentService: MockProxy<EnvironmentService>; let environmentService: MockProxy<EnvironmentService>;
let migrationRunner: MockProxy<MigrationRunner>; let migrationRunner: MockProxy<MigrationRunner>;
let state: State<GlobalState, Account>; let state: State<GlobalState, Account>;
const userId = "userId"; const userId = "userId" as UserId;
const accountService = mockAccountServiceWith(userId);
let sut: BrowserStateService; let sut: BrowserStateService;
@ -44,7 +45,6 @@ describe("Browser State Service", () => {
diskStorageService = mock(); diskStorageService = mock();
logService = mock(); logService = mock();
stateFactory = mock(); stateFactory = mock();
accountService = mock();
environmentService = mock(); environmentService = mock();
migrationRunner = mock(); migrationRunner = mock();
// turn off account cache for tests // turn off account cache for tests
@ -57,6 +57,10 @@ describe("Browser State Service", () => {
state.activeUserId = userId; state.activeUserId = userId;
}); });
afterEach(() => {
jest.resetAllMocks();
});
describe("state methods", () => { describe("state methods", () => {
let memoryStorageService: MockProxy<AbstractMemoryStorageService>; let memoryStorageService: MockProxy<AbstractMemoryStorageService>;

View File

@ -34,8 +34,6 @@ export class BrowserStateService
protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>; protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>;
@sessionSync({ initializer: (s: string) => s }) @sessionSync({ initializer: (s: string) => s })
protected activeAccountSubject: BehaviorSubject<string>; protected activeAccountSubject: BehaviorSubject<string>;
@sessionSync({ initializer: (b: boolean) => b })
protected activeAccountUnlockedSubject: BehaviorSubject<boolean>;
protected accountDeserializer = Account.fromJSON; protected accountDeserializer = Account.fromJSON;

View File

@ -73,6 +73,7 @@ import { ConfigService } from "@bitwarden/common/platform/services/config/config
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; 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 { DerivedStateProvider, StateProvider } from "@bitwarden/common/platform/state";
import { SearchService } from "@bitwarden/common/services/search.service"; import { SearchService } from "@bitwarden/common/services/search.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; 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 BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service"; import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
import { BrowserConfigService } from "../../platform/services/browser-config.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 { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service"; import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service";
import { BrowserI18nService } from "../../platform/services/browser-i18n.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: CipherService, useFactory: getBgService<CipherService>("cipherService"), deps: [] },
{ {
provide: CryptoFunctionService, provide: CryptoFunctionService,
useFactory: getBgService<CryptoFunctionService>("cryptoFunctionService"), useFactory: () => new WebCryptoFunctionService(window),
deps: [], deps: [],
}, },
{ {
@ -258,12 +260,36 @@ function getBgService<T>(service: keyof MainBackground) {
}, },
{ {
provide: CryptoService, provide: CryptoService,
useFactory: (encryptService: EncryptService) => { useFactory: (
const cryptoService = getBgService<CryptoService>("cryptoService")(); 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); new ContainerService(cryptoService, encryptService).attachToGlobal(self);
return cryptoService; return cryptoService;
}, },
deps: [EncryptService], deps: [
CryptoFunctionService,
EncryptService,
PlatformUtilsService,
LogServiceAbstraction,
StateServiceAbstraction,
AccountServiceAbstraction,
StateProvider,
],
}, },
{ {
provide: AuthRequestCryptoServiceAbstraction, provide: AuthRequestCryptoServiceAbstraction,

View File

@ -30,6 +30,7 @@ const runtime = {
addListener: jest.fn(), addListener: jest.fn(),
removeListener: jest.fn(), removeListener: jest.fn(),
}, },
reload: jest.fn(),
}; };
const contextMenus = { const contextMenus = {

View File

@ -76,7 +76,17 @@
{{ i.amount | currency: "$" }} {{ i.amount | currency: "$" }}
</td> </td>
<td bitCell class="tw-text-right"> <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> </td>
</tr> </tr>
</ng-container> </ng-container>

View File

@ -111,6 +111,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
}) })
.sort(sortSubscriptionItems); .sort(sortSubscriptionItems);
} }
if (this.sub?.customerDiscount?.percentOff == 100) {
this.lineItems.reverse();
}
} }
const apiKeyResponse = await this.organizationApiService.getApiKeyInformation( const apiKeyResponse = await this.organizationApiService.getApiKeyInformation(
@ -152,6 +156,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
sponsoredSubscriptionItem: lineItem.sponsoredSubscriptionItem, sponsoredSubscriptionItem: lineItem.sponsoredSubscriptionItem,
addonSubscriptionItem: lineItem.addonSubscriptionItem, addonSubscriptionItem: lineItem.addonSubscriptionItem,
productName: lineItem.productName, productName: lineItem.productName,
productId: lineItem.productId,
})); }));
} }

View File

@ -1,19 +1,7 @@
import { AfterContentInit, Directive, HostListener, Input } from "@angular/core"; import { AfterContentInit, Directive, HostListener, Input } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { SsoComponent } from "@bitwarden/angular/auth/components/sso.component"; 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 { 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({ @Directive({
selector: "[app-link-sso]", selector: "[app-link-sso]",
@ -21,6 +9,8 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/ge
export class LinkSsoDirective extends SsoComponent implements AfterContentInit { export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
@Input() organization: Organization; @Input() organization: Organization;
returnUri = "/settings/organizations"; returnUri = "/settings/organizations";
redirectUri = window.location.origin + "/sso-connector.html";
clientId = "web";
@HostListener("click", ["$event"]) @HostListener("click", ["$event"])
async onClick($event: MouseEvent) { async onClick($event: MouseEvent) {
@ -28,42 +18,6 @@ export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
await this.submit(this.returnUri, true); 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() { async ngAfterContentInit() {
this.identifier = this.organization.identifier; this.identifier = this.organization.identifier;
} }

View File

@ -4,7 +4,7 @@ import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components";
import { SharedModule } from "../../shared"; 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"/> <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"/> <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>`; </svg>`;

View File

@ -7584,5 +7584,8 @@
"tooExpensive": { "tooExpensive": {
"message": "Too expensive", "message": "Too expensive",
"description": "An option for the offboarding survey shown when a user cancels their subscription." "description": "An option for the offboarding survey shown when a user cancels their subscription."
},
"freeForOneYear": {
"message": "Free for 1 year"
} }
} }

View File

@ -65,6 +65,10 @@ export class FakeAccountService implements AccountService {
await this.mock.setAccountStatus(userId, status); 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> { async switchAccount(userId: UserId): Promise<void> {
await this.mock.switchAccount(userId); await this.mock.switchAccount(userId);
} }

View File

@ -140,7 +140,9 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
} }
export class FakeStateProvider implements StateProvider { export class FakeStateProvider implements StateProvider {
mock = mock<StateProvider>();
getUserState$<T>(keyDefinition: KeyDefinition<T>, userId?: UserId): Observable<T> { getUserState$<T>(keyDefinition: KeyDefinition<T>, userId?: UserId): Observable<T> {
this.mock.getUserState$(keyDefinition, userId);
if (userId) { if (userId) {
return this.getUser<T>(userId, keyDefinition).state$; return this.getUser<T>(userId, keyDefinition).state$;
} }
@ -152,6 +154,7 @@ export class FakeStateProvider implements StateProvider {
value: T, value: T,
userId?: UserId, userId?: UserId,
): Promise<[UserId, T]> { ): Promise<[UserId, T]> {
await this.mock.setUserState(keyDefinition, value, userId);
if (userId) { if (userId) {
return [userId, await this.getUser(userId, keyDefinition).update(() => value)]; return [userId, await this.getUser(userId, keyDefinition).update(() => value)];
} else { } else {

View File

@ -47,6 +47,17 @@ export abstract class AccountService {
* @param status * @param status
*/ */
abstract setAccountStatus(userId: UserId, status: AuthenticationStatus): Promise<void>; 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. * Updates the `activeAccount$` observable with the new active account.
* @param userId * @param userId

View File

@ -207,4 +207,26 @@ describe("accountService", () => {
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist"); 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),
});
});
});
}); });

View File

@ -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> { async switchAccount(userId: UserId): Promise<void> {
await this.activeAccountIdState.update( await this.activeAccountIdState.update(
(_, accounts) => { (_, accounts) => {

View File

@ -1,3 +1,5 @@
import { firstValueFrom } from "rxjs";
import { AppIdService } from "../../platform/abstractions/app-id.service"; import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { CryptoService } from "../../platform/abstractions/crypto.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 // 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 deviceIdentifier = await this.appIdService.getAppId();
const secretVerificationRequest = new SecretVerificationRequest(); const secretVerificationRequest = new SecretVerificationRequest();

View File

@ -1,4 +1,5 @@
import { matches, mock } from "jest-mock-extended"; import { matches, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { DeviceType } from "../../enums"; import { DeviceType } from "../../enums";
import { AppIdService } from "../../platform/abstractions/app-id.service"; 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 { ProtectedDeviceResponse } from "../models/response/protected-device.response";
import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation"; import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation";
describe("deviceTrustCryptoService", () => { describe("deviceTrustCryptoService", () => {
let deviceTrustCryptoService: DeviceTrustCryptoService; let deviceTrustCryptoService: DeviceTrustCryptoService;
@ -495,6 +497,7 @@ describe("deviceTrustCryptoService", () => {
const fakeNewUserKeyData = new Uint8Array(64); const fakeNewUserKeyData = new Uint8Array(64);
fakeNewUserKeyData.fill(FakeNewUserKeyMarker, 0, 1); fakeNewUserKeyData.fill(FakeNewUserKeyMarker, 0, 1);
fakeNewUserKey = new SymmetricCryptoKey(fakeNewUserKeyData) as UserKey; 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 () => { 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); fakeOldUserKeyData.fill(FakeOldUserKeyMarker, 0, 1);
// Mock the retrieval of a user key that differs from the new one passed into the method // Mock the retrieval of a user key that differs from the new one passed into the method
stateService.getUserKey.mockResolvedValue( cryptoService.activeUserKey$ = of(new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey);
new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey,
);
appIdService.getAppId.mockResolvedValue("test_device_identifier"); appIdService.getAppId.mockResolvedValue("test_device_identifier");

View File

@ -12,10 +12,13 @@ import { EncString } from "../models/domain/enc-string";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
export abstract class CryptoService { export abstract class CryptoService {
activeUserKey$: Observable<UserKey>;
/** /**
* Sets the provided user key and stores * Sets the provided user key and stores
* any other necessary versions (such as auto, biometrics, * any other necessary versions (such as auto, biometrics,
* or pin) * or pin)
*
* @throws when key is null. Use {@link clearUserKey} instead
* @param key The user key to set * @param key The user key to set
* @param userId The desired user * @param userId The desired user
*/ */

View File

@ -16,7 +16,7 @@ import { UsernameGeneratorOptions } from "../../tools/generator/username";
import { SendData } from "../../tools/send/models/data/send.data"; import { SendData } from "../../tools/send/models/data/send.data";
import { SendView } from "../../tools/send/models/view/send.view"; import { SendView } from "../../tools/send/models/view/send.view";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { DeviceKey, MasterKey, UserKey } from "../../types/key"; import { DeviceKey, MasterKey } from "../../types/key";
import { UriMatchType } from "../../vault/enums"; import { UriMatchType } from "../../vault/enums";
import { CipherData } from "../../vault/models/data/cipher.data"; import { CipherData } from "../../vault/models/data/cipher.data";
import { LocalData } from "../../vault/models/data/local.data"; import { LocalData } from "../../vault/models/data/local.data";
@ -50,6 +50,9 @@ export type InitOptions = {
export abstract class StateService<T extends Account = Account> { export abstract class StateService<T extends Account = Account> {
accounts$: Observable<{ [userId: string]: T }>; accounts$: Observable<{ [userId: string]: T }>;
activeAccount$: Observable<string>; activeAccount$: Observable<string>;
/**
* @deprecated use accountService.activeAccount$ instead
*/
activeAccountUnlocked$: Observable<boolean>; activeAccountUnlocked$: Observable<boolean>;
addAccount: (account: T) => Promise<void>; addAccount: (account: T) => Promise<void>;
@ -82,14 +85,6 @@ export abstract class StateService<T extends Account = Account> {
setClearClipboard: (value: number, options?: StorageOptions) => Promise<void>; setClearClipboard: (value: number, options?: StorageOptions) => Promise<void>;
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>; getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>; 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 * 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 * @deprecated For migration purposes only, use getUserKeyMasterKey instead
*/ */
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>; 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 * @deprecated For legacy purposes only, use getMasterKey instead
*/ */

View File

@ -19,7 +19,7 @@ import { UsernameGeneratorOptions } from "../../../tools/generator/username/user
import { SendData } from "../../../tools/send/models/data/send.data"; import { SendData } from "../../../tools/send/models/data/send.data";
import { SendView } from "../../../tools/send/models/view/send.view"; import { SendView } from "../../../tools/send/models/view/send.view";
import { DeepJsonify } from "../../../types/deep-jsonify"; import { DeepJsonify } from "../../../types/deep-jsonify";
import { MasterKey, UserKey } from "../../../types/key"; import { MasterKey } from "../../../types/key";
import { UriMatchType } from "../../../vault/enums"; import { UriMatchType } from "../../../vault/enums";
import { CipherData } from "../../../vault/models/data/cipher.data"; import { CipherData } from "../../../vault/models/data/cipher.data";
import { CipherView } from "../../../vault/models/view/cipher.view"; import { CipherView } from "../../../vault/models/view/cipher.view";
@ -113,7 +113,6 @@ export class AccountData {
} }
export class AccountKeys { export class AccountKeys {
userKey?: UserKey;
masterKey?: MasterKey; masterKey?: MasterKey;
masterKeyEncryptedUserKey?: string; masterKeyEncryptedUserKey?: string;
deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>; deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>;
@ -146,7 +145,6 @@ export class AccountKeys {
return null; return null;
} }
return Object.assign(new AccountKeys(), obj, { return Object.assign(new AccountKeys(), obj, {
userKey: SymmetricCryptoKey.fromJSON(obj?.userKey),
masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey), masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey),
deviceKey: obj?.deviceKey, deviceKey: obj?.deviceKey,
cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey), cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey),

View File

@ -4,6 +4,7 @@ import { firstValueFrom } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state"; import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { CsprngArray } from "../../types/csprng"; import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { UserKey, MasterKey, PinKey } from "../../types/key"; 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 { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
import { CryptoService } from "../services/crypto.service"; 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", () => { describe("cryptoService", () => {
let cryptoService: CryptoService; let cryptoService: CryptoService;
@ -57,42 +58,50 @@ describe("cryptoService", () => {
describe("getUserKey", () => { describe("getUserKey", () => {
let mockUserKey: UserKey; let mockUserKey: UserKey;
let stateSvcGetUserKey: jest.SpyInstance;
beforeEach(() => { beforeEach(() => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray; const mockRandomBytes = new Uint8Array(64) as CsprngArray;
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; 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 () => { 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); const userKey = await cryptoService.getUserKey(mockUserId);
expect(stateSvcGetUserKey).toHaveBeenCalledWith({ userId: mockUserId });
expect(userKey).toEqual(mockUserKey); 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 = const autoKeyB64 =
"IT5cA1i5Hncd953pb00E58D2FqJX+fWTj4AvoI67qkGHSQPgulAqKv+LaKRAo9Bg0xzP9Nw00wk4TqjMmGSM+g=="; "IT5cA1i5Hncd953pb00E58D2FqJX+fWTj4AvoI67qkGHSQPgulAqKv+LaKRAo9Bg0xzP9Nw00wk4TqjMmGSM+g==";
stateService.getUserKeyAutoUnlock.mockResolvedValue(autoKeyB64); stateService.getUserKeyAutoUnlock.mockResolvedValue(autoKeyB64);
const setKeySpy = jest.spyOn(cryptoService, "setUserKey");
const userKey = await cryptoService.getUserKey(mockUserId); const userKey = await cryptoService.getUserKey(mockUserId);
expect(stateService.setUserKey).toHaveBeenCalledWith(expect.any(SymmetricCryptoKey), { expect(setKeySpy).toHaveBeenCalledWith(expect.any(SymmetricCryptoKey), mockUserId);
userId: mockUserId, expect(setKeySpy).toHaveBeenCalledTimes(1);
});
expect(userKey.keyB64).toEqual(autoKeyB64); 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", () => { describe("getUserKeyWithLegacySupport", () => {
let mockUserKey: UserKey; let mockUserKey: UserKey;
let mockMasterKey: MasterKey; let mockMasterKey: MasterKey;
let stateSvcGetUserKey: jest.SpyInstance;
let stateSvcGetMasterKey: jest.SpyInstance; let stateSvcGetMasterKey: jest.SpyInstance;
beforeEach(() => { beforeEach(() => {
@ -100,23 +109,22 @@ describe("cryptoService", () => {
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey; mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
stateSvcGetUserKey = jest.spyOn(stateService, "getUserKey");
stateSvcGetMasterKey = jest.spyOn(stateService, "getMasterKey"); stateSvcGetMasterKey = jest.spyOn(stateService, "getMasterKey");
}); });
it("returns the User Key if available", async () => { 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); const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId);
expect(stateSvcGetUserKey).toHaveBeenCalledWith({ userId: mockUserId }); expect(getKeySpy).toHaveBeenCalledWith(mockUserId);
expect(stateSvcGetMasterKey).not.toHaveBeenCalled(); expect(stateSvcGetMasterKey).not.toHaveBeenCalled();
expect(userKey).toEqual(mockUserKey); expect(userKey).toEqual(mockUserKey);
}); });
it("returns the user's master key when User Key is not available", async () => { it("returns the user's master key when User Key is not available", async () => {
stateSvcGetUserKey.mockResolvedValue(null);
stateSvcGetMasterKey.mockResolvedValue(mockMasterKey); stateSvcGetMasterKey.mockResolvedValue(mockMasterKey);
const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId); 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", () => { describe("Pin Key refresh", () => {
let cryptoSvcMakePinKey: jest.SpyInstance; let cryptoSvcMakePinKey: jest.SpyInstance;
const protectedPin = 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();
}
},
);
});
}); });

View File

@ -6,6 +6,7 @@ import { ProfileOrganizationResponse } from "../../admin-console/models/response
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
import { AccountService } from "../../auth/abstractions/account.service"; import { AccountService } from "../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { Utils } from "../../platform/misc/utils"; import { Utils } from "../../platform/misc/utils";
import { OrganizationId, ProviderId, UserId } from "../../types/guid"; import { OrganizationId, ProviderId, UserId } from "../../types/guid";
@ -52,9 +53,11 @@ import {
USER_EVER_HAD_USER_KEY, USER_EVER_HAD_USER_KEY,
USER_PRIVATE_KEY, USER_PRIVATE_KEY,
USER_PUBLIC_KEY, USER_PUBLIC_KEY,
USER_KEY,
} from "./key-state/user-key.state"; } from "./key-state/user-key.state";
export class CryptoService implements CryptoServiceAbstraction { export class CryptoService implements CryptoServiceAbstraction {
private readonly activeUserKeyState: ActiveUserState<UserKey>;
private readonly activeUserEverHadUserKey: ActiveUserState<boolean>; private readonly activeUserEverHadUserKey: ActiveUserState<boolean>;
private readonly activeUserEncryptedOrgKeysState: ActiveUserState< private readonly activeUserEncryptedOrgKeysState: ActiveUserState<
Record<OrganizationId, EncryptedOrganizationKeyData> Record<OrganizationId, EncryptedOrganizationKeyData>
@ -68,6 +71,8 @@ export class CryptoService implements CryptoServiceAbstraction {
private readonly activeUserPrivateKeyState: DerivedState<UserPrivateKey>; private readonly activeUserPrivateKeyState: DerivedState<UserPrivateKey>;
private readonly activeUserPublicKeyState: DerivedState<UserPublicKey>; private readonly activeUserPublicKeyState: DerivedState<UserPublicKey>;
readonly activeUserKey$: Observable<UserKey>;
readonly activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>; readonly activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>;
readonly activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>; readonly activeUserProviderKeys$: Observable<Record<ProviderId, ProviderKey>>;
readonly activeUserPrivateKey$: Observable<UserPrivateKey>; readonly activeUserPrivateKey$: Observable<UserPrivateKey>;
@ -84,6 +89,8 @@ export class CryptoService implements CryptoServiceAbstraction {
protected stateProvider: StateProvider, protected stateProvider: StateProvider,
) { ) {
// User Key // User Key
this.activeUserKeyState = stateProvider.getActive(USER_KEY);
this.activeUserKey$ = this.activeUserKeyState.state$;
this.activeUserEverHadUserKey = stateProvider.getActive(USER_EVER_HAD_USER_KEY); this.activeUserEverHadUserKey = stateProvider.getActive(USER_EVER_HAD_USER_KEY);
this.everHadUserKey$ = this.activeUserEverHadUserKey.state$.pipe(map((x) => x ?? false)); 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> { async setUserKey(key: UserKey, userId?: UserId): Promise<void> {
// TODO: make this non-nullable in signature if (key == null) {
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; throw new Error("No key provided. Use ClearUserKey to clear the key");
if (key != null) {
// Key should never be null anyway
await this.stateProvider.getUser(userId, USER_EVER_HAD_USER_KEY).update(() => true);
} }
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); await this.storeAdditionalKeys(key, userId);
} }
@ -147,7 +156,7 @@ export class CryptoService implements CryptoServiceAbstraction {
} }
async getUserKey(userId?: UserId): Promise<UserKey> { 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) { if (userKey) {
return userKey; return userKey;
} }
@ -197,7 +206,7 @@ export class CryptoService implements CryptoServiceAbstraction {
} }
async hasUserKeyInMemory(userId?: UserId): Promise<boolean> { 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> { 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> { 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) { if (clearStoredKeys) {
await this.clearAllStoredUserKeys(userId); await this.clearAllStoredUserKeys(userId);
} }

View File

@ -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 { CryptoFunctionService } from "../../abstractions/crypto-function.service";
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { EncString, EncryptedString } from "../../models/domain/enc-string"; 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"; import { CryptoService } from "../crypto.service";
export const USER_EVER_HAD_USER_KEY = new KeyDefinition<boolean>(CRYPTO_DISK, "everHadUserKey", { 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; return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey;
}, },
}); });
export const USER_KEY = new KeyDefinition<UserKey>(CRYPTO_MEMORY, "userKey", {
deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey,
});

View File

@ -1,4 +1,4 @@
import { BehaviorSubject, concatMap } from "rxjs"; import { BehaviorSubject, Observable, map } from "rxjs";
import { Jsonify, JsonValue } from "type-fest"; import { Jsonify, JsonValue } from "type-fest";
import { OrganizationData } from "../../admin-console/models/data/organization.data"; 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 { SendData } from "../../tools/send/models/data/send.data";
import { SendView } from "../../tools/send/models/view/send.view"; import { SendView } from "../../tools/send/models/view/send.view";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { DeviceKey, MasterKey, UserKey } from "../../types/key"; import { DeviceKey, MasterKey } from "../../types/key";
import { UriMatchType } from "../../vault/enums"; import { UriMatchType } from "../../vault/enums";
import { CipherData } from "../../vault/models/data/cipher.data"; import { CipherData } from "../../vault/models/data/cipher.data";
import { LocalData } from "../../vault/models/data/local.data"; import { LocalData } from "../../vault/models/data/local.data";
@ -87,8 +87,7 @@ export class StateService<
protected activeAccountSubject = new BehaviorSubject<string | null>(null); protected activeAccountSubject = new BehaviorSubject<string | null>(null);
activeAccount$ = this.activeAccountSubject.asObservable(); activeAccount$ = this.activeAccountSubject.asObservable();
protected activeAccountUnlockedSubject = new BehaviorSubject<boolean>(false); activeAccountUnlocked$: Observable<boolean>;
activeAccountUnlocked$ = this.activeAccountUnlockedSubject.asObservable();
private hasBeenInited = false; private hasBeenInited = false;
protected isRecoveredSession = false; protected isRecoveredSession = false;
@ -109,22 +108,11 @@ export class StateService<
private migrationRunner: MigrationRunner, private migrationRunner: MigrationRunner,
protected useAccountCache: boolean = true, protected useAccountCache: boolean = true,
) { ) {
// If the account gets changed, verify the new account is unlocked this.activeAccountUnlocked$ = this.accountService.activeAccount$.pipe(
this.activeAccountSubject map((a) => {
.pipe( return a?.status === AuthenticationStatus.Unlocked;
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();
} }
async init(initOptions: InitOptions = {}): Promise<void> { async init(initOptions: InitOptions = {}): Promise<void> {
@ -522,68 +510,6 @@ export class StateService<
return account?.keys?.cryptoMasterKey; 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 * 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) @withPrototypeForArrayMembers(GeneratedPasswordHistory)
async getDecryptedPasswordGenerationHistory( async getDecryptedPasswordGenerationHistory(
options?: StorageOptions, options?: StorageOptions,
@ -1565,20 +1464,6 @@ export class StateService<
)?.keys.cryptoSymmetricKey.encrypted; )?.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) @withPrototypeForArrayMembers(GeneratedPasswordHistory)
async getEncryptedPasswordGenerationHistory( async getEncryptedPasswordGenerationHistory(
options?: StorageOptions, options?: StorageOptions,

View File

@ -22,6 +22,7 @@ export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk"); export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk");
export const CRYPTO_DISK = new StateDefinition("crypto", "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"); export const SSO_DISK = new StateDefinition("ssoLogin", "disk");

View File

@ -40,6 +40,18 @@ export class MigrationHelper {
return this.storageService.save(key, value); 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 * 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); 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 * 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); 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 { info(message: string): void {
this.logService.info(message); this.logService.info(message);
} }

View File

@ -230,10 +230,8 @@ describe("PSONO JSON Importer", () => {
const result = await importer.parse(FoldersTestDataJson); const result = await importer.parse(FoldersTestDataJson);
expect(result != null).toBe(true); expect(result != null).toBe(true);
const folders = result.folders;
// // Check that ciphers have a folder assigned to them // // 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.length).toEqual(result.folderRelationships.length);
expect(result.ciphers.filter((c) => c.folderId === folders[1].id).length).toBeGreaterThan(0);
}); });
it("should create collections if part of an organization", async () => { it("should create collections if part of an organization", async () => {

View File

@ -23,6 +23,24 @@ export const FoldersTestData: PsonoJsonExport = {
callback_user: "callbackUser", callback_user: "callbackUser",
callback_pass: "callbackPassword", 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",
},
], ],
}, },
{ {

View File

@ -61,20 +61,22 @@ export class PsonoJsonImporter extends BaseImporter implements Importer {
this.parseFolders(result, folder.folders, folder.name); this.parseFolders(result, folder.folders, folder.name);
} }
this.processFolder(result, folder.name, folderHasItems); if (!folderHasItems) {
this.processFolder(result, folder.name, folderHasItems);
this.handleItemParsing(result, folder.items); } 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) { if (items == null || items.length === 0) {
return; return;
} }
items.forEach((record) => { items.forEach((record) => {
const cipher = this.parsePsonoItem(record); const cipher = this.parsePsonoItem(record);
this.processFolder(result, folderName, true);
this.cleanupCipher(cipher); this.cleanupCipher(cipher);
result.ciphers.push(cipher); result.ciphers.push(cipher);
}); });