1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-10-08 05:47:50 +02:00

[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
This commit is contained in:
✨ Audrey ✨ 2024-09-27 09:02:59 -04:00 committed by GitHub
parent f1ac1d44e3
commit 433ae13513
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1761 additions and 167 deletions

View File

@ -1 +1,2 @@
<tools-password-generator /> <!-- Note: this is all throwaway markup, so it won't follow best practices -->
<tools-username-generator />

View File

@ -1,11 +1,12 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { PasswordGeneratorComponent } from "@bitwarden/generator-components"; import { SectionComponent } from "@bitwarden/components";
import { UsernameGeneratorComponent } from "@bitwarden/generator-components";
@Component({ @Component({
standalone: true, standalone: true,
selector: "credential-generator", selector: "credential-generator",
templateUrl: "credential-generator.component.html", templateUrl: "credential-generator.component.html",
imports: [PasswordGeneratorComponent], imports: [UsernameGeneratorComponent, SectionComponent],
}) })
export class CredentialGeneratorComponent {} export class CredentialGeneratorComponent {}

View File

@ -0,0 +1,6 @@
<form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field>
<bit-label>{{ "domainName" | i18n }}</bit-label>
<input bitInput formControlName="catchallDomain" type="text" />
</bit-form-field>
</form>

View File

@ -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<CatchallGenerationOptions>();
/** 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<void>();
ngOnDestroy(): void {
this.destroyed$.complete();
}
}

View File

@ -19,6 +19,7 @@ import {
ItemModule, ItemModule,
SectionComponent, SectionComponent,
SectionHeaderComponent, SectionHeaderComponent,
SelectModule,
ToggleGroupModule, ToggleGroupModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { import {
@ -46,6 +47,7 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
ReactiveFormsModule, ReactiveFormsModule,
SectionComponent, SectionComponent,
SectionHeaderComponent, SectionHeaderComponent,
SelectModule,
ToggleGroupModule, ToggleGroupModule,
], ],
providers: [ providers: [

View File

@ -1,5 +1,9 @@
export { PassphraseSettingsComponent } from "./passphrase-settings.component"; export { CatchallSettingsComponent } from "./catchall-settings.component";
export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component";
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
export { PassphraseSettingsComponent } from "./passphrase-settings.component";
export { PasswordSettingsComponent } from "./password-settings.component"; export { PasswordSettingsComponent } from "./password-settings.component";
export { PasswordGeneratorComponent } from "./password-generator.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";

View File

@ -39,7 +39,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
private accountService: AccountService, 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 * When this input is not provided, the form binds to the active
* user * user
*/ */
@ -59,15 +59,15 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
readonly onUpdated = new EventEmitter<PassphraseGenerationOptions>(); readonly onUpdated = new EventEmitter<PassphraseGenerationOptions>();
protected settings = this.formBuilder.group({ protected settings = this.formBuilder.group({
[Controls.numWords]: [Generators.Passphrase.settings.initial.numWords], [Controls.numWords]: [Generators.passphrase.settings.initial.numWords],
[Controls.wordSeparator]: [Generators.Passphrase.settings.initial.wordSeparator], [Controls.wordSeparator]: [Generators.passphrase.settings.initial.wordSeparator],
[Controls.capitalize]: [Generators.Passphrase.settings.initial.capitalize], [Controls.capitalize]: [Generators.passphrase.settings.initial.capitalize],
[Controls.includeNumber]: [Generators.Passphrase.settings.initial.includeNumber], [Controls.includeNumber]: [Generators.passphrase.settings.initial.includeNumber],
}); });
async ngOnInit() { async ngOnInit() {
const singleUserId$ = this.singleUserId$(); 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 // skips reactive event emissions to break a subscription cycle
settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => {
@ -79,16 +79,16 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
// dynamic policy enforcement // dynamic policy enforcement
this.generatorService this.generatorService
.policy$(Generators.Passphrase, { userId$: singleUserId$ }) .policy$(Generators.passphrase, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$)) .pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => { .subscribe(({ constraints }) => {
this.settings this.settings
.get(Controls.numWords) .get(Controls.numWords)
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, constraints)); .setValidators(toValidators(Controls.numWords, Generators.passphrase, constraints));
this.settings this.settings
.get(Controls.wordSeparator) .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) // forward word boundaries to the template (can't do it through the rx form)
this.minNumWords = constraints.numWords.min; this.minNumWords = constraints.numWords.min;

View File

@ -3,8 +3,12 @@ import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUnt
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { CredentialGeneratorService, Generators, GeneratorType } from "@bitwarden/generator-core"; import {
import { GeneratedCredential } from "@bitwarden/generator-history"; CredentialGeneratorService,
Generators,
PasswordAlgorithm,
GeneratedCredential,
} from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies"; import { DependenciesModule } from "./dependencies";
import { PassphraseSettingsComponent } from "./passphrase-settings.component"; import { PassphraseSettingsComponent } from "./passphrase-settings.component";
@ -24,7 +28,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
private zone: NgZone, 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 * When this input is not provided, the form binds to the active
* user * user
*/ */
@ -32,7 +36,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
userId: UserId | null; userId: UserId | null;
/** tracks the currently selected credential type */ /** tracks the currently selected credential type */
protected credentialType$ = new BehaviorSubject<GeneratorType>("password"); protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>("password");
/** Emits the last generated value. */ /** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>(""); protected readonly value$ = new BehaviorSubject<string>("");
@ -46,7 +50,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
/** Tracks changes to the selected credential type /** Tracks changes to the selected credential type
* @param type the new credential type * @param type the new credential type
*/ */
protected onCredentialTypeChanged(type: GeneratorType) { protected onCredentialTypeChanged(type: PasswordAlgorithm) {
if (this.credentialType$.value !== type) { if (this.credentialType$.value !== type) {
this.credentialType$.next(type); this.credentialType$.next(type);
this.generate$.next(); this.generate$.next();
@ -85,7 +89,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
}); });
} }
private typeToGenerator$(type: GeneratorType) { private typeToGenerator$(type: PasswordAlgorithm) {
const dependencies = { const dependencies = {
on$: this.generate$, on$: this.generate$,
userId$: this.userId$, userId$: this.userId$,
@ -93,10 +97,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
switch (type) { switch (type) {
case "password": case "password":
return this.generatorService.generate$(Generators.Password, dependencies); return this.generatorService.generate$(Generators.password, dependencies);
case "passphrase": case "passphrase":
return this.generatorService.generate$(Generators.Passphrase, dependencies); return this.generatorService.generate$(Generators.passphrase, dependencies);
default: default:
throw new Error(`Invalid generator type: "${type}"`); throw new Error(`Invalid generator type: "${type}"`);
} }

View File

@ -67,14 +67,14 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
readonly onUpdated = new EventEmitter<PasswordGenerationOptions>(); readonly onUpdated = new EventEmitter<PasswordGenerationOptions>();
protected settings = this.formBuilder.group({ protected settings = this.formBuilder.group({
[Controls.length]: [Generators.Password.settings.initial.length], [Controls.length]: [Generators.password.settings.initial.length],
[Controls.uppercase]: [Generators.Password.settings.initial.uppercase], [Controls.uppercase]: [Generators.password.settings.initial.uppercase],
[Controls.lowercase]: [Generators.Password.settings.initial.lowercase], [Controls.lowercase]: [Generators.password.settings.initial.lowercase],
[Controls.number]: [Generators.Password.settings.initial.number], [Controls.number]: [Generators.password.settings.initial.number],
[Controls.special]: [Generators.Password.settings.initial.special], [Controls.special]: [Generators.password.settings.initial.special],
[Controls.minNumber]: [Generators.Password.settings.initial.minNumber], [Controls.minNumber]: [Generators.password.settings.initial.minNumber],
[Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial], [Controls.minSpecial]: [Generators.password.settings.initial.minSpecial],
[Controls.avoidAmbiguous]: [!Generators.Password.settings.initial.ambiguous], [Controls.avoidAmbiguous]: [!Generators.password.settings.initial.ambiguous],
}); });
private get numbers() { private get numbers() {
@ -95,7 +95,7 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
async ngOnInit() { async ngOnInit() {
const singleUserId$ = this.singleUserId$(); 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 // bind settings to the UI
settings settings
@ -116,19 +116,19 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
// bind policy to the template // bind policy to the template
this.generatorService this.generatorService
.policy$(Generators.Password, { userId$: singleUserId$ }) .policy$(Generators.password, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$)) .pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => { .subscribe(({ constraints }) => {
this.settings this.settings
.get(Controls.length) .get(Controls.length)
.setValidators(toValidators(Controls.length, Generators.Password, constraints)); .setValidators(toValidators(Controls.length, Generators.password, constraints));
this.minNumber.setValidators( this.minNumber.setValidators(
toValidators(Controls.minNumber, Generators.Password, constraints), toValidators(Controls.minNumber, Generators.password, constraints),
); );
this.minSpecial.setValidators( 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) // forward word boundaries to the template (can't do it through the rx form)

View File

@ -0,0 +1,6 @@
<form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label>
<input bitInput formControlName="subaddressEmail" type="text" />
</bit-form-field>
</form>

View File

@ -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<SubaddressGenerationOptions>();
/** 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<void>();
ngOnDestroy(): void {
this.destroyed$.complete();
}
}

View File

@ -0,0 +1,51 @@
<bit-card class="tw-flex tw-justify-between tw-mb-4">
<div class="tw-grow tw-flex tw-items-center">
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
</div>
<div class="tw-space-x-1">
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
{{ "generatePassword" | i18n }}
</button>
<button
type="button"
bitIconButton="bwi-clone"
buttonType="main"
[appCopyClick]="value$ | async"
>
{{ "copyPassword" | i18n }}
</button>
</div>
</bit-card>
<bit-section>
<bit-section-header>
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
</bit-section-header>
<div class="tw-mb-4">
<bit-card>
<form class="box" [formGroup]="credential" class="tw-container">
<bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label>
<bit-select [items]="typeOptions$ | async" formControlName="type"> </bit-select>
<bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{
credentialTypeHint$ | async
}}</bit-hint>
</bit-form-field>
</form>
<tools-catchall-settings
*ngIf="(algorithm$ | async)?.id === 'catchall'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
<tools-subaddress-settings
*ngIf="(algorithm$ | async)?.id === 'subaddress'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
<tools-username-settings
*ngIf="(algorithm$ | async)?.id === 'username'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
</bit-card>
</div>
</bit-section>

View File

@ -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<GeneratedCredential>();
/** 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<Option<CredentialAlgorithm>[]>([]);
/** tracks the currently selected credential type */
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1);
/** Emits hint key for the currently selected credential type */
protected credentialTypeHint$ = new ReplaySubject<string>(1);
/** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>("");
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */
protected readonly generate$ = new Subject<void>();
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<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
value: algorithm.id,
label: this.i18nService.t(algorithm.nameKey),
}));
return options;
}
private readonly destroyed = new Subject<void>();
ngOnDestroy() {
this.destroyed.complete();
// finalize subjects
this.generate$.complete();
this.value$.complete();
// finalize component bindings
this.onGenerated.complete();
}
}

View File

@ -0,0 +1,10 @@
<form class="box" [formGroup]="settings" class="tw-container">
<bit-form-control>
<input bitCheckbox formControlName="wordCapitalize" type="checkbox" />
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input bitCheckbox formControlName="wordIncludeNumber" type="checkbox" />
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
</form>

View File

@ -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<EffUsernameGenerationOptions>();
/** 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<void>();
ngOnDestroy(): void {
this.destroyed$.complete();
}
}

View File

@ -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),
}),
});

View File

@ -1,5 +1,15 @@
/** Types of passwords that may be configured by the password generator */ /** Types of passwords that may be generated by the credential generator */
export const PasswordTypes = Object.freeze(["password", "passphrase"] as const); export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as const);
/** Types of generators that may be configured by the password generator */ /** Types of usernames that may be generated by the credential generator */
export const GeneratorTypes = Object.freeze([...PasswordTypes, "username"] as const); 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);

View File

@ -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 { Randomizer } from "../abstractions";
import { PasswordRandomizer } from "../engine"; import { EmailRandomizer, PasswordRandomizer, UsernameRandomizer } from "../engine";
import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "../strategies/storage";
import { 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, CredentialGenerator,
CredentialGeneratorConfiguration,
EffUsernameGenerationOptions,
NoPolicy,
PassphraseGenerationOptions, PassphraseGenerationOptions,
PassphraseGeneratorPolicy, PassphraseGeneratorPolicy,
PasswordGenerationOptions, PasswordGenerationOptions,
PasswordGeneratorPolicy, PasswordGeneratorPolicy,
SubaddressGenerationOptions,
} from "../types"; } 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 { DefaultPassphraseBoundaries } from "./default-passphrase-boundaries";
import { DefaultPassphraseGenerationOptions } from "./default-passphrase-generation-options"; import { DefaultPassphraseGenerationOptions } from "./default-passphrase-generation-options";
import { DefaultPasswordBoundaries } from "./default-password-boundaries"; import { DefaultPasswordBoundaries } from "./default-password-boundaries";
import { DefaultPasswordGenerationOptions } from "./default-password-generation-options"; import { DefaultPasswordGenerationOptions } from "./default-password-generation-options";
import { Policies } from "./policies"; import { DefaultSubaddressOptions } from "./default-subaddress-generator-options";
const PASSPHRASE = Object.freeze({ const PASSPHRASE = Object.freeze({
category: "passphrase", id: "passphrase",
category: "password",
nameKey: "passphrase",
onlyOnRequest: false,
engine: { engine: {
create(randomizer: Randomizer): CredentialGenerator<PassphraseGenerationOptions> { create(randomizer: Randomizer): CredentialGenerator<PassphraseGenerationOptions> {
return new PasswordRandomizer(randomizer); return new PasswordRandomizer(randomizer);
@ -34,14 +62,27 @@ const PASSPHRASE = Object.freeze({
}, },
account: PASSPHRASE_SETTINGS, 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< } satisfies CredentialGeneratorConfiguration<
PassphraseGenerationOptions, PassphraseGenerationOptions,
PassphraseGeneratorPolicy PassphraseGeneratorPolicy
>); >);
const PASSWORD = Object.freeze({ const PASSWORD = Object.freeze({
id: "password",
category: "password", category: "password",
nameKey: "password",
onlyOnRequest: false,
engine: { engine: {
create(randomizer: Randomizer): CredentialGenerator<PasswordGenerationOptions> { create(randomizer: Randomizer): CredentialGenerator<PasswordGenerationOptions> {
return new PasswordRandomizer(randomizer); return new PasswordRandomizer(randomizer);
@ -65,14 +106,129 @@ const PASSWORD = Object.freeze({
}, },
account: PASSWORD_SETTINGS, 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<PasswordGenerationOptions, PasswordGeneratorPolicy>); } satisfies CredentialGeneratorConfiguration<PasswordGenerationOptions, PasswordGeneratorPolicy>);
const USERNAME = Object.freeze({
id: "username",
category: "username",
nameKey: "randomWord",
onlyOnRequest: false,
engine: {
create(randomizer: Randomizer): CredentialGenerator<EffUsernameGenerationOptions> {
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<EffUsernameGenerationOptions>();
},
toConstraints(_policy: NoPolicy) {
return new IdentityConstraint<EffUsernameGenerationOptions>();
},
},
} satisfies CredentialGeneratorConfiguration<EffUsernameGenerationOptions, NoPolicy>);
const CATCHALL = Object.freeze({
id: "catchall",
category: "email",
nameKey: "catchallEmail",
descriptionKey: "catchallEmailDesc",
onlyOnRequest: false,
engine: {
create(randomizer: Randomizer): CredentialGenerator<CatchallGenerationOptions> {
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<CatchallGenerationOptions>();
},
toConstraints(_policy: NoPolicy) {
return new IdentityConstraint<CatchallGenerationOptions>();
},
},
} satisfies CredentialGeneratorConfiguration<CatchallGenerationOptions, NoPolicy>);
const SUBADDRESS = Object.freeze({
id: "subaddress",
category: "email",
nameKey: "plusAddressedEmail",
descriptionKey: "plusAddressedEmailDesc",
onlyOnRequest: false,
engine: {
create(randomizer: Randomizer): CredentialGenerator<SubaddressGenerationOptions> {
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<SubaddressGenerationOptions>();
},
toConstraints(_policy: NoPolicy) {
return new IdentityConstraint<SubaddressGenerationOptions>();
},
},
} satisfies CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy>);
/** Generator configurations */ /** Generator configurations */
export const Generators = Object.freeze({ export const Generators = Object.freeze({
/** Passphrase generator configuration */ /** Passphrase generator configuration */
Passphrase: PASSPHRASE, passphrase: PASSPHRASE,
/** Password generator configuration */ /** Password generator configuration */
Password: PASSWORD, password: PASSWORD,
/** Username generator configuration */
username: USERNAME,
/** Catchall email generator configuration */
catchall: CATCHALL,
/** Email subaddress generator configuration */
subaddress: SUBADDRESS,
}); });

View File

@ -10,6 +10,7 @@ export * from "./default-eff-username-options";
export * from "./default-firefox-relay-options"; export * from "./default-firefox-relay-options";
export * from "./default-passphrase-generation-options"; export * from "./default-passphrase-generation-options";
export * from "./default-password-generation-options"; export * from "./default-password-generation-options";
export * from "./default-credential-preferences";
export * from "./default-subaddress-generator-options"; export * from "./default-subaddress-generator-options";
export * from "./default-simple-login-options"; export * from "./default-simple-login-options";
export * from "./forwarders"; export * from "./forwarders";

View File

@ -1,13 +1,3 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
DynamicPasswordPolicyConstraints,
passphraseLeastPrivilege,
passwordLeastPrivilege,
PassphraseGeneratorOptionsEvaluator,
PassphrasePolicyConstraints,
PasswordGeneratorOptionsEvaluator,
} from "../policies";
import { import {
PassphraseGenerationOptions, PassphraseGenerationOptions,
PassphraseGeneratorPolicy, PassphraseGeneratorPolicy,
@ -16,39 +6,18 @@ import {
PolicyConfiguration, PolicyConfiguration,
} from "../types"; } from "../types";
const PASSPHRASE = Object.freeze({ import { Generators } from "./generators";
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<PassphraseGeneratorPolicy, PassphraseGenerationOptions>);
const PASSWORD = Object.freeze({ /** Policy configurations
type: PolicyType.PasswordGenerator, * @deprecated use Generator.*.policy instead
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<PasswordGeneratorPolicy, PasswordGenerationOptions>);
/** Policy configurations */
export const Policies = Object.freeze({ export const Policies = Object.freeze({
Passphrase: Generators.passphrase.policy,
Password: Generators.password.policy,
} satisfies {
/** Passphrase policy configuration */ /** Passphrase policy configuration */
Passphrase: PASSPHRASE, Passphrase: PolicyConfiguration<PassphraseGeneratorPolicy, PassphraseGenerationOptions>;
/** Passphrase policy configuration */ /** Password policy configuration */
Password: PASSWORD, Password: PolicyConfiguration<PasswordGeneratorPolicy, PasswordGenerationOptions>;
}); });

View File

@ -208,4 +208,40 @@ describe("EmailRandomizer", () => {
expect(randomizer.pickWord).toHaveBeenCalledWith(expectedWordList, { titleCase: false }); 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);
});
});
}); });

View File

@ -1,10 +1,22 @@
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; 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 { Randomizer } from "./abstractions";
import { SUBADDRESS_PARSER } from "./data"; import { SUBADDRESS_PARSER } from "./data";
/** Generation algorithms that produce randomized email addresses */ /** Generation algorithms that produce randomized email addresses */
export class EmailRandomizer { export class EmailRandomizer
implements
CredentialGenerator<CatchallGenerationOptions>,
CredentialGenerator<SubaddressGenerationOptions>
{
/** Instantiates the email randomizer /** Instantiates the email randomizer
* @param random data source for random data * @param random data source for random data
*/ */
@ -96,4 +108,37 @@ export class EmailRandomizer {
return result; return result;
} }
generate(
request: GenerationRequest,
settings: CatchallGenerationOptions,
): Promise<GeneratedCredential>;
generate(
request: GenerationRequest,
settings: SubaddressGenerationOptions,
): Promise<GeneratedCredential>;
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 ?? {});
} }

View File

@ -102,4 +102,27 @@ describe("UsernameRandomizer", () => {
expect(randomizer.pickWord).toHaveBeenNthCalledWith(2, EFFLongWordList, { titleCase: false }); 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);
});
});
}); });

View File

@ -1,10 +1,13 @@
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; 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 { Randomizer } from "./abstractions";
import { WordsRequest } from "./types"; import { WordsRequest } from "./types";
/** Generation algorithms that produce randomized usernames */ /** Generation algorithms that produce randomized usernames */
export class UsernameRandomizer { export class UsernameRandomizer implements CredentialGenerator<EffUsernameGenerationOptions> {
/** Instantiates the username randomizer /** Instantiates the username randomizer
* @param random data source for random data * @param random data source for random data
*/ */
@ -44,4 +47,21 @@ export class UsernameRandomizer {
return result; 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 ?? {});
} }

View File

@ -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);
}
});
});

View File

@ -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;
}

View File

@ -6,7 +6,7 @@ import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-opti
describe("Password generator options builder", () => { describe("Password generator options builder", () => {
describe("constructor()", () => { describe("constructor()", () => {
it("should set the policy object to a copy of the input policy", () => { 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 policy.minNumberWords = 10; // arbitrary change for deep equality check
const builder = new PassphraseGeneratorOptionsEvaluator(policy); const builder = new PassphraseGeneratorOptionsEvaluator(policy);
@ -25,7 +25,7 @@ describe("Password generator options builder", () => {
it.each([1, 2])( it.each([1, 2])(
"should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)",
(minNumberWords) => { (minNumberWords) => {
const policy = Object.assign({}, Policies.Passphrase.disabledValue); const policy: any = Object.assign({}, Policies.Passphrase.disabledValue);
policy.minNumberWords = minNumberWords; policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy); const builder = new PassphraseGeneratorOptionsEvaluator(policy);
@ -37,7 +37,7 @@ describe("Password generator options builder", () => {
it.each([8, 12, 18])( it.each([8, 12, 18])(
"should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words",
(minNumberWords) => { (minNumberWords) => {
const policy = Object.assign({}, Policies.Passphrase.disabledValue); const policy: any = Object.assign({}, Policies.Passphrase.disabledValue);
policy.minNumberWords = minNumberWords; policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy); const builder = new PassphraseGeneratorOptionsEvaluator(policy);
@ -50,7 +50,7 @@ describe("Password generator options builder", () => {
it.each([150, 300, 9000])( it.each([150, 300, 9000])(
"should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries",
(minNumberWords) => { (minNumberWords) => {
const policy = Object.assign({}, Policies.Passphrase.disabledValue); const policy: any = Object.assign({}, Policies.Passphrase.disabledValue);
policy.minNumberWords = minNumberWords; policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy); 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", () => { 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; policy.minNumberWords = DefaultPassphraseBoundaries.numWords.min + 1;
const builder = new PassphraseGeneratorOptionsEvaluator(policy); const builder = new PassphraseGeneratorOptionsEvaluator(policy);
@ -78,7 +78,7 @@ describe("Password generator options builder", () => {
}); });
it("should return true when the policy has capitalize enabled", () => { 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; policy.capitalize = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy); const builder = new PassphraseGeneratorOptionsEvaluator(policy);
@ -86,7 +86,7 @@ describe("Password generator options builder", () => {
}); });
it("should return true when the policy has includeNumber enabled", () => { 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; policy.includeNumber = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy); 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", () => { 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; policy.capitalize = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy); const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ capitalize: false }); 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", () => { 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; policy.includeNumber = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy); const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ includeNumber: false }); const options = Object.freeze({ includeNumber: false });

View File

@ -8,7 +8,7 @@ describe("Password generator options builder", () => {
describe("constructor()", () => { describe("constructor()", () => {
it("should set the policy object to a copy of the input policy", () => { 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 policy.minLength = 10; // arbitrary change for deep equality check
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -32,7 +32,7 @@ describe("Password generator options builder", () => {
(minLength) => { (minLength) => {
expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min); expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min);
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.minLength = minLength; policy.minLength = minLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -47,7 +47,7 @@ describe("Password generator options builder", () => {
expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min); expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min);
expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max); expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max);
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.minLength = expectedLength; policy.minLength = expectedLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -62,7 +62,7 @@ describe("Password generator options builder", () => {
(expectedLength) => { (expectedLength) => {
expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max); expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max);
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.minLength = expectedLength; policy.minLength = expectedLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -78,7 +78,7 @@ describe("Password generator options builder", () => {
expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min); expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min);
expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max); expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max);
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.numberCount = expectedMinDigits; policy.numberCount = expectedMinDigits;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -93,7 +93,7 @@ describe("Password generator options builder", () => {
(expectedMinDigits) => { (expectedMinDigits) => {
expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max); expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max);
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.numberCount = expectedMinDigits; policy.numberCount = expectedMinDigits;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -113,7 +113,7 @@ describe("Password generator options builder", () => {
DefaultPasswordBoundaries.minSpecialCharacters.max, DefaultPasswordBoundaries.minSpecialCharacters.max,
); );
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.specialCount = expectedSpecialCharacters; policy.specialCount = expectedSpecialCharacters;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -132,7 +132,7 @@ describe("Password generator options builder", () => {
DefaultPasswordBoundaries.minSpecialCharacters.max, DefaultPasswordBoundaries.minSpecialCharacters.max,
); );
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.specialCount = expectedSpecialCharacters; policy.specialCount = expectedSpecialCharacters;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -151,7 +151,7 @@ describe("Password generator options builder", () => {
(expectedLength, numberCount, specialCount) => { (expectedLength, numberCount, specialCount) => {
expect(expectedLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); 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.numberCount = numberCount;
policy.specialCount = specialCount; 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", () => { 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; policy.minLength = DefaultPasswordBoundaries.length.min + 1;
const builder = new PasswordGeneratorOptionsEvaluator(policy); 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", () => { 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; policy.numberCount = DefaultPasswordBoundaries.minDigits.min + 1;
const builder = new PasswordGeneratorOptionsEvaluator(policy); 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", () => { 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; policy.specialCount = DefaultPasswordBoundaries.minSpecialCharacters.min + 1;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -195,7 +195,7 @@ describe("Password generator options builder", () => {
}); });
it("should return true when the policy has uppercase enabled", () => { 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; policy.useUppercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -203,7 +203,7 @@ describe("Password generator options builder", () => {
}); });
it("should return true when the policy has lowercase enabled", () => { 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; policy.useLowercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
@ -211,7 +211,7 @@ describe("Password generator options builder", () => {
}); });
it("should return true when the policy has numbers enabled", () => { 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; policy.useNumbers = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy); 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", () => { 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; policy.useSpecial = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy); 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'", "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'",
(expectedUppercase, uppercase) => { (expectedUppercase, uppercase) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.useUppercase = false; policy.useUppercase = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, uppercase }); const options = Object.freeze({ ...defaultOptions, uppercase });
@ -251,7 +251,7 @@ describe("Password generator options builder", () => {
it.each([false, true, undefined])( it.each([false, true, undefined])(
"should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true",
(uppercase) => { (uppercase) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.useUppercase = true; policy.useUppercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, uppercase }); 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'", "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'",
(expectedLowercase, lowercase) => { (expectedLowercase, lowercase) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.useLowercase = false; policy.useLowercase = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, lowercase }); const options = Object.freeze({ ...defaultOptions, lowercase });
@ -283,7 +283,7 @@ describe("Password generator options builder", () => {
it.each([false, true, undefined])( it.each([false, true, undefined])(
"should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true",
(lowercase) => { (lowercase) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.useLowercase = true; policy.useLowercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, lowercase }); 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'", "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'",
(expectedNumber, number) => { (expectedNumber, number) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.useNumbers = false; policy.useNumbers = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number }); const options = Object.freeze({ ...defaultOptions, number });
@ -315,7 +315,7 @@ describe("Password generator options builder", () => {
it.each([false, true, undefined])( it.each([false, true, undefined])(
"should set `options.number` (= %s) to true when `policy.useNumbers` is true", "should set `options.number` (= %s) to true when `policy.useNumbers` is true",
(number) => { (number) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.useNumbers = true; policy.useNumbers = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number }); 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'", "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'",
(expectedSpecial, special) => { (expectedSpecial, special) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.useSpecial = false; policy.useSpecial = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special }); const options = Object.freeze({ ...defaultOptions, special });
@ -347,7 +347,7 @@ describe("Password generator options builder", () => {
it.each([false, true, undefined])( it.each([false, true, undefined])(
"should set `options.special` (= %s) to true when `policy.useSpecial` is true", "should set `options.special` (= %s) to true when `policy.useSpecial` is true",
(special) => { (special) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.useSpecial = true; policy.useSpecial = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy); const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special }); const options = Object.freeze({ ...defaultOptions, special });
@ -447,7 +447,7 @@ describe("Password generator options builder", () => {
it.each([1, 2, 3, 4])( it.each([1, 2, 3, 4])(
"should set `options.minNumber` (= %i) to the minimum it is less than the minimum number", "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number",
(minNumber) => { (minNumber) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.numberCount = 5; // arbitrary value greater than minNumber policy.numberCount = 5; // arbitrary value greater than minNumber
expect(minNumber).toBeLessThan(policy.numberCount); expect(minNumber).toBeLessThan(policy.numberCount);
@ -534,7 +534,7 @@ describe("Password generator options builder", () => {
it.each([1, 2, 3, 4])( it.each([1, 2, 3, 4])(
"should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters", "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters",
(minSpecial) => { (minSpecial) => {
const policy = Object.assign({}, Policies.Password.disabledValue); const policy: any = Object.assign({}, Policies.Password.disabledValue);
policy.specialCount = 5; // arbitrary value greater than minSpecial policy.specialCount = 5; // arbitrary value greater than minSpecial
expect(minSpecial).toBeLessThan(policy.specialCount); expect(minSpecial).toBeLessThan(policy.specialCount);

View File

@ -15,6 +15,7 @@ import {
ObservableTracker, ObservableTracker,
} from "../../../../../common/spec"; } from "../../../../../common/spec";
import { Randomizer } from "../abstractions"; import { Randomizer } from "../abstractions";
import { Generators } from "../data";
import { import {
CredentialGeneratorConfiguration, CredentialGeneratorConfiguration,
GeneratedCredential, GeneratedCredential,
@ -33,7 +34,7 @@ const SettingsKey = new UserKeyDefinition<SomeSettings>(GENERATOR_DISK, "SomeSet
clearOn: [], clearOn: [],
}); });
// fake policy // fake policies
const policyService = mock<PolicyService>(); const policyService = mock<PolicyService>();
const somePolicy = new Policy({ const somePolicy = new Policy({
data: { fooPolicy: true }, data: { fooPolicy: true },
@ -42,19 +43,43 @@ const somePolicy = new Policy({
organizationId: "" as OrganizationId, organizationId: "" as OrganizationId,
enabled: true, 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 SomeTime = new Date(1);
const SomeCategory = "passphrase"; const SomeAlgorithm = "passphrase";
const SomeCategory = "password";
const SomeNameKey = "passphraseKey";
// fake the configuration // fake the configuration
const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = { const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = {
id: SomeAlgorithm,
category: SomeCategory, category: SomeCategory,
nameKey: SomeNameKey,
onlyOnRequest: false,
engine: { engine: {
create: (randomizer) => { create: (randomizer) => {
return { return {
generate: (request, settings) => { generate: (request, settings) => {
const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; 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); return Promise.resolve(result);
}, },
}; };
@ -114,7 +139,7 @@ const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePoli
// fake user information // fake user information
const SomeUser = "SomeUser" as UserId; const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId; const AnotherUser = "SomeOtherUser" as UserId;
const accountService = new FakeAccountService({ const accounts = {
[SomeUser]: { [SomeUser]: {
name: "some user", name: "some user",
email: "some.user@example.com", email: "some.user@example.com",
@ -125,7 +150,8 @@ const accountService = new FakeAccountService({
email: "some.other.user@example.com", email: "some.other.user@example.com",
emailVerified: true, emailVerified: true,
}, },
}); };
const accountService = new FakeAccountService(accounts);
// fake state // fake state
const stateProvider = new FakeStateProvider(accountService); const stateProvider = new FakeStateProvider(accountService);
@ -149,7 +175,7 @@ describe("CredentialGeneratorService", () => {
const result = await generated.expectEmission(); 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 () => { it("follows the active user", async () => {
@ -165,8 +191,8 @@ describe("CredentialGeneratorService", () => {
generated.unsubscribe(); generated.unsubscribe();
expect(generated.emissions).toEqual([ expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeCategory, SomeTime), new GeneratedCredential("some value", SomeAlgorithm, SomeTime),
new GeneratedCredential("another value", SomeCategory, SomeTime), new GeneratedCredential("another value", SomeAlgorithm, SomeTime),
]); ]);
}); });
@ -182,8 +208,8 @@ describe("CredentialGeneratorService", () => {
generated.unsubscribe(); generated.unsubscribe();
expect(generated.emissions).toEqual([ expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeCategory, SomeTime), new GeneratedCredential("some value", SomeAlgorithm, SomeTime),
new GeneratedCredential("another value", SomeCategory, SomeTime), new GeneratedCredential("another value", SomeAlgorithm, SomeTime),
]); ]);
}); });
@ -200,7 +226,9 @@ describe("CredentialGeneratorService", () => {
const result = await generated.expectEmission(); 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 () => { it("errors when `website$` errors", async () => {
@ -246,7 +274,7 @@ describe("CredentialGeneratorService", () => {
const result = await generated.expectEmission(); 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 () => { it("emits a generation for a specific user when `user$` emits", async () => {
@ -261,8 +289,8 @@ describe("CredentialGeneratorService", () => {
const result = await generated.pauseUntilReceived(2); const result = await generated.pauseUntilReceived(2);
expect(result).toEqual([ expect(result).toEqual([
new GeneratedCredential("value", SomeCategory, SomeTime), new GeneratedCredential("value", SomeAlgorithm, SomeTime),
new GeneratedCredential("another", SomeCategory, SomeTime), new GeneratedCredential("another", SomeAlgorithm, SomeTime),
]); ]);
}); });
@ -317,7 +345,7 @@ describe("CredentialGeneratorService", () => {
// confirm forwarded emission // confirm forwarded emission
on$.next(); on$.next();
await awaitAsync(); 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 // confirm updating settings does not cause an emission
await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser);
@ -330,8 +358,8 @@ describe("CredentialGeneratorService", () => {
sub.unsubscribe(); sub.unsubscribe();
expect(results).toEqual([ expect(results).toEqual([
new GeneratedCredential("value", SomeCategory, SomeTime), new GeneratedCredential("value", SomeAlgorithm, SomeTime),
new GeneratedCredential("next", SomeCategory, SomeTime), new GeneratedCredential("next", SomeAlgorithm, SomeTime),
]); ]);
}); });
@ -370,6 +398,245 @@ describe("CredentialGeneratorService", () => {
expect(complete).toBeTruthy(); 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$", () => { describe("settings$", () => {
@ -405,6 +672,11 @@ describe("CredentialGeneratorService", () => {
}); });
it("follows changes to the active user", async () => { 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 someSettings = { foo: "value" };
const anotherSettings = { foo: "another" }; const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);

View File

@ -1,16 +1,19 @@
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
concat,
concatMap, concatMap,
distinctUntilChanged, distinctUntilChanged,
endWith, endWith,
filter, filter,
first,
firstValueFrom, firstValueFrom,
ignoreElements, ignoreElements,
map, map,
mergeMap,
Observable, Observable,
race, race,
share,
skipUntil,
switchMap, switchMap,
takeUntil, takeUntil,
withLatestFrom, withLatestFrom,
@ -18,6 +21,7 @@ import {
import { Simplify } from "type-fest"; import { Simplify } from "type-fest";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; 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 { StateProvider } from "@bitwarden/common/platform/state";
import { import {
OnDependency, 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 { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { Randomizer } from "../abstractions"; import { Randomizer } from "../abstractions";
import { Generators } from "../data";
import { availableAlgorithms } from "../policies/available-algorithms-policy";
import { mapPolicyToConstraints } from "../rx"; import { mapPolicyToConstraints } from "../rx";
import {
CredentialAlgorithm,
CredentialCategories,
CredentialCategory,
CredentialGeneratorInfo,
CredentialPreference,
} from "../types";
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration";
import { GeneratorConstraints } from "../types/generator-constraints"; import { GeneratorConstraints } from "../types/generator-constraints";
import { PREFERENCES } from "./credential-preferences";
type Policy$Dependencies = UserDependency; type Policy$Dependencies = UserDependency;
type Settings$Dependencies = Partial<UserDependency>; type Settings$Dependencies = Partial<UserDependency>;
type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDependency>> & { type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDependency>> & {
@ -46,6 +61,8 @@ type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDepend
website$?: Observable<string>; website$?: Observable<string>;
}; };
type Algorithms$Dependencies = Partial<UserDependency>;
export class CredentialGeneratorService { export class CredentialGeneratorService {
constructor( constructor(
private randomizer: Randomizer, private randomizer: Randomizer,
@ -53,6 +70,9 @@ export class CredentialGeneratorService {
private policyService: PolicyService, 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 /** Generates a stream of credentials
* @param configuration determines which generator's settings are loaded * @param configuration determines which generator's settings are loaded
* @param dependencies.on$ when specified, a new credential is emitted when * @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 settingsComplete$ = request$.pipe(ignoreElements(), endWith(true));
const complete$ = race(requestComplete$, settingsComplete$); const complete$ = race(requestComplete$, settingsComplete$);
// if on$ triggers before settings are loaded, trigger as soon
// as they become available.
let readyOn$: Observable<any> = 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 // generation proper
const generate$ = (dependencies?.on$ ?? settings$).pipe( const generate$ = (readyOn$ ?? settings$).pipe(
withLatestFrom(request$, settings$), withLatestFrom(request$, settings$),
concatMap(([, request, settings]) => engine.generate(request, settings)), concatMap(([, request, settings]) => engine.generate(request, settings)),
takeUntil(complete$), takeUntil(complete$),
@ -86,6 +122,79 @@ export class CredentialGeneratorService {
return generate$; 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<CredentialGeneratorInfo[]>;
algorithms$(
category: CredentialCategory[],
dependencies?: Algorithms$Dependencies,
): Observable<CredentialGeneratorInfo[]>;
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 /** Get the settings for the provided configuration
* @param configuration determines which generator's settings are loaded * @param configuration determines which generator's settings are loaded
* @param dependencies.userId$ identifies the user to which the settings are bound. * @param dependencies.userId$ identifies the user to which the settings are bound.
@ -125,6 +234,29 @@ export class CredentialGeneratorService {
return settings$; 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<UserStateSubject<CredentialPreference>> {
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 /** Get a subject bound to a specific user's settings
* @param configuration determines which generator's settings are loaded * @param configuration determines which generator's settings are loaded
* @param dependencies.singleUserId$ identifies the user to which the settings are bound * @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 completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true));
const constraints$ = dependencies.userId$.pipe( const constraints$ = dependencies.userId$.pipe(
mergeMap((userId) => { switchMap((userId) => {
// complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely // complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely
const policies$ = this.policyService const policies$ = this.policyService
.getAll$(configuration.policy.type, userId) .getAll$(configuration.policy.type, userId)

View File

@ -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));
});
});
});

View File

@ -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<CredentialPreference>(
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"],
},
);

View File

@ -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";

View File

@ -2,16 +2,35 @@ import { UserKeyDefinition } from "@bitwarden/common/platform/state";
import { Constraints } from "@bitwarden/common/tools/types"; import { Constraints } from "@bitwarden/common/tools/types";
import { Randomizer } from "../abstractions"; import { Randomizer } from "../abstractions";
import { PolicyConfiguration } from "../types"; import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from "../types";
import { CredentialCategory } from "./credential-category";
import { CredentialGenerator } from "./credential-generator"; import { CredentialGenerator } from "./credential-generator";
export type CredentialGeneratorConfiguration<Settings, Policy> = { /** Credential generator metadata common across credential generators */
/** Category describing usage of the credential generated by this configuration export type CredentialGeneratorInfo = {
/** Uniquely identifies the credential configuration
*/ */
id: CredentialAlgorithm;
/** The kind of credential generated by this configuration */
category: CredentialCategory; 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<Settings, Policy> = CredentialGeneratorInfo & {
/** An algorithm that generates credentials when ran. */ /** An algorithm that generates credentials when ran. */
engine: { engine: {
/** Factory for the generator /** Factory for the generator
@ -28,6 +47,7 @@ export type CredentialGeneratorConfiguration<Settings, Policy> = {
/** value used when an account's settings haven't been initialized */ /** value used when an account's settings haven't been initialized */
initial: Readonly<Partial<Settings>>; initial: Readonly<Partial<Settings>>;
/** Application-global constraints that apply to account settings */
constraints: Constraints<Settings>; constraints: Constraints<Settings>;
/** storage location for account-global settings */ /** storage location for account-global settings */

View File

@ -1,4 +1,4 @@
import { CredentialCategory, GeneratedCredential } from "."; import { CredentialAlgorithm, GeneratedCredential } from ".";
describe("GeneratedCredential", () => { describe("GeneratedCredential", () => {
describe("constructor", () => { describe("constructor", () => {
@ -34,7 +34,7 @@ describe("GeneratedCredential", () => {
expect(result).toEqual({ expect(result).toEqual({
credential: "example", credential: "example",
category: "password" as CredentialCategory, category: "password" as CredentialAlgorithm,
generationDate: 100, generationDate: 100,
}); });
}); });
@ -42,7 +42,7 @@ describe("GeneratedCredential", () => {
it("fromJSON converts Json objects into credentials", () => { it("fromJSON converts Json objects into credentials", () => {
const jsonValue = { const jsonValue = {
credential: "example", credential: "example",
category: "password" as CredentialCategory, category: "password" as CredentialAlgorithm,
generationDate: 100, generationDate: 100,
}; };

View File

@ -1,6 +1,6 @@
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { CredentialCategory } from "./credential-category"; import { CredentialAlgorithm } from "./generator-type";
/** A credential generation result */ /** A credential generation result */
export class GeneratedCredential { export class GeneratedCredential {
@ -14,7 +14,7 @@ export class GeneratedCredential {
*/ */
constructor( constructor(
readonly credential: string, readonly credential: string,
readonly category: CredentialCategory, readonly category: CredentialAlgorithm,
generationDate: Date | number, generationDate: Date | number,
) { ) {
if (typeof generationDate === "number") { if (typeof generationDate === "number") {

View File

@ -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. */ /** A type of password that may be generated by the credential generator. */
export type GeneratorType = (typeof GeneratorTypes)[number]; export type PasswordAlgorithm = (typeof PasswordAlgorithms)[number];
/** The kinds of passwords that can be generated. */ /** A type of username that may be generated by the credential generator. */
export type PasswordType = (typeof PasswordTypes)[number]; 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<PasswordAlgorithm[]>,
/** Lists algorithms in the "username" credential category */
username: UsernameAlgorithms as Readonly<UsernameAlgorithm[]>,
/** Lists algorithms in the "email" credential category */
email: EmailAlgorithms as Readonly<EmailAlgorithm[]>,
});
/** 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;
};
};

View File

@ -1,6 +1,7 @@
import { CredentialAlgorithm, PasswordAlgorithm } from "./generator-type";
export * from "./boundary"; export * from "./boundary";
export * from "./catchall-generator-options"; export * from "./catchall-generator-options";
export * from "./credential-category";
export * from "./credential-generator"; export * from "./credential-generator";
export * from "./credential-generator-configuration"; export * from "./credential-generator-configuration";
export * from "./eff-username-generator-options"; export * from "./eff-username-generator-options";
@ -17,3 +18,13 @@ export * from "./password-generator-policy";
export * from "./policy-configuration"; export * from "./policy-configuration";
export * from "./subaddress-generator-options"; export * from "./subaddress-generator-options";
export * from "./word-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;

View File

@ -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 { DefaultGeneratorNavigation } from "./default-generator-navigation";
import { GeneratorNavigation } from "./generator-navigation"; import { GeneratorNavigation } from "./generator-navigation";
@ -17,7 +17,7 @@ export class GeneratorNavigationEvaluator
/** {@link PolicyEvaluator.policyInEffect} */ /** {@link PolicyEvaluator.policyInEffect} */
get policyInEffect(): boolean { get policyInEffect(): boolean {
return PasswordTypes.includes(this.policy?.overridePasswordType); return PasswordAlgorithms.includes(this.policy?.overridePasswordType);
} }
/** Apply policy to the input options. /** Apply policy to the input options.