From 433ae13513c684e1784a793079a9ecc755e2554e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 27 Sep 2024 09:02:59 -0400 Subject: [PATCH] [PM-5611] username generator panel (#11201) * add username and email engines to generators * introduce username and email settings components * introduce generator algorithm metadata * inline generator policies * wait until settings are available during generation --- .../credential-generator.component.html | 3 +- .../credential-generator.component.ts | 5 +- .../src/catchall-settings.component.html | 6 + .../src/catchall-settings.component.ts | 86 +++++ .../generator/components/src/dependencies.ts | 2 + libs/tools/generator/components/src/index.ts | 6 +- .../src/passphrase-settings.component.ts | 18 +- .../src/password-generator.component.ts | 20 +- .../src/password-settings.component.ts | 26 +- .../src/subaddress-settings.component.html | 6 + .../src/subaddress-settings.component.ts | 86 +++++ .../src/username-generator.component.html | 51 +++ .../src/username-generator.component.ts | 238 ++++++++++++++ .../src/username-settings.component.html | 10 + .../src/username-settings.component.ts | 87 +++++ .../data/default-credential-preferences.ts | 18 ++ .../core/src/data/generator-types.ts | 18 +- .../generator/core/src/data/generators.ts | 174 +++++++++- libs/tools/generator/core/src/data/index.ts | 1 + .../tools/generator/core/src/data/policies.ts | 51 +-- .../core/src/engine/email-randomizer.spec.ts | 36 +++ .../core/src/engine/email-randomizer.ts | 47 ++- .../src/engine/username-randomizer.spec.ts | 23 ++ .../core/src/engine/username-randomizer.ts | 22 +- .../available-algorithms-policy.spec.ts | 140 ++++++++ .../policies/available-algorithms-policy.ts | 32 ++ ...phrase-generator-options-evaluator.spec.ts | 18 +- ...ssword-generator-options-evaluator.spec.ts | 52 +-- .../credential-generator.service.spec.ts | 306 +++++++++++++++++- .../services/credential-generator.service.ts | 138 +++++++- .../services/credential-preferences.spec.ts | 53 +++ .../src/services/credential-preferences.ts | 30 ++ .../core/src/types/credential-category.ts | 5 - .../credential-generator-configuration.ts | 28 +- .../src/types/generated-credential.spec.ts | 6 +- .../core/src/types/generated-credential.ts | 4 +- .../core/src/types/generator-type.ts | 59 +++- libs/tools/generator/core/src/types/index.ts | 13 +- .../src/generator-navigation-evaluator.ts | 4 +- 39 files changed, 1761 insertions(+), 167 deletions(-) create mode 100644 libs/tools/generator/components/src/catchall-settings.component.html create mode 100644 libs/tools/generator/components/src/catchall-settings.component.ts create mode 100644 libs/tools/generator/components/src/subaddress-settings.component.html create mode 100644 libs/tools/generator/components/src/subaddress-settings.component.ts create mode 100644 libs/tools/generator/components/src/username-generator.component.html create mode 100644 libs/tools/generator/components/src/username-generator.component.ts create mode 100644 libs/tools/generator/components/src/username-settings.component.html create mode 100644 libs/tools/generator/components/src/username-settings.component.ts create mode 100644 libs/tools/generator/core/src/data/default-credential-preferences.ts create mode 100644 libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts create mode 100644 libs/tools/generator/core/src/policies/available-algorithms-policy.ts create mode 100644 libs/tools/generator/core/src/services/credential-preferences.spec.ts create mode 100644 libs/tools/generator/core/src/services/credential-preferences.ts delete mode 100644 libs/tools/generator/core/src/types/credential-category.ts diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.html b/apps/browser/src/tools/popup/generator/credential-generator.component.html index 45a6c67f78..932816ba7a 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.html +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.html @@ -1 +1,2 @@ - + + diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.ts b/apps/browser/src/tools/popup/generator/credential-generator.component.ts index 16938fbe79..760f0627ab 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -1,11 +1,12 @@ import { Component } from "@angular/core"; -import { PasswordGeneratorComponent } from "@bitwarden/generator-components"; +import { SectionComponent } from "@bitwarden/components"; +import { UsernameGeneratorComponent } from "@bitwarden/generator-components"; @Component({ standalone: true, selector: "credential-generator", templateUrl: "credential-generator.component.html", - imports: [PasswordGeneratorComponent], + imports: [UsernameGeneratorComponent, SectionComponent], }) export class CredentialGeneratorComponent {} diff --git a/libs/tools/generator/components/src/catchall-settings.component.html b/libs/tools/generator/components/src/catchall-settings.component.html new file mode 100644 index 0000000000..0b2a9e69ef --- /dev/null +++ b/libs/tools/generator/components/src/catchall-settings.component.html @@ -0,0 +1,6 @@ +
+ + {{ "domainName" | i18n }} + + +
diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts new file mode 100644 index 0000000000..1d7ba7608d --- /dev/null +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -0,0 +1,86 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + CatchallGenerationOptions, + CredentialGeneratorService, + Generators, +} from "@bitwarden/generator-core"; + +import { DependenciesModule } from "./dependencies"; +import { completeOnAccountSwitch } from "./util"; + +/** Options group for catchall emails */ +@Component({ + standalone: true, + selector: "tools-catchall-settings", + templateUrl: "catchall-settings.component.html", + imports: [DependenciesModule], +}) +export class CatchallSettingsComponent implements OnInit, OnDestroy { + /** Instantiates the component + * @param accountService queries user availability + * @param generatorService settings and policy logic + * @param formBuilder reactive form controls + */ + constructor( + private formBuilder: FormBuilder, + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + ) {} + + /** Binds the component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + /** Emits settings updates and completes if the settings become unavailable. + * @remarks this does not emit the initial settings. If you would like + * to receive live settings updates including the initial update, + * use `CredentialGeneratorService.settings$(...)` instead. + */ + @Output() + readonly onUpdated = new EventEmitter(); + + /** The template's control bindings */ + protected settings = this.formBuilder.group({ + catchallDomain: [Generators.catchall.settings.initial.catchallDomain], + }); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + const settings = await this.generatorService.settings(Generators.catchall, { singleUserId$ }); + + settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { + this.settings.patchValue(s, { emitEvent: false }); + }); + + // the first emission is the current value; subsequent emissions are updates + settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); + + this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + } + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed$), + ); + } + + private readonly destroyed$ = new Subject(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/components/src/dependencies.ts b/libs/tools/generator/components/src/dependencies.ts index d96ff0db8d..ba9219b237 100644 --- a/libs/tools/generator/components/src/dependencies.ts +++ b/libs/tools/generator/components/src/dependencies.ts @@ -19,6 +19,7 @@ import { ItemModule, SectionComponent, SectionHeaderComponent, + SelectModule, ToggleGroupModule, } from "@bitwarden/components"; import { @@ -46,6 +47,7 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); ReactiveFormsModule, SectionComponent, SectionHeaderComponent, + SelectModule, ToggleGroupModule, ], providers: [ diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index 4423f8a1ec..9367a32546 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -1,5 +1,9 @@ -export { PassphraseSettingsComponent } from "./passphrase-settings.component"; +export { CatchallSettingsComponent } from "./catchall-settings.component"; export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; +export { PassphraseSettingsComponent } from "./passphrase-settings.component"; export { PasswordSettingsComponent } from "./password-settings.component"; export { PasswordGeneratorComponent } from "./password-generator.component"; +export { SubaddressSettingsComponent } from "./subaddress-settings.component"; +export { UsernameGeneratorComponent } from "./username-generator.component"; +export { UsernameSettingsComponent } from "./username-settings.component"; diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index acbd96f10d..bfb3425bf6 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -39,7 +39,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { private accountService: AccountService, ) {} - /** Binds the passphrase component to a specific user's settings. + /** Binds the component to a specific user's settings. * When this input is not provided, the form binds to the active * user */ @@ -59,15 +59,15 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { readonly onUpdated = new EventEmitter(); protected settings = this.formBuilder.group({ - [Controls.numWords]: [Generators.Passphrase.settings.initial.numWords], - [Controls.wordSeparator]: [Generators.Passphrase.settings.initial.wordSeparator], - [Controls.capitalize]: [Generators.Passphrase.settings.initial.capitalize], - [Controls.includeNumber]: [Generators.Passphrase.settings.initial.includeNumber], + [Controls.numWords]: [Generators.passphrase.settings.initial.numWords], + [Controls.wordSeparator]: [Generators.passphrase.settings.initial.wordSeparator], + [Controls.capitalize]: [Generators.passphrase.settings.initial.capitalize], + [Controls.includeNumber]: [Generators.passphrase.settings.initial.includeNumber], }); async ngOnInit() { const singleUserId$ = this.singleUserId$(); - const settings = await this.generatorService.settings(Generators.Passphrase, { singleUserId$ }); + const settings = await this.generatorService.settings(Generators.passphrase, { singleUserId$ }); // skips reactive event emissions to break a subscription cycle settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { @@ -79,16 +79,16 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { // dynamic policy enforcement this.generatorService - .policy$(Generators.Passphrase, { userId$: singleUserId$ }) + .policy$(Generators.passphrase, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { this.settings .get(Controls.numWords) - .setValidators(toValidators(Controls.numWords, Generators.Passphrase, constraints)); + .setValidators(toValidators(Controls.numWords, Generators.passphrase, constraints)); this.settings .get(Controls.wordSeparator) - .setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, constraints)); + .setValidators(toValidators(Controls.wordSeparator, Generators.passphrase, constraints)); // forward word boundaries to the template (can't do it through the rx form) this.minNumWords = constraints.numWords.min; diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 1519df4a67..b6d8fbf60d 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -3,8 +3,12 @@ import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUnt import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserId } from "@bitwarden/common/types/guid"; -import { CredentialGeneratorService, Generators, GeneratorType } from "@bitwarden/generator-core"; -import { GeneratedCredential } from "@bitwarden/generator-history"; +import { + CredentialGeneratorService, + Generators, + PasswordAlgorithm, + GeneratedCredential, +} from "@bitwarden/generator-core"; import { DependenciesModule } from "./dependencies"; import { PassphraseSettingsComponent } from "./passphrase-settings.component"; @@ -24,7 +28,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { private zone: NgZone, ) {} - /** Binds the passphrase component to a specific user's settings. + /** Binds the component to a specific user's settings. * When this input is not provided, the form binds to the active * user */ @@ -32,7 +36,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { userId: UserId | null; /** tracks the currently selected credential type */ - protected credentialType$ = new BehaviorSubject("password"); + protected credentialType$ = new BehaviorSubject("password"); /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -46,7 +50,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { /** Tracks changes to the selected credential type * @param type the new credential type */ - protected onCredentialTypeChanged(type: GeneratorType) { + protected onCredentialTypeChanged(type: PasswordAlgorithm) { if (this.credentialType$.value !== type) { this.credentialType$.next(type); this.generate$.next(); @@ -85,7 +89,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { }); } - private typeToGenerator$(type: GeneratorType) { + private typeToGenerator$(type: PasswordAlgorithm) { const dependencies = { on$: this.generate$, userId$: this.userId$, @@ -93,10 +97,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { switch (type) { case "password": - return this.generatorService.generate$(Generators.Password, dependencies); + return this.generatorService.generate$(Generators.password, dependencies); case "passphrase": - return this.generatorService.generate$(Generators.Passphrase, dependencies); + return this.generatorService.generate$(Generators.passphrase, dependencies); default: throw new Error(`Invalid generator type: "${type}"`); } diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 2553bba3f7..d9fd6cd99c 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -67,14 +67,14 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { readonly onUpdated = new EventEmitter(); protected settings = this.formBuilder.group({ - [Controls.length]: [Generators.Password.settings.initial.length], - [Controls.uppercase]: [Generators.Password.settings.initial.uppercase], - [Controls.lowercase]: [Generators.Password.settings.initial.lowercase], - [Controls.number]: [Generators.Password.settings.initial.number], - [Controls.special]: [Generators.Password.settings.initial.special], - [Controls.minNumber]: [Generators.Password.settings.initial.minNumber], - [Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial], - [Controls.avoidAmbiguous]: [!Generators.Password.settings.initial.ambiguous], + [Controls.length]: [Generators.password.settings.initial.length], + [Controls.uppercase]: [Generators.password.settings.initial.uppercase], + [Controls.lowercase]: [Generators.password.settings.initial.lowercase], + [Controls.number]: [Generators.password.settings.initial.number], + [Controls.special]: [Generators.password.settings.initial.special], + [Controls.minNumber]: [Generators.password.settings.initial.minNumber], + [Controls.minSpecial]: [Generators.password.settings.initial.minSpecial], + [Controls.avoidAmbiguous]: [!Generators.password.settings.initial.ambiguous], }); private get numbers() { @@ -95,7 +95,7 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { async ngOnInit() { const singleUserId$ = this.singleUserId$(); - const settings = await this.generatorService.settings(Generators.Password, { singleUserId$ }); + const settings = await this.generatorService.settings(Generators.password, { singleUserId$ }); // bind settings to the UI settings @@ -116,19 +116,19 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { // bind policy to the template this.generatorService - .policy$(Generators.Password, { userId$: singleUserId$ }) + .policy$(Generators.password, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { this.settings .get(Controls.length) - .setValidators(toValidators(Controls.length, Generators.Password, constraints)); + .setValidators(toValidators(Controls.length, Generators.password, constraints)); this.minNumber.setValidators( - toValidators(Controls.minNumber, Generators.Password, constraints), + toValidators(Controls.minNumber, Generators.password, constraints), ); this.minSpecial.setValidators( - toValidators(Controls.minSpecial, Generators.Password, constraints), + toValidators(Controls.minSpecial, Generators.password, constraints), ); // forward word boundaries to the template (can't do it through the rx form) diff --git a/libs/tools/generator/components/src/subaddress-settings.component.html b/libs/tools/generator/components/src/subaddress-settings.component.html new file mode 100644 index 0000000000..16f3aea28b --- /dev/null +++ b/libs/tools/generator/components/src/subaddress-settings.component.html @@ -0,0 +1,6 @@ +
+ + {{ "email" | i18n }} + + +
diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts new file mode 100644 index 0000000000..ed55cb51ba --- /dev/null +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -0,0 +1,86 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + CredentialGeneratorService, + Generators, + SubaddressGenerationOptions, +} from "@bitwarden/generator-core"; + +import { DependenciesModule } from "./dependencies"; +import { completeOnAccountSwitch } from "./util"; + +/** Options group for plus-addressed emails */ +@Component({ + standalone: true, + selector: "tools-subaddress-settings", + templateUrl: "subaddress-settings.component.html", + imports: [DependenciesModule], +}) +export class SubaddressSettingsComponent implements OnInit, OnDestroy { + /** Instantiates the component + * @param accountService queries user availability + * @param generatorService settings and policy logic + * @param formBuilder reactive form controls + */ + constructor( + private formBuilder: FormBuilder, + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + ) {} + + /** Binds the component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + /** Emits settings updates and completes if the settings become unavailable. + * @remarks this does not emit the initial settings. If you would like + * to receive live settings updates including the initial update, + * use `CredentialGeneratorService.settings$(...)` instead. + */ + @Output() + readonly onUpdated = new EventEmitter(); + + /** The template's control bindings */ + protected settings = this.formBuilder.group({ + subaddressEmail: [Generators.subaddress.settings.initial.subaddressEmail], + }); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + const settings = await this.generatorService.settings(Generators.subaddress, { singleUserId$ }); + + settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { + this.settings.patchValue(s, { emitEvent: false }); + }); + + // the first emission is the current value; subsequent emissions are updates + settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); + + this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + } + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed$), + ); + } + + private readonly destroyed$ = new Subject(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html new file mode 100644 index 0000000000..413de93145 --- /dev/null +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -0,0 +1,51 @@ + +
+ +
+
+ + +
+
+ + +
{{ "options" | i18n }}
+
+
+ +
+ + {{ "type" | i18n }} + + {{ + credentialTypeHint$ | async + }} + +
+ + + +
+
+
diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts new file mode 100644 index 0000000000..e5327cc66e --- /dev/null +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -0,0 +1,238 @@ +import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + BehaviorSubject, + distinctUntilChanged, + filter, + map, + ReplaySubject, + Subject, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { Option } from "@bitwarden/components/src/select/option"; +import { + CredentialAlgorithm, + CredentialGeneratorInfo, + CredentialGeneratorService, + GeneratedCredential, + Generators, + isEmailAlgorithm, + isUsernameAlgorithm, +} from "@bitwarden/generator-core"; + +import { CatchallSettingsComponent } from "./catchall-settings.component"; +import { DependenciesModule } from "./dependencies"; +import { SubaddressSettingsComponent } from "./subaddress-settings.component"; +import { UsernameSettingsComponent } from "./username-settings.component"; +import { completeOnAccountSwitch } from "./util"; + +/** Component that generates usernames and emails */ +@Component({ + standalone: true, + selector: "tools-username-generator", + templateUrl: "username-generator.component.html", + imports: [ + DependenciesModule, + CatchallSettingsComponent, + SubaddressSettingsComponent, + UsernameSettingsComponent, + ], +}) +export class UsernameGeneratorComponent implements OnInit, OnDestroy { + /** Instantiates the username generator + * @param generatorService generates credentials; stores preferences + * @param i18nService localizes generator algorithm descriptions + * @param accountService discovers the active user when one is not provided + * @param zone detects generator settings updates originating from the generator services + * @param formBuilder binds reactive form + */ + constructor( + private generatorService: CredentialGeneratorService, + private i18nService: I18nService, + private accountService: AccountService, + private zone: NgZone, + private formBuilder: FormBuilder, + ) {} + + /** Binds the component to a specific user's settings. When this input is not provided, + * the form binds to the active user + */ + @Input() + userId: UserId | null; + + /** Emits credentials created from a generation request. */ + @Output() + readonly onGenerated = new EventEmitter(); + + /** Tracks the selected generation algorithm */ + protected credential = this.formBuilder.group({ + type: ["username" as CredentialAlgorithm], + }); + + async ngOnInit() { + if (this.userId) { + this.userId$.next(this.userId); + } else { + this.singleUserId$().pipe(takeUntil(this.destroyed)).subscribe(this.userId$); + } + + this.generatorService + .algorithms$(["email", "username"], { userId$: this.userId$ }) + .pipe( + map((algorithms) => this.toOptions(algorithms)), + takeUntil(this.destroyed), + ) + .subscribe(this.typeOptions$); + + this.algorithm$ + .pipe( + map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + takeUntil(this.destroyed), + ) + .subscribe((hint) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.credentialTypeHint$.next(hint); + }); + }); + + // wire up the generator + this.algorithm$ + .pipe( + switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + takeUntil(this.destroyed), + ) + .subscribe((generated) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.onGenerated.next(generated); + this.value$.next(generated.credential); + }); + }); + + // assume the last-visible generator algorithm is the user's preferred one + const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + this.credential.valueChanges + .pipe(withLatestFrom(preferences), takeUntil(this.destroyed)) + .subscribe(([{ type }, preference]) => { + if (isEmailAlgorithm(type)) { + preference.email.algorithm = type; + preference.email.updated = new Date(); + } else if (isUsernameAlgorithm(type)) { + preference.username.algorithm = type; + preference.username.updated = new Date(); + } else { + return; + } + + preferences.next(preference); + }); + + // populate the form with the user's preferences to kick off interactivity + preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username }) => { + // this generator supports email & username; the last preference + // set by the user "wins" + const preference = email.updated > username.updated ? email.algorithm : username.algorithm; + + // break subscription loop + this.credential.setValue({ type: preference }, { emitEvent: false }); + + const algorithm = this.generatorService.algorithm(preference); + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // generate on load unless the generator prohibits it + this.algorithm$ + .pipe( + distinctUntilChanged((prev, next) => prev.id === next.id), + filter((a) => !a.onlyOnRequest), + takeUntil(this.destroyed), + ) + .subscribe(() => this.generate$.next()); + } + + private typeToGenerator$(type: CredentialAlgorithm) { + const dependencies = { + on$: this.generate$, + userId$: this.userId$, + }; + + switch (type) { + case "catchall": + return this.generatorService.generate$(Generators.catchall, dependencies); + + case "subaddress": + return this.generatorService.generate$(Generators.subaddress, dependencies); + + case "username": + return this.generatorService.generate$(Generators.username, dependencies); + + default: + throw new Error(`Invalid generator type: "${type}"`); + } + } + + /** Lists the credential types supported by the component. */ + protected typeOptions$ = new BehaviorSubject[]>([]); + + /** tracks the currently selected credential type */ + protected algorithm$ = new ReplaySubject(1); + + /** Emits hint key for the currently selected credential type */ + protected credentialTypeHint$ = new ReplaySubject(1); + + /** Emits the last generated value. */ + protected readonly value$ = new BehaviorSubject(""); + + /** Emits when the userId changes */ + protected readonly userId$ = new BehaviorSubject(null); + + /** Emits when a new credential is requested */ + protected readonly generate$ = new Subject(); + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed), + ); + } + + private toOptions(algorithms: CredentialGeneratorInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: algorithm.id, + label: this.i18nService.t(algorithm.nameKey), + })); + + return options; + } + + private readonly destroyed = new Subject(); + ngOnDestroy() { + this.destroyed.complete(); + + // finalize subjects + this.generate$.complete(); + this.value$.complete(); + + // finalize component bindings + this.onGenerated.complete(); + } +} diff --git a/libs/tools/generator/components/src/username-settings.component.html b/libs/tools/generator/components/src/username-settings.component.html new file mode 100644 index 0000000000..4a4f8cd9fe --- /dev/null +++ b/libs/tools/generator/components/src/username-settings.component.html @@ -0,0 +1,10 @@ +
+ + + {{ "capitalize" | i18n }} + + + + {{ "includeNumber" | i18n }} + +
diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts new file mode 100644 index 0000000000..978bd05ca7 --- /dev/null +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -0,0 +1,87 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + CredentialGeneratorService, + EffUsernameGenerationOptions, + Generators, +} from "@bitwarden/generator-core"; + +import { DependenciesModule } from "./dependencies"; +import { completeOnAccountSwitch } from "./util"; + +/** Options group for usernames */ +@Component({ + standalone: true, + selector: "tools-username-settings", + templateUrl: "username-settings.component.html", + imports: [DependenciesModule], +}) +export class UsernameSettingsComponent implements OnInit, OnDestroy { + /** Instantiates the component + * @param accountService queries user availability + * @param generatorService settings and policy logic + * @param formBuilder reactive form controls + */ + constructor( + private formBuilder: FormBuilder, + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + ) {} + + /** Binds the component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + /** Emits settings updates and completes if the settings become unavailable. + * @remarks this does not emit the initial settings. If you would like + * to receive live settings updates including the initial update, + * use `CredentialGeneratorService.settings$(...)` instead. + */ + @Output() + readonly onUpdated = new EventEmitter(); + + /** The template's control bindings */ + protected settings = this.formBuilder.group({ + wordCapitalize: [Generators.username.settings.initial.wordCapitalize], + wordIncludeNumber: [Generators.username.settings.initial.wordIncludeNumber], + }); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + const settings = await this.generatorService.settings(Generators.username, { singleUserId$ }); + + settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { + this.settings.patchValue(s, { emitEvent: false }); + }); + + // the first emission is the current value; subsequent emissions are updates + settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); + + this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + } + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed$), + ); + } + + private readonly destroyed$ = new Subject(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/core/src/data/default-credential-preferences.ts b/libs/tools/generator/core/src/data/default-credential-preferences.ts new file mode 100644 index 0000000000..c26d44b3b7 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-credential-preferences.ts @@ -0,0 +1,18 @@ +import { CredentialPreference } from "../types"; + +import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "./generator-types"; + +export const DefaultCredentialPreferences: CredentialPreference = Object.freeze({ + email: Object.freeze({ + algorithm: EmailAlgorithms[0], + updated: new Date(0), + }), + password: Object.freeze({ + algorithm: PasswordAlgorithms[0], + updated: new Date(0), + }), + username: Object.freeze({ + algorithm: UsernameAlgorithms[0], + updated: new Date(0), + }), +}); diff --git a/libs/tools/generator/core/src/data/generator-types.ts b/libs/tools/generator/core/src/data/generator-types.ts index c68131f604..6c351b82e3 100644 --- a/libs/tools/generator/core/src/data/generator-types.ts +++ b/libs/tools/generator/core/src/data/generator-types.ts @@ -1,5 +1,15 @@ -/** Types of passwords that may be configured by the password generator */ -export const PasswordTypes = Object.freeze(["password", "passphrase"] as const); +/** Types of passwords that may be generated by the credential generator */ +export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as const); -/** Types of generators that may be configured by the password generator */ -export const GeneratorTypes = Object.freeze([...PasswordTypes, "username"] as const); +/** Types of usernames that may be generated by the credential generator */ +export const UsernameAlgorithms = Object.freeze(["username"] as const); + +/** Types of email addresses that may be generated by the credential generator */ +export const EmailAlgorithms = Object.freeze(["catchall", "forwarder", "subaddress"] as const); + +/** All types of credentials that may be generated by the credential generator */ +export const CredentialAlgorithms = Object.freeze([ + ...PasswordAlgorithms, + ...UsernameAlgorithms, + ...EmailAlgorithms, +] as const); diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index f71d484f9c..2c96b0c2d3 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,23 +1,51 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; + import { Randomizer } from "../abstractions"; -import { PasswordRandomizer } from "../engine"; -import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "../strategies/storage"; +import { EmailRandomizer, PasswordRandomizer, UsernameRandomizer } from "../engine"; import { + DefaultPolicyEvaluator, + DynamicPasswordPolicyConstraints, + PassphraseGeneratorOptionsEvaluator, + passphraseLeastPrivilege, + PassphrasePolicyConstraints, + PasswordGeneratorOptionsEvaluator, + passwordLeastPrivilege, +} from "../policies"; +import { + CATCHALL_SETTINGS, + EFF_USERNAME_SETTINGS, + PASSPHRASE_SETTINGS, + PASSWORD_SETTINGS, + SUBADDRESS_SETTINGS, +} from "../strategies/storage"; +import { + CatchallGenerationOptions, CredentialGenerator, + CredentialGeneratorConfiguration, + EffUsernameGenerationOptions, + NoPolicy, PassphraseGenerationOptions, PassphraseGeneratorPolicy, PasswordGenerationOptions, PasswordGeneratorPolicy, + SubaddressGenerationOptions, } from "../types"; -import { CredentialGeneratorConfiguration } from "../types/credential-generator-configuration"; +import { DefaultCatchallOptions } from "./default-catchall-options"; +import { DefaultEffUsernameOptions } from "./default-eff-username-options"; import { DefaultPassphraseBoundaries } from "./default-passphrase-boundaries"; import { DefaultPassphraseGenerationOptions } from "./default-passphrase-generation-options"; import { DefaultPasswordBoundaries } from "./default-password-boundaries"; import { DefaultPasswordGenerationOptions } from "./default-password-generation-options"; -import { Policies } from "./policies"; +import { DefaultSubaddressOptions } from "./default-subaddress-generator-options"; const PASSPHRASE = Object.freeze({ - category: "passphrase", + id: "passphrase", + category: "password", + nameKey: "passphrase", + onlyOnRequest: false, engine: { create(randomizer: Randomizer): CredentialGenerator { return new PasswordRandomizer(randomizer); @@ -34,14 +62,27 @@ const PASSPHRASE = Object.freeze({ }, account: PASSPHRASE_SETTINGS, }, - policy: Policies.Passphrase, + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: Object.freeze({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }), + combine: passphraseLeastPrivilege, + createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), + toConstraints: (policy) => new PassphrasePolicyConstraints(policy), + }, } satisfies CredentialGeneratorConfiguration< PassphraseGenerationOptions, PassphraseGeneratorPolicy >); const PASSWORD = Object.freeze({ + id: "password", category: "password", + nameKey: "password", + onlyOnRequest: false, engine: { create(randomizer: Randomizer): CredentialGenerator { return new PasswordRandomizer(randomizer); @@ -65,14 +106,129 @@ const PASSWORD = Object.freeze({ }, account: PASSWORD_SETTINGS, }, - policy: Policies.Password, + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: Object.freeze({ + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }), + combine: passwordLeastPrivilege, + createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), + toConstraints: (policy) => new DynamicPasswordPolicyConstraints(policy), + }, } satisfies CredentialGeneratorConfiguration); +const USERNAME = Object.freeze({ + id: "username", + category: "username", + nameKey: "randomWord", + onlyOnRequest: false, + engine: { + create(randomizer: Randomizer): CredentialGenerator { + return new UsernameRandomizer(randomizer); + }, + }, + settings: { + initial: DefaultEffUsernameOptions, + constraints: {}, + account: EFF_USERNAME_SETTINGS, + }, + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy) { + return new IdentityConstraint(); + }, + }, +} satisfies CredentialGeneratorConfiguration); + +const CATCHALL = Object.freeze({ + id: "catchall", + category: "email", + nameKey: "catchallEmail", + descriptionKey: "catchallEmailDesc", + onlyOnRequest: false, + engine: { + create(randomizer: Randomizer): CredentialGenerator { + return new EmailRandomizer(randomizer); + }, + }, + settings: { + initial: DefaultCatchallOptions, + constraints: { catchallDomain: { minLength: 1 } }, + account: CATCHALL_SETTINGS, + }, + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy) { + return new IdentityConstraint(); + }, + }, +} satisfies CredentialGeneratorConfiguration); + +const SUBADDRESS = Object.freeze({ + id: "subaddress", + category: "email", + nameKey: "plusAddressedEmail", + descriptionKey: "plusAddressedEmailDesc", + onlyOnRequest: false, + engine: { + create(randomizer: Randomizer): CredentialGenerator { + return new EmailRandomizer(randomizer); + }, + }, + settings: { + initial: DefaultSubaddressOptions, + constraints: {}, + account: SUBADDRESS_SETTINGS, + }, + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy) { + return new IdentityConstraint(); + }, + }, +} satisfies CredentialGeneratorConfiguration); + /** Generator configurations */ export const Generators = Object.freeze({ /** Passphrase generator configuration */ - Passphrase: PASSPHRASE, + passphrase: PASSPHRASE, /** Password generator configuration */ - Password: PASSWORD, + password: PASSWORD, + + /** Username generator configuration */ + username: USERNAME, + + /** Catchall email generator configuration */ + catchall: CATCHALL, + + /** Email subaddress generator configuration */ + subaddress: SUBADDRESS, }); diff --git a/libs/tools/generator/core/src/data/index.ts b/libs/tools/generator/core/src/data/index.ts index eaaac757ff..482703fd3c 100644 --- a/libs/tools/generator/core/src/data/index.ts +++ b/libs/tools/generator/core/src/data/index.ts @@ -10,6 +10,7 @@ export * from "./default-eff-username-options"; export * from "./default-firefox-relay-options"; export * from "./default-passphrase-generation-options"; export * from "./default-password-generation-options"; +export * from "./default-credential-preferences"; export * from "./default-subaddress-generator-options"; export * from "./default-simple-login-options"; export * from "./forwarders"; diff --git a/libs/tools/generator/core/src/data/policies.ts b/libs/tools/generator/core/src/data/policies.ts index 4d758fc465..4e46718a39 100644 --- a/libs/tools/generator/core/src/data/policies.ts +++ b/libs/tools/generator/core/src/data/policies.ts @@ -1,13 +1,3 @@ -import { PolicyType } from "@bitwarden/common/admin-console/enums"; - -import { - DynamicPasswordPolicyConstraints, - passphraseLeastPrivilege, - passwordLeastPrivilege, - PassphraseGeneratorOptionsEvaluator, - PassphrasePolicyConstraints, - PasswordGeneratorOptionsEvaluator, -} from "../policies"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy, @@ -16,39 +6,18 @@ import { PolicyConfiguration, } from "../types"; -const PASSPHRASE = Object.freeze({ - type: PolicyType.PasswordGenerator, - disabledValue: Object.freeze({ - minNumberWords: 0, - capitalize: false, - includeNumber: false, - }), - combine: passphraseLeastPrivilege, - createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), - toConstraints: (policy) => new PassphrasePolicyConstraints(policy), -} as PolicyConfiguration); +import { Generators } from "./generators"; -const PASSWORD = Object.freeze({ - type: PolicyType.PasswordGenerator, - disabledValue: Object.freeze({ - minLength: 0, - useUppercase: false, - useLowercase: false, - useNumbers: false, - numberCount: 0, - useSpecial: false, - specialCount: 0, - }), - combine: passwordLeastPrivilege, - createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), - toConstraints: (policy) => new DynamicPasswordPolicyConstraints(policy), -} as PolicyConfiguration); - -/** Policy configurations */ +/** Policy configurations + * @deprecated use Generator.*.policy instead + */ export const Policies = Object.freeze({ + Passphrase: Generators.passphrase.policy, + Password: Generators.password.policy, +} satisfies { /** Passphrase policy configuration */ - Passphrase: PASSPHRASE, + Passphrase: PolicyConfiguration; - /** Passphrase policy configuration */ - Password: PASSWORD, + /** Password policy configuration */ + Password: PolicyConfiguration; }); diff --git a/libs/tools/generator/core/src/engine/email-randomizer.spec.ts b/libs/tools/generator/core/src/engine/email-randomizer.spec.ts index 8670b8c176..fb953af165 100644 --- a/libs/tools/generator/core/src/engine/email-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/email-randomizer.spec.ts @@ -208,4 +208,40 @@ describe("EmailRandomizer", () => { expect(randomizer.pickWord).toHaveBeenCalledWith(expectedWordList, { titleCase: false }); }); }); + + describe("generate", () => { + it("processes catchall generation options", async () => { + const email = new EmailRandomizer(randomizer); + + const result = await email.generate( + {}, + { + catchallDomain: "example.com", + }, + ); + + expect(result.category).toEqual("catchall"); + }); + + it("processes subaddress generation options", async () => { + const email = new EmailRandomizer(randomizer); + + const result = await email.generate( + {}, + { + subaddressEmail: "foo@example.com", + }, + ); + + expect(result.category).toEqual("subaddress"); + }); + + it("throws when it cannot recognize the options type", async () => { + const email = new EmailRandomizer(randomizer); + + const result = email.generate({}, {}); + + await expect(result).rejects.toBeInstanceOf(Error); + }); + }); }); diff --git a/libs/tools/generator/core/src/engine/email-randomizer.ts b/libs/tools/generator/core/src/engine/email-randomizer.ts index 5029cdbc42..f4a7e1304d 100644 --- a/libs/tools/generator/core/src/engine/email-randomizer.ts +++ b/libs/tools/generator/core/src/engine/email-randomizer.ts @@ -1,10 +1,22 @@ import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { + CatchallGenerationOptions, + CredentialGenerator, + GeneratedCredential, + SubaddressGenerationOptions, +} from "../types"; import { Randomizer } from "./abstractions"; import { SUBADDRESS_PARSER } from "./data"; /** Generation algorithms that produce randomized email addresses */ -export class EmailRandomizer { +export class EmailRandomizer + implements + CredentialGenerator, + CredentialGenerator +{ /** Instantiates the email randomizer * @param random data source for random data */ @@ -96,4 +108,37 @@ export class EmailRandomizer { return result; } + + generate( + request: GenerationRequest, + settings: CatchallGenerationOptions, + ): Promise; + generate( + request: GenerationRequest, + settings: SubaddressGenerationOptions, + ): Promise; + async generate( + _request: GenerationRequest, + settings: CatchallGenerationOptions | SubaddressGenerationOptions, + ) { + if (isCatchallGenerationOptions(settings)) { + const email = await this.randomAsciiCatchall(settings.catchallDomain); + + return new GeneratedCredential(email, "catchall", Date.now()); + } else if (isSubaddressGenerationOptions(settings)) { + const email = await this.randomAsciiSubaddress(settings.subaddressEmail); + + return new GeneratedCredential(email, "subaddress", Date.now()); + } + + throw new Error("Invalid settings received by generator."); + } +} + +function isCatchallGenerationOptions(settings: any): settings is CatchallGenerationOptions { + return "catchallDomain" in (settings ?? {}); +} + +function isSubaddressGenerationOptions(settings: any): settings is SubaddressGenerationOptions { + return "subaddressEmail" in (settings ?? {}); } diff --git a/libs/tools/generator/core/src/engine/username-randomizer.spec.ts b/libs/tools/generator/core/src/engine/username-randomizer.spec.ts index e30db28645..54d140e446 100644 --- a/libs/tools/generator/core/src/engine/username-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/username-randomizer.spec.ts @@ -102,4 +102,27 @@ describe("UsernameRandomizer", () => { expect(randomizer.pickWord).toHaveBeenNthCalledWith(2, EFFLongWordList, { titleCase: false }); }); }); + + describe("generate", () => { + it("processes username generation options", async () => { + const username = new UsernameRandomizer(randomizer); + + const result = await username.generate( + {}, + { + wordIncludeNumber: true, + }, + ); + + expect(result.category).toEqual("username"); + }); + + it("throws when it cannot recognize the options type", async () => { + const username = new UsernameRandomizer(randomizer); + + const result = username.generate({}, {}); + + await expect(result).rejects.toBeInstanceOf(Error); + }); + }); }); diff --git a/libs/tools/generator/core/src/engine/username-randomizer.ts b/libs/tools/generator/core/src/engine/username-randomizer.ts index 4a6aa43f60..d5872e21ce 100644 --- a/libs/tools/generator/core/src/engine/username-randomizer.ts +++ b/libs/tools/generator/core/src/engine/username-randomizer.ts @@ -1,10 +1,13 @@ import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { CredentialGenerator, EffUsernameGenerationOptions, GeneratedCredential } from "../types"; import { Randomizer } from "./abstractions"; import { WordsRequest } from "./types"; /** Generation algorithms that produce randomized usernames */ -export class UsernameRandomizer { +export class UsernameRandomizer implements CredentialGenerator { /** Instantiates the username randomizer * @param random data source for random data */ @@ -44,4 +47,21 @@ export class UsernameRandomizer { return result; } + + async generate(_request: GenerationRequest, settings: EffUsernameGenerationOptions) { + if (isEffUsernameGenerationOptions(settings)) { + const username = await this.randomWords({ + digits: settings.wordIncludeNumber ? 1 : 0, + casing: settings.wordCapitalize ? "TitleCase" : "lowercase", + }); + + return new GeneratedCredential(username, "username", Date.now()); + } + + throw new Error("Invalid settings received by generator."); + } +} + +function isEffUsernameGenerationOptions(settings: any): settings is EffUsernameGenerationOptions { + return "wordIncludeNumber" in (settings ?? {}); } diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts new file mode 100644 index 0000000000..1ef0adc1af --- /dev/null +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts @@ -0,0 +1,140 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { PolicyId } from "@bitwarden/common/types/guid"; + +import { CredentialAlgorithms, PasswordAlgorithms } from "../data"; + +import { availableAlgorithms } from "./available-algorithms-policy"; + +describe("availableAlgorithmsPolicy", () => { + it("returns all algorithms", () => { + const result = availableAlgorithms([]); + + for (const expected of CredentialAlgorithms) { + expect(result).toContain(expected); + } + }); + + it.each([["password"], ["passphrase"]])("enforces a %p override", (override) => { + const policy = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.PasswordGenerator, + data: { + overridePasswordType: override, + }, + enabled: true, + }); + + const result = availableAlgorithms([policy]); + + expect(result).toContain(override); + + for (const expected of PasswordAlgorithms.filter((a) => a !== override)) { + expect(result).not.toContain(expected); + } + }); + + it.each([["password"], ["passphrase"]])("combines %p overrides", (override) => { + const policy = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.PasswordGenerator, + data: { + overridePasswordType: override, + }, + enabled: true, + }); + + const result = availableAlgorithms([policy, policy]); + + expect(result).toContain(override); + + for (const expected of PasswordAlgorithms.filter((a) => a !== override)) { + expect(result).not.toContain(expected); + } + }); + + it("overrides passphrase policies with password policies", () => { + const password = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.PasswordGenerator, + data: { + overridePasswordType: "password", + }, + enabled: true, + }); + const passphrase = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.PasswordGenerator, + data: { + overridePasswordType: "passphrase", + }, + enabled: true, + }); + + const result = availableAlgorithms([password, passphrase]); + + expect(result).toContain("password"); + + for (const expected of PasswordAlgorithms.filter((a) => a !== "password")) { + expect(result).not.toContain(expected); + } + }); + + it("ignores unrelated policies", () => { + const policy = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.ActivateAutofill, + data: { + some: "policy", + }, + enabled: true, + }); + + const result = availableAlgorithms([policy]); + + for (const expected of CredentialAlgorithms) { + expect(result).toContain(expected); + } + }); + + it("ignores disabled policies", () => { + const policy = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.PasswordGenerator, + data: { + some: "policy", + }, + enabled: false, + }); + + const result = availableAlgorithms([policy]); + + for (const expected of CredentialAlgorithms) { + expect(result).toContain(expected); + } + }); + + it("ignores policies without `overridePasswordType`", () => { + const policy = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.PasswordGenerator, + data: { + some: "policy", + }, + enabled: true, + }); + + const result = availableAlgorithms([policy]); + + for (const expected of CredentialAlgorithms) { + expect(result).toContain(expected); + } + }); +}); diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts new file mode 100644 index 0000000000..72eea38214 --- /dev/null +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts @@ -0,0 +1,32 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { + CredentialAlgorithm, + EmailAlgorithms, + PasswordAlgorithms, + UsernameAlgorithms, +} from "@bitwarden/generator-core"; + +/** Reduces policies to a set of available algorithms + * @param policies the policies to reduce + * @returns the resulting `AlgorithmAvailabilityPolicy` + */ +export function availableAlgorithms(policies: Policy[]): CredentialAlgorithm[] { + const overridePassword = policies + .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) + .reduce( + (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)), + null as CredentialAlgorithm, + ); + + const policy: CredentialAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms]; + if (overridePassword) { + policy.push(overridePassword); + } else { + policy.push(...PasswordAlgorithms); + } + + return policy; +} diff --git a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts index 811c4aa822..688315e929 100644 --- a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts @@ -6,7 +6,7 @@ import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-opti describe("Password generator options builder", () => { describe("constructor()", () => { it("should set the policy object to a copy of the input policy", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = 10; // arbitrary change for deep equality check const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -25,7 +25,7 @@ describe("Password generator options builder", () => { it.each([1, 2])( "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", (minNumberWords) => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -37,7 +37,7 @@ describe("Password generator options builder", () => { it.each([8, 12, 18])( "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", (minNumberWords) => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -50,7 +50,7 @@ describe("Password generator options builder", () => { it.each([150, 300, 9000])( "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", (minNumberWords) => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -70,7 +70,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a numWords greater than the default boundary", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = DefaultPassphraseBoundaries.numWords.min + 1; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -78,7 +78,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has capitalize enabled", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -86,7 +86,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has includeNumber enabled", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -108,7 +108,7 @@ describe("Password generator options builder", () => { }); it("should set `capitalize` to `true` when the policy overrides it", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ capitalize: false }); @@ -129,7 +129,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to true when the policy overrides it", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ includeNumber: false }); diff --git a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts index c6cc96dd82..91334f91f8 100644 --- a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts +++ b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts @@ -8,7 +8,7 @@ describe("Password generator options builder", () => { describe("constructor()", () => { it("should set the policy object to a copy of the input policy", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.minLength = 10; // arbitrary change for deep equality check const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -32,7 +32,7 @@ describe("Password generator options builder", () => { (minLength) => { expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.minLength = minLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -47,7 +47,7 @@ describe("Password generator options builder", () => { expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min); expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -62,7 +62,7 @@ describe("Password generator options builder", () => { (expectedLength) => { expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -78,7 +78,7 @@ describe("Password generator options builder", () => { expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min); expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -93,7 +93,7 @@ describe("Password generator options builder", () => { (expectedMinDigits) => { expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -113,7 +113,7 @@ describe("Password generator options builder", () => { DefaultPasswordBoundaries.minSpecialCharacters.max, ); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -132,7 +132,7 @@ describe("Password generator options builder", () => { DefaultPasswordBoundaries.minSpecialCharacters.max, ); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -151,7 +151,7 @@ describe("Password generator options builder", () => { (expectedLength, numberCount, specialCount) => { expect(expectedLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = numberCount; policy.specialCount = specialCount; @@ -171,7 +171,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a minlength greater than the default boundary", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.minLength = DefaultPasswordBoundaries.length.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -179,7 +179,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a number count greater than the default boundary", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = DefaultPasswordBoundaries.minDigits.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -187,7 +187,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a special character count greater than the default boundary", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.specialCount = DefaultPasswordBoundaries.minSpecialCharacters.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -195,7 +195,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has uppercase enabled", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -203,7 +203,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has lowercase enabled", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -211,7 +211,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has numbers enabled", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -219,7 +219,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has special characters enabled", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -237,7 +237,7 @@ describe("Password generator options builder", () => { ])( "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'", (expectedUppercase, uppercase) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useUppercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -251,7 +251,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", (uppercase) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -269,7 +269,7 @@ describe("Password generator options builder", () => { ])( "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'", (expectedLowercase, lowercase) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useLowercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -283,7 +283,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", (lowercase) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -301,7 +301,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'", (expectedNumber, number) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useNumbers = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -315,7 +315,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.number` (= %s) to true when `policy.useNumbers` is true", (number) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -333,7 +333,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'", (expectedSpecial, special) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useSpecial = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -347,7 +347,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.special` (= %s) to true when `policy.useSpecial` is true", (special) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -447,7 +447,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number", (minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = 5; // arbitrary value greater than minNumber expect(minNumber).toBeLessThan(policy.numberCount); @@ -534,7 +534,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters", (minSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Policies.Password.disabledValue); policy.specialCount = 5; // arbitrary value greater than minSpecial expect(minSpecial).toBeLessThan(policy.specialCount); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index 7e249bc135..88f1447e98 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -15,6 +15,7 @@ import { ObservableTracker, } from "../../../../../common/spec"; import { Randomizer } from "../abstractions"; +import { Generators } from "../data"; import { CredentialGeneratorConfiguration, GeneratedCredential, @@ -33,7 +34,7 @@ const SettingsKey = new UserKeyDefinition(GENERATOR_DISK, "SomeSet clearOn: [], }); -// fake policy +// fake policies const policyService = mock(); const somePolicy = new Policy({ data: { fooPolicy: true }, @@ -42,19 +43,43 @@ const somePolicy = new Policy({ organizationId: "" as OrganizationId, enabled: true, }); +const passwordOverridePolicy = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.PasswordGenerator, + data: { + overridePasswordType: "password", + }, + enabled: true, +}); + +const passphraseOverridePolicy = new Policy({ + id: "" as PolicyId, + organizationId: "", + type: PolicyType.PasswordGenerator, + data: { + overridePasswordType: "passphrase", + }, + enabled: true, +}); const SomeTime = new Date(1); -const SomeCategory = "passphrase"; +const SomeAlgorithm = "passphrase"; +const SomeCategory = "password"; +const SomeNameKey = "passphraseKey"; // fake the configuration const SomeConfiguration: CredentialGeneratorConfiguration = { + id: SomeAlgorithm, category: SomeCategory, + nameKey: SomeNameKey, + onlyOnRequest: false, engine: { create: (randomizer) => { return { generate: (request, settings) => { const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; - const result = new GeneratedCredential(credential, SomeCategory, SomeTime); + const result = new GeneratedCredential(credential, SomeAlgorithm, SomeTime); return Promise.resolve(result); }, }; @@ -114,7 +139,7 @@ const SomeConfiguration: CredentialGeneratorConfiguration { const result = await generated.expectEmission(); - expect(result).toEqual(new GeneratedCredential("value", SomeCategory, SomeTime)); + expect(result).toEqual(new GeneratedCredential("value", SomeAlgorithm, SomeTime)); }); it("follows the active user", async () => { @@ -165,8 +191,8 @@ describe("CredentialGeneratorService", () => { generated.unsubscribe(); expect(generated.emissions).toEqual([ - new GeneratedCredential("some value", SomeCategory, SomeTime), - new GeneratedCredential("another value", SomeCategory, SomeTime), + new GeneratedCredential("some value", SomeAlgorithm, SomeTime), + new GeneratedCredential("another value", SomeAlgorithm, SomeTime), ]); }); @@ -182,8 +208,8 @@ describe("CredentialGeneratorService", () => { generated.unsubscribe(); expect(generated.emissions).toEqual([ - new GeneratedCredential("some value", SomeCategory, SomeTime), - new GeneratedCredential("another value", SomeCategory, SomeTime), + new GeneratedCredential("some value", SomeAlgorithm, SomeTime), + new GeneratedCredential("another value", SomeAlgorithm, SomeTime), ]); }); @@ -200,7 +226,9 @@ describe("CredentialGeneratorService", () => { const result = await generated.expectEmission(); - expect(result).toEqual(new GeneratedCredential("some website|value", SomeCategory, SomeTime)); + expect(result).toEqual( + new GeneratedCredential("some website|value", SomeAlgorithm, SomeTime), + ); }); it("errors when `website$` errors", async () => { @@ -246,7 +274,7 @@ describe("CredentialGeneratorService", () => { const result = await generated.expectEmission(); - expect(result).toEqual(new GeneratedCredential("another", SomeCategory, SomeTime)); + expect(result).toEqual(new GeneratedCredential("another", SomeAlgorithm, SomeTime)); }); it("emits a generation for a specific user when `user$` emits", async () => { @@ -261,8 +289,8 @@ describe("CredentialGeneratorService", () => { const result = await generated.pauseUntilReceived(2); expect(result).toEqual([ - new GeneratedCredential("value", SomeCategory, SomeTime), - new GeneratedCredential("another", SomeCategory, SomeTime), + new GeneratedCredential("value", SomeAlgorithm, SomeTime), + new GeneratedCredential("another", SomeAlgorithm, SomeTime), ]); }); @@ -317,7 +345,7 @@ describe("CredentialGeneratorService", () => { // confirm forwarded emission on$.next(); await awaitAsync(); - expect(results).toEqual([new GeneratedCredential("value", SomeCategory, SomeTime)]); + expect(results).toEqual([new GeneratedCredential("value", SomeAlgorithm, SomeTime)]); // confirm updating settings does not cause an emission await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser); @@ -330,8 +358,8 @@ describe("CredentialGeneratorService", () => { sub.unsubscribe(); expect(results).toEqual([ - new GeneratedCredential("value", SomeCategory, SomeTime), - new GeneratedCredential("next", SomeCategory, SomeTime), + new GeneratedCredential("value", SomeAlgorithm, SomeTime), + new GeneratedCredential("next", SomeAlgorithm, SomeTime), ]); }); @@ -370,6 +398,245 @@ describe("CredentialGeneratorService", () => { expect(complete).toBeTruthy(); }); + + // FIXME: test these when the fake state provider can delay its first emission + it.todo("emits when settings$ become available if on$ is called before they're ready."); + it.todo("emits when website$ become available if on$ is called before they're ready."); + }); + + describe("algorithms", () => { + it("outputs password generation metadata", () => { + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = generator.algorithms("password"); + + expect(result).toContain(Generators.password); + expect(result).toContain(Generators.passphrase); + + // this test shouldn't contain entries outside of the current category + expect(result).not.toContain(Generators.username); + expect(result).not.toContain(Generators.catchall); + }); + + it("outputs username generation metadata", () => { + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = generator.algorithms("username"); + + expect(result).toContain(Generators.username); + + // this test shouldn't contain entries outside of the current category + expect(result).not.toContain(Generators.catchall); + expect(result).not.toContain(Generators.password); + }); + + it("outputs email generation metadata", () => { + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = generator.algorithms("email"); + + expect(result).toContain(Generators.catchall); + expect(result).toContain(Generators.subaddress); + + // this test shouldn't contain entries outside of the current category + expect(result).not.toContain(Generators.username); + expect(result).not.toContain(Generators.password); + }); + + it("combines metadata across categories", () => { + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = generator.algorithms(["username", "email"]); + + expect(result).toContain(Generators.username); + expect(result).toContain(Generators.catchall); + expect(result).toContain(Generators.subaddress); + + // this test shouldn't contain entries outside of the current categories + expect(result).not.toContain(Generators.password); + }); + }); + + describe("algorithms$", () => { + // these tests cannot use the observable tracker because they return + // data that cannot be cloned + it("returns password metadata", async () => { + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = await firstValueFrom(generator.algorithms$("password")); + + expect(result).toContain(Generators.password); + expect(result).toContain(Generators.passphrase); + }); + + it("returns username metadata", async () => { + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = await firstValueFrom(generator.algorithms$("username")); + + expect(result).toContain(Generators.username); + }); + + it("returns email metadata", async () => { + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = await firstValueFrom(generator.algorithms$("email")); + + expect(result).toContain(Generators.catchall); + expect(result).toContain(Generators.subaddress); + }); + + it("returns username and email metadata", async () => { + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = await firstValueFrom(generator.algorithms$(["username", "email"])); + + expect(result).toContain(Generators.username); + expect(result).toContain(Generators.catchall); + expect(result).toContain(Generators.subaddress); + }); + + // Subsequent tests focus on passwords and passphrases as an example of policy + // awareness; they exercise the logic without being comprehensive + it("enforces the active user's policy", async () => { + const policy$ = new BehaviorSubject([passwordOverridePolicy]); + policyService.getAll$.mockReturnValue(policy$); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + + const result = await firstValueFrom(generator.algorithms$(["password"])); + + expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); + expect(result).toContain(Generators.password); + expect(result).not.toContain(Generators.passphrase); + }); + + it("follows changes to the active user", async () => { + // initialize local account service and state provider because this test is sensitive + // to some shared data in `FakeAccountService`. + const accountService = new FakeAccountService(accounts); + const stateProvider = new FakeStateProvider(accountService); + await accountService.switchAccount(SomeUser); + policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const results: any = []; + const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); + + await accountService.switchAccount(AnotherUser); + await awaitAsync(); + sub.unsubscribe(); + + const [someResult, anotherResult] = results; + + expect(policyService.getAll$).toHaveBeenNthCalledWith( + 1, + PolicyType.PasswordGenerator, + SomeUser, + ); + expect(someResult).toContain(Generators.password); + expect(someResult).not.toContain(Generators.passphrase); + + expect(policyService.getAll$).toHaveBeenNthCalledWith( + 2, + PolicyType.PasswordGenerator, + AnotherUser, + ); + expect(anotherResult).toContain(Generators.passphrase); + expect(anotherResult).not.toContain(Generators.password); + }); + + it("reads an arbitrary user's settings", async () => { + policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId$ = new BehaviorSubject(AnotherUser).asObservable(); + + const result = await firstValueFrom(generator.algorithms$("password", { userId$ })); + + expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); + expect(result).toContain(Generators.password); + expect(result).not.toContain(Generators.passphrase); + }); + + it("follows changes to the arbitrary user", async () => { + policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + const results: any = []; + const sub = generator.algorithms$("password", { userId$ }).subscribe((r) => results.push(r)); + + userId.next(AnotherUser); + await awaitAsync(); + sub.unsubscribe(); + + const [someResult, anotherResult] = results; + expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); + expect(someResult).toContain(Generators.password); + expect(someResult).not.toContain(Generators.passphrase); + + expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); + expect(anotherResult).toContain(Generators.passphrase); + expect(anotherResult).not.toContain(Generators.password); + }); + + it("errors when the arbitrary user's stream errors", async () => { + policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + let error = null; + + generator.algorithms$("password", { userId$ }).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + userId.error({ some: "error" }); + await awaitAsync(); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when the arbitrary user's stream completes", async () => { + policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + let completed = false; + + generator.algorithms$("password", { userId$ }).subscribe({ + complete: () => { + completed = true; + }, + }); + userId.complete(); + await awaitAsync(); + + expect(completed).toBeTruthy(); + }); + + it("ignores repeated arbitrary user emissions", async () => { + policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + let count = 0; + + const sub = generator.algorithms$("password", { userId$ }).subscribe({ + next: () => { + count++; + }, + }); + await awaitAsync(); + userId.next(SomeUser); + await awaitAsync(); + userId.next(SomeUser); + await awaitAsync(); + sub.unsubscribe(); + + expect(count).toEqual(1); + }); }); describe("settings$", () => { @@ -405,6 +672,11 @@ describe("CredentialGeneratorService", () => { }); it("follows changes to the active user", async () => { + // initialize local accound service and state provider because this test is sensitive + // to some shared data in `FakeAccountService`. + const accountService = new FakeAccountService(accounts); + const stateProvider = new FakeStateProvider(accountService); + await accountService.switchAccount(SomeUser); const someSettings = { foo: "value" }; const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index dc6b861940..693ffd654d 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -1,16 +1,19 @@ import { BehaviorSubject, combineLatest, + concat, concatMap, distinctUntilChanged, endWith, filter, + first, firstValueFrom, ignoreElements, map, - mergeMap, Observable, race, + share, + skipUntil, switchMap, takeUntil, withLatestFrom, @@ -18,6 +21,7 @@ import { import { Simplify } from "type-fest"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { OnDependency, @@ -28,10 +32,21 @@ import { isDynamic } from "@bitwarden/common/tools/state/state-constraints-depen import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { Randomizer } from "../abstractions"; +import { Generators } from "../data"; +import { availableAlgorithms } from "../policies/available-algorithms-policy"; import { mapPolicyToConstraints } from "../rx"; +import { + CredentialAlgorithm, + CredentialCategories, + CredentialCategory, + CredentialGeneratorInfo, + CredentialPreference, +} from "../types"; import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; import { GeneratorConstraints } from "../types/generator-constraints"; +import { PREFERENCES } from "./credential-preferences"; + type Policy$Dependencies = UserDependency; type Settings$Dependencies = Partial; type Generate$Dependencies = Simplify & Partial> & { @@ -46,6 +61,8 @@ type Generate$Dependencies = Simplify & Partial; }; +type Algorithms$Dependencies = Partial; + export class CredentialGeneratorService { constructor( private randomizer: Randomizer, @@ -53,6 +70,9 @@ export class CredentialGeneratorService { private policyService: PolicyService, ) {} + // FIXME: the rxjs methods of this service can be a lot more resilient if + // `Subjects` are introduced where sharing occurs + /** Generates a stream of credentials * @param configuration determines which generator's settings are loaded * @param dependencies.on$ when specified, a new credential is emitted when @@ -76,8 +96,24 @@ export class CredentialGeneratorService { const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true)); const complete$ = race(requestComplete$, settingsComplete$); + // if on$ triggers before settings are loaded, trigger as soon + // as they become available. + let readyOn$: Observable = null; + if (dependencies?.on$) { + const NO_EMISSIONS = {}; + const ready$ = combineLatest([settings$, request$]).pipe( + first(null, NO_EMISSIONS), + filter((value) => value !== NO_EMISSIONS), + share(), + ); + readyOn$ = concat( + dependencies.on$?.pipe(switchMap(() => ready$)), + dependencies.on$.pipe(skipUntil(ready$)), + ); + } + // generation proper - const generate$ = (dependencies?.on$ ?? settings$).pipe( + const generate$ = (readyOn$ ?? settings$).pipe( withLatestFrom(request$, settings$), concatMap(([, request, settings]) => engine.generate(request, settings)), takeUntil(complete$), @@ -86,6 +122,79 @@ export class CredentialGeneratorService { return generate$; } + /** Emits metadata concerning the provided generation algorithms + * @param category the category or categories of interest + * @param dependences.userId$ when provided, the algorithms are filter to only + * those matching the provided user's policy. Otherwise, emits the algorithms + * available to the active user. + * @returns An observable that emits algorithm metadata. + */ + algorithms$( + category: CredentialCategory, + dependencies?: Algorithms$Dependencies, + ): Observable; + algorithms$( + category: CredentialCategory[], + dependencies?: Algorithms$Dependencies, + ): Observable; + algorithms$( + category: CredentialCategory | CredentialCategory[], + dependencies?: Algorithms$Dependencies, + ) { + // any cast required here because TypeScript fails to bind `category` + // to the union-typed overload of `algorithms`. + const algorithms = this.algorithms(category as any); + + // fall back to default bindings + const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$; + + // monitor completion + const completion$ = userId$.pipe(ignoreElements(), endWith(true)); + + // apply policy + const algorithms$ = userId$.pipe( + distinctUntilChanged(), + switchMap((userId) => { + // complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely + const policies$ = this.policyService.getAll$(PolicyType.PasswordGenerator, userId).pipe( + map((p) => new Set(availableAlgorithms(p))), + takeUntil(completion$), + ); + return policies$; + }), + map((available) => { + const filtered = algorithms.filter((c) => available.has(c.id)); + return filtered; + }), + ); + + return algorithms$; + } + + /** Lists metadata for the algorithms in a credential category + * @param category the category or categories of interest + * @returns A list containing the requested metadata. + */ + algorithms(category: CredentialCategory): CredentialGeneratorInfo[]; + algorithms(category: CredentialCategory[]): CredentialGeneratorInfo[]; + algorithms(category: CredentialCategory | CredentialCategory[]): CredentialGeneratorInfo[] { + const categories = Array.isArray(category) ? category : [category]; + const algorithms = categories + .flatMap((c) => CredentialCategories[c]) + .map((c) => (c === "forwarder" ? null : Generators[c])) + .filter((info) => info !== null); + + return algorithms; + } + + /** Look up the metadata for a specific generator algorithm + * @param id identifies the algorithm + * @returns the requested metadata, or `null` if the metadata wasn't found. + */ + algorithm(id: CredentialAlgorithm): CredentialGeneratorInfo { + return (id === "forwarder" ? null : Generators[id]) ?? null; + } + /** Get the settings for the provided configuration * @param configuration determines which generator's settings are loaded * @param dependencies.userId$ identifies the user to which the settings are bound. @@ -125,6 +234,29 @@ export class CredentialGeneratorService { return settings$; } + /** Get a subject bound to credential generator preferences. + * @param dependencies.singleUserId$ identifies the user to which the preferences are bound + * @returns a promise that resolves with the subject once `dependencies.singleUserId$` + * becomes available. + * @remarks Preferences determine which algorithms are used when generating a + * credential from a credential category (e.g. `PassX` or `Username`). Preferences + * should not be used to hold navigation history. Use @bitwarden/generator-navigation + * instead. + */ + async preferences( + dependencies: SingleUserDependency, + ): Promise> { + const userId = await firstValueFrom( + dependencies.singleUserId$.pipe(filter((userId) => !!userId)), + ); + + // FIXME: enforce policy + const state = this.stateProvider.getUser(userId, PREFERENCES); + const subject = new UserStateSubject(state, { ...dependencies }); + + return subject; + } + /** Get a subject bound to a specific user's settings * @param configuration determines which generator's settings are loaded * @param dependencies.singleUserId$ identifies the user to which the settings are bound @@ -159,7 +291,7 @@ export class CredentialGeneratorService { const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true)); const constraints$ = dependencies.userId$.pipe( - mergeMap((userId) => { + switchMap((userId) => { // complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely const policies$ = this.policyService .getAll$(configuration.policy.type, userId) diff --git a/libs/tools/generator/core/src/services/credential-preferences.spec.ts b/libs/tools/generator/core/src/services/credential-preferences.spec.ts new file mode 100644 index 0000000000..fc7c3e1bbc --- /dev/null +++ b/libs/tools/generator/core/src/services/credential-preferences.spec.ts @@ -0,0 +1,53 @@ +import { DefaultCredentialPreferences } from "../data"; + +import { PREFERENCES } from "./credential-preferences"; + +describe("PREFERENCES", () => { + describe("deserializer", () => { + it.each([[null], [undefined]])("creates new preferences (= %p)", (value) => { + const result = PREFERENCES.deserializer(value); + + expect(result).toEqual(DefaultCredentialPreferences); + }); + + it("fills missing password preferences", () => { + const input = { ...DefaultCredentialPreferences }; + delete input.password; + + const result = PREFERENCES.deserializer(input as any); + + expect(result).toEqual(DefaultCredentialPreferences); + }); + + it("fills missing email preferences", () => { + const input = { ...DefaultCredentialPreferences }; + delete input.email; + + const result = PREFERENCES.deserializer(input as any); + + expect(result).toEqual(DefaultCredentialPreferences); + }); + + it("fills missing username preferences", () => { + const input = { ...DefaultCredentialPreferences }; + delete input.username; + + const result = PREFERENCES.deserializer(input as any); + + expect(result).toEqual(DefaultCredentialPreferences); + }); + + it("converts updated fields to Dates", () => { + const input = structuredClone(DefaultCredentialPreferences); + input.email.updated = "1970-01-01T00:00:00.100Z" as any; + input.password.updated = "1970-01-01T00:00:00.200Z" as any; + input.username.updated = "1970-01-01T00:00:00.300Z" as any; + + const result = PREFERENCES.deserializer(input as any); + + expect(result.email.updated).toEqual(new Date(100)); + expect(result.password.updated).toEqual(new Date(200)); + expect(result.username.updated).toEqual(new Date(300)); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/credential-preferences.ts b/libs/tools/generator/core/src/services/credential-preferences.ts new file mode 100644 index 0000000000..3f6a6c1e1b --- /dev/null +++ b/libs/tools/generator/core/src/services/credential-preferences.ts @@ -0,0 +1,30 @@ +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { DefaultCredentialPreferences } from "../data"; +import { CredentialPreference } from "../types"; + +/** plaintext password generation options */ +export const PREFERENCES = new UserKeyDefinition( + GENERATOR_DISK, + "credentialPreferences", + { + deserializer: (value) => { + const result = (value as any) ?? {}; + + for (const key in DefaultCredentialPreferences) { + // bind `key` to `category` to transmute the type + const category: keyof typeof DefaultCredentialPreferences = key as any; + + const preference = result[category] ?? { ...DefaultCredentialPreferences[category] }; + if (typeof preference.updated === "string") { + preference.updated = new Date(preference.updated); + } + + result[category] = preference; + } + + return result; + }, + clearOn: ["logout"], + }, +); diff --git a/libs/tools/generator/core/src/types/credential-category.ts b/libs/tools/generator/core/src/types/credential-category.ts deleted file mode 100644 index 54c8c5ed8e..0000000000 --- a/libs/tools/generator/core/src/types/credential-category.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** Kinds of credentials that can be stored by the history service - * password - a secret consisting of arbitrary characters used to authenticate a user - * passphrase - a secret consisting of words used to authenticate a user - */ -export type CredentialCategory = "password" | "passphrase"; diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts index 80d977a73c..8302450d44 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -2,16 +2,35 @@ import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { Constraints } from "@bitwarden/common/tools/types"; import { Randomizer } from "../abstractions"; -import { PolicyConfiguration } from "../types"; +import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from "../types"; -import { CredentialCategory } from "./credential-category"; import { CredentialGenerator } from "./credential-generator"; -export type CredentialGeneratorConfiguration = { - /** Category describing usage of the credential generated by this configuration +/** Credential generator metadata common across credential generators */ +export type CredentialGeneratorInfo = { + /** Uniquely identifies the credential configuration */ + id: CredentialAlgorithm; + + /** The kind of credential generated by this configuration */ category: CredentialCategory; + /** Key used to localize the credential name in the I18nService */ + nameKey: string; + + /** Key used to localize the credential description in the I18nService */ + descriptionKey?: string; + + /** When true, credential generation must be explicitly requested. + * @remarks this property is useful when credential generation + * carries side effects, such as configuring a service external + * to Bitwarden. + */ + onlyOnRequest: boolean; +}; + +/** Credential generator metadata that relies upon typed setting and policy definitions. */ +export type CredentialGeneratorConfiguration = CredentialGeneratorInfo & { /** An algorithm that generates credentials when ran. */ engine: { /** Factory for the generator @@ -28,6 +47,7 @@ export type CredentialGeneratorConfiguration = { /** value used when an account's settings haven't been initialized */ initial: Readonly>; + /** Application-global constraints that apply to account settings */ constraints: Constraints; /** storage location for account-global settings */ diff --git a/libs/tools/generator/core/src/types/generated-credential.spec.ts b/libs/tools/generator/core/src/types/generated-credential.spec.ts index a687676576..6a498282fe 100644 --- a/libs/tools/generator/core/src/types/generated-credential.spec.ts +++ b/libs/tools/generator/core/src/types/generated-credential.spec.ts @@ -1,4 +1,4 @@ -import { CredentialCategory, GeneratedCredential } from "."; +import { CredentialAlgorithm, GeneratedCredential } from "."; describe("GeneratedCredential", () => { describe("constructor", () => { @@ -34,7 +34,7 @@ describe("GeneratedCredential", () => { expect(result).toEqual({ credential: "example", - category: "password" as CredentialCategory, + category: "password" as CredentialAlgorithm, generationDate: 100, }); }); @@ -42,7 +42,7 @@ describe("GeneratedCredential", () => { it("fromJSON converts Json objects into credentials", () => { const jsonValue = { credential: "example", - category: "password" as CredentialCategory, + category: "password" as CredentialAlgorithm, generationDate: 100, }; diff --git a/libs/tools/generator/core/src/types/generated-credential.ts b/libs/tools/generator/core/src/types/generated-credential.ts index ff174b04a5..6d18a1c789 100644 --- a/libs/tools/generator/core/src/types/generated-credential.ts +++ b/libs/tools/generator/core/src/types/generated-credential.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest"; -import { CredentialCategory } from "./credential-category"; +import { CredentialAlgorithm } from "./generator-type"; /** A credential generation result */ export class GeneratedCredential { @@ -14,7 +14,7 @@ export class GeneratedCredential { */ constructor( readonly credential: string, - readonly category: CredentialCategory, + readonly category: CredentialAlgorithm, generationDate: Date | number, ) { if (typeof generationDate === "number") { diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts index 717b0e994e..59727fb98f 100644 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ b/libs/tools/generator/core/src/types/generator-type.ts @@ -1,7 +1,56 @@ -import { GeneratorTypes, PasswordTypes } from "../data/generator-types"; +import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; -/** The kind of credential being generated. */ -export type GeneratorType = (typeof GeneratorTypes)[number]; +/** A type of password that may be generated by the credential generator. */ +export type PasswordAlgorithm = (typeof PasswordAlgorithms)[number]; -/** The kinds of passwords that can be generated. */ -export type PasswordType = (typeof PasswordTypes)[number]; +/** A type of username that may be generated by the credential generator. */ +export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number]; + +/** A type of email address that may be generated by the credential generator. */ +export type EmailAlgorithm = (typeof EmailAlgorithms)[number]; + +/** A type of credential that may be generated by the credential generator. */ +export type CredentialAlgorithm = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; + +/** Compound credential types supported by the credential generator. */ +export const CredentialCategories = Object.freeze({ + /** Lists algorithms in the "password" credential category */ + password: PasswordAlgorithms as Readonly, + + /** Lists algorithms in the "username" credential category */ + username: UsernameAlgorithms as Readonly, + + /** Lists algorithms in the "email" credential category */ + email: EmailAlgorithms as Readonly, +}); + +/** Returns true when the input algorithm is a password algorithm. */ +export function isPasswordAlgorithm( + algorithm: CredentialAlgorithm, +): algorithm is PasswordAlgorithm { + return PasswordAlgorithms.includes(algorithm as any); +} + +/** Returns true when the input algorithm is a username algorithm. */ +export function isUsernameAlgorithm( + algorithm: CredentialAlgorithm, +): algorithm is UsernameAlgorithm { + return UsernameAlgorithms.includes(algorithm as any); +} + +/** Returns true when the input algorithm is an email algorithm. */ +export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { + return EmailAlgorithms.includes(algorithm as any); +} + +/** A type of compound credential that may be generated by the credential generator. */ +export type CredentialCategory = keyof typeof CredentialCategories; + +/** The kind of credential to generate using a compound configuration. */ +// FIXME: extend the preferences to include a preferred forwarder +export type CredentialPreference = { + [Key in CredentialCategory]: { + algorithm: (typeof CredentialCategories)[Key][number]; + updated: Date; + }; +}; diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 4f74c487f2..884d976007 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,6 +1,7 @@ +import { CredentialAlgorithm, PasswordAlgorithm } from "./generator-type"; + export * from "./boundary"; export * from "./catchall-generator-options"; -export * from "./credential-category"; export * from "./credential-generator"; export * from "./credential-generator-configuration"; export * from "./eff-username-generator-options"; @@ -17,3 +18,13 @@ export * from "./password-generator-policy"; export * from "./policy-configuration"; export * from "./subaddress-generator-options"; export * from "./word-options"; + +/** Provided for backwards compatibility only. + * @deprecated Use one of the Algorithm types instead. + */ +export type GeneratorType = CredentialAlgorithm; + +/** Provided for backwards compatibility only. + * @deprecated Use one of the Algorithm types instead. + */ +export type PasswordType = PasswordAlgorithm; diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts index 75871e056c..51049fa56b 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts @@ -1,4 +1,4 @@ -import { PasswordTypes, PolicyEvaluator } from "@bitwarden/generator-core"; +import { PasswordAlgorithms, PolicyEvaluator } from "@bitwarden/generator-core"; import { DefaultGeneratorNavigation } from "./default-generator-navigation"; import { GeneratorNavigation } from "./generator-navigation"; @@ -17,7 +17,7 @@ export class GeneratorNavigationEvaluator /** {@link PolicyEvaluator.policyInEffect} */ get policyInEffect(): boolean { - return PasswordTypes.includes(this.policy?.overridePasswordType); + return PasswordAlgorithms.includes(this.policy?.overridePasswordType); } /** Apply policy to the input options.