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 0b43b0e257c..d8c49da5b1a 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 @@ - + 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 f07affd2374..16938fbe79f 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -1,14 +1,11 @@ import { Component } from "@angular/core"; -import { - PassphraseSettingsComponent, - PasswordSettingsComponent, -} from "@bitwarden/generator-components"; +import { PasswordGeneratorComponent } from "@bitwarden/generator-components"; @Component({ standalone: true, selector: "credential-generator", templateUrl: "credential-generator.component.html", - imports: [PassphraseSettingsComponent, PasswordSettingsComponent], + imports: [PasswordGeneratorComponent], }) export class CredentialGeneratorComponent {} diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 0488291b446..8b860591d54 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -77,7 +77,7 @@ export type SingleUserDependency = { export type OnDependency = { /** The stream that controls emissions */ - on$: Observable; + on$: Observable; }; /** A pattern for types that emit when a dependency is `true`. diff --git a/libs/common/src/tools/integration/rpc/integration-request.ts b/libs/common/src/tools/integration/rpc/integration-request.ts index 84a7b517abe..9f1808a632b 100644 --- a/libs/common/src/tools/integration/rpc/integration-request.ts +++ b/libs/common/src/tools/integration/rpc/integration-request.ts @@ -1,11 +1,6 @@ +import { GenerationRequest } from "../../types"; + /** Options that provide contextual information about the application state * when an integration is invoked. */ -export type IntegrationRequest = { - /** @param website The domain of the website the requested integration is used - * within. This should be set to `null` when the request is not specific - * to any website. - * @remarks this field contains sensitive data - */ - website: string | null; -}; +export type IntegrationRequest = Partial; diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts index 0c2f2832eaa..83d69edb06c 100644 --- a/libs/common/src/tools/types.ts +++ b/libs/common/src/tools/types.ts @@ -46,3 +46,20 @@ export type Constraints = { /** utility type for methods that evaluate constraints generically. */ export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints; + +/** Options that provide contextual information about the application state + * when a generator is invoked. + */ +export type VaultItemRequest = { + /** The domain of the website the requested credential is used + * within. This should be set to `null` when the request is not specific + * to any website. + * @remarks this field contains sensitive data + */ + website: string | null; +}; + +/** Options that provide contextual information about the application state + * when a generator is invoked. + */ +export type GenerationRequest = Partial; diff --git a/libs/tools/generator/components/src/dependencies.ts b/libs/tools/generator/components/src/dependencies.ts index 927c3811c86..d96ff0db8d6 100644 --- a/libs/tools/generator/components/src/dependencies.ts +++ b/libs/tools/generator/components/src/dependencies.ts @@ -4,41 +4,60 @@ import { ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CardComponent, CheckboxModule, ColorPasswordModule, FormFieldModule, + IconButtonModule, InputModule, + ItemModule, SectionComponent, SectionHeaderComponent, + ToggleGroupModule, } from "@bitwarden/components"; -import { CredentialGeneratorService } from "@bitwarden/generator-core"; +import { + createRandomizer, + CredentialGeneratorService, + Randomizer, +} from "@bitwarden/generator-core"; + +const RANDOMIZER = new SafeInjectionToken("Randomizer"); /** Shared module containing generator component dependencies */ @NgModule({ - imports: [SectionComponent, SectionHeaderComponent, CardComponent], + imports: [CardComponent, SectionComponent, SectionHeaderComponent], exports: [ + CardComponent, + CheckboxModule, + CommonModule, + ColorPasswordModule, + FormFieldModule, + IconButtonModule, + InputModule, + ItemModule, JslibModule, JslibServicesModule, - FormFieldModule, - CommonModule, ReactiveFormsModule, - ColorPasswordModule, - InputModule, - CheckboxModule, SectionComponent, SectionHeaderComponent, - CardComponent, + ToggleGroupModule, ], providers: [ + safeProvider({ + provide: RANDOMIZER, + useFactory: createRandomizer, + deps: [CryptoService], + }), safeProvider({ provide: CredentialGeneratorService, useClass: CredentialGeneratorService, - deps: [StateProvider, PolicyService], + deps: [RANDOMIZER, StateProvider, PolicyService], }), ], declarations: [], diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index 5915c5d59f6..4423f8a1ec9 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -2,3 +2,4 @@ export { PassphraseSettingsComponent } from "./passphrase-settings.component"; export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; export { PasswordSettingsComponent } from "./password-settings.component"; +export { PasswordGeneratorComponent } from "./password-generator.component"; diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html new file mode 100644 index 00000000000..db5a1ed379b --- /dev/null +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -0,0 +1,44 @@ + + + {{ "password" | i18n }} + + + {{ "passphrase" | i18n }} + + + +
+ +
+
+ + +
+
+ + diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts new file mode 100644 index 00000000000..6c84d83c4cf --- /dev/null +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -0,0 +1,117 @@ +import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; +import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs"; + +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 { DependenciesModule } from "./dependencies"; +import { PassphraseSettingsComponent } from "./passphrase-settings.component"; +import { PasswordSettingsComponent } from "./password-settings.component"; + +/** Options group for passwords */ +@Component({ + standalone: true, + selector: "bit-password-generator", + templateUrl: "password-generator.component.html", + imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent], +}) +export class PasswordGeneratorComponent implements OnInit, OnDestroy { + constructor( + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + private zone: NgZone, + ) {} + + /** Binds the passphrase component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + /** tracks the currently selected credential type */ + protected credentialType$ = new BehaviorSubject("password"); + + /** 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(); + + /** Tracks changes to the selected credential type + * @param type the new credential type + */ + protected onCredentialTypeChanged(type: GeneratorType) { + if (this.credentialType$.value !== type) { + this.credentialType$.next(type); + this.generate$.next(); + } + } + + /** Emits credentials created from a generation request. */ + @Output() + readonly onGenerated = new EventEmitter(); + + async ngOnInit() { + if (this.userId) { + this.userId$.next(this.userId); + } else { + this.accountService.activeAccount$ + .pipe( + map((acct) => acct.id), + distinctUntilChanged(), + takeUntil(this.destroyed), + ) + .subscribe(this.userId$); + } + + this.credentialType$ + .pipe( + switchMap((type) => this.typeToGenerator$(type)), + 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); + }); + }); + } + + private typeToGenerator$(type: GeneratorType) { + const dependencies = { + on$: this.generate$, + userId$: this.userId$, + }; + + switch (type) { + case "password": + return this.generatorService.generate$(Generators.Password, dependencies); + + case "passphrase": + return this.generatorService.generate$(Generators.Passphrase, dependencies); + default: + throw new Error(`Invalid generator type: "${type}"`); + } + } + + private readonly destroyed = new Subject(); + ngOnDestroy(): void { + // tear down subscriptions + this.destroyed.complete(); + + // finalize subjects + this.generate$.complete(); + this.value$.complete(); + + // finalize component bindings + this.onGenerated.complete(); + } +} diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 94e289be03e..f71d484f9c2 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,5 +1,8 @@ +import { Randomizer } from "../abstractions"; +import { PasswordRandomizer } from "../engine"; import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "../strategies/storage"; import { + CredentialGenerator, PassphraseGenerationOptions, PassphraseGeneratorPolicy, PasswordGenerationOptions, @@ -14,6 +17,12 @@ import { DefaultPasswordGenerationOptions } from "./default-password-generation- import { Policies } from "./policies"; const PASSPHRASE = Object.freeze({ + category: "passphrase", + engine: { + create(randomizer: Randomizer): CredentialGenerator { + return new PasswordRandomizer(randomizer); + }, + }, settings: { initial: DefaultPassphraseGenerationOptions, constraints: { @@ -32,6 +41,12 @@ const PASSPHRASE = Object.freeze({ >); const PASSWORD = Object.freeze({ + category: "password", + engine: { + create(randomizer: Randomizer): CredentialGenerator { + return new PasswordRandomizer(randomizer); + }, + }, settings: { initial: DefaultPasswordGenerationOptions, constraints: { diff --git a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts index bbc31a4a293..fca98855fd5 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts @@ -335,4 +335,40 @@ describe("PasswordRandomizer", () => { expect(result).toEqual("foo-foo"); }); }); + + describe("generate", () => { + it("processes password generation options", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.generate( + {}, + { + length: 10, + }, + ); + + expect(result.category).toEqual("password"); + }); + + it("processes passphrase generation options", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.generate( + {}, + { + numWords: 10, + }, + ); + + expect(result.category).toEqual("passphrase"); + }); + + it("throws when it cannot recognize the options type", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = password.generate({}, {}); + + await expect(result).rejects.toBeInstanceOf(Error); + }); + }); }); diff --git a/libs/tools/generator/core/src/engine/password-randomizer.ts b/libs/tools/generator/core/src/engine/password-randomizer.ts index 438ea8b8b47..c3a2e2b5d93 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.ts @@ -1,13 +1,26 @@ import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { + CredentialGenerator, + GeneratedCredential, + PassphraseGenerationOptions, + PasswordGenerationOptions, +} from "../types"; +import { optionsToEffWordListRequest, optionsToRandomAsciiRequest } from "../util"; import { Randomizer } from "./abstractions"; import { Ascii } from "./data"; import { CharacterSet, EffWordListRequest, RandomAsciiRequest } from "./types"; /** Generation algorithms that produce randomized secrets */ -export class PasswordRandomizer { +export class PasswordRandomizer + implements + CredentialGenerator, + CredentialGenerator +{ /** Instantiates the password randomizer - * @param random data source for random data + * @param randomizer data source for random data */ constructor(private randomizer: Randomizer) {} @@ -52,6 +65,41 @@ export class PasswordRandomizer { return wordList.join(request.separator); } + + generate( + request: GenerationRequest, + settings: PasswordGenerationOptions, + ): Promise; + generate( + request: GenerationRequest, + settings: PassphraseGenerationOptions, + ): Promise; + async generate( + _request: GenerationRequest, + settings: PasswordGenerationOptions | PassphraseGenerationOptions, + ) { + if (isPasswordGenerationOptions(settings)) { + const request = optionsToRandomAsciiRequest(settings); + const password = await this.randomAscii(request); + + return new GeneratedCredential(password, "password", Date.now()); + } else if (isPassphraseGenerationOptions(settings)) { + const request = optionsToEffWordListRequest(settings); + const passphrase = await this.randomEffLongWords(request); + + return new GeneratedCredential(passphrase, "passphrase", Date.now()); + } + + throw new Error("Invalid settings received by generator."); + } +} + +function isPasswordGenerationOptions(settings: any): settings is PasswordGenerationOptions { + return "length" in (settings ?? {}); +} + +function isPassphraseGenerationOptions(settings: any): settings is PassphraseGenerationOptions { + return "numWords" in (settings ?? {}); } // given a generator request, convert each of its `number | undefined` properties 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 31f5134918b..5b784b3d07b 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 @@ -1,5 +1,5 @@ import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -8,9 +8,14 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st import { Constraints } from "@bitwarden/common/tools/types"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; -import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec"; -import { PolicyEvaluator } from "../abstractions"; -import { CredentialGeneratorConfiguration } from "../types"; +import { + FakeStateProvider, + FakeAccountService, + awaitAsync, + ObservableTracker, +} from "../../../../../common/spec"; +import { PolicyEvaluator, Randomizer } from "../abstractions"; +import { CredentialGeneratorConfiguration, GeneratedCredential } from "../types"; import { CredentialGeneratorService } from "./credential-generator.service"; @@ -34,8 +39,23 @@ const somePolicy = new Policy({ enabled: true, }); +const SomeTime = new Date(1); +const SomeCategory = "passphrase"; + // fake the configuration const SomeConfiguration: CredentialGeneratorConfiguration = { + category: SomeCategory, + engine: { + create: (randomizer) => { + return { + generate: (request, settings) => { + const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; + const result = new GeneratedCredential(credential, SomeCategory, SomeTime); + return Promise.resolve(result); + }, + }; + }, + }, settings: { initial: { foo: "initial" }, constraints: { foo: {} }, @@ -87,6 +107,9 @@ const accountService = new FakeAccountService({ // fake state const stateProvider = new FakeStateProvider(accountService); +// fake randomizer +const randomizer = mock(); + describe("CredentialGeneratorService", () => { beforeEach(async () => { await accountService.switchAccount(SomeUser); @@ -94,10 +117,242 @@ describe("CredentialGeneratorService", () => { jest.clearAllMocks(); }); + describe("generate$", () => { + it("emits a generation for the active user when subscribed", async () => { + const settings = { foo: "value" }; + await stateProvider.setUserState(SettingsKey, settings, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); + + const result = await generated.expectEmission(); + + expect(result).toEqual(new GeneratedCredential("value", SomeCategory, SomeTime)); + }); + + it("follows the active user", async () => { + const someSettings = { foo: "some value" }; + const anotherSettings = { foo: "another value" }; + await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); + await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); + + await accountService.switchAccount(AnotherUser); + await generated.pauseUntilReceived(2); + generated.unsubscribe(); + + expect(generated.emissions).toEqual([ + new GeneratedCredential("some value", SomeCategory, SomeTime), + new GeneratedCredential("another value", SomeCategory, SomeTime), + ]); + }); + + it("emits a generation when the settings change", async () => { + const someSettings = { foo: "some value" }; + const anotherSettings = { foo: "another value" }; + await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); + + await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser); + await generated.pauseUntilReceived(2); + generated.unsubscribe(); + + expect(generated.emissions).toEqual([ + new GeneratedCredential("some value", SomeCategory, SomeTime), + new GeneratedCredential("another value", SomeCategory, SomeTime), + ]); + }); + + // FIXME: test these when the fake state provider can create the required emissions + it.todo("errors when the settings error"); + it.todo("completes when the settings complete"); + + it("includes `website$`'s last emitted value", async () => { + const settings = { foo: "value" }; + await stateProvider.setUserState(SettingsKey, settings, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const website$ = new BehaviorSubject("some website"); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); + + const result = await generated.expectEmission(); + + expect(result).toEqual(new GeneratedCredential("some website|value", SomeCategory, SomeTime)); + }); + + it("errors when `website$` errors", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const website$ = new BehaviorSubject("some website"); + let error = null; + + generator.generate$(SomeConfiguration, { website$ }).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + website$.error({ some: "error" }); + await awaitAsync(); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when `website$` completes", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const website$ = new BehaviorSubject("some website"); + let completed = false; + + generator.generate$(SomeConfiguration, { website$ }).subscribe({ + complete: () => { + completed = true; + }, + }); + website$.complete(); + await awaitAsync(); + + expect(completed).toBeTruthy(); + }); + + it("emits a generation for a specific user when `user$` supplied", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId$ = new BehaviorSubject(AnotherUser).asObservable(); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); + + const result = await generated.expectEmission(); + + expect(result).toEqual(new GeneratedCredential("another", SomeCategory, SomeTime)); + }); + + it("emits a generation for a specific user when `user$` emits", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.pipe(filter((u) => !!u)); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); + + userId.next(AnotherUser); + const result = await generated.pauseUntilReceived(2); + + expect(result).toEqual([ + new GeneratedCredential("value", SomeCategory, SomeTime), + new GeneratedCredential("another", SomeCategory, SomeTime), + ]); + }); + + it("errors when `user$` errors", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId$ = new BehaviorSubject(SomeUser); + let error = null; + + generator.generate$(SomeConfiguration, { userId$ }).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + userId$.error({ some: "error" }); + await awaitAsync(); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when `user$` completes", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const userId$ = new BehaviorSubject(SomeUser); + let completed = false; + + generator.generate$(SomeConfiguration, { userId$ }).subscribe({ + complete: () => { + completed = true; + }, + }); + userId$.complete(); + await awaitAsync(); + + expect(completed).toBeTruthy(); + }); + + it("emits a generation only when `on$` emits", async () => { + // This test breaks from arrange/act/assert because it is testing causality + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const on$ = new Subject(); + const results: any[] = []; + + // confirm no emission during subscription + const sub = generator + .generate$(SomeConfiguration, { on$ }) + .subscribe((result) => results.push(result)); + await awaitAsync(); + expect(results.length).toEqual(0); + + // confirm forwarded emission + on$.next(); + await awaitAsync(); + expect(results).toEqual([new GeneratedCredential("value", SomeCategory, SomeTime)]); + + // confirm updating settings does not cause an emission + await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser); + await awaitAsync(); + expect(results.length).toBe(1); + + // confirm forwarded emission takes latest value + on$.next(); + await awaitAsync(); + sub.unsubscribe(); + + expect(results).toEqual([ + new GeneratedCredential("value", SomeCategory, SomeTime), + new GeneratedCredential("next", SomeCategory, SomeTime), + ]); + }); + + it("errors when `on$` errors", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const on$ = new Subject(); + let error: any = null; + + // confirm no emission during subscription + generator.generate$(SomeConfiguration, { on$ }).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + on$.error({ some: "error" }); + await awaitAsync(); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when `on$` completes", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const on$ = new Subject(); + let complete = false; + + // confirm no emission during subscription + generator.generate$(SomeConfiguration, { on$ }).subscribe({ + complete: () => { + complete = true; + }, + }); + on$.complete(); + await awaitAsync(); + + expect(complete).toBeTruthy(); + }); + }); + describe("settings$", () => { it("defaults to the configuration's initial settings if settings aren't found", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -107,7 +362,7 @@ describe("CredentialGeneratorService", () => { it("reads from the active user's configuration-defined storage", async () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -119,7 +374,7 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, settings, SomeUser); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -131,7 +386,7 @@ describe("CredentialGeneratorService", () => { const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const results: any = []; const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); @@ -148,7 +403,7 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ })); @@ -161,7 +416,7 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const results: any = []; @@ -180,7 +435,7 @@ describe("CredentialGeneratorService", () => { it("errors when the arbitrary user's stream errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let error = null; @@ -198,7 +453,7 @@ describe("CredentialGeneratorService", () => { it("completes when the arbitrary user's stream completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let completed = false; @@ -216,7 +471,7 @@ describe("CredentialGeneratorService", () => { it("ignores repeated arbitrary user emissions", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let count = 0; @@ -240,7 +495,7 @@ describe("CredentialGeneratorService", () => { describe("settings", () => { it("writes to the user's state", async () => { const singleUserId$ = new BehaviorSubject(SomeUser).asObservable(); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); subject.next({ foo: "next value" }); @@ -253,7 +508,7 @@ describe("CredentialGeneratorService", () => { it("waits for the user to become available", async () => { const singleUserId = new BehaviorSubject(null); const singleUserId$ = singleUserId.asObservable(); - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); let completed = false; const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => { @@ -271,7 +526,7 @@ describe("CredentialGeneratorService", () => { describe("policy$", () => { it("creates a disabled policy evaluator when there is no policy", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); @@ -281,7 +536,7 @@ describe("CredentialGeneratorService", () => { }); it("creates an active policy evaluator when there is a policy", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); @@ -293,7 +548,7 @@ describe("CredentialGeneratorService", () => { }); it("follows policy emissions", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicySubject = new BehaviorSubject([somePolicy]); @@ -316,7 +571,7 @@ describe("CredentialGeneratorService", () => { }); it("follows user emissions", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); @@ -340,7 +595,7 @@ describe("CredentialGeneratorService", () => { }); it("errors when the user errors", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const expectedError = { some: "error" }; @@ -358,7 +613,7 @@ describe("CredentialGeneratorService", () => { }); it("completes when the user completes", async () => { - const generator = new CredentialGeneratorService(stateProvider, policyService); + const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); 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 891d0016fef..d2012ecf20f 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -1,5 +1,7 @@ import { + BehaviorSubject, combineLatest, + concatMap, distinctUntilChanged, endWith, filter, @@ -8,32 +10,84 @@ import { map, mergeMap, Observable, + race, switchMap, takeUntil, + withLatestFrom, } from "rxjs"; +import { Simplify } from "type-fest"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { SingleUserDependency, UserDependency } from "@bitwarden/common/tools/dependencies"; +import { + OnDependency, + SingleUserDependency, + UserDependency, +} from "@bitwarden/common/tools/dependencies"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { Constraints } from "@bitwarden/common/tools/types"; -import { PolicyEvaluator } from "../abstractions"; +import { PolicyEvaluator, Randomizer } from "../abstractions"; import { mapPolicyToEvaluatorV2 } from "../rx"; import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; type Policy$Dependencies = UserDependency; type Settings$Dependencies = Partial; +type Generate$Dependencies = Simplify & Partial> & { + /** Emits the active website when subscribed. + * + * The generator does not respond to emissions of this interface; + * If it is provided, the generator blocks until a value becomes available. + * When `website$` is omitted, the generator uses the empty string instead. + * When `website$` completes, the generator completes. + * When `website$` errors, the generator forwards the error. + */ + website$?: Observable; +}; // FIXME: once the modernization is complete, switch the type parameters // in `PolicyEvaluator` and bake-in the constraints type. type Evaluator = PolicyEvaluator & Constraints; export class CredentialGeneratorService { constructor( + private randomizer: Randomizer, private stateProvider: StateProvider, private policyService: PolicyService, ) {} + /** 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 + * this emits. Otherwise, a new credential is emitted when the settings + * update. + */ + generate$( + configuration: Readonly>, + dependencies?: Generate$Dependencies, + ) { + // instantiate the engine + const engine = configuration.engine.create(this.randomizer); + + // stream blocks until all of these values are received + const website$ = dependencies?.website$ ?? new BehaviorSubject(null); + const request$ = website$.pipe(map((website) => ({ website }))); + const settings$ = this.settings$(configuration, dependencies); + + // monitor completion + const requestComplete$ = request$.pipe(ignoreElements(), endWith(true)); + const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true)); + const complete$ = race(requestComplete$, settingsComplete$); + + // generation proper + const generate$ = (dependencies?.on$ ?? settings$).pipe( + withLatestFrom(request$, settings$), + concatMap(([, request, settings]) => engine.generate(request, settings)), + takeUntil(complete$), + ); + + return generate$; + } + /** 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. @@ -82,7 +136,7 @@ export class CredentialGeneratorService { * @remarks the subject enforces policy for the settings */ async settings( - configuration: Configuration, + configuration: Readonly>, dependencies: SingleUserDependency, ) { const userId = await firstValueFrom( diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts index f9b346e02b0..6591b179fc2 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts @@ -17,7 +17,7 @@ import { PASSPHRASE_SETTINGS } from "./storage"; const SomeUser = "some user" as UserId; -describe("Password generation strategy", () => { +describe("Passphrase generation strategy", () => { describe("toEvaluator()", () => { it("should map to the policy evaluator", async () => { const strategy = new PassphraseGeneratorStrategy(null, null); diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts index fe2731f9dd5..37d8b9e3fba 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts @@ -2,11 +2,11 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { GeneratorStrategy } from "../abstractions"; -import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { DefaultPassphraseGenerationOptions, Policies } from "../data"; import { PasswordRandomizer } from "../engine"; import { mapPolicyToEvaluator } from "../rx"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; -import { observe$PerUserId, sharedStateByUserId } from "../util"; +import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } from "../util"; import { PASSPHRASE_SETTINGS } from "./storage"; @@ -33,13 +33,7 @@ export class PassphraseGeneratorStrategy // algorithm async generate(options: PassphraseGenerationOptions): Promise { - const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords; - const request = { - numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min), - capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize, - number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber, - separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator, - }; + const request = optionsToEffWordListRequest(options); return this.randomizer.randomEffLongWords(request); } diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts index 9ed62490c06..9ff8a3d88b0 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts @@ -6,7 +6,7 @@ import { Policies, DefaultPasswordGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; import { mapPolicyToEvaluator } from "../rx"; import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types"; -import { observe$PerUserId, sharedStateByUserId, sum } from "../util"; +import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } from "../util"; import { PASSWORD_SETTINGS } from "./storage"; @@ -32,62 +32,7 @@ export class PasswordGeneratorStrategy // algorithm async generate(options: PasswordGenerationOptions): Promise { - // converts password generation option sets, which are defined by - // an "enabled" and "quantity" parameter, to the password engine's - // parameters, which represent disabled options as `undefined` - // properties. - function process( - // values read from the options - enabled: boolean, - quantity: number, - // value used if an option is missing - defaultEnabled: boolean, - defaultQuantity: number, - ) { - const isEnabled = enabled ?? defaultEnabled; - const actualQuantity = quantity ?? defaultQuantity; - const result = isEnabled ? actualQuantity : undefined; - - return result; - } - - const request = { - uppercase: process( - options.uppercase, - options.minUppercase, - DefaultPasswordGenerationOptions.uppercase, - DefaultPasswordGenerationOptions.minUppercase, - ), - lowercase: process( - options.lowercase, - options.minLowercase, - DefaultPasswordGenerationOptions.lowercase, - DefaultPasswordGenerationOptions.minLowercase, - ), - digits: process( - options.number, - options.minNumber, - DefaultPasswordGenerationOptions.number, - DefaultPasswordGenerationOptions.minNumber, - ), - special: process( - options.special, - options.minSpecial, - DefaultPasswordGenerationOptions.special, - DefaultPasswordGenerationOptions.minSpecial, - ), - ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous, - all: 0, - }; - - // engine represents character sets as "include only"; you assert how many all - // characters there can be rather than a total length. This conversion has - // the character classes win, so that the result is always consistent with policy - // minimums. - const required = sum(request.uppercase, request.lowercase, request.digits, request.special); - const remaining = (options.length ?? 0) - required; - request.all = Math.max(remaining, 0); - + const request = optionsToRandomAsciiRequest(options); const result = await this.randomizer.randomAscii(request); return result; diff --git a/libs/tools/generator/core/src/types/credential-category.ts b/libs/tools/generator/core/src/types/credential-category.ts new file mode 100644 index 00000000000..54c8c5ed8e9 --- /dev/null +++ b/libs/tools/generator/core/src/types/credential-category.ts @@ -0,0 +1,5 @@ +/** 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 2a8b07b0e82..80d977a73c8 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -1,9 +1,29 @@ import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { Constraints } from "@bitwarden/common/tools/types"; +import { Randomizer } from "../abstractions"; import { 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 + */ + category: CredentialCategory; + + /** An algorithm that generates credentials when ran. */ + engine: { + /** Factory for the generator + */ + // FIXME: note that this erases the engine's type so that credentials are + // generated uniformly. This property needs to be maintained for + // the credential generator, but engine configurations should return + // the underlying type. `create` may be able to do double-duty w/ an + // engine definition if `CredentialGenerator` can be made covariant. + create: (randomizer: Randomizer) => CredentialGenerator; + }; + /** Defines the stored parameters for credential generation */ settings: { /** value used when an account's settings haven't been initialized */ initial: Readonly>; diff --git a/libs/tools/generator/core/src/types/credential-generator.ts b/libs/tools/generator/core/src/types/credential-generator.ts new file mode 100644 index 00000000000..c95ff25afff --- /dev/null +++ b/libs/tools/generator/core/src/types/credential-generator.ts @@ -0,0 +1,12 @@ +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { GeneratedCredential } from "./generated-credential"; + +/** An algorithm that generates credentials. */ +export type CredentialGenerator = { + /** Generates a credential + * @param request runtime parameters + * @param settings stored parameters + */ + generate: (request: GenerationRequest, settings: Settings) => Promise; +}; diff --git a/libs/tools/generator/core/src/types/generated-credential.spec.ts b/libs/tools/generator/core/src/types/generated-credential.spec.ts new file mode 100644 index 00000000000..a687676576b --- /dev/null +++ b/libs/tools/generator/core/src/types/generated-credential.spec.ts @@ -0,0 +1,58 @@ +import { CredentialCategory, GeneratedCredential } from "."; + +describe("GeneratedCredential", () => { + describe("constructor", () => { + it("assigns credential", () => { + const result = new GeneratedCredential("example", "passphrase", new Date(100)); + + expect(result.credential).toEqual("example"); + }); + + it("assigns category", () => { + const result = new GeneratedCredential("example", "passphrase", new Date(100)); + + expect(result.category).toEqual("passphrase"); + }); + + it("passes through date parameters", () => { + const result = new GeneratedCredential("example", "password", new Date(100)); + + expect(result.generationDate).toEqual(new Date(100)); + }); + + it("converts numeric dates to Dates", () => { + const result = new GeneratedCredential("example", "password", 100); + + expect(result.generationDate).toEqual(new Date(100)); + }); + }); + + it("toJSON converts from a credential into a JSON object", () => { + const credential = new GeneratedCredential("example", "password", new Date(100)); + + const result = credential.toJSON(); + + expect(result).toEqual({ + credential: "example", + category: "password" as CredentialCategory, + generationDate: 100, + }); + }); + + it("fromJSON converts Json objects into credentials", () => { + const jsonValue = { + credential: "example", + category: "password" as CredentialCategory, + generationDate: 100, + }; + + const result = GeneratedCredential.fromJSON(jsonValue); + + expect(result).toBeInstanceOf(GeneratedCredential); + expect(result).toEqual({ + credential: "example", + category: "password", + generationDate: new Date(100), + }); + }); +}); diff --git a/libs/tools/generator/core/src/types/generated-credential.ts b/libs/tools/generator/core/src/types/generated-credential.ts new file mode 100644 index 00000000000..ff174b04a58 --- /dev/null +++ b/libs/tools/generator/core/src/types/generated-credential.ts @@ -0,0 +1,47 @@ +import { Jsonify } from "type-fest"; + +import { CredentialCategory } from "./credential-category"; + +/** A credential generation result */ +export class GeneratedCredential { + /** + * Instantiates a generated credential + * @param credential The value of the generated credential (e.g. a password) + * @param category The kind of credential + * @param generationDate The date that the credential was generated. + * Numeric values should are interpreted using {@link Date.valueOf} + * semantics. + */ + constructor( + readonly credential: string, + readonly category: CredentialCategory, + generationDate: Date | number, + ) { + if (typeof generationDate === "number") { + this.generationDate = new Date(generationDate); + } else { + this.generationDate = generationDate; + } + } + + /** The date that the credential was generated */ + generationDate: Date; + + /** Constructs a credential from its `toJSON` representation */ + static fromJSON(jsonValue: Jsonify) { + return new GeneratedCredential( + jsonValue.credential, + jsonValue.category, + jsonValue.generationDate, + ); + } + + /** Serializes a credential to a JSON-compatible object */ + toJSON() { + return { + credential: this.credential, + category: this.category, + generationDate: this.generationDate.valueOf(), + }; + } +} diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 786b15b9d11..229ac1c0c38 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,8 +1,11 @@ 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"; export * from "./forwarder-options"; +export * from "./generated-credential"; export * from "./generator-options"; export * from "./generator-type"; export * from "./no-policy"; diff --git a/libs/tools/generator/core/src/util.spec.ts b/libs/tools/generator/core/src/util.spec.ts index 32bdc3ad3af..7ffd869535b 100644 --- a/libs/tools/generator/core/src/util.spec.ts +++ b/libs/tools/generator/core/src/util.spec.ts @@ -1,4 +1,5 @@ -import { sum } from "./util"; +import { DefaultPassphraseGenerationOptions } from "./data"; +import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum } from "./util"; describe("sum", () => { it("returns 0 when the list is empty", () => { @@ -15,3 +16,411 @@ describe("sum", () => { expect(sum(1, 2, 3)).toBe(6); }); }); + +describe("optionsToRandomAsciiRequest", () => { + it("should map options", async () => { + const result = optionsToRandomAsciiRequest({ + length: 20, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 2, + minNumber: 3, + minSpecial: 4, + }); + + expect(result).toEqual({ + all: 10, + uppercase: 1, + lowercase: 2, + digits: 3, + special: 4, + ambiguous: true, + }); + }); + + it("should disable uppercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 3, + ambiguous: true, + uppercase: false, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual({ + all: 0, + uppercase: undefined, + lowercase: 1, + digits: 1, + special: 1, + ambiguous: true, + }); + }); + + it("should disable lowercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: false, + number: true, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 1, + lowercase: undefined, + digits: 1, + special: 1, + ambiguous: true, + }); + }); + + it("should disable digits", async () => { + const result = optionsToRandomAsciiRequest({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: true, + number: false, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 1, + lowercase: 1, + digits: undefined, + special: 1, + ambiguous: true, + }); + }); + + it("should disable special", async () => { + const result = optionsToRandomAsciiRequest({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: false, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 1, + lowercase: 1, + digits: 1, + special: undefined, + ambiguous: true, + }); + }); + + it("should override length with minimums", async () => { + const result = optionsToRandomAsciiRequest({ + length: 20, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 2, + minNumber: 3, + minSpecial: 4, + }); + + expect(result).toEqual({ + all: 10, + uppercase: 1, + lowercase: 2, + digits: 3, + special: 4, + ambiguous: true, + }); + }); + + it("should default uppercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 2, + ambiguous: true, + lowercase: true, + number: true, + special: true, + minUppercase: 2, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 2, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default lowercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 2, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 2, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default number", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 2, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 2, + special: 0, + ambiguous: true, + }); + }); + + it("should default special", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 0, + special: undefined, + ambiguous: true, + }); + }); + + it("should default minUppercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 1, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default minLowercase", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 1, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default minNumber", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minSpecial: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 1, + special: 0, + ambiguous: true, + }); + }); + + it("should default minSpecial", async () => { + const result = optionsToRandomAsciiRequest({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 0, + }); + + expect(result).toEqual({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); +}); + +describe("optionsToEffWordListRequest", () => { + it("should map options", async () => { + const result = optionsToEffWordListRequest({ + numWords: 4, + capitalize: true, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual({ + numberOfWords: 4, + capitalize: true, + number: true, + separator: "!", + }); + }); + + it("should default numWords", async () => { + const result = optionsToEffWordListRequest({ + capitalize: true, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual({ + numberOfWords: DefaultPassphraseGenerationOptions.numWords, + capitalize: true, + number: true, + separator: "!", + }); + }); + + it("should default capitalize", async () => { + const result = optionsToEffWordListRequest({ + numWords: 4, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual({ + numberOfWords: 4, + capitalize: DefaultPassphraseGenerationOptions.capitalize, + number: true, + separator: "!", + }); + }); + + it("should default includeNumber", async () => { + const result = optionsToEffWordListRequest({ + numWords: 4, + capitalize: true, + wordSeparator: "!", + }); + + expect(result).toEqual({ + numberOfWords: 4, + capitalize: true, + number: DefaultPassphraseGenerationOptions.includeNumber, + separator: "!", + }); + }); + + it("should default wordSeparator", async () => { + const result = optionsToEffWordListRequest({ + numWords: 4, + capitalize: true, + includeNumber: true, + }); + + expect(result).toEqual({ + numberOfWords: 4, + capitalize: true, + number: true, + separator: DefaultPassphraseGenerationOptions.wordSeparator, + }); + }); +}); diff --git a/libs/tools/generator/core/src/util.ts b/libs/tools/generator/core/src/util.ts index cca2c75834c..21e901765e2 100644 --- a/libs/tools/generator/core/src/util.ts +++ b/libs/tools/generator/core/src/util.ts @@ -7,6 +7,13 @@ import { } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; +import { + DefaultPassphraseBoundaries, + DefaultPassphraseGenerationOptions, + DefaultPasswordGenerationOptions, +} from "./data"; +import { PassphraseGenerationOptions, PasswordGenerationOptions } from "./types"; + /** construct a method that outputs a copy of `defaultValue` as an observable. */ export function observe$PerUserId( create: () => Partial, @@ -50,3 +57,79 @@ export function sharedStateByUserId(key: UserKeyDefinition, provid /** returns the sum of items in the list. */ export const sum = (...items: number[]) => (items ?? []).reduce((sum: number, current: number) => sum + (current ?? 0), 0); + +/* converts password generation option sets, which are defined by + * an "enabled" and "quantity" parameter, to the password engine's + * parameters, which represent disabled options as `undefined` + * properties. + */ +export function optionsToRandomAsciiRequest(options: PasswordGenerationOptions) { + // helper for processing common option sets + function process( + // values read from the options + enabled: boolean, + quantity: number, + // value used if an option is missing + defaultEnabled: boolean, + defaultQuantity: number, + ) { + const isEnabled = enabled ?? defaultEnabled; + const actualQuantity = quantity ?? defaultQuantity; + const result = isEnabled ? actualQuantity : undefined; + + return result; + } + + const request = { + uppercase: process( + options.uppercase, + options.minUppercase, + DefaultPasswordGenerationOptions.uppercase, + DefaultPasswordGenerationOptions.minUppercase, + ), + lowercase: process( + options.lowercase, + options.minLowercase, + DefaultPasswordGenerationOptions.lowercase, + DefaultPasswordGenerationOptions.minLowercase, + ), + digits: process( + options.number, + options.minNumber, + DefaultPasswordGenerationOptions.number, + DefaultPasswordGenerationOptions.minNumber, + ), + special: process( + options.special, + options.minSpecial, + DefaultPasswordGenerationOptions.special, + DefaultPasswordGenerationOptions.minSpecial, + ), + ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous, + all: 0, + }; + + // engine represents character sets as "include only"; you assert how many all + // characters there can be rather than a total length. This conversion has + // the character classes win, so that the result is always consistent with policy + // minimums. + const required = sum(request.uppercase, request.lowercase, request.digits, request.special); + const remaining = (options.length ?? 0) - required; + request.all = Math.max(remaining, 0); + + return request; +} + +/* converts passphrase generation option sets to the eff word list request + */ +export function optionsToEffWordListRequest(options: PassphraseGenerationOptions) { + const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords; + const request = { + numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min), + capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize, + number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber, + separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator, + }; + + return request; +}