From a16dc84a0afc70735903c4e993e8d4661b383786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Mon, 20 May 2024 13:08:49 -0400 Subject: [PATCH] [PM-6819] Credential generator MV3 integration (#8998) * replace `PasswordGeneratorService` with `legacyPasswordGenerationServiceFactory` * replace `UsernameGeneratorService` with `legacyUsernameGenerationServiceFactory` * migrate generator options and history * apply policy immediately once available * suppress duplicate policy emissions * run password generation response code in `ngZone` --- .../browser/src/background/main.background.ts | 28 +- .../popup/generator/generator.component.ts | 11 +- apps/cli/src/service-container.ts | 12 +- .../src/app/tools/generator.component.spec.ts | 5 - .../src/app/tools/generator.component.ts | 11 +- apps/web/src/app/tools/generator.component.ts | 11 +- .../src/services/jslib-services.module.ts | 34 ++- .../components/generator.component.ts | 224 ++++++++------ .../password-generator-history.component.ts | 3 +- .../password-generator-policy-options.ts | 2 +- libs/common/src/state-migrations/migrate.ts | 10 +- .../63-migrate-password-settings.spec.ts | 123 ++++++++ .../63-migrate-password-settings.ts | 150 +++++++++ .../64-migrate-generator-history.spec.ts | 68 +++++ .../64-migrate-generator-history.ts | 42 +++ .../65-migrate-forwarder-settings.spec.ts | 218 ++++++++++++++ .../65-migrate-forwarder-settings.ts | 245 +++++++++++++++ .../generator-history.abstraction.ts | 6 + .../generator-strategy.abstraction.ts | 3 - .../src/tools/generator/abstractions/index.ts | 1 + ...password-generation.service.abstraction.ts | 5 +- ...username-generation.service.abstraction.ts | 3 + .../default-generator.service.spec.ts | 4 - .../generator/default-generator.service.ts | 21 +- .../legacy-password-history-decryptor.ts | 29 ++ .../local-generator-history.service.spec.ts | 3 +- .../local-generator-history.service.ts | 37 ++- libs/common/src/tools/generator/index.ts | 2 + .../tools/generator/key-definition.spec.ts | 129 ++++++++ .../src/tools/generator/key-definitions.ts | 92 +++++- ...legacy-password-generation.service.spec.ts | 137 +++++++-- .../legacy-password-generation.service.ts | 285 ++++++++++++++++-- ...legacy-username-generation.service.spec.ts | 24 +- .../legacy-username-generation.service.ts | 45 +-- .../default-generator-navigation.service.ts | 3 +- .../passphrase-generator-strategy.spec.ts | 9 - .../passphrase-generator-strategy.ts | 10 +- .../password/password-generation.service.ts | 9 +- .../password/password-generator-options.ts | 2 +- .../password-generator-strategy.spec.ts | 9 - .../password/password-generator-strategy.ts | 9 +- .../reduce-collection.operator.spec.ts | 33 -- .../src/tools/generator/rx-operators.spec.ts | 87 ++++++ ...collection.operator.ts => rx-operators.ts} | 20 +- .../catchall-generator-strategy.spec.ts | 9 - .../username/catchall-generator-strategy.ts | 7 - .../eff-username-generator-strategy.spec.ts | 9 - .../eff-username-generator-strategy.ts | 7 - .../forwarder-generator-strategy.spec.ts | 20 +- .../username/forwarder-generator-strategy.ts | 64 ++-- .../generator/username/forwarders/addy-io.ts | 7 +- .../username/forwarders/duck-duck-go.ts | 7 +- .../generator/username/forwarders/fastmail.ts | 7 +- .../username/forwarders/firefox-relay.ts | 7 +- .../username/forwarders/forward-email.ts | 7 +- .../username/forwarders/simple-login.ts | 7 +- .../subaddress-generator-strategy.spec.ts | 9 - .../username/subaddress-generator-strategy.ts | 7 - .../username/username-generation.service.ts | 6 + 59 files changed, 1995 insertions(+), 399 deletions(-) create mode 100644 libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts create mode 100644 libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts create mode 100644 libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts create mode 100644 libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts delete mode 100644 libs/common/src/tools/generator/reduce-collection.operator.spec.ts create mode 100644 libs/common/src/tools/generator/rx-operators.spec.ts rename libs/common/src/tools/generator/{reduce-collection.operator.ts => rx-operators.ts} (55%) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4d1307b407..90a018a8fe 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -144,13 +144,11 @@ import { NotificationsService } from "@bitwarden/common/services/notifications.s import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { - PasswordGenerationService, - PasswordGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/password"; -import { - UsernameGenerationService, - UsernameGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/username"; + legacyPasswordGenerationServiceFactory, + legacyUsernameGenerationServiceFactory, +} from "@bitwarden/common/tools/generator"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, @@ -649,10 +647,12 @@ export default class MainBackground { this.passwordStrengthService = new PasswordStrengthService(); - this.passwordGenerationService = new PasswordGenerationService( + this.passwordGenerationService = legacyPasswordGenerationServiceFactory( + this.encryptService, this.cryptoService, this.policyService, - this.stateService, + this.accountService, + this.stateProvider, ); this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); @@ -1092,10 +1092,14 @@ export default class MainBackground { this.vaultTimeoutSettingsService, ); - this.usernameGenerationService = new UsernameGenerationService( - this.cryptoService, - this.stateService, + this.usernameGenerationService = legacyUsernameGenerationServiceFactory( this.apiService, + this.i18nService, + this.cryptoService, + this.encryptService, + this.policyService, + this.accountService, + this.stateProvider, ); if (!this.popupOnlyContext) { diff --git a/apps/browser/src/tools/popup/generator/generator.component.ts b/apps/browser/src/tools/popup/generator/generator.component.ts index fbe02d34f5..1afe696576 100644 --- a/apps/browser/src/tools/popup/generator/generator.component.ts +++ b/apps/browser/src/tools/popup/generator/generator.component.ts @@ -1,5 +1,5 @@ import { Location } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, NgZone } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { firstValueFrom } from "rxjs"; @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv 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"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -29,22 +28,22 @@ export class GeneratorComponent extends BaseGeneratorComponent { usernameGenerationService: UsernameGenerationServiceAbstraction, platformUtilsService: PlatformUtilsService, i18nService: I18nService, - stateService: StateService, + accountService: AccountService, cipherService: CipherService, route: ActivatedRoute, logService: LogService, - accountService: AccountService, + ngZone: NgZone, private location: Location, ) { super( passwordGenerationService, usernameGenerationService, platformUtilsService, - stateService, + accountService, i18nService, logService, route, - accountService, + ngZone, window, ); this.cipherService = cipherService; diff --git a/apps/cli/src/service-container.ts b/apps/cli/src/service-container.ts index cffdc53444..a47a943724 100644 --- a/apps/cli/src/service-container.ts +++ b/apps/cli/src/service-container.ts @@ -103,10 +103,8 @@ import { EventUploadService } from "@bitwarden/common/services/event/event-uploa import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; -import { - PasswordGenerationService, - PasswordGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/password"; +import { legacyPasswordGenerationServiceFactory } from "@bitwarden/common/tools/generator"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, @@ -499,10 +497,12 @@ export class ServiceContainer { this.passwordStrengthService = new PasswordStrengthService(); - this.passwordGenerationService = new PasswordGenerationService( + this.passwordGenerationService = legacyPasswordGenerationServiceFactory( + this.encryptService, this.cryptoService, this.policyService, - this.stateService, + this.accountService, + this.stateProvider, ); this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); diff --git a/apps/desktop/src/app/tools/generator.component.spec.ts b/apps/desktop/src/app/tools/generator.component.spec.ts index d908de8ef7..dff7da9600 100644 --- a/apps/desktop/src/app/tools/generator.component.spec.ts +++ b/apps/desktop/src/app/tools/generator.component.spec.ts @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv 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"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -36,10 +35,6 @@ describe("GeneratorComponent", () => { provide: UsernameGenerationServiceAbstraction, useValue: mock(), }, - { - provide: StateService, - useValue: mock(), - }, { provide: PlatformUtilsService, useValue: platformUtilsServiceMock, diff --git a/apps/desktop/src/app/tools/generator.component.ts b/apps/desktop/src/app/tools/generator.component.ts index 5bc5328943..a5c3d39387 100644 --- a/apps/desktop/src/app/tools/generator.component.ts +++ b/apps/desktop/src/app/tools/generator.component.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, NgZone } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component"; @@ -6,7 +6,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv 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"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; @@ -18,22 +17,22 @@ export class GeneratorComponent extends BaseGeneratorComponent { constructor( passwordGenerationService: PasswordGenerationServiceAbstraction, usernameGenerationService: UsernameGenerationServiceAbstraction, - stateService: StateService, + accountService: AccountService, platformUtilsService: PlatformUtilsService, i18nService: I18nService, route: ActivatedRoute, + ngZone: NgZone, logService: LogService, - accountService: AccountService, ) { super( passwordGenerationService, usernameGenerationService, platformUtilsService, - stateService, + accountService, i18nService, logService, route, - accountService, + ngZone, window, ); } diff --git a/apps/web/src/app/tools/generator.component.ts b/apps/web/src/app/tools/generator.component.ts index 0ddf3064b9..fc27e65846 100644 --- a/apps/web/src/app/tools/generator.component.ts +++ b/apps/web/src/app/tools/generator.component.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, NgZone } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component"; @@ -6,7 +6,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv 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"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { DialogService } from "@bitwarden/components"; @@ -21,23 +20,23 @@ export class GeneratorComponent extends BaseGeneratorComponent { constructor( passwordGenerationService: PasswordGenerationServiceAbstraction, usernameGenerationService: UsernameGenerationServiceAbstraction, - stateService: StateService, + accountService: AccountService, platformUtilsService: PlatformUtilsService, i18nService: I18nService, logService: LogService, route: ActivatedRoute, + ngZone: NgZone, private dialogService: DialogService, - accountService: AccountService, ) { super( passwordGenerationService, usernameGenerationService, platformUtilsService, - stateService, + accountService, i18nService, logService, route, - accountService, + ngZone, window, ); if (platformUtilsService.isSelfHost()) { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c812f5fbec..1f7b714fc8 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -193,13 +193,11 @@ import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; import { - PasswordGenerationService, - PasswordGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/password"; -import { - UsernameGenerationService, - UsernameGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/username"; + legacyPasswordGenerationServiceFactory, + legacyUsernameGenerationServiceFactory, +} from "@bitwarden/common/tools/generator"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, @@ -559,13 +557,27 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: PasswordGenerationServiceAbstraction, - useClass: PasswordGenerationService, - deps: [CryptoServiceAbstraction, PolicyServiceAbstraction, StateServiceAbstraction], + useFactory: legacyPasswordGenerationServiceFactory, + deps: [ + EncryptService, + CryptoServiceAbstraction, + PolicyServiceAbstraction, + AccountServiceAbstraction, + StateProvider, + ], }), safeProvider({ provide: UsernameGenerationServiceAbstraction, - useClass: UsernameGenerationService, - deps: [CryptoServiceAbstraction, StateServiceAbstraction, ApiServiceAbstraction], + useFactory: legacyUsernameGenerationServiceFactory, + deps: [ + ApiServiceAbstraction, + I18nServiceAbstraction, + CryptoServiceAbstraction, + EncryptService, + PolicyServiceAbstraction, + AccountServiceAbstraction, + StateProvider, + ], }), safeProvider({ provide: ApiServiceAbstraction, diff --git a/libs/angular/src/tools/generator/components/generator.component.ts b/libs/angular/src/tools/generator/components/generator.component.ts index 5015fca7fc..b94d9bc6f0 100644 --- a/libs/angular/src/tools/generator/components/generator.component.ts +++ b/libs/angular/src/tools/generator/components/generator.component.ts @@ -1,15 +1,14 @@ -import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Directive, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { debounceTime, first, map } from "rxjs/operators"; +import { BehaviorSubject, combineLatest, firstValueFrom, Subject } from "rxjs"; +import { debounceTime, first, map, skipWhile, takeUntil } from "rxjs/operators"; import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.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 { GeneratorOptions } from "@bitwarden/common/tools/generator/generator-options"; +import { GeneratorType } from "@bitwarden/common/tools/generator/generator-type"; import { PasswordGenerationServiceAbstraction, PasswordGeneratorOptions, @@ -22,9 +21,9 @@ import { import { EmailForwarderOptions } from "@bitwarden/common/tools/models/domain/email-forwarder-options"; @Directive() -export class GeneratorComponent implements OnInit { +export class GeneratorComponent implements OnInit, OnDestroy { @Input() comingFromAddEdit = false; - @Input() type: string; + @Input() type: GeneratorType | ""; @Output() onSelected = new EventEmitter(); usernameGeneratingPromise: Promise; @@ -43,6 +42,9 @@ export class GeneratorComponent implements OnInit { enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions; usernameWebsite: string = null; + private destroy$ = new Subject(); + private isInitialized$ = new BehaviorSubject(false); + // update screen reader minimum password length with 500ms debounce // so that the user isn't flooded with status updates private _passwordOptionsMinLengthForReader = new BehaviorSubject( @@ -53,15 +55,17 @@ export class GeneratorComponent implements OnInit { debounceTime(500), ); + private _password = new BehaviorSubject("-"); + constructor( protected passwordGenerationService: PasswordGenerationServiceAbstraction, protected usernameGenerationService: UsernameGenerationServiceAbstraction, protected platformUtilsService: PlatformUtilsService, - protected stateService: StateService, + protected accountService: AccountService, protected i18nService: I18nService, protected logService: LogService, protected route: ActivatedRoute, - protected accountService: AccountService, + protected ngZone: NgZone, private win: Window, ) { this.typeOptions = [ @@ -92,61 +96,115 @@ export class GeneratorComponent implements OnInit { ]; this.subaddressOptions = [{ name: i18nService.t("random"), value: "random" }]; this.catchallOptions = [{ name: i18nService.t("random"), value: "random" }]; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.initForwardOptions(); - } - async ngOnInit() { - // eslint-disable-next-line rxjs/no-async-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - const passwordOptionsResponse = await this.passwordGenerationService.getOptions(); - this.passwordOptions = passwordOptionsResponse[0]; - this.enforcedPasswordPolicyOptions = passwordOptionsResponse[1]; - this.avoidAmbiguous = !this.passwordOptions.ambiguous; - this.passwordOptions.type = - this.passwordOptions.type === "passphrase" ? "passphrase" : "password"; + this.forwardOptions = [ + { name: "", value: "", validForSelfHosted: false }, + { name: "addy.io", value: "anonaddy", validForSelfHosted: true }, + { name: "DuckDuckGo", value: "duckduckgo", validForSelfHosted: false }, + { name: "Fastmail", value: "fastmail", validForSelfHosted: true }, + { name: "Firefox Relay", value: "firefoxrelay", validForSelfHosted: false }, + { name: "SimpleLogin", value: "simplelogin", validForSelfHosted: true }, + { name: "Forward Email", value: "forwardemail", validForSelfHosted: true }, + ].sort((a, b) => a.name.localeCompare(b.name)); - this.usernameOptions = await this.usernameGenerationService.getOptions(); - if (this.usernameOptions.type == null) { - this.usernameOptions.type = "word"; - } - if ( - this.usernameOptions.subaddressEmail == null || - this.usernameOptions.subaddressEmail === "" - ) { - this.usernameOptions.subaddressEmail = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.email)), - ); - } - if (this.usernameWebsite == null) { - this.usernameOptions.subaddressType = this.usernameOptions.catchallType = "random"; - } else { - this.usernameOptions.website = this.usernameWebsite; - const websiteOption = { name: this.i18nService.t("websiteName"), value: "website-name" }; - this.subaddressOptions.push(websiteOption); - this.catchallOptions.push(websiteOption); - } - - if (this.type !== "username" && this.type !== "password") { - if (qParams.type === "username" || qParams.type === "password") { - this.type = qParams.type; - } else { - const generatorOptions = await this.stateService.getGeneratorOptions(); - this.type = generatorOptions?.type ?? "password"; - } - } - if (this.regenerateWithoutButtonPress()) { - await this.regenerate(); - } + this._password.pipe(debounceTime(250)).subscribe((password) => { + ngZone.run(() => { + this.password = password; + }); + this.passwordGenerationService.addHistory(this.password).catch((e) => { + this.logService.error(e); + }); }); } - async typeChanged() { - await this.stateService.setGeneratorOptions({ type: this.type } as GeneratorOptions); - if (this.regenerateWithoutButtonPress()) { - await this.regenerate(); + cascadeOptions(navigationType: GeneratorType = undefined, accountEmail: string) { + this.avoidAmbiguous = !this.passwordOptions.ambiguous; + + if (!this.type) { + if (navigationType) { + this.type = navigationType; + } else { + this.type = this.passwordOptions.type === "username" ? "username" : "password"; + } } + + this.passwordOptions.type = + this.passwordOptions.type === "passphrase" ? "passphrase" : "password"; + + if (this.usernameOptions.type == null) { + this.usernameOptions.type = "word"; + } + if ( + this.usernameOptions.subaddressEmail == null || + this.usernameOptions.subaddressEmail === "" + ) { + this.usernameOptions.subaddressEmail = accountEmail; + } + if (this.usernameWebsite == null) { + this.usernameOptions.subaddressType = this.usernameOptions.catchallType = "random"; + } else { + this.usernameOptions.website = this.usernameWebsite; + const websiteOption = { name: this.i18nService.t("websiteName"), value: "website-name" }; + this.subaddressOptions.push(websiteOption); + this.catchallOptions.push(websiteOption); + } + } + + async ngOnInit() { + combineLatest([ + this.route.queryParams.pipe(first()), + this.accountService.activeAccount$.pipe(first()), + this.passwordGenerationService.getOptions$(), + this.usernameGenerationService.getOptions$(), + ]) + .pipe( + map(([qParams, account, [passwordOptions, passwordPolicy], usernameOptions]) => ({ + navigationType: qParams.type as GeneratorType, + accountEmail: account.email, + passwordOptions, + passwordPolicy, + usernameOptions, + })), + takeUntil(this.destroy$), + ) + .subscribe((options) => { + this.passwordOptions = options.passwordOptions; + this.enforcedPasswordPolicyOptions = options.passwordPolicy; + this.usernameOptions = options.usernameOptions; + + this.cascadeOptions(options.navigationType, options.accountEmail); + this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength); + + if (this.regenerateWithoutButtonPress()) { + this.regenerate().catch((e) => { + this.logService.error(e); + }); + } + + this.isInitialized$.next(true); + }); + + // once initialization is complete, `ngOnInit` should return. + // + // FIXME(#6944): if a sync is in progress, wait to complete until after + // the sync completes. + await firstValueFrom( + this.isInitialized$.pipe( + skipWhile((initialized) => !initialized), + takeUntil(this.destroy$), + ), + ); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + this.isInitialized$.complete(); + this._passwordOptionsMinLengthForReader.complete(); + } + + async typeChanged() { + await this.savePasswordOptions(); } async regenerate() { @@ -160,7 +218,7 @@ export class GeneratorComponent implements OnInit { async sliderChanged() { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.savePasswordOptions(false); + this.savePasswordOptions(); await this.passwordGenerationService.addHistory(this.password); } @@ -204,31 +262,34 @@ export class GeneratorComponent implements OnInit { async sliderInput() { await this.normalizePasswordOptions(); - this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); } - async savePasswordOptions(regenerate = true) { + async savePasswordOptions() { + // map navigation state into generator type + const restoreType = this.passwordOptions.type; + if (this.type === "username") { + this.passwordOptions.type = this.type; + } + + // save options await this.normalizePasswordOptions(); await this.passwordGenerationService.saveOptions(this.passwordOptions); - if (regenerate && this.regenerateWithoutButtonPress()) { - await this.regeneratePassword(); - } + // restore the original format + this.passwordOptions.type = restoreType; } - async saveUsernameOptions(regenerate = true) { + async saveUsernameOptions() { await this.usernameGenerationService.saveOptions(this.usernameOptions); if (this.usernameOptions.type === "forwarded") { this.username = "-"; } - if (regenerate && this.regenerateWithoutButtonPress()) { - await this.regenerateUsername(); - } } async regeneratePassword() { - this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); - await this.passwordGenerationService.addHistory(this.password); + this._password.next( + await this.passwordGenerationService.generatePassword(this.passwordOptions), + ); } regenerateUsername() { @@ -297,28 +358,5 @@ export class GeneratorComponent implements OnInit { await this.passwordGenerationService.enforcePasswordGeneratorPoliciesOnOptions( this.passwordOptions, ); - - this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength); - } - - private async initForwardOptions() { - this.forwardOptions = [ - { name: "addy.io", value: "anonaddy", validForSelfHosted: true }, - { name: "DuckDuckGo", value: "duckduckgo", validForSelfHosted: false }, - { name: "Fastmail", value: "fastmail", validForSelfHosted: true }, - { name: "Firefox Relay", value: "firefoxrelay", validForSelfHosted: false }, - { name: "SimpleLogin", value: "simplelogin", validForSelfHosted: true }, - { name: "Forward Email", value: "forwardemail", validForSelfHosted: true }, - ]; - - this.usernameOptions = await this.usernameGenerationService.getOptions(); - if ( - this.usernameOptions.forwardedService == null || - this.usernameOptions.forwardedService === "" - ) { - this.forwardOptions.push({ name: "", value: null, validForSelfHosted: false }); - } - - this.forwardOptions = this.forwardOptions.sort((a, b) => a.name.localeCompare(b.name)); } } diff --git a/libs/angular/src/tools/generator/components/password-generator-history.component.ts b/libs/angular/src/tools/generator/components/password-generator-history.component.ts index 7197e9bf4c..9ad0c0cbdb 100644 --- a/libs/angular/src/tools/generator/components/password-generator-history.component.ts +++ b/libs/angular/src/tools/generator/components/password-generator-history.component.ts @@ -23,8 +23,7 @@ export class PasswordGeneratorHistoryComponent implements OnInit { } clear = async () => { - this.history = []; - await this.passwordGenerationService.clear(); + this.history = await this.passwordGenerationService.clear(); }; copy(password: string) { diff --git a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts index 9d2e7eadd5..c52962a0a1 100644 --- a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts +++ b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts @@ -70,7 +70,7 @@ export class PasswordGeneratorPolicyOptions extends Domain { */ inEffect() { return ( - this.defaultType !== "" || + this.defaultType || this.minLength > 0 || this.numberCount > 0 || this.specialCount > 0 || diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index ed438cda88..d0543fb8c3 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -60,13 +60,16 @@ import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key import { KnownAccountsMigrator } from "./migrations/60-known-accounts"; import { PinStateMigrator } from "./migrations/61-move-pin-state-to-providers"; import { VaultTimeoutSettingsServiceStateProviderMigrator } from "./migrations/62-migrate-vault-timeout-settings-svc-to-state-provider"; +import { PasswordOptionsMigrator } from "./migrations/63-migrate-password-settings"; +import { GeneratorHistoryMigrator } from "./migrations/64-migrate-generator-history"; +import { ForwarderOptionsMigrator } from "./migrations/65-migrate-forwarder-settings"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 62; +export const CURRENT_VERSION = 65; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -130,7 +133,10 @@ export function createMigrationBuilder() { .with(KdfConfigMigrator, 58, 59) .with(KnownAccountsMigrator, 59, 60) .with(PinStateMigrator, 60, 61) - .with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, CURRENT_VERSION); + .with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, 62) + .with(PasswordOptionsMigrator, 62, 63) + .with(GeneratorHistoryMigrator, 63, 64) + .with(ForwarderOptionsMigrator, 64, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts b/libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts new file mode 100644 index 0000000000..adbe8a999f --- /dev/null +++ b/libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts @@ -0,0 +1,123 @@ +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + ExpectedOptions, + PasswordOptionsMigrator, + NAVIGATION, + PASSWORD, + PASSPHRASE, +} from "./63-migrate-password-settings"; + +function migrationHelper(passwordGenerationOptions: ExpectedOptions) { + const helper = mockMigrationHelper( + { + global_account_accounts: { + SomeAccount: { + email: "SomeAccount", + name: "SomeAccount", + emailVerified: true, + }, + }, + SomeAccount: { + settings: { + passwordGenerationOptions, + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }, + }, + 62, + ); + + return helper; +} + +function expectOtherSettingsRemain(helper: MigrationHelper) { + expect(helper.set).toHaveBeenCalledWith("SomeAccount", { + settings: { + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }); +} + +describe("PasswordOptionsMigrator", () => { + describe("migrate", () => { + it("migrates generator type", async () => { + const helper = migrationHelper({ + type: "password", + }); + helper.getFromUser.mockResolvedValue({ some: { other: "data" } }); + const migrator = new PasswordOptionsMigrator(62, 63); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", NAVIGATION, { + type: "password", + some: { other: "data" }, + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates password settings", async () => { + const helper = migrationHelper({ + length: 20, + ambiguous: true, + uppercase: false, + minUppercase: 4, + lowercase: true, + minLowercase: 3, + number: false, + minNumber: 2, + special: true, + minSpecial: 1, + }); + const migrator = new PasswordOptionsMigrator(62, 63); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", PASSWORD, { + length: 20, + ambiguous: true, + uppercase: false, + minUppercase: 4, + lowercase: true, + minLowercase: 3, + number: false, + minNumber: 2, + special: true, + minSpecial: 1, + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates passphrase settings", async () => { + const helper = migrationHelper({ + numWords: 5, + wordSeparator: "4", + capitalize: true, + includeNumber: false, + }); + const migrator = new PasswordOptionsMigrator(62, 63); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", PASSPHRASE, { + numWords: 5, + wordSeparator: "4", + capitalize: true, + includeNumber: false, + }); + expectOtherSettingsRemain(helper); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts b/libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts new file mode 100644 index 0000000000..a0849fd598 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts @@ -0,0 +1,150 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +/** settings targeted by migrator */ +export type AccountType = { + settings?: { + passwordGenerationOptions?: ExpectedOptions; + }; +}; + +export type GeneratorType = "password" | "passphrase" | "username"; + +/** username generation options prior to refactoring */ +export type ExpectedOptions = { + type?: GeneratorType; + length?: number; + minLength?: number; + ambiguous?: boolean; + uppercase?: boolean; + minUppercase?: number; + lowercase?: boolean; + minLowercase?: number; + number?: boolean; + minNumber?: number; + special?: boolean; + minSpecial?: number; + numWords?: number; + wordSeparator?: string; + capitalize?: boolean; + includeNumber?: boolean; +}; + +/** username generation options after refactoring */ +type ConvertedOptions = { + generator: GeneratorNavigation; + password: PasswordGenerationOptions; + passphrase: PassphraseGenerationOptions; +}; + +export const NAVIGATION: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "generatorSettings", +}; + +export const PASSWORD: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "passwordGeneratorSettings", +}; + +export const PASSPHRASE: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "passphraseGeneratorSettings", +}; + +export type GeneratorNavigation = { + type?: string; +}; + +export type PassphraseGenerationOptions = { + numWords?: number; + wordSeparator?: string; + capitalize?: boolean; + includeNumber?: boolean; +}; + +export type PasswordGenerationOptions = { + length?: number; + minLength?: number; + ambiguous?: boolean; + uppercase?: boolean; + minUppercase?: number; + lowercase?: boolean; + minLowercase?: number; + number?: boolean; + minNumber?: number; + special?: boolean; + minSpecial?: number; +}; + +export class PasswordOptionsMigrator extends Migrator<62, 63> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: AccountType) { + const legacyOptions = account?.settings?.passwordGenerationOptions; + + if (legacyOptions) { + const converted = convertSettings(legacyOptions); + await storeSettings(helper, userId, converted); + await deleteSettings(helper, userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + // not supported + } +} + +function convertSettings(options: ExpectedOptions): ConvertedOptions { + const password = { + length: options.length, + ambiguous: options.ambiguous, + uppercase: options.uppercase, + minUppercase: options.minUppercase, + lowercase: options.lowercase, + minLowercase: options.minLowercase, + number: options.number, + minNumber: options.minNumber, + special: options.special, + minSpecial: options.minSpecial, + }; + + const generator = { + type: options.type, + }; + + const passphrase = { + numWords: options.numWords, + wordSeparator: options.wordSeparator, + capitalize: options.capitalize, + includeNumber: options.includeNumber, + }; + + return { generator, password, passphrase }; +} + +async function storeSettings(helper: MigrationHelper, userId: string, converted: ConvertedOptions) { + const existing = (await helper.getFromUser(userId, NAVIGATION)) ?? {}; + const updated = Object.assign(existing, converted.generator); + + await Promise.all([ + helper.setToUser(userId, NAVIGATION, updated), + helper.setToUser(userId, PASSPHRASE, converted.passphrase), + helper.setToUser(userId, PASSWORD, converted.password), + ]); +} + +async function deleteSettings(helper: MigrationHelper, userId: string, account: AccountType) { + delete account?.settings?.passwordGenerationOptions; + await helper.set(userId, account); +} diff --git a/libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts b/libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts new file mode 100644 index 0000000000..3bcf15ceb3 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts @@ -0,0 +1,68 @@ +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + EncryptedHistory, + GeneratorHistoryMigrator, + HISTORY, +} from "./64-migrate-generator-history"; + +function migrationHelper(encrypted: EncryptedHistory) { + const helper = mockMigrationHelper( + { + global_account_accounts: { + SomeAccount: { + email: "SomeAccount", + name: "SomeAccount", + emailVerified: true, + }, + }, + SomeAccount: { + data: { + passwordGenerationHistory: { + encrypted, + }, + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }, + }, + 63, + ); + + return helper; +} + +function expectOtherSettingsRemain(helper: MigrationHelper) { + expect(helper.set).toHaveBeenCalledWith("SomeAccount", { + data: { + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }); +} + +describe("PasswordOptionsMigrator", () => { + describe("migrate", () => { + it("migrates generator type", async () => { + const helper = migrationHelper([{ this: "should be copied" }, { this: "too" }]); + const migrator = new GeneratorHistoryMigrator(63, 64); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", HISTORY, [ + { this: "should be copied" }, + { this: "too" }, + ]); + expectOtherSettingsRemain(helper); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts b/libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts new file mode 100644 index 0000000000..3ca4c64318 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts @@ -0,0 +1,42 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +/** settings targeted by migrator */ +export type AccountType = { + data?: { + passwordGenerationHistory?: { + encrypted: EncryptedHistory; + }; + }; +}; + +/** the actual data stored in the history is opaque to the migrator */ +export type EncryptedHistory = Array; + +export const HISTORY: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "localGeneratorHistoryBuffer", +}; + +export class GeneratorHistoryMigrator extends Migrator<63, 64> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: AccountType) { + const data = account?.data?.passwordGenerationHistory; + if (data && data.encrypted) { + await helper.setToUser(userId, HISTORY, data.encrypted); + delete account.data.passwordGenerationHistory; + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + // not supported + } +} diff --git a/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts b/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts new file mode 100644 index 0000000000..3fca95ada8 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts @@ -0,0 +1,218 @@ +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + ADDY_IO, + CATCHALL, + DUCK_DUCK_GO, + EFF_USERNAME, + ExpectedOptions, + FASTMAIL, + FIREFOX_RELAY, + FORWARD_EMAIL, + ForwarderOptionsMigrator, + NAVIGATION, + SIMPLE_LOGIN, + SUBADDRESS, +} from "./65-migrate-forwarder-settings"; + +function migrationHelper(usernameGenerationOptions: ExpectedOptions) { + const helper = mockMigrationHelper( + { + global_account_accounts: { + SomeAccount: { + email: "SomeAccount", + name: "SomeAccount", + emailVerified: true, + }, + }, + SomeAccount: { + settings: { + usernameGenerationOptions, + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }, + }, + 64, + ); + + return helper; +} + +function expectOtherSettingsRemain(helper: MigrationHelper) { + expect(helper.set).toHaveBeenCalledWith("SomeAccount", { + settings: { + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }); +} + +describe("ForwarderOptionsMigrator", () => { + describe("migrate", () => { + it("migrates generator settings", async () => { + const helper = migrationHelper({ + type: "catchall", + forwardedService: "simplelogin", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", NAVIGATION, { + username: "catchall", + forwarder: "simplelogin", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates catchall settings", async () => { + const helper = migrationHelper({ + catchallType: "random", + catchallDomain: "example.com", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", CATCHALL, { + catchallType: "random", + catchallDomain: "example.com", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates EFF username settings", async () => { + const helper = migrationHelper({ + wordCapitalize: true, + wordIncludeNumber: false, + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", EFF_USERNAME, { + wordCapitalize: true, + wordIncludeNumber: false, + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates subaddress settings", async () => { + const helper = migrationHelper({ + subaddressType: "random", + subaddressEmail: "j.d@example.com", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", SUBADDRESS, { + subaddressType: "random", + subaddressEmail: "j.d@example.com", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates addyIo settings", async () => { + const helper = migrationHelper({ + forwardedAnonAddyBaseUrl: "some_addyio_base", + forwardedAnonAddyApiToken: "some_addyio_token", + forwardedAnonAddyDomain: "some_addyio_domain", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", ADDY_IO, { + baseUrl: "some_addyio_base", + token: "some_addyio_token", + domain: "some_addyio_domain", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates DuckDuckGo settings", async () => { + const helper = migrationHelper({ + forwardedDuckDuckGoToken: "some_duckduckgo_token", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", DUCK_DUCK_GO, { + token: "some_duckduckgo_token", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates Firefox Relay settings", async () => { + const helper = migrationHelper({ + forwardedFirefoxApiToken: "some_firefox_token", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FIREFOX_RELAY, { + token: "some_firefox_token", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates Fastmail settings", async () => { + const helper = migrationHelper({ + forwardedFastmailApiToken: "some_fastmail_token", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FASTMAIL, { + token: "some_fastmail_token", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates ForwardEmail settings", async () => { + const helper = migrationHelper({ + forwardedForwardEmailApiToken: "some_forwardemail_token", + forwardedForwardEmailDomain: "some_forwardemail_domain", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FORWARD_EMAIL, { + token: "some_forwardemail_token", + domain: "some_forwardemail_domain", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates SimpleLogin settings", async () => { + const helper = migrationHelper({ + forwardedSimpleLoginApiKey: "some_simplelogin_token", + forwardedSimpleLoginBaseUrl: "some_simplelogin_baseurl", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", SIMPLE_LOGIN, { + token: "some_simplelogin_token", + baseUrl: "some_simplelogin_baseurl", + }); + expectOtherSettingsRemain(helper); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts b/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts new file mode 100644 index 0000000000..6dad7ae342 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts @@ -0,0 +1,245 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +/** settings targeted by migrator */ +export type AccountType = { + settings?: { + usernameGenerationOptions?: ExpectedOptions; + }; +}; + +/** username generation options prior to refactoring */ +export type ExpectedOptions = { + type?: "word" | "subaddress" | "catchall" | "forwarded"; + wordCapitalize?: boolean; + wordIncludeNumber?: boolean; + subaddressType?: "random" | "website-name"; + subaddressEmail?: string; + catchallType?: "random" | "website-name"; + catchallDomain?: string; + forwardedService?: string; + forwardedAnonAddyApiToken?: string; + forwardedAnonAddyDomain?: string; + forwardedAnonAddyBaseUrl?: string; + forwardedDuckDuckGoToken?: string; + forwardedFirefoxApiToken?: string; + forwardedFastmailApiToken?: string; + forwardedForwardEmailApiToken?: string; + forwardedForwardEmailDomain?: string; + forwardedSimpleLoginApiKey?: string; + forwardedSimpleLoginBaseUrl?: string; +}; + +/** username generation options after refactoring */ +type ConvertedOptions = { + generator: GeneratorNavigation; + algorithms: { + catchall: CatchallGenerationOptions; + effUsername: EffUsernameGenerationOptions; + subaddress: SubaddressGenerationOptions; + }; + forwarders: { + addyIo: SelfHostedApiOptions & EmailDomainOptions; + duckDuckGo: ApiOptions; + fastmail: ApiOptions; + firefoxRelay: ApiOptions; + forwardEmail: ApiOptions & EmailDomainOptions; + simpleLogin: SelfHostedApiOptions; + }; +}; + +export const NAVIGATION: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "generatorSettings", +}; + +export const CATCHALL: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "catchallGeneratorSettings", +}; + +export const EFF_USERNAME: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "effUsernameGeneratorSettings", +}; + +export const SUBADDRESS: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "subaddressGeneratorSettings", +}; + +export const ADDY_IO: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "addyIoBuffer", +}; + +export const DUCK_DUCK_GO: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "duckDuckGoBuffer", +}; + +export const FASTMAIL: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "fastmailBuffer", +}; + +export const FIREFOX_RELAY: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "firefoxRelayBuffer", +}; + +export const FORWARD_EMAIL: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "forwardEmailBuffer", +}; + +export const SIMPLE_LOGIN: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "simpleLoginBuffer", +}; + +export type GeneratorNavigation = { + type?: string; + username?: string; + forwarder?: string; +}; + +type UsernameGenerationMode = "random" | "website-name"; + +type CatchallGenerationOptions = { + catchallType?: UsernameGenerationMode; + catchallDomain?: string; +}; + +type EffUsernameGenerationOptions = { + wordCapitalize?: boolean; + wordIncludeNumber?: boolean; +}; + +type SubaddressGenerationOptions = { + subaddressType?: UsernameGenerationMode; + subaddressEmail?: string; +}; + +type ApiOptions = { + token?: string; +}; + +type SelfHostedApiOptions = ApiOptions & { + baseUrl: string; +}; + +type EmailDomainOptions = { + domain: string; +}; + +export class ForwarderOptionsMigrator extends Migrator<64, 65> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: AccountType) { + const legacyOptions = account?.settings?.usernameGenerationOptions; + + if (legacyOptions) { + const converted = convertSettings(legacyOptions); + await storeSettings(helper, userId, converted); + await deleteSettings(helper, userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + // not supported + } +} + +function convertSettings(options: ExpectedOptions): ConvertedOptions { + const forwarders = { + addyIo: { + baseUrl: options.forwardedAnonAddyBaseUrl, + token: options.forwardedAnonAddyApiToken, + domain: options.forwardedAnonAddyDomain, + }, + duckDuckGo: { + token: options.forwardedDuckDuckGoToken, + }, + fastmail: { + token: options.forwardedFastmailApiToken, + }, + firefoxRelay: { + token: options.forwardedFirefoxApiToken, + }, + forwardEmail: { + token: options.forwardedForwardEmailApiToken, + domain: options.forwardedForwardEmailDomain, + }, + simpleLogin: { + token: options.forwardedSimpleLoginApiKey, + baseUrl: options.forwardedSimpleLoginBaseUrl, + }, + }; + + const generator = { + username: options.type, + forwarder: options.forwardedService, + }; + + const algorithms = { + effUsername: { + wordCapitalize: options.wordCapitalize, + wordIncludeNumber: options.wordIncludeNumber, + }, + subaddress: { + subaddressType: options.subaddressType, + subaddressEmail: options.subaddressEmail, + }, + catchall: { + catchallType: options.catchallType, + catchallDomain: options.catchallDomain, + }, + }; + + return { generator, algorithms, forwarders }; +} + +async function storeSettings(helper: MigrationHelper, userId: string, converted: ConvertedOptions) { + await Promise.all([ + helper.setToUser(userId, NAVIGATION, converted.generator), + helper.setToUser(userId, CATCHALL, converted.algorithms.catchall), + helper.setToUser(userId, EFF_USERNAME, converted.algorithms.effUsername), + helper.setToUser(userId, SUBADDRESS, converted.algorithms.subaddress), + helper.setToUser(userId, ADDY_IO, converted.forwarders.addyIo), + helper.setToUser(userId, DUCK_DUCK_GO, converted.forwarders.duckDuckGo), + helper.setToUser(userId, FASTMAIL, converted.forwarders.fastmail), + helper.setToUser(userId, FIREFOX_RELAY, converted.forwarders.firefoxRelay), + helper.setToUser(userId, FORWARD_EMAIL, converted.forwarders.forwardEmail), + helper.setToUser(userId, SIMPLE_LOGIN, converted.forwarders.simpleLogin), + ]); +} + +async function deleteSettings(helper: MigrationHelper, userId: string, account: AccountType) { + delete account?.settings?.usernameGenerationOptions; + await helper.set(userId, account); +} diff --git a/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts index edda0dcb2b..a1d358a13f 100644 --- a/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts @@ -38,6 +38,12 @@ export abstract class GeneratorHistoryService { */ take: (userId: UserId, credential: string) => Promise; + /** Deletes a user's credential history. + * @param userId identifies the user taking the credential. + * @returns A promise that completes when the history is cleared. + */ + clear: (userId: UserId) => Promise; + /** Lists all credentials for a user. * @param userId identifies the user listing the credential. * @remarks This field is eventually consistent with `track` and `take` operations. diff --git a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts index 7cfe320abe..7bc0f21739 100644 --- a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts @@ -23,9 +23,6 @@ export abstract class GeneratorStrategy { /** Identifies the policy enforced by the generator. */ policy: PolicyType; - /** Length of time in milliseconds to cache the evaluator */ - cache_ms: number; - /** Operator function that converts a policy collection observable to a single * policy evaluator observable. * @param policy The policy being evaluated. diff --git a/libs/common/src/tools/generator/abstractions/index.ts b/libs/common/src/tools/generator/abstractions/index.ts index 13dce17d17..ef40dfd434 100644 --- a/libs/common/src/tools/generator/abstractions/index.ts +++ b/libs/common/src/tools/generator/abstractions/index.ts @@ -1,3 +1,4 @@ +export { GeneratorHistoryService } from "./generator-history.abstraction"; export { GeneratorNavigationService } from "./generator-navigation.service.abstraction"; export { GeneratorService } from "./generator.service.abstraction"; export { GeneratorStrategy } from "./generator-strategy.abstraction"; diff --git a/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts index b3bd30be5c..f6b5ca9cab 100644 --- a/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; import { GeneratedPasswordHistory } from "../password/generated-password-history"; import { PasswordGeneratorOptions } from "../password/password-generator-options"; @@ -7,11 +9,12 @@ export abstract class PasswordGenerationServiceAbstraction { generatePassword: (options: PasswordGeneratorOptions) => Promise; generatePassphrase: (options: PasswordGeneratorOptions) => Promise; getOptions: () => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; + getOptions$: () => Observable<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; enforcePasswordGeneratorPoliciesOnOptions: ( options: PasswordGeneratorOptions, ) => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; saveOptions: (options: PasswordGeneratorOptions) => Promise; getHistory: () => Promise; addHistory: (password: string) => Promise; - clear: (userId?: string) => Promise; + clear: (userId?: string) => Promise; } diff --git a/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts index 02b25e6113..f11cbf02ed 100644 --- a/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { UsernameGeneratorOptions } from "../username/username-generation-options"; /** @deprecated Use {@link GeneratorService} with a username {@link GeneratorStrategy} instead. */ @@ -8,5 +10,6 @@ export abstract class UsernameGenerationServiceAbstraction { generateCatchall: (options: UsernameGeneratorOptions) => Promise; generateForwarded: (options: UsernameGeneratorOptions) => Promise; getOptions: () => Promise; + getOptions$: () => Observable; saveOptions: (options: UsernameGeneratorOptions) => Promise; } diff --git a/libs/common/src/tools/generator/default-generator.service.spec.ts b/libs/common/src/tools/generator/default-generator.service.spec.ts index c93aec44d9..94d7d62fa8 100644 --- a/libs/common/src/tools/generator/default-generator.service.spec.ts +++ b/libs/common/src/tools/generator/default-generator.service.spec.ts @@ -23,13 +23,9 @@ import { DefaultGeneratorService } from "."; function mockPolicyService(config?: { state?: BehaviorSubject }) { const service = mock(); - // FIXME: swap out the mock return value when `getAll$` becomes available const stateValue = config?.state ?? new BehaviorSubject([null]); service.getAll$.mockReturnValue(stateValue); - // const stateValue = config?.state ?? new BehaviorSubject(null); - // service.getAll$.mockReturnValue(stateValue); - return service; } diff --git a/libs/common/src/tools/generator/default-generator.service.ts b/libs/common/src/tools/generator/default-generator.service.ts index 7fd794472c..cec8b75f0c 100644 --- a/libs/common/src/tools/generator/default-generator.service.ts +++ b/libs/common/src/tools/generator/default-generator.service.ts @@ -7,6 +7,13 @@ import { UserId } from "../../types/guid"; import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "./abstractions"; +type DefaultGeneratorServiceTuning = { + /* amount of time to keep the most recent policy after a subscription ends. Once the + * cache expires, the ignoreQty and timeoutMs settings apply to the next lookup. + */ + policyCacheMs: number; +}; + /** {@link GeneratorServiceAbstraction} */ export class DefaultGeneratorService implements GeneratorService { /** Instantiates the generator service @@ -17,8 +24,18 @@ export class DefaultGeneratorService implements GeneratorServic constructor( private strategy: GeneratorStrategy, private policy: PolicyService, - ) {} + tuning: Partial = {}, + ) { + this.tuning = Object.assign( + { + // a minute + policyCacheMs: 60000, + }, + tuning, + ); + } + private tuning: DefaultGeneratorServiceTuning; private _evaluators$ = new Map>>(); /** {@link GeneratorService.options$} */ @@ -57,7 +74,7 @@ export class DefaultGeneratorService implements GeneratorServic // and reduce GC pressure. share({ connector: () => new ReplaySubject(1), - resetOnRefCountZero: () => timer(this.strategy.cache_ms), + resetOnRefCountZero: () => timer(this.tuning.policyCacheMs), }), ); diff --git a/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts b/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts new file mode 100644 index 0000000000..6c59ca837c --- /dev/null +++ b/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts @@ -0,0 +1,29 @@ +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { UserId } from "../../../types/guid"; +import { GeneratedPasswordHistory } from "../password/generated-password-history"; + +/** Strategy that decrypts a password history */ +export class LegacyPasswordHistoryDecryptor { + constructor( + private userId: UserId, + private cryptoService: CryptoService, + private encryptService: EncryptService, + ) {} + + /** Decrypts a password history. */ + async decrypt(history: GeneratedPasswordHistory[]): Promise { + const key = await this.cryptoService.getUserKey(this.userId); + + const promises = (history ?? []).map(async (item) => { + const encrypted = new EncString(item.password); + const decrypted = await this.encryptService.decryptToUtf8(encrypted, key); + return new GeneratedPasswordHistory(decrypted, item.date); + }); + + const decrypted = await Promise.all(promises); + + return decrypted; + } +} diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts index 57dde51fc1..9640016584 100644 --- a/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts +++ b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts @@ -1,5 +1,5 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../spec"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; @@ -24,6 +24,7 @@ describe("LocalGeneratorHistoryService", () => { encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString)); keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); + keyService.getInMemoryUserKeyFor$.mockImplementation(() => of(true as unknown as UserKey)); }); afterEach(() => { diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.ts b/libs/common/src/tools/generator/history/local-generator-history.service.ts index 3a65890c50..dd93e630ca 100644 --- a/libs/common/src/tools/generator/history/local-generator-history.service.ts +++ b/libs/common/src/tools/generator/history/local-generator-history.service.ts @@ -5,12 +5,14 @@ import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { SingleUserState, StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorHistoryService } from "../abstractions/generator-history.abstraction"; -import { GENERATOR_HISTORY } from "../key-definitions"; +import { GENERATOR_HISTORY, GENERATOR_HISTORY_BUFFER } from "../key-definitions"; +import { BufferedState } from "../state/buffered-state"; import { PaddedDataPacker } from "../state/padded-data-packer"; import { SecretState } from "../state/secret-state"; import { UserKeyEncryptor } from "../state/user-key-encryptor"; import { GeneratedCredential } from "./generated-credential"; +import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor"; import { GeneratorCategory, HistoryServiceOptions } from "./options"; const OPTIONS_FRAME_SIZE = 2048; @@ -51,7 +53,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { }, { shouldUpdate: (credentials) => - credentials?.some((f) => f.credential !== credential) ?? true, + !(credentials?.some((f) => f.credential === credential) ?? false), }, ); @@ -82,6 +84,13 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { return result; }; + /** {@link GeneratorHistoryService.take} */ + clear = async (userId: UserId) => { + const state = this.getCredentialState(userId); + const result = (await state.update(() => null)) ?? []; + return result; + }; + /** {@link GeneratorHistoryService.credentials$} */ credentials$ = (userId: UserId) => { return this.getCredentialState(userId).state$.pipe(map((credentials) => credentials ?? [])); @@ -98,11 +107,12 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { return state; } - private createSecretState(userId: UserId) { + private createSecretState(userId: UserId): SingleUserState { // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + // construct the durable state const state = SecretState.from< GeneratedCredential[], number, @@ -111,6 +121,25 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { GeneratedCredential >(userId, GENERATOR_HISTORY, this.stateProvider, encryptor); - return state; + // decryptor is just an algorithm, but it can't run until the key is available; + // providing it via an observable makes running it early impossible + const decryptor = new LegacyPasswordHistoryDecryptor( + userId, + this.keyService, + this.encryptService, + ); + const decryptor$ = this.keyService + .getInMemoryUserKeyFor$(userId) + .pipe(map((key) => key && decryptor)); + + // move data from the old password history once decryptor is available + const buffer = new BufferedState( + this.stateProvider, + GENERATOR_HISTORY_BUFFER, + state, + decryptor$, + ); + + return buffer; } } diff --git a/libs/common/src/tools/generator/index.ts b/libs/common/src/tools/generator/index.ts index ae35c9ce0a..9df054a502 100644 --- a/libs/common/src/tools/generator/index.ts +++ b/libs/common/src/tools/generator/index.ts @@ -2,3 +2,5 @@ export * from "./abstractions/index"; export * from "./password/index"; export { DefaultGeneratorService } from "./default-generator.service"; +export { legacyPasswordGenerationServiceFactory } from "./legacy-password-generation.service"; +export { legacyUsernameGenerationServiceFactory } from "./legacy-username-generation.service"; diff --git a/libs/common/src/tools/generator/key-definition.spec.ts b/libs/common/src/tools/generator/key-definition.spec.ts index 9cbbc44e14..d4992af0b1 100644 --- a/libs/common/src/tools/generator/key-definition.spec.ts +++ b/libs/common/src/tools/generator/key-definition.spec.ts @@ -1,3 +1,7 @@ +import { mock } from "jest-mock-extended"; + +import { GeneratedCredential } from "./history"; +import { LegacyPasswordHistoryDecryptor } from "./history/legacy-password-history-decryptor"; import { EFF_USERNAME_SETTINGS, CATCHALL_SETTINGS, @@ -11,7 +15,15 @@ import { DUCK_DUCK_GO_FORWARDER, ADDY_IO_FORWARDER, GENERATOR_SETTINGS, + ADDY_IO_BUFFER, + DUCK_DUCK_GO_BUFFER, + FASTMAIL_BUFFER, + FIREFOX_RELAY_BUFFER, + FORWARD_EMAIL_BUFFER, + SIMPLE_LOGIN_BUFFER, + GENERATOR_HISTORY_BUFFER, } from "./key-definitions"; +import { GeneratedPasswordHistory } from "./password"; describe("Key definitions", () => { describe("GENERATOR_SETTINGS", () => { @@ -109,4 +121,121 @@ describe("Key definitions", () => { expect(result).toBe(value); }); }); + + describe("ADDY_IO_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = ADDY_IO_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("DUCK_DUCK_GO_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = DUCK_DUCK_GO_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FASTMAIL_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FASTMAIL_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FIREFOX_RELAY_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FIREFOX_RELAY_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FORWARD_EMAIL_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FORWARD_EMAIL_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("SIMPLE_LOGIN_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = SIMPLE_LOGIN_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("GENERATOR_HISTORY_BUFFER", () => { + describe("options.deserializer", () => { + it("should deserialize generated password history", () => { + const value: any = [{ password: "foo", date: 1 }]; + + const [result] = GENERATOR_HISTORY_BUFFER.options.deserializer(value); + + expect(result).toEqual(value[0]); + expect(result).toBeInstanceOf(GeneratedPasswordHistory); + }); + + it.each([[undefined], [null]])("should ignore nullish (= %p) history", (value: any) => { + const result = GENERATOR_HISTORY_BUFFER.options.deserializer(value); + + expect(result).toEqual(undefined); + }); + }); + + it("should map generated password history to generated credentials", async () => { + const value: any = [new GeneratedPasswordHistory("foo", 1)]; + const decryptor = mock({ + decrypt(value) { + return Promise.resolve(value); + }, + }); + + const [result] = await GENERATOR_HISTORY_BUFFER.map(value, decryptor); + + expect(result).toEqual({ + credential: "foo", + category: "password", + generationDate: new Date(1), + }); + expect(result).toBeInstanceOf(GeneratedCredential); + }); + + describe("isValid", () => { + it("should accept histories with at least one entry", async () => { + const value: any = [new GeneratedPasswordHistory("foo", 1)]; + const decryptor = {} as any; + + const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor); + + expect(result).toEqual(true); + }); + + it("should reject histories with no entries", async () => { + const value: any = []; + const decryptor = {} as any; + + const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor); + + expect(result).toEqual(false); + }); + }); + }); }); diff --git a/libs/common/src/tools/generator/key-definitions.ts b/libs/common/src/tools/generator/key-definitions.ts index 074df48468..1ce2ec8ad1 100644 --- a/libs/common/src/tools/generator/key-definitions.ts +++ b/libs/common/src/tools/generator/key-definitions.ts @@ -1,9 +1,14 @@ -import { GENERATOR_DISK, GENERATOR_MEMORY, UserKeyDefinition } from "../../platform/state"; +import { Jsonify } from "type-fest"; + +import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state"; import { GeneratedCredential } from "./history/generated-credential"; +import { LegacyPasswordHistoryDecryptor } from "./history/legacy-password-history-decryptor"; import { GeneratorNavigation } from "./navigation/generator-navigation"; import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options"; +import { GeneratedPasswordHistory } from "./password/generated-password-history"; import { PasswordGenerationOptions } from "./password/password-generation-options"; +import { BufferedKeyDefinition } from "./state/buffered-key-definition"; import { SecretClassifier } from "./state/secret-classifier"; import { SecretKeyDefinition } from "./state/secret-key-definition"; import { CatchallGenerationOptions } from "./username/catchall-generator-options"; @@ -18,11 +23,11 @@ import { SubaddressGenerationOptions } from "./username/subaddress-generator-opt /** plaintext password generation options */ export const GENERATOR_SETTINGS = new UserKeyDefinition( - GENERATOR_MEMORY, + GENERATOR_DISK, "generatorSettings", { deserializer: (value) => value, - clearOn: ["lock", "logout"], + clearOn: ["logout"], }, ); @@ -136,6 +141,66 @@ export const SIMPLE_LOGIN_FORWARDER = new UserKeyDefinition( + GENERATOR_DISK, + "addyIoBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.DuckDuckGo} */ +export const DUCK_DUCK_GO_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "duckDuckGoBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.FastMail} */ +export const FASTMAIL_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "fastmailBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.FireFoxRelay} */ +export const FIREFOX_RELAY_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "firefoxRelayBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.ForwardEmail} */ +export const FORWARD_EMAIL_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "forwardEmailBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link forwarders.SimpleLogin} */ +export const SIMPLE_LOGIN_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "simpleLoginBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + /** encrypted password generation history */ export const GENERATOR_HISTORY = SecretKeyDefinition.array( GENERATOR_DISK, @@ -146,3 +211,24 @@ export const GENERATOR_HISTORY = SecretKeyDefinition.array( clearOn: ["logout"], }, ); + +/** encrypted password generation history subject to migration */ +export const GENERATOR_HISTORY_BUFFER = new BufferedKeyDefinition< + GeneratedPasswordHistory[], + GeneratedCredential[], + LegacyPasswordHistoryDecryptor +>(GENERATOR_DISK, "localGeneratorHistoryBuffer", { + deserializer(history) { + const items = history as Jsonify[]; + return items?.map((h) => new GeneratedPasswordHistory(h.password, h.date)); + }, + async isValid(history) { + return history.length ? true : false; + }, + async map(history, decryptor) { + const credentials = await decryptor.decrypt(history); + const mapped = credentials.map((c) => new GeneratedCredential(c.password, "password", c.date)); + return mapped; + }, + clearOn: ["logout"], +}); diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts b/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts index 093c68b3e8..c86bb9f8b0 100644 --- a/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts +++ b/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts @@ -8,7 +8,12 @@ import { of } from "rxjs"; import { mockAccountServiceWith } from "../../../spec"; import { UserId } from "../../types/guid"; -import { GeneratorNavigationService, GeneratorService } from "./abstractions"; +import { + GeneratorHistoryService, + GeneratorNavigationService, + GeneratorService, +} from "./abstractions"; +import { GeneratedCredential } from "./history"; import { LegacyPasswordGenerationService } from "./legacy-password-generation.service"; import { DefaultGeneratorNavigation, GeneratorNavigation } from "./navigation/generator-navigation"; import { GeneratorNavigationEvaluator } from "./navigation/generator-navigation-evaluator"; @@ -22,6 +27,7 @@ import { import { DisabledPassphraseGeneratorPolicy } from "./passphrase/passphrase-generator-policy"; import { DefaultPasswordGenerationOptions, + GeneratedPasswordHistory, PasswordGenerationOptions, PasswordGeneratorOptions, PasswordGeneratorOptionsEvaluator, @@ -97,10 +103,10 @@ function createNavigationGenerator( defaults$(id: UserId) { return of(DefaultGeneratorNavigation); }, - saveOptions(userId, options) { + saveOptions: jest.fn((userId, options) => { savedOptions = options; return Promise.resolve(); - }, + }), }); return generator; @@ -113,7 +119,7 @@ describe("LegacyPasswordGenerationService", () => { describe("generatePassword", () => { it("invokes the inner password generator to generate passwords", async () => { const innerPassword = createPasswordGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null); + const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null, null); const options = { type: "password" } as PasswordGeneratorOptions; await generator.generatePassword(options); @@ -123,7 +129,13 @@ describe("LegacyPasswordGenerationService", () => { it("invokes the inner passphrase generator to generate passphrases", async () => { const innerPassphrase = createPassphraseGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, null, innerPassphrase); + const generator = new LegacyPasswordGenerationService( + null, + null, + null, + innerPassphrase, + null, + ); const options = { type: "passphrase" } as PasswordGeneratorOptions; await generator.generatePassword(options); @@ -135,7 +147,13 @@ describe("LegacyPasswordGenerationService", () => { describe("generatePassphrase", () => { it("invokes the inner passphrase generator", async () => { const innerPassphrase = createPassphraseGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, null, innerPassphrase); + const generator = new LegacyPasswordGenerationService( + null, + null, + null, + innerPassphrase, + null, + ); const options = {} as PasswordGeneratorOptions; await generator.generatePassphrase(options); @@ -157,7 +175,7 @@ describe("LegacyPasswordGenerationService", () => { number: true, minNumber: 3, special: false, - minSpecial: 4, + minSpecial: 0, }); const innerPassphrase = createPassphraseGenerator({ numWords: 10, @@ -176,29 +194,29 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [result] = await generator.getOptions(); expect(result).toEqual({ type: "passphrase", - username: "word", - forwarder: "simplelogin", length: 29, - minLength: 20, + minLength: 5, ambiguous: false, uppercase: true, minUppercase: 1, lowercase: false, - minLowercase: 2, + minLowercase: 0, number: true, minNumber: 3, special: false, - minSpecial: 4, + minSpecial: 0, numWords: 10, wordSeparator: "-", capitalize: true, includeNumber: false, + policyUpdated: true, }); }); @@ -212,14 +230,18 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [result] = await generator.getOptions(); expect(result).toEqual({ - ...DefaultGeneratorNavigation, + type: DefaultGeneratorNavigation.type, ...DefaultPassphraseGenerationOptions, ...DefaultPasswordGenerationOptions, + minLowercase: 1, + minUppercase: 1, + policyUpdated: true, }); }); @@ -256,6 +278,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [, policy] = await generator.getOptions(); @@ -301,6 +324,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); @@ -340,6 +364,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); @@ -385,6 +410,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({}); @@ -416,22 +442,21 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const options = { type: "password" as const, - username: "word" as const, - forwarder: "simplelogin" as const, length: 29, - minLength: 20, + minLength: 5, ambiguous: false, uppercase: true, minUppercase: 1, lowercase: false, - minLowercase: 2, + minLowercase: 0, number: true, minNumber: 3, special: false, - minSpecial: 4, + minSpecial: 0, }; await generator.saveOptions(options); @@ -450,11 +475,10 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const options = { type: "passphrase" as const, - username: "word" as const, - forwarder: "simplelogin" as const, numWords: 10, wordSeparator: "-", capitalize: true, @@ -466,5 +490,78 @@ describe("LegacyPasswordGenerationService", () => { expect(result).toMatchObject(options); }); + + it("preserves saved navigation options", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator(); + const navigation = createNavigationGenerator({ + type: "password", + username: "forwarded", + forwarder: "firefoxrelay", + }); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + const options = { + type: "passphrase" as const, + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + }; + + await generator.saveOptions(options); + + expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { + type: "passphrase", + username: "forwarded", + forwarder: "firefoxrelay", + }); + }); + }); + + describe("getHistory", () => { + it("gets the active user's history from the history service", async () => { + const history = mock(); + history.credentials$.mockReturnValue( + of([new GeneratedCredential("foo", "password", new Date(100))]), + ); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + null, + null, + null, + history, + ); + + const result = await generator.getHistory(); + + expect(history.credentials$).toHaveBeenCalledWith(SomeUser); + expect(result).toEqual([new GeneratedPasswordHistory("foo", 100)]); + }); + }); + + describe("addHistory", () => { + it("adds a history item as a password credential", async () => { + const history = mock(); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + null, + null, + null, + history, + ); + + await generator.addHistory("foo"); + + expect(history.track).toHaveBeenCalledWith(SomeUser, "foo", "password"); + }); }); }); diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.ts b/libs/common/src/tools/generator/legacy-password-generation.service.ts index 0b429b356b..74b2ab46e6 100644 --- a/libs/common/src/tools/generator/legacy-password-generation.service.ts +++ b/libs/common/src/tools/generator/legacy-password-generation.service.ts @@ -1,21 +1,42 @@ -import { concatMap, zip, map, firstValueFrom } from "rxjs"; +import { + concatMap, + zip, + map, + firstValueFrom, + combineLatest, + pairwise, + of, + concat, + Observable, +} from "rxjs"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; import { PasswordGeneratorPolicyOptions } from "../../admin-console/models/domain/password-generator-policy-options"; import { AccountService } from "../../auth/abstractions/account.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { StateProvider } from "../../platform/state"; -import { GeneratorService, GeneratorNavigationService } from "./abstractions"; +import { + GeneratorHistoryService, + GeneratorService, + GeneratorNavigationService, + PolicyEvaluator, +} from "./abstractions"; import { PasswordGenerationServiceAbstraction } from "./abstractions/password-generation.service.abstraction"; import { DefaultGeneratorService } from "./default-generator.service"; +import { GeneratedCredential } from "./history"; +import { LocalGeneratorHistoryService } from "./history/local-generator-history.service"; +import { GeneratorNavigation } from "./navigation"; import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service"; +import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy, PassphraseGeneratorStrategy, } from "./passphrase"; import { + GeneratedPasswordHistory, PasswordGenerationOptions, PasswordGenerationService, PasswordGeneratorOptions, @@ -23,7 +44,15 @@ import { PasswordGeneratorStrategy, } from "./password"; +type MappedOptions = { + generator: GeneratorNavigation; + password: PasswordGenerationOptions; + passphrase: PassphraseGenerationOptions; + policyUpdated: boolean; +}; + export function legacyPasswordGenerationServiceFactory( + encryptService: EncryptService, cryptoService: CryptoService, policyService: PolicyService, accountService: AccountService, @@ -45,7 +74,15 @@ export function legacyPasswordGenerationServiceFactory( const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService); - return new LegacyPasswordGenerationService(accountService, navigation, passwords, passphrases); + const history = new LocalGeneratorHistoryService(encryptService, cryptoService, stateProvider); + + return new LegacyPasswordGenerationService( + accountService, + navigation, + passwords, + passphrases, + history, + ); } /** Adapts the generator 2.0 design to 1.0 angular services. */ @@ -61,6 +98,7 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic PassphraseGenerationOptions, PassphraseGeneratorPolicy >, + private readonly history: GeneratorHistoryService, ) {} generatePassword(options: PasswordGeneratorOptions) { @@ -75,21 +113,112 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic return this.passphrases.generate(options); } - async getOptions() { - const options$ = this.accountService.activeAccount$.pipe( + private getRawOptions$() { + // give the typechecker a nudge to avoid "implicit any" errors + type RawOptionsIntermediateType = [ + PasswordGenerationOptions, + PasswordGenerationOptions, + [PolicyEvaluator, number], + PassphraseGenerationOptions, + PassphraseGenerationOptions, + [PolicyEvaluator, number], + GeneratorNavigation, + GeneratorNavigation, + [PolicyEvaluator, number], + ]; + + function withSequenceNumber(observable$: Observable) { + return observable$.pipe(map((evaluator, i) => [evaluator, i] as const)); + } + + // initial array ensures that destructuring never fails; sequence numbers + // set to `-1` so that the first update reflects that the policy changed from + // "unknown" to "whatever was provided by the service". This needs to be called + // each time the active user changes or the `concat` will block. + function initial$() { + const initial: RawOptionsIntermediateType = [ + null, + null, + [null, -1], + null, + null, + [null, -1], + null, + null, + [null, -1], + ]; + + return of(initial); + } + + function intermediatePairsToRawOptions([previous, current]: [ + RawOptionsIntermediateType, + RawOptionsIntermediateType, + ]) { + const [, , [, passwordPrevious], , , [, passphrasePrevious], , , [, generatorPrevious]] = + previous; + const [ + passwordOptions, + passwordDefaults, + [passwordEvaluator, passwordCurrent], + passphraseOptions, + passphraseDefaults, + [passphraseEvaluator, passphraseCurrent], + generatorOptions, + generatorDefaults, + [generatorEvaluator, generatorCurrent], + ] = current; + + // when any of the sequence numbers change, the emission occurs as the result of + // a policy update + const policyEmitted = + passwordPrevious < passwordCurrent || + passphrasePrevious < passphraseCurrent || + generatorPrevious < generatorCurrent; + + const result = [ + passwordOptions, + passwordDefaults, + passwordEvaluator, + passphraseOptions, + passphraseDefaults, + passphraseEvaluator, + generatorOptions, + generatorDefaults, + generatorEvaluator, + policyEmitted, + ] as const; + + return result; + } + + // look upon my works, ye mighty, and despair! + const rawOptions$ = this.accountService.activeAccount$.pipe( concatMap((activeUser) => - zip( - this.passwords.options$(activeUser.id), - this.passwords.defaults$(activeUser.id), - this.passwords.evaluator$(activeUser.id), - this.passphrases.options$(activeUser.id), - this.passphrases.defaults$(activeUser.id), - this.passphrases.evaluator$(activeUser.id), - this.navigation.options$(activeUser.id), - this.navigation.defaults$(activeUser.id), - this.navigation.evaluator$(activeUser.id), + concat( + initial$(), + combineLatest([ + this.passwords.options$(activeUser.id), + this.passwords.defaults$(activeUser.id), + withSequenceNumber(this.passwords.evaluator$(activeUser.id)), + this.passphrases.options$(activeUser.id), + this.passphrases.defaults$(activeUser.id), + withSequenceNumber(this.passphrases.evaluator$(activeUser.id)), + this.navigation.options$(activeUser.id), + this.navigation.defaults$(activeUser.id), + withSequenceNumber(this.navigation.evaluator$(activeUser.id)), + ]), ), ), + pairwise(), + map(intermediatePairsToRawOptions), + ); + + return rawOptions$; + } + + getOptions$() { + const options$ = this.getRawOptions$().pipe( map( ([ passwordOptions, @@ -101,14 +230,25 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic generatorOptions, generatorDefaults, generatorEvaluator, + policyUpdated, ]) => { - const options: PasswordGeneratorOptions = Object.assign( - {}, + const passwordOptionsWithPolicy = passwordEvaluator.applyPolicy( passwordOptions ?? passwordDefaults, + ); + const passphraseOptionsWithPolicy = passphraseEvaluator.applyPolicy( passphraseOptions ?? passphraseDefaults, + ); + const generatorOptionsWithPolicy = generatorEvaluator.applyPolicy( generatorOptions ?? generatorDefaults, ); + const options = this.toPasswordGeneratorOptions({ + password: passwordEvaluator.sanitize(passwordOptionsWithPolicy), + passphrase: passphraseEvaluator.sanitize(passphraseOptionsWithPolicy), + generator: generatorEvaluator.sanitize(generatorOptionsWithPolicy), + policyUpdated, + }); + const policy = Object.assign( new PasswordGeneratorPolicyOptions(), passwordEvaluator.policy, @@ -116,13 +256,16 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic generatorEvaluator.policy, ); - return [options, policy] as [PasswordGenerationOptions, PasswordGeneratorPolicyOptions]; + return [options, policy] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]; }, ), ); - const options = await firstValueFrom(options$); - return options; + return options$; + } + + async getOptions() { + return await firstValueFrom(this.getOptions$()); } async enforcePasswordGeneratorPoliciesOnOptions(options: PasswordGeneratorOptions) { @@ -164,21 +307,103 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic // callers assume this function updates the options parameter Object.assign(options, sanitized), policy, - ] as [PasswordGenerationOptions, PasswordGeneratorPolicyOptions]; + ] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]; } async saveOptions(options: PasswordGeneratorOptions) { + const stored = this.toStoredOptions(options); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - await this.navigation.saveOptions(activeAccount.id, options); - if (options.type === "password") { - await this.passwords.saveOptions(activeAccount.id, options); - } else { - await this.passphrases.saveOptions(activeAccount.id, options); - } + // generator settings needs to preserve whether password or passphrase is selected, + // so `navigationOptions` is mutated. + const navigationOptions$ = zip( + this.navigation.options$(activeAccount.id), + this.navigation.defaults$(activeAccount.id), + ).pipe(map(([options, defaults]) => options ?? defaults)); + let navigationOptions = await firstValueFrom(navigationOptions$); + navigationOptions = Object.assign(navigationOptions, stored.generator); + await this.navigation.saveOptions(activeAccount.id, navigationOptions); + + // overwrite all other settings with latest values + await this.passwords.saveOptions(activeAccount.id, stored.password); + await this.passphrases.saveOptions(activeAccount.id, stored.passphrase); } - getHistory: () => Promise; - addHistory: (password: string) => Promise; - clear: (userId?: string) => Promise; + private toStoredOptions(options: PasswordGeneratorOptions): MappedOptions { + return { + generator: { + type: options.type, + }, + password: { + length: options.length, + minLength: options.minLength, + ambiguous: options.ambiguous, + uppercase: options.uppercase, + minUppercase: options.minUppercase, + lowercase: options.lowercase, + minLowercase: options.minLowercase, + number: options.number, + minNumber: options.minNumber, + special: options.special, + minSpecial: options.minSpecial, + }, + passphrase: { + numWords: options.numWords, + wordSeparator: options.wordSeparator, + capitalize: options.capitalize, + includeNumber: options.includeNumber, + }, + policyUpdated: false, + }; + } + + private toPasswordGeneratorOptions(options: MappedOptions): PasswordGeneratorOptions { + return { + type: options.generator.type, + length: options.password.length, + minLength: options.password.minLength, + ambiguous: options.password.ambiguous, + uppercase: options.password.uppercase, + minUppercase: options.password.minUppercase, + lowercase: options.password.lowercase, + minLowercase: options.password.minLowercase, + number: options.password.number, + minNumber: options.password.minNumber, + special: options.password.special, + minSpecial: options.password.minSpecial, + numWords: options.passphrase.numWords, + wordSeparator: options.passphrase.wordSeparator, + capitalize: options.passphrase.capitalize, + includeNumber: options.passphrase.includeNumber, + policyUpdated: options.policyUpdated, + }; + } + + getHistory() { + const history = this.accountService.activeAccount$.pipe( + concatMap((account) => this.history.credentials$(account.id)), + map((history) => history.map(toGeneratedPasswordHistory)), + ); + + return firstValueFrom(history); + } + + async addHistory(password: string) { + const account = await firstValueFrom(this.accountService.activeAccount$); + // legacy service doesn't distinguish credential types + await this.history.track(account.id, password, "password"); + } + + clear() { + const history$ = this.accountService.activeAccount$.pipe( + concatMap((account) => this.history.clear(account.id)), + map((history) => history.map(toGeneratedPasswordHistory)), + ); + + return firstValueFrom(history$); + } +} + +function toGeneratedPasswordHistory(value: GeneratedCredential) { + return new GeneratedPasswordHistory(value.credential, value.generationDate.valueOf()); } diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts b/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts index 41d9c78dd2..8b5c8b81e5 100644 --- a/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts +++ b/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts @@ -94,7 +94,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a catchall username", async () => { const options = { type: "catchall" } as UsernameGeneratorOptions; const catchall = createGenerator(null, null); - catchall.generate.mockReturnValue(Promise.resolve("catchall@example.com")); + catchall.generate.mockResolvedValue("catchall@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -118,7 +118,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate an EFF word username", async () => { const options = { type: "word" } as UsernameGeneratorOptions; const effWord = createGenerator(null, null); - effWord.generate.mockReturnValue(Promise.resolve("eff word")); + effWord.generate.mockResolvedValue("eff word"); const generator = new LegacyUsernameGenerationService( null, null, @@ -142,7 +142,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a subaddress username", async () => { const options = { type: "subaddress" } as UsernameGeneratorOptions; const subaddress = createGenerator(null, null); - subaddress.generate.mockReturnValue(Promise.resolve("subaddress@example.com")); + subaddress.generate.mockResolvedValue("subaddress@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -170,7 +170,7 @@ describe("LegacyUsernameGenerationService", () => { forwardedService: Forwarders.AddyIo.id, } as UsernameGeneratorOptions; const addyIo = createGenerator(null, null); - addyIo.generate.mockReturnValue(Promise.resolve("addyio@example.com")); + addyIo.generate.mockResolvedValue("addyio@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -196,7 +196,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a catchall username", async () => { const options = { type: "catchall" } as UsernameGeneratorOptions; const catchall = createGenerator(null, null); - catchall.generate.mockReturnValue(Promise.resolve("catchall@example.com")); + catchall.generate.mockResolvedValue("catchall@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -222,7 +222,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a subaddress username", async () => { const options = { type: "subaddress" } as UsernameGeneratorOptions; const subaddress = createGenerator(null, null); - subaddress.generate.mockReturnValue(Promise.resolve("subaddress@example.com")); + subaddress.generate.mockResolvedValue("subaddress@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -254,7 +254,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const addyIo = createGenerator(null, null); - addyIo.generate.mockReturnValue(Promise.resolve("addyio@example.com")); + addyIo.generate.mockResolvedValue("addyio@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -287,7 +287,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const duckDuckGo = createGenerator(null, null); - duckDuckGo.generate.mockReturnValue(Promise.resolve("ddg@example.com")); + duckDuckGo.generate.mockResolvedValue("ddg@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -318,7 +318,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const fastmail = createGenerator(null, null); - fastmail.generate.mockReturnValue(Promise.resolve("fastmail@example.com")); + fastmail.generate.mockResolvedValue("fastmail@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -349,7 +349,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const firefoxRelay = createGenerator(null, null); - firefoxRelay.generate.mockReturnValue(Promise.resolve("firefoxrelay@example.com")); + firefoxRelay.generate.mockResolvedValue("firefoxrelay@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -381,7 +381,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const forwardEmail = createGenerator(null, null); - forwardEmail.generate.mockReturnValue(Promise.resolve("forwardemail@example.com")); + forwardEmail.generate.mockResolvedValue("forwardemail@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -414,7 +414,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const simpleLogin = createGenerator(null, null); - simpleLogin.generate.mockReturnValue(Promise.resolve("simplelogin@example.com")); + simpleLogin.generate.mockResolvedValue("simplelogin@example.com"); const generator = new LegacyUsernameGenerationService( null, null, diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.ts b/libs/common/src/tools/generator/legacy-username-generation.service.ts index 7611a86c27..61c19ee314 100644 --- a/libs/common/src/tools/generator/legacy-username-generation.service.ts +++ b/libs/common/src/tools/generator/legacy-username-generation.service.ts @@ -1,4 +1,4 @@ -import { zip, firstValueFrom, map, concatMap } from "rxjs"; +import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs"; import { ApiService } from "../../abstractions/api.service"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; @@ -56,7 +56,7 @@ type MappedOptions = { }; }; -export function legacyPasswordGenerationServiceFactory( +export function legacyUsernameGenerationServiceFactory( apiService: ApiService, i18nService: I18nService, cryptoService: CryptoService, @@ -205,10 +205,11 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic } } - getOptions() { + getOptions$() { + // look upon my works, ye mighty, and despair! const options$ = this.accountService.activeAccount$.pipe( concatMap((account) => - zip( + combineLatest([ this.navigation.options$(account.id), this.navigation.defaults$(account.id), this.catchall.options$(account.id), @@ -229,7 +230,7 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic this.forwardEmail.defaults$(account.id), this.simpleLogin.options$(account.id), this.simpleLogin.defaults$(account.id), - ), + ]), ), map( ([ @@ -273,30 +274,38 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic ), ); - return firstValueFrom(options$); + return options$; + } + + getOptions() { + return firstValueFrom(this.getOptions$()); } async saveOptions(options: UsernameGeneratorOptions) { const stored = this.toStoredOptions(options); - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a.id))); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); // generator settings needs to preserve whether password or passphrase is selected, // so `navigationOptions` is mutated. - let navigationOptions = await firstValueFrom(this.navigation.options$(userId)); + const navigationOptions$ = zip( + this.navigation.options$(activeAccount.id), + this.navigation.defaults$(activeAccount.id), + ).pipe(map(([options, defaults]) => options ?? defaults)); + let navigationOptions = await firstValueFrom(navigationOptions$); navigationOptions = Object.assign(navigationOptions, stored.generator); - await this.navigation.saveOptions(userId, navigationOptions); + await this.navigation.saveOptions(activeAccount.id, navigationOptions); // overwrite all other settings with latest values await Promise.all([ - this.catchall.saveOptions(userId, stored.algorithms.catchall), - this.effUsername.saveOptions(userId, stored.algorithms.effUsername), - this.subaddress.saveOptions(userId, stored.algorithms.subaddress), - this.addyIo.saveOptions(userId, stored.forwarders.addyIo), - this.duckDuckGo.saveOptions(userId, stored.forwarders.duckDuckGo), - this.fastmail.saveOptions(userId, stored.forwarders.fastmail), - this.firefoxRelay.saveOptions(userId, stored.forwarders.firefoxRelay), - this.forwardEmail.saveOptions(userId, stored.forwarders.forwardEmail), - this.simpleLogin.saveOptions(userId, stored.forwarders.simpleLogin), + this.catchall.saveOptions(activeAccount.id, stored.algorithms.catchall), + this.effUsername.saveOptions(activeAccount.id, stored.algorithms.effUsername), + this.subaddress.saveOptions(activeAccount.id, stored.algorithms.subaddress), + this.addyIo.saveOptions(activeAccount.id, stored.forwarders.addyIo), + this.duckDuckGo.saveOptions(activeAccount.id, stored.forwarders.duckDuckGo), + this.fastmail.saveOptions(activeAccount.id, stored.forwarders.fastmail), + this.firefoxRelay.saveOptions(activeAccount.id, stored.forwarders.firefoxRelay), + this.forwardEmail.saveOptions(activeAccount.id, stored.forwarders.forwardEmail), + this.simpleLogin.saveOptions(activeAccount.id, stored.forwarders.simpleLogin), ]); } diff --git a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts index 3199efc8c3..e5c259d841 100644 --- a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts +++ b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts @@ -6,7 +6,7 @@ import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorNavigationService } from "../abstractions/generator-navigation.service.abstraction"; import { GENERATOR_SETTINGS } from "../key-definitions"; -import { reduceCollection } from "../reduce-collection.operator"; +import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation"; import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; @@ -42,6 +42,7 @@ export class DefaultGeneratorNavigationService implements GeneratorNavigationSer evaluator$(userId: UserId) { const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe( reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy), + distinctIfShallowMatch(), map((policy) => new GeneratorNavigationEvaluator(policy)), ); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts index adcfc39527..6ad1bd90dd 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts @@ -84,15 +84,6 @@ describe("Password generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new PassphraseGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts index 1a7c24082f..c7b5ff8b78 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts @@ -6,7 +6,7 @@ import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; import { PASSPHRASE_SETTINGS } from "../key-definitions"; -import { reduceCollection } from "../reduce-collection.operator"; +import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; import { PassphraseGenerationOptions, @@ -19,8 +19,6 @@ import { leastPrivilege, } from "./passphrase-generator-policy"; -const ONE_MINUTE = 60 * 1000; - /** {@link GeneratorStrategy} */ export class PassphraseGeneratorStrategy implements GeneratorStrategy @@ -49,15 +47,11 @@ export class PassphraseGeneratorStrategy return PolicyType.PasswordGenerator; } - /** {@link GeneratorStrategy.cache_ms} */ - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe( reduceCollection(leastPrivilege, DisabledPassphraseGeneratorPolicy), + distinctIfShallowMatch(), map((policy) => new PassphraseGeneratorOptionsEvaluator(policy)), ); } diff --git a/libs/common/src/tools/generator/password/password-generation.service.ts b/libs/common/src/tools/generator/password/password-generation.service.ts index fced2dfe43..e193b0fd33 100644 --- a/libs/common/src/tools/generator/password/password-generation.service.ts +++ b/libs/common/src/tools/generator/password/password-generation.service.ts @@ -1,3 +1,5 @@ +import { from } from "rxjs"; + import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "../../../admin-console/enums"; import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; @@ -171,6 +173,10 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr return wordList.join(o.wordSeparator); } + getOptions$() { + return from(this.getOptions()); + } + async getOptions(): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> { let options = await this.stateService.getPasswordGenerationOptions(); if (options == null) { @@ -336,9 +342,10 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr return await this.stateService.setEncryptedPasswordGenerationHistory(newHistory); } - async clear(userId?: string): Promise { + async clear(userId?: string): Promise { await this.stateService.setEncryptedPasswordGenerationHistory(null, { userId: userId }); await this.stateService.setDecryptedPasswordGenerationHistory(null, { userId: userId }); + return []; } private capitalize(str: string) { diff --git a/libs/common/src/tools/generator/password/password-generator-options.ts b/libs/common/src/tools/generator/password/password-generator-options.ts index aa0a6f7dab..04a2f8c77a 100644 --- a/libs/common/src/tools/generator/password/password-generator-options.ts +++ b/libs/common/src/tools/generator/password/password-generator-options.ts @@ -8,4 +8,4 @@ import { PasswordGenerationOptions } from "./password-generation-options"; */ export type PasswordGeneratorOptions = PasswordGenerationOptions & PassphraseGenerationOptions & - GeneratorNavigation; + GeneratorNavigation & { policyUpdated?: boolean }; diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts index 5efc6a85a7..a7509e8b43 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts @@ -93,15 +93,6 @@ describe("Password generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new PasswordGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.ts b/libs/common/src/tools/generator/password/password-generator-strategy.ts index e98ae6fb16..23828d7b59 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.ts @@ -6,7 +6,7 @@ import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; import { PASSWORD_SETTINGS } from "../key-definitions"; -import { reduceCollection } from "../reduce-collection.operator"; +import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; import { DefaultPasswordGenerationOptions, @@ -19,8 +19,6 @@ import { leastPrivilege, } from "./password-generator-policy"; -const ONE_MINUTE = 60 * 1000; - /** {@link GeneratorStrategy} */ export class PasswordGeneratorStrategy implements GeneratorStrategy @@ -48,14 +46,11 @@ export class PasswordGeneratorStrategy return PolicyType.PasswordGenerator; } - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe( reduceCollection(leastPrivilege, DisabledPasswordGeneratorPolicy), + distinctIfShallowMatch(), map((policy) => new PasswordGeneratorOptionsEvaluator(policy)), ); } diff --git a/libs/common/src/tools/generator/reduce-collection.operator.spec.ts b/libs/common/src/tools/generator/reduce-collection.operator.spec.ts deleted file mode 100644 index 49648dfdf0..0000000000 --- a/libs/common/src/tools/generator/reduce-collection.operator.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ - -import { of, firstValueFrom } from "rxjs"; - -import { reduceCollection } from "./reduce-collection.operator"; - -describe("reduceCollection", () => { - it.each([[null], [undefined], [[]]])( - "should return the default value when the collection is %p", - async (value: number[]) => { - const reduce = (acc: number, value: number) => acc + value; - const source$ = of(value); - - const result$ = source$.pipe(reduceCollection(reduce, 100)); - const result = await firstValueFrom(result$); - - expect(result).toEqual(100); - }, - ); - - it("should reduce the collection to a single value", async () => { - const reduce = (acc: number, value: number) => acc + value; - const source$ = of([1, 2, 3]); - - const result$ = source$.pipe(reduceCollection(reduce, 0)); - const result = await firstValueFrom(result$); - - expect(result).toEqual(6); - }); -}); diff --git a/libs/common/src/tools/generator/rx-operators.spec.ts b/libs/common/src/tools/generator/rx-operators.spec.ts new file mode 100644 index 0000000000..3d7dd4530f --- /dev/null +++ b/libs/common/src/tools/generator/rx-operators.spec.ts @@ -0,0 +1,87 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ + +import { of, firstValueFrom } from "rxjs"; + +import { awaitAsync, trackEmissions } from "../../../spec"; + +import { distinctIfShallowMatch, reduceCollection } from "./rx-operators"; + +describe("reduceCollection", () => { + it.each([[null], [undefined], [[]]])( + "should return the default value when the collection is %p", + async (value: number[]) => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of(value); + + const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result = await firstValueFrom(result$); + + expect(result).toEqual(100); + }, + ); + + it("should reduce the collection to a single value", async () => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of([1, 2, 3]); + + const result$ = source$.pipe(reduceCollection(reduce, 0)); + const result = await firstValueFrom(result$); + + expect(result).toEqual(6); + }); +}); + +describe("distinctIfShallowMatch", () => { + it("emits a single value", async () => { + const source$ = of({ foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); + }); + + it("emits different values", async () => { + const source$ = of({ foo: true }, { foo: false }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: false }]); + }); + + it("emits new keys", async () => { + const source$ = of({ foo: true }, { foo: true, bar: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: true, bar: true }]); + }); + + it("suppresses identical values", async () => { + const source$ = of({ foo: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); + }); + + it("suppresses removed keys", async () => { + const source$ = of({ foo: true, bar: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true, bar: true }]); + }); +}); diff --git a/libs/common/src/tools/generator/reduce-collection.operator.ts b/libs/common/src/tools/generator/rx-operators.ts similarity index 55% rename from libs/common/src/tools/generator/reduce-collection.operator.ts rename to libs/common/src/tools/generator/rx-operators.ts index 224595eeba..6524ef7994 100644 --- a/libs/common/src/tools/generator/reduce-collection.operator.ts +++ b/libs/common/src/tools/generator/rx-operators.ts @@ -1,4 +1,4 @@ -import { map, OperatorFunction } from "rxjs"; +import { distinctUntilChanged, map, OperatorFunction } from "rxjs"; /** * An observable operator that reduces an emitted collection to a single object, @@ -18,3 +18,21 @@ export function reduceCollection( return reduced; }); } + +/** + * An observable operator that emits distinct values by checking that all + * values in the previous entry match the next entry. This method emits + * when a key is added and does not when a key is removed. + * @remarks This method checks objects. It does not check items in arrays. + */ +export function distinctIfShallowMatch(): OperatorFunction { + return distinctUntilChanged((previous, current) => { + let isDistinct = true; + + for (const key in current) { + isDistinct &&= previous[key] === current[key]; + } + + return isDistinct; + }); +} diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts index 52cfa00aaf..30f11b1e89 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts @@ -59,15 +59,6 @@ describe("Email subaddress list generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new CatchallGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts index 5111b06e90..ee86fd9fd6 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts @@ -11,8 +11,6 @@ import { NoPolicy } from "../no-policy"; import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options"; -const ONE_MINUTE = 60 * 1000; - /** Strategy for creating usernames using a catchall email address */ export class CatchallGeneratorStrategy implements GeneratorStrategy @@ -42,11 +40,6 @@ export class CatchallGeneratorStrategy return PolicyType.PasswordGenerator; } - /** {@link GeneratorStrategy.cache_ms} */ - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts index 9b0e4cc069..76e51f609c 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts @@ -59,15 +59,6 @@ describe("EFF long word list generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new EffUsernameGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts index 1a4efdcb44..70d1f85420 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts @@ -14,8 +14,6 @@ import { EffUsernameGenerationOptions, } from "./eff-username-generator-options"; -const ONE_MINUTE = 60 * 1000; - /** Strategy for creating usernames from the EFF wordlist */ export class EffUsernameGeneratorStrategy implements GeneratorStrategy @@ -45,11 +43,6 @@ export class EffUsernameGeneratorStrategy return PolicyType.PasswordGenerator; } - /** {@link GeneratorStrategy.cache_ms} */ - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts index c2a606eae0..d3bec745f1 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts @@ -10,9 +10,10 @@ import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { UserKey } from "../../../types/key"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; -import { DUCK_DUCK_GO_FORWARDER } from "../key-definitions"; -import { SecretState } from "../state/secret-state"; +import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../key-definitions"; +import { BufferedState } from "../state/buffered-state"; import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy"; import { DefaultDuckDuckGoOptions } from "./forwarders/duck-duck-go"; @@ -32,6 +33,10 @@ class TestForwarder extends ForwarderGeneratorStrategy { return DUCK_DUCK_GO_FORWARDER; } + get rolloverKey() { + return DUCK_DUCK_GO_BUFFER; + } + defaults$ = (userId: UserId) => { return of(DefaultDuckDuckGoOptions); }; @@ -51,13 +56,22 @@ describe("ForwarderGeneratorStrategy", () => { const keyService = mock(); const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + beforeEach(() => { + const keyAvailable = of({} as UserKey); + keyService.getInMemoryUserKeyFor$.mockReturnValue(keyAvailable); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + describe("durableState", () => { it("constructs a secret state", () => { const strategy = new TestForwarder(encryptService, keyService, stateProvider); const result = strategy.durableState(SomeUser); - expect(result).toBeInstanceOf(SecretState); + expect(result).toBeInstanceOf(BufferedState); }); it("returns the same secret state for a single user", () => { diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts index b4205b9fc9..1abefcc23c 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts @@ -8,6 +8,8 @@ import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { NoPolicy } from "../no-policy"; +import { BufferedKeyDefinition } from "../state/buffered-key-definition"; +import { BufferedState } from "../state/buffered-state"; import { PaddedDataPacker } from "../state/padded-data-packer"; import { SecretClassifier } from "../state/secret-classifier"; import { SecretKeyDefinition } from "../state/secret-key-definition"; @@ -16,7 +18,6 @@ import { UserKeyEncryptor } from "../state/user-key-encryptor"; import { ApiOptions } from "./options/forwarder-options"; -const ONE_MINUTE = 60 * 1000; const OPTIONS_FRAME_SIZE = 512; /** An email forwarding service configurable through an API. */ @@ -37,8 +38,6 @@ export abstract class ForwarderGeneratorStrategy< // Uses password generator since there aren't policies // specific to usernames. this.policy = PolicyType.PasswordGenerator; - - this.cache_ms = ONE_MINUTE; } private durableStates = new Map>(); @@ -48,25 +47,7 @@ export abstract class ForwarderGeneratorStrategy< let state = this.durableStates.get(userId); if (!state) { - const encryptor = this.createEncryptor(); - // always exclude request properties - const classifier = SecretClassifier.allSecret().exclude("website"); - - // Derive the secret key definition - const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { - deserializer: (d) => this.key.deserializer(d), - cleanupDelayMs: this.key.cleanupDelayMs, - clearOn: this.key.clearOn, - }); - - // the type parameter is explicit because type inference fails for `Omit` - state = SecretState.from< - Options, - void, - Options, - Record, - Omit - >(userId, key, this.stateProvider, encryptor); + state = this.createState(userId); this.durableStates.set(userId, state); } @@ -74,10 +55,42 @@ export abstract class ForwarderGeneratorStrategy< return state; }; - private createEncryptor() { + private createState(userId: UserId): SingleUserState { // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); - return new UserKeyEncryptor(this.encryptService, this.keyService, packer); + const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + + // always exclude request properties + const classifier = SecretClassifier.allSecret().exclude("website"); + + // Derive the secret key definition + const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { + deserializer: (d) => this.key.deserializer(d), + cleanupDelayMs: this.key.cleanupDelayMs, + clearOn: this.key.clearOn, + }); + + // the type parameter is explicit because type inference fails for `Omit` + const secretState = SecretState.from< + Options, + void, + Options, + Record, + Omit + >(userId, key, this.stateProvider, encryptor); + + // rollover should occur once the user key is available for decryption + const canDecrypt$ = this.keyService + .getInMemoryUserKeyFor$(userId) + .pipe(map((key) => key !== null)); + const rolloverState = new BufferedState( + this.stateProvider, + this.rolloverKey, + secretState, + canDecrypt$, + ); + + return rolloverState; } /** Gets the default options. */ @@ -86,6 +99,9 @@ export abstract class ForwarderGeneratorStrategy< /** Determine where forwarder configuration is stored */ protected abstract readonly key: UserKeyDefinition; + /** Determine where forwarder rollover configuration is stored */ + protected abstract readonly rolloverKey: BufferedKeyDefinition; + /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator = () => { return pipe(map((_) => new DefaultPolicyEvaluator())); diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.ts index 3e4960f7e7..1212174951 100644 --- a/libs/common/src/tools/generator/username/forwarders/addy-io.ts +++ b/libs/common/src/tools/generator/username/forwarders/addy-io.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { ADDY_IO_FORWARDER } from "../../key-definitions"; +import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailDomainOptions, SelfHostedApiOptions } from "../options/forwarder-options"; @@ -44,6 +44,11 @@ export class AddyIoForwarder extends ForwarderGeneratorStrategy< return ADDY_IO_FORWARDER; } + /** {@link ForwarderGeneratorStrategy.rolloverKey} */ + get rolloverKey() { + return ADDY_IO_BUFFER; + } + /** {@link ForwarderGeneratorStrategy.defaults$} */ defaults$ = (userId: UserId) => { return new BehaviorSubject({ ...DefaultAddyIoOptions }); diff --git a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts index 9b5d93d742..4a9040d74a 100644 --- a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts +++ b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { DUCK_DUCK_GO_FORWARDER } from "../../key-definitions"; +import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { ApiOptions } from "../options/forwarder-options"; @@ -40,6 +40,11 @@ export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy return DUCK_DUCK_GO_FORWARDER; } + /** {@link ForwarderGeneratorStrategy.rolloverKey} */ + get rolloverKey() { + return DUCK_DUCK_GO_BUFFER; + } + /** {@link ForwarderGeneratorStrategy.defaults$} */ defaults$ = (userId: UserId) => { return new BehaviorSubject({ ...DefaultDuckDuckGoOptions }); diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.ts index 9d62cd0039..e6ab28b59e 100644 --- a/libs/common/src/tools/generator/username/forwarders/fastmail.ts +++ b/libs/common/src/tools/generator/username/forwarders/fastmail.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { FASTMAIL_FORWARDER } from "../../key-definitions"; +import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailPrefixOptions, ApiOptions } from "../options/forwarder-options"; @@ -47,6 +47,11 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy { if (!options.token || options.token === "") { diff --git a/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts b/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts index a4122c53f8..c381d3d3f2 100644 --- a/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts +++ b/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { FIREFOX_RELAY_FORWARDER } from "../../key-definitions"; +import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { ApiOptions } from "../options/forwarder-options"; @@ -40,6 +40,11 @@ export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy { return new BehaviorSubject({ ...DefaultFirefoxRelayOptions }); diff --git a/libs/common/src/tools/generator/username/forwarders/forward-email.ts b/libs/common/src/tools/generator/username/forwarders/forward-email.ts index 93f4680414..af654d3917 100644 --- a/libs/common/src/tools/generator/username/forwarders/forward-email.ts +++ b/libs/common/src/tools/generator/username/forwarders/forward-email.ts @@ -7,7 +7,7 @@ import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { Utils } from "../../../../platform/misc/utils"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { FORWARD_EMAIL_FORWARDER } from "../../key-definitions"; +import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailDomainOptions, ApiOptions } from "../options/forwarder-options"; @@ -49,6 +49,11 @@ export class ForwardEmailForwarder extends ForwarderGeneratorStrategy< return new BehaviorSubject({ ...DefaultForwardEmailOptions }); }; + /** {@link ForwarderGeneratorStrategy.rolloverKey} */ + get rolloverKey() { + return FORWARD_EMAIL_BUFFER; + } + /** {@link ForwarderGeneratorStrategy.generate} */ generate = async (options: ApiOptions & EmailDomainOptions) => { if (!options.token || options.token === "") { diff --git a/libs/common/src/tools/generator/username/forwarders/simple-login.ts b/libs/common/src/tools/generator/username/forwarders/simple-login.ts index d047fc42d1..ee91a41145 100644 --- a/libs/common/src/tools/generator/username/forwarders/simple-login.ts +++ b/libs/common/src/tools/generator/username/forwarders/simple-login.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { SIMPLE_LOGIN_FORWARDER } from "../../key-definitions"; +import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { SelfHostedApiOptions } from "../options/forwarder-options"; @@ -41,6 +41,11 @@ export class SimpleLoginForwarder extends ForwarderGeneratorStrategy { return new BehaviorSubject({ ...DefaultSimpleLoginOptions }); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts index 827bc7aed0..b5ac9c4cf9 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts @@ -62,15 +62,6 @@ describe("Email subaddress list generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new SubaddressGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts index 818741f8a9..6106d6d476 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts @@ -14,8 +14,6 @@ import { SubaddressGenerationOptions, } from "./subaddress-generator-options"; -const ONE_MINUTE = 60 * 1000; - /** Strategy for creating an email subaddress * @remarks The subaddress is the part following the `+`. * For example, if the email address is `jd+xyz@domain.io`, @@ -49,11 +47,6 @@ export class SubaddressGeneratorStrategy return PolicyType.PasswordGenerator; } - /** {@link GeneratorStrategy.cache_ms} */ - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); diff --git a/libs/common/src/tools/generator/username/username-generation.service.ts b/libs/common/src/tools/generator/username/username-generation.service.ts index 1ee642da5e..e659aacb51 100644 --- a/libs/common/src/tools/generator/username/username-generation.service.ts +++ b/libs/common/src/tools/generator/username/username-generation.service.ts @@ -1,3 +1,5 @@ +import { from } from "rxjs"; + import { ApiService } from "../../../abstractions/api.service"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { StateService } from "../../../platform/abstractions/state.service"; @@ -158,6 +160,10 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr return forwarder.generate(this.apiService, forwarderOptions); } + getOptions$() { + return from(this.getOptions()); + } + async getOptions(): Promise { let options = await this.stateService.getUsernameGenerationOptions(); if (options == null) {