1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

[PM-8279] password generation component (#10805)

This commit is contained in:
✨ Audrey ✨
2024-09-10 12:43:33 -04:00
committed by GitHub
parent 2d02b6ca5c
commit 4128b18b27
25 changed files with 1292 additions and 118 deletions

View File

@@ -1 +1 @@
<bit-password-settings />
<bit-password-generator />

View File

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

View File

@@ -77,7 +77,7 @@ export type SingleUserDependency = {
export type OnDependency = {
/** The stream that controls emissions
*/
on$: Observable<void>;
on$: Observable<any>;
};
/** A pattern for types that emit when a dependency is `true`.

View File

@@ -1,11 +1,6 @@
import { GenerationRequest } from "../../types";
/** Options that provide contextual information about the application state
* when an integration is invoked.
*/
export type IntegrationRequest = {
/** @param website The domain of the website the requested integration is used
* within. This should be set to `null` when the request is not specific
* to any website.
* @remarks this field contains sensitive data
*/
website: string | null;
};
export type IntegrationRequest = Partial<GenerationRequest>;

View File

@@ -46,3 +46,20 @@ export type Constraints<T> = {
/** utility type for methods that evaluate constraints generically. */
export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints;
/** Options that provide contextual information about the application state
* when a generator is invoked.
*/
export type VaultItemRequest = {
/** The domain of the website the requested credential is used
* within. This should be set to `null` when the request is not specific
* to any website.
* @remarks this field contains sensitive data
*/
website: string | null;
};
/** Options that provide contextual information about the application state
* when a generator is invoked.
*/
export type GenerationRequest = Partial<VaultItemRequest>;

View File

@@ -4,41 +4,60 @@ import { ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import {
CardComponent,
CheckboxModule,
ColorPasswordModule,
FormFieldModule,
IconButtonModule,
InputModule,
ItemModule,
SectionComponent,
SectionHeaderComponent,
ToggleGroupModule,
} from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core";
import {
createRandomizer,
CredentialGeneratorService,
Randomizer,
} from "@bitwarden/generator-core";
const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
/** Shared module containing generator component dependencies */
@NgModule({
imports: [SectionComponent, SectionHeaderComponent, CardComponent],
imports: [CardComponent, SectionComponent, SectionHeaderComponent],
exports: [
CardComponent,
CheckboxModule,
CommonModule,
ColorPasswordModule,
FormFieldModule,
IconButtonModule,
InputModule,
ItemModule,
JslibModule,
JslibServicesModule,
FormFieldModule,
CommonModule,
ReactiveFormsModule,
ColorPasswordModule,
InputModule,
CheckboxModule,
SectionComponent,
SectionHeaderComponent,
CardComponent,
ToggleGroupModule,
],
providers: [
safeProvider({
provide: RANDOMIZER,
useFactory: createRandomizer,
deps: [CryptoService],
}),
safeProvider({
provide: CredentialGeneratorService,
useClass: CredentialGeneratorService,
deps: [StateProvider, PolicyService],
deps: [RANDOMIZER, StateProvider, PolicyService],
}),
],
declarations: [],

View File

@@ -2,3 +2,4 @@ export { PassphraseSettingsComponent } from "./passphrase-settings.component";
export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component";
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
export { PasswordSettingsComponent } from "./password-settings.component";
export { PasswordGeneratorComponent } from "./password-generator.component";

View File

@@ -0,0 +1,44 @@
<bit-toggle-group
fullWidth
class="tw-mb-4"
[selected]="credentialType$ | async"
(selectedChange)="onCredentialTypeChanged($event)"
attr.aria-label="{{ 'type' | i18n }}"
>
<bit-toggle value="password">
{{ "password" | i18n }}
</bit-toggle>
<bit-toggle value="passphrase">
{{ "passphrase" | i18n }}
</bit-toggle>
</bit-toggle-group>
<bit-card class="tw-flex tw-justify-between tw-mb-4">
<div class="tw-grow">
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
</div>
<div class="tw-space-x-1 tw-flex-none tw-w-4">
<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-password-settings
class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'password'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
<bit-passphrase-settings
class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'passphrase'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>

View File

@@ -0,0 +1,117 @@
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CredentialGeneratorService, Generators, GeneratorType } from "@bitwarden/generator-core";
import { GeneratedCredential } from "@bitwarden/generator-history";
import { DependenciesModule } from "./dependencies";
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
import { PasswordSettingsComponent } from "./password-settings.component";
/** Options group for passwords */
@Component({
standalone: true,
selector: "bit-password-generator",
templateUrl: "password-generator.component.html",
imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent],
})
export class PasswordGeneratorComponent implements OnInit, OnDestroy {
constructor(
private generatorService: CredentialGeneratorService,
private accountService: AccountService,
private zone: NgZone,
) {}
/** Binds the passphrase component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@Input()
userId: UserId | null;
/** tracks the currently selected credential type */
protected credentialType$ = new BehaviorSubject<GeneratorType>("password");
/** 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>();
/** Tracks changes to the selected credential type
* @param type the new credential type
*/
protected onCredentialTypeChanged(type: GeneratorType) {
if (this.credentialType$.value !== type) {
this.credentialType$.next(type);
this.generate$.next();
}
}
/** Emits credentials created from a generation request. */
@Output()
readonly onGenerated = new EventEmitter<GeneratedCredential>();
async ngOnInit() {
if (this.userId) {
this.userId$.next(this.userId);
} else {
this.accountService.activeAccount$
.pipe(
map((acct) => acct.id),
distinctUntilChanged(),
takeUntil(this.destroyed),
)
.subscribe(this.userId$);
}
this.credentialType$
.pipe(
switchMap((type) => this.typeToGenerator$(type)),
takeUntil(this.destroyed),
)
.subscribe((generated) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.onGenerated.next(generated);
this.value$.next(generated.credential);
});
});
}
private typeToGenerator$(type: GeneratorType) {
const dependencies = {
on$: this.generate$,
userId$: this.userId$,
};
switch (type) {
case "password":
return this.generatorService.generate$(Generators.Password, dependencies);
case "passphrase":
return this.generatorService.generate$(Generators.Passphrase, dependencies);
default:
throw new Error(`Invalid generator type: "${type}"`);
}
}
private readonly destroyed = new Subject<void>();
ngOnDestroy(): void {
// tear down subscriptions
this.destroyed.complete();
// finalize subjects
this.generate$.complete();
this.value$.complete();
// finalize component bindings
this.onGenerated.complete();
}
}

View File

@@ -1,5 +1,8 @@
import { Randomizer } from "../abstractions";
import { PasswordRandomizer } from "../engine";
import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "../strategies/storage";
import {
CredentialGenerator,
PassphraseGenerationOptions,
PassphraseGeneratorPolicy,
PasswordGenerationOptions,
@@ -14,6 +17,12 @@ import { DefaultPasswordGenerationOptions } from "./default-password-generation-
import { Policies } from "./policies";
const PASSPHRASE = Object.freeze({
category: "passphrase",
engine: {
create(randomizer: Randomizer): CredentialGenerator<PassphraseGenerationOptions> {
return new PasswordRandomizer(randomizer);
},
},
settings: {
initial: DefaultPassphraseGenerationOptions,
constraints: {
@@ -32,6 +41,12 @@ const PASSPHRASE = Object.freeze({
>);
const PASSWORD = Object.freeze({
category: "password",
engine: {
create(randomizer: Randomizer): CredentialGenerator<PasswordGenerationOptions> {
return new PasswordRandomizer(randomizer);
},
},
settings: {
initial: DefaultPasswordGenerationOptions,
constraints: {

View File

@@ -335,4 +335,40 @@ describe("PasswordRandomizer", () => {
expect(result).toEqual("foo-foo");
});
});
describe("generate", () => {
it("processes password generation options", async () => {
const password = new PasswordRandomizer(randomizer);
const result = await password.generate(
{},
{
length: 10,
},
);
expect(result.category).toEqual("password");
});
it("processes passphrase generation options", async () => {
const password = new PasswordRandomizer(randomizer);
const result = await password.generate(
{},
{
numWords: 10,
},
);
expect(result.category).toEqual("passphrase");
});
it("throws when it cannot recognize the options type", async () => {
const password = new PasswordRandomizer(randomizer);
const result = password.generate({}, {});
await expect(result).rejects.toBeInstanceOf(Error);
});
});
});

View File

@@ -1,13 +1,26 @@
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
import { GenerationRequest } from "@bitwarden/common/tools/types";
import {
CredentialGenerator,
GeneratedCredential,
PassphraseGenerationOptions,
PasswordGenerationOptions,
} from "../types";
import { optionsToEffWordListRequest, optionsToRandomAsciiRequest } from "../util";
import { Randomizer } from "./abstractions";
import { Ascii } from "./data";
import { CharacterSet, EffWordListRequest, RandomAsciiRequest } from "./types";
/** Generation algorithms that produce randomized secrets */
export class PasswordRandomizer {
export class PasswordRandomizer
implements
CredentialGenerator<PassphraseGenerationOptions>,
CredentialGenerator<PasswordGenerationOptions>
{
/** Instantiates the password randomizer
* @param random data source for random data
* @param randomizer data source for random data
*/
constructor(private randomizer: Randomizer) {}
@@ -52,6 +65,41 @@ export class PasswordRandomizer {
return wordList.join(request.separator);
}
generate(
request: GenerationRequest,
settings: PasswordGenerationOptions,
): Promise<GeneratedCredential>;
generate(
request: GenerationRequest,
settings: PassphraseGenerationOptions,
): Promise<GeneratedCredential>;
async generate(
_request: GenerationRequest,
settings: PasswordGenerationOptions | PassphraseGenerationOptions,
) {
if (isPasswordGenerationOptions(settings)) {
const request = optionsToRandomAsciiRequest(settings);
const password = await this.randomAscii(request);
return new GeneratedCredential(password, "password", Date.now());
} else if (isPassphraseGenerationOptions(settings)) {
const request = optionsToEffWordListRequest(settings);
const passphrase = await this.randomEffLongWords(request);
return new GeneratedCredential(passphrase, "passphrase", Date.now());
}
throw new Error("Invalid settings received by generator.");
}
}
function isPasswordGenerationOptions(settings: any): settings is PasswordGenerationOptions {
return "length" in (settings ?? {});
}
function isPassphraseGenerationOptions(settings: any): settings is PassphraseGenerationOptions {
return "numWords" in (settings ?? {});
}
// given a generator request, convert each of its `number | undefined` properties

View File

@@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -8,9 +8,14 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st
import { Constraints } from "@bitwarden/common/tools/types";
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec";
import { PolicyEvaluator } from "../abstractions";
import { CredentialGeneratorConfiguration } from "../types";
import {
FakeStateProvider,
FakeAccountService,
awaitAsync,
ObservableTracker,
} from "../../../../../common/spec";
import { PolicyEvaluator, Randomizer } from "../abstractions";
import { CredentialGeneratorConfiguration, GeneratedCredential } from "../types";
import { CredentialGeneratorService } from "./credential-generator.service";
@@ -34,8 +39,23 @@ const somePolicy = new Policy({
enabled: true,
});
const SomeTime = new Date(1);
const SomeCategory = "passphrase";
// fake the configuration
const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = {
category: SomeCategory,
engine: {
create: (randomizer) => {
return {
generate: (request, settings) => {
const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo;
const result = new GeneratedCredential(credential, SomeCategory, SomeTime);
return Promise.resolve(result);
},
};
},
},
settings: {
initial: { foo: "initial" },
constraints: { foo: {} },
@@ -87,6 +107,9 @@ const accountService = new FakeAccountService({
// fake state
const stateProvider = new FakeStateProvider(accountService);
// fake randomizer
const randomizer = mock<Randomizer>();
describe("CredentialGeneratorService", () => {
beforeEach(async () => {
await accountService.switchAccount(SomeUser);
@@ -94,10 +117,242 @@ describe("CredentialGeneratorService", () => {
jest.clearAllMocks();
});
describe("generate$", () => {
it("emits a generation for the active user when subscribed", async () => {
const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
const result = await generated.expectEmission();
expect(result).toEqual(new GeneratedCredential("value", SomeCategory, SomeTime));
});
it("follows the active user", async () => {
const someSettings = { foo: "some value" };
const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
await accountService.switchAccount(AnotherUser);
await generated.pauseUntilReceived(2);
generated.unsubscribe();
expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeCategory, SomeTime),
new GeneratedCredential("another value", SomeCategory, SomeTime),
]);
});
it("emits a generation when the settings change", async () => {
const someSettings = { foo: "some value" };
const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser);
await generated.pauseUntilReceived(2);
generated.unsubscribe();
expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeCategory, SomeTime),
new GeneratedCredential("another value", SomeCategory, SomeTime),
]);
});
// FIXME: test these when the fake state provider can create the required emissions
it.todo("errors when the settings error");
it.todo("completes when the settings complete");
it("includes `website$`'s last emitted value", async () => {
const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const website$ = new BehaviorSubject("some website");
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
const result = await generated.expectEmission();
expect(result).toEqual(new GeneratedCredential("some website|value", SomeCategory, SomeTime));
});
it("errors when `website$` errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const website$ = new BehaviorSubject("some website");
let error = null;
generator.generate$(SomeConfiguration, { website$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
website$.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
});
it("completes when `website$` completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const website$ = new BehaviorSubject("some website");
let completed = false;
generator.generate$(SomeConfiguration, { website$ }).subscribe({
complete: () => {
completed = true;
},
});
website$.complete();
await awaitAsync();
expect(completed).toBeTruthy();
});
it("emits a generation for a specific user when `user$` supplied", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
const result = await generated.expectEmission();
expect(result).toEqual(new GeneratedCredential("another", SomeCategory, SomeTime));
});
it("emits a generation for a specific user when `user$` emits", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.pipe(filter((u) => !!u));
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
userId.next(AnotherUser);
const result = await generated.pauseUntilReceived(2);
expect(result).toEqual([
new GeneratedCredential("value", SomeCategory, SomeTime),
new GeneratedCredential("another", SomeCategory, SomeTime),
]);
});
it("errors when `user$` errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser);
let error = null;
generator.generate$(SomeConfiguration, { userId$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
userId$.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
});
it("completes when `user$` completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser);
let completed = false;
generator.generate$(SomeConfiguration, { userId$ }).subscribe({
complete: () => {
completed = true;
},
});
userId$.complete();
await awaitAsync();
expect(completed).toBeTruthy();
});
it("emits a generation only when `on$` emits", async () => {
// This test breaks from arrange/act/assert because it is testing causality
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const on$ = new Subject<void>();
const results: any[] = [];
// confirm no emission during subscription
const sub = generator
.generate$(SomeConfiguration, { on$ })
.subscribe((result) => results.push(result));
await awaitAsync();
expect(results.length).toEqual(0);
// confirm forwarded emission
on$.next();
await awaitAsync();
expect(results).toEqual([new GeneratedCredential("value", SomeCategory, SomeTime)]);
// confirm updating settings does not cause an emission
await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser);
await awaitAsync();
expect(results.length).toBe(1);
// confirm forwarded emission takes latest value
on$.next();
await awaitAsync();
sub.unsubscribe();
expect(results).toEqual([
new GeneratedCredential("value", SomeCategory, SomeTime),
new GeneratedCredential("next", SomeCategory, SomeTime),
]);
});
it("errors when `on$` errors", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const on$ = new Subject<void>();
let error: any = null;
// confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
on$.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
});
it("completes when `on$` completes", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const on$ = new Subject<void>();
let complete = false;
// confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({
complete: () => {
complete = true;
},
});
on$.complete();
await awaitAsync();
expect(complete).toBeTruthy();
});
});
describe("settings$", () => {
it("defaults to the configuration's initial settings if settings aren't found", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@@ -107,7 +362,7 @@ describe("CredentialGeneratorService", () => {
it("reads from the active user's configuration-defined storage", async () => {
const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@@ -119,7 +374,7 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@@ -131,7 +386,7 @@ describe("CredentialGeneratorService", () => {
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const results: any = [];
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
@@ -148,7 +403,7 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ }));
@@ -161,7 +416,7 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const results: any = [];
@@ -180,7 +435,7 @@ describe("CredentialGeneratorService", () => {
it("errors when the arbitrary user's stream errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let error = null;
@@ -198,7 +453,7 @@ describe("CredentialGeneratorService", () => {
it("completes when the arbitrary user's stream completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let completed = false;
@@ -216,7 +471,7 @@ describe("CredentialGeneratorService", () => {
it("ignores repeated arbitrary user emissions", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let count = 0;
@@ -240,7 +495,7 @@ describe("CredentialGeneratorService", () => {
describe("settings", () => {
it("writes to the user's state", async () => {
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
subject.next({ foo: "next value" });
@@ -253,7 +508,7 @@ describe("CredentialGeneratorService", () => {
it("waits for the user to become available", async () => {
const singleUserId = new BehaviorSubject(null);
const singleUserId$ = singleUserId.asObservable();
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
let completed = false;
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
@@ -271,7 +526,7 @@ describe("CredentialGeneratorService", () => {
describe("policy$", () => {
it("creates a disabled policy evaluator when there is no policy", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
@@ -281,7 +536,7 @@ describe("CredentialGeneratorService", () => {
});
it("creates an active policy evaluator when there is a policy", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$);
@@ -293,7 +548,7 @@ describe("CredentialGeneratorService", () => {
});
it("follows policy emissions", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const somePolicySubject = new BehaviorSubject([somePolicy]);
@@ -316,7 +571,7 @@ describe("CredentialGeneratorService", () => {
});
it("follows user emissions", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
@@ -340,7 +595,7 @@ describe("CredentialGeneratorService", () => {
});
it("errors when the user errors", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const expectedError = { some: "error" };
@@ -358,7 +613,7 @@ describe("CredentialGeneratorService", () => {
});
it("completes when the user completes", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();

View File

@@ -1,5 +1,7 @@
import {
BehaviorSubject,
combineLatest,
concatMap,
distinctUntilChanged,
endWith,
filter,
@@ -8,32 +10,84 @@ import {
map,
mergeMap,
Observable,
race,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
import { Simplify } from "type-fest";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SingleUserDependency, UserDependency } from "@bitwarden/common/tools/dependencies";
import {
OnDependency,
SingleUserDependency,
UserDependency,
} from "@bitwarden/common/tools/dependencies";
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { Constraints } from "@bitwarden/common/tools/types";
import { PolicyEvaluator } from "../abstractions";
import { PolicyEvaluator, Randomizer } from "../abstractions";
import { mapPolicyToEvaluatorV2 } from "../rx";
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration";
type Policy$Dependencies = UserDependency;
type Settings$Dependencies = Partial<UserDependency>;
type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDependency>> & {
/** Emits the active website when subscribed.
*
* The generator does not respond to emissions of this interface;
* If it is provided, the generator blocks until a value becomes available.
* When `website$` is omitted, the generator uses the empty string instead.
* When `website$` completes, the generator completes.
* When `website$` errors, the generator forwards the error.
*/
website$?: Observable<string>;
};
// FIXME: once the modernization is complete, switch the type parameters
// in `PolicyEvaluator<P, S>` and bake-in the constraints type.
type Evaluator<Settings, Policy> = PolicyEvaluator<Policy, Settings> & Constraints<Settings>;
export class CredentialGeneratorService {
constructor(
private randomizer: Randomizer,
private stateProvider: StateProvider,
private policyService: PolicyService,
) {}
/** Generates a stream of credentials
* @param configuration determines which generator's settings are loaded
* @param dependencies.on$ when specified, a new credential is emitted when
* this emits. Otherwise, a new credential is emitted when the settings
* update.
*/
generate$<Settings, Policy>(
configuration: Readonly<Configuration<Settings, Policy>>,
dependencies?: Generate$Dependencies,
) {
// instantiate the engine
const engine = configuration.engine.create(this.randomizer);
// stream blocks until all of these values are received
const website$ = dependencies?.website$ ?? new BehaviorSubject<string>(null);
const request$ = website$.pipe(map((website) => ({ website })));
const settings$ = this.settings$(configuration, dependencies);
// monitor completion
const requestComplete$ = request$.pipe(ignoreElements(), endWith(true));
const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true));
const complete$ = race(requestComplete$, settingsComplete$);
// generation proper
const generate$ = (dependencies?.on$ ?? settings$).pipe(
withLatestFrom(request$, settings$),
concatMap(([, request, settings]) => engine.generate(request, settings)),
takeUntil(complete$),
);
return generate$;
}
/** Get the settings for the provided configuration
* @param configuration determines which generator's settings are loaded
* @param dependencies.userId$ identifies the user to which the settings are bound.
@@ -82,7 +136,7 @@ export class CredentialGeneratorService {
* @remarks the subject enforces policy for the settings
*/
async settings<Settings, Policy>(
configuration: Configuration<Settings, Policy>,
configuration: Readonly<Configuration<Settings, Policy>>,
dependencies: SingleUserDependency,
) {
const userId = await firstValueFrom(

View File

@@ -17,7 +17,7 @@ import { PASSPHRASE_SETTINGS } from "./storage";
const SomeUser = "some user" as UserId;
describe("Password generation strategy", () => {
describe("Passphrase generation strategy", () => {
describe("toEvaluator()", () => {
it("should map to the policy evaluator", async () => {
const strategy = new PassphraseGeneratorStrategy(null, null);

View File

@@ -2,11 +2,11 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { StateProvider } from "@bitwarden/common/platform/state";
import { GeneratorStrategy } from "../abstractions";
import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Policies } from "../data";
import { DefaultPassphraseGenerationOptions, Policies } from "../data";
import { PasswordRandomizer } from "../engine";
import { mapPolicyToEvaluator } from "../rx";
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
import { observe$PerUserId, sharedStateByUserId } from "../util";
import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } from "../util";
import { PASSPHRASE_SETTINGS } from "./storage";
@@ -33,13 +33,7 @@ export class PassphraseGeneratorStrategy
// algorithm
async generate(options: PassphraseGenerationOptions): Promise<string> {
const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords;
const request = {
numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min),
capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize,
number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber,
separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator,
};
const request = optionsToEffWordListRequest(options);
return this.randomizer.randomEffLongWords(request);
}

View File

@@ -6,7 +6,7 @@ import { Policies, DefaultPasswordGenerationOptions } from "../data";
import { PasswordRandomizer } from "../engine";
import { mapPolicyToEvaluator } from "../rx";
import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types";
import { observe$PerUserId, sharedStateByUserId, sum } from "../util";
import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } from "../util";
import { PASSWORD_SETTINGS } from "./storage";
@@ -32,62 +32,7 @@ export class PasswordGeneratorStrategy
// algorithm
async generate(options: PasswordGenerationOptions): Promise<string> {
// converts password generation option sets, which are defined by
// an "enabled" and "quantity" parameter, to the password engine's
// parameters, which represent disabled options as `undefined`
// properties.
function process(
// values read from the options
enabled: boolean,
quantity: number,
// value used if an option is missing
defaultEnabled: boolean,
defaultQuantity: number,
) {
const isEnabled = enabled ?? defaultEnabled;
const actualQuantity = quantity ?? defaultQuantity;
const result = isEnabled ? actualQuantity : undefined;
return result;
}
const request = {
uppercase: process(
options.uppercase,
options.minUppercase,
DefaultPasswordGenerationOptions.uppercase,
DefaultPasswordGenerationOptions.minUppercase,
),
lowercase: process(
options.lowercase,
options.minLowercase,
DefaultPasswordGenerationOptions.lowercase,
DefaultPasswordGenerationOptions.minLowercase,
),
digits: process(
options.number,
options.minNumber,
DefaultPasswordGenerationOptions.number,
DefaultPasswordGenerationOptions.minNumber,
),
special: process(
options.special,
options.minSpecial,
DefaultPasswordGenerationOptions.special,
DefaultPasswordGenerationOptions.minSpecial,
),
ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous,
all: 0,
};
// engine represents character sets as "include only"; you assert how many all
// characters there can be rather than a total length. This conversion has
// the character classes win, so that the result is always consistent with policy
// minimums.
const required = sum(request.uppercase, request.lowercase, request.digits, request.special);
const remaining = (options.length ?? 0) - required;
request.all = Math.max(remaining, 0);
const request = optionsToRandomAsciiRequest(options);
const result = await this.randomizer.randomAscii(request);
return result;

View File

@@ -0,0 +1,5 @@
/** Kinds of credentials that can be stored by the history service
* password - a secret consisting of arbitrary characters used to authenticate a user
* passphrase - a secret consisting of words used to authenticate a user
*/
export type CredentialCategory = "password" | "passphrase";

View File

@@ -1,9 +1,29 @@
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
import { Constraints } from "@bitwarden/common/tools/types";
import { Randomizer } from "../abstractions";
import { PolicyConfiguration } from "../types";
import { CredentialCategory } from "./credential-category";
import { CredentialGenerator } from "./credential-generator";
export type CredentialGeneratorConfiguration<Settings, Policy> = {
/** Category describing usage of the credential generated by this configuration
*/
category: CredentialCategory;
/** An algorithm that generates credentials when ran. */
engine: {
/** Factory for the generator
*/
// FIXME: note that this erases the engine's type so that credentials are
// generated uniformly. This property needs to be maintained for
// the credential generator, but engine configurations should return
// the underlying type. `create` may be able to do double-duty w/ an
// engine definition if `CredentialGenerator` can be made covariant.
create: (randomizer: Randomizer) => CredentialGenerator<Settings>;
};
/** Defines the stored parameters for credential generation */
settings: {
/** value used when an account's settings haven't been initialized */
initial: Readonly<Partial<Settings>>;

View File

@@ -0,0 +1,12 @@
import { GenerationRequest } from "@bitwarden/common/tools/types";
import { GeneratedCredential } from "./generated-credential";
/** An algorithm that generates credentials. */
export type CredentialGenerator<Settings> = {
/** Generates a credential
* @param request runtime parameters
* @param settings stored parameters
*/
generate: (request: GenerationRequest, settings: Settings) => Promise<GeneratedCredential>;
};

View File

@@ -0,0 +1,58 @@
import { CredentialCategory, GeneratedCredential } from ".";
describe("GeneratedCredential", () => {
describe("constructor", () => {
it("assigns credential", () => {
const result = new GeneratedCredential("example", "passphrase", new Date(100));
expect(result.credential).toEqual("example");
});
it("assigns category", () => {
const result = new GeneratedCredential("example", "passphrase", new Date(100));
expect(result.category).toEqual("passphrase");
});
it("passes through date parameters", () => {
const result = new GeneratedCredential("example", "password", new Date(100));
expect(result.generationDate).toEqual(new Date(100));
});
it("converts numeric dates to Dates", () => {
const result = new GeneratedCredential("example", "password", 100);
expect(result.generationDate).toEqual(new Date(100));
});
});
it("toJSON converts from a credential into a JSON object", () => {
const credential = new GeneratedCredential("example", "password", new Date(100));
const result = credential.toJSON();
expect(result).toEqual({
credential: "example",
category: "password" as CredentialCategory,
generationDate: 100,
});
});
it("fromJSON converts Json objects into credentials", () => {
const jsonValue = {
credential: "example",
category: "password" as CredentialCategory,
generationDate: 100,
};
const result = GeneratedCredential.fromJSON(jsonValue);
expect(result).toBeInstanceOf(GeneratedCredential);
expect(result).toEqual({
credential: "example",
category: "password",
generationDate: new Date(100),
});
});
});

View File

@@ -0,0 +1,47 @@
import { Jsonify } from "type-fest";
import { CredentialCategory } from "./credential-category";
/** A credential generation result */
export class GeneratedCredential {
/**
* Instantiates a generated credential
* @param credential The value of the generated credential (e.g. a password)
* @param category The kind of credential
* @param generationDate The date that the credential was generated.
* Numeric values should are interpreted using {@link Date.valueOf}
* semantics.
*/
constructor(
readonly credential: string,
readonly category: CredentialCategory,
generationDate: Date | number,
) {
if (typeof generationDate === "number") {
this.generationDate = new Date(generationDate);
} else {
this.generationDate = generationDate;
}
}
/** The date that the credential was generated */
generationDate: Date;
/** Constructs a credential from its `toJSON` representation */
static fromJSON(jsonValue: Jsonify<GeneratedCredential>) {
return new GeneratedCredential(
jsonValue.credential,
jsonValue.category,
jsonValue.generationDate,
);
}
/** Serializes a credential to a JSON-compatible object */
toJSON() {
return {
credential: this.credential,
category: this.category,
generationDate: this.generationDate.valueOf(),
};
}
}

View File

@@ -1,8 +1,11 @@
export * from "./boundary";
export * from "./catchall-generator-options";
export * from "./credential-category";
export * from "./credential-generator";
export * from "./credential-generator-configuration";
export * from "./eff-username-generator-options";
export * from "./forwarder-options";
export * from "./generated-credential";
export * from "./generator-options";
export * from "./generator-type";
export * from "./no-policy";

View File

@@ -1,4 +1,5 @@
import { sum } from "./util";
import { DefaultPassphraseGenerationOptions } from "./data";
import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum } from "./util";
describe("sum", () => {
it("returns 0 when the list is empty", () => {
@@ -15,3 +16,411 @@ describe("sum", () => {
expect(sum(1, 2, 3)).toBe(6);
});
});
describe("optionsToRandomAsciiRequest", () => {
it("should map options", async () => {
const result = optionsToRandomAsciiRequest({
length: 20,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 1,
minLowercase: 2,
minNumber: 3,
minSpecial: 4,
});
expect(result).toEqual({
all: 10,
uppercase: 1,
lowercase: 2,
digits: 3,
special: 4,
ambiguous: true,
});
});
it("should disable uppercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 3,
ambiguous: true,
uppercase: false,
lowercase: true,
number: true,
special: true,
minUppercase: 1,
minLowercase: 1,
minNumber: 1,
minSpecial: 1,
});
expect(result).toEqual({
all: 0,
uppercase: undefined,
lowercase: 1,
digits: 1,
special: 1,
ambiguous: true,
});
});
it("should disable lowercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 3,
ambiguous: true,
uppercase: true,
lowercase: false,
number: true,
special: true,
minUppercase: 1,
minLowercase: 1,
minNumber: 1,
minSpecial: 1,
});
expect(result).toEqual({
all: 0,
uppercase: 1,
lowercase: undefined,
digits: 1,
special: 1,
ambiguous: true,
});
});
it("should disable digits", async () => {
const result = optionsToRandomAsciiRequest({
length: 3,
ambiguous: true,
uppercase: true,
lowercase: true,
number: false,
special: true,
minUppercase: 1,
minLowercase: 1,
minNumber: 1,
minSpecial: 1,
});
expect(result).toEqual({
all: 0,
uppercase: 1,
lowercase: 1,
digits: undefined,
special: 1,
ambiguous: true,
});
});
it("should disable special", async () => {
const result = optionsToRandomAsciiRequest({
length: 3,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: false,
minUppercase: 1,
minLowercase: 1,
minNumber: 1,
minSpecial: 1,
});
expect(result).toEqual({
all: 0,
uppercase: 1,
lowercase: 1,
digits: 1,
special: undefined,
ambiguous: true,
});
});
it("should override length with minimums", async () => {
const result = optionsToRandomAsciiRequest({
length: 20,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 1,
minLowercase: 2,
minNumber: 3,
minSpecial: 4,
});
expect(result).toEqual({
all: 10,
uppercase: 1,
lowercase: 2,
digits: 3,
special: 4,
ambiguous: true,
});
});
it("should default uppercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 2,
ambiguous: true,
lowercase: true,
number: true,
special: true,
minUppercase: 2,
minLowercase: 0,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 2,
lowercase: 0,
digits: 0,
special: 0,
ambiguous: true,
});
});
it("should default lowercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
number: true,
special: true,
minUppercase: 0,
minLowercase: 2,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 2,
digits: 0,
special: 0,
ambiguous: true,
});
});
it("should default number", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
special: true,
minUppercase: 0,
minLowercase: 0,
minNumber: 2,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 0,
digits: 2,
special: 0,
ambiguous: true,
});
});
it("should default special", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
minUppercase: 0,
minLowercase: 0,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 0,
digits: 0,
special: undefined,
ambiguous: true,
});
});
it("should default minUppercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minLowercase: 0,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 1,
lowercase: 0,
digits: 0,
special: 0,
ambiguous: true,
});
});
it("should default minLowercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 0,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 1,
digits: 0,
special: 0,
ambiguous: true,
});
});
it("should default minNumber", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 0,
minLowercase: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 0,
digits: 1,
special: 0,
ambiguous: true,
});
});
it("should default minSpecial", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 0,
minLowercase: 0,
minNumber: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 0,
digits: 0,
special: 0,
ambiguous: true,
});
});
});
describe("optionsToEffWordListRequest", () => {
it("should map options", async () => {
const result = optionsToEffWordListRequest({
numWords: 4,
capitalize: true,
includeNumber: true,
wordSeparator: "!",
});
expect(result).toEqual({
numberOfWords: 4,
capitalize: true,
number: true,
separator: "!",
});
});
it("should default numWords", async () => {
const result = optionsToEffWordListRequest({
capitalize: true,
includeNumber: true,
wordSeparator: "!",
});
expect(result).toEqual({
numberOfWords: DefaultPassphraseGenerationOptions.numWords,
capitalize: true,
number: true,
separator: "!",
});
});
it("should default capitalize", async () => {
const result = optionsToEffWordListRequest({
numWords: 4,
includeNumber: true,
wordSeparator: "!",
});
expect(result).toEqual({
numberOfWords: 4,
capitalize: DefaultPassphraseGenerationOptions.capitalize,
number: true,
separator: "!",
});
});
it("should default includeNumber", async () => {
const result = optionsToEffWordListRequest({
numWords: 4,
capitalize: true,
wordSeparator: "!",
});
expect(result).toEqual({
numberOfWords: 4,
capitalize: true,
number: DefaultPassphraseGenerationOptions.includeNumber,
separator: "!",
});
});
it("should default wordSeparator", async () => {
const result = optionsToEffWordListRequest({
numWords: 4,
capitalize: true,
includeNumber: true,
});
expect(result).toEqual({
numberOfWords: 4,
capitalize: true,
number: true,
separator: DefaultPassphraseGenerationOptions.wordSeparator,
});
});
});

View File

@@ -7,6 +7,13 @@ import {
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import {
DefaultPassphraseBoundaries,
DefaultPassphraseGenerationOptions,
DefaultPasswordGenerationOptions,
} from "./data";
import { PassphraseGenerationOptions, PasswordGenerationOptions } from "./types";
/** construct a method that outputs a copy of `defaultValue` as an observable. */
export function observe$PerUserId<Value>(
create: () => Partial<Value>,
@@ -50,3 +57,79 @@ export function sharedStateByUserId<Value>(key: UserKeyDefinition<Value>, provid
/** returns the sum of items in the list. */
export const sum = (...items: number[]) =>
(items ?? []).reduce((sum: number, current: number) => sum + (current ?? 0), 0);
/* converts password generation option sets, which are defined by
* an "enabled" and "quantity" parameter, to the password engine's
* parameters, which represent disabled options as `undefined`
* properties.
*/
export function optionsToRandomAsciiRequest(options: PasswordGenerationOptions) {
// helper for processing common option sets
function process(
// values read from the options
enabled: boolean,
quantity: number,
// value used if an option is missing
defaultEnabled: boolean,
defaultQuantity: number,
) {
const isEnabled = enabled ?? defaultEnabled;
const actualQuantity = quantity ?? defaultQuantity;
const result = isEnabled ? actualQuantity : undefined;
return result;
}
const request = {
uppercase: process(
options.uppercase,
options.minUppercase,
DefaultPasswordGenerationOptions.uppercase,
DefaultPasswordGenerationOptions.minUppercase,
),
lowercase: process(
options.lowercase,
options.minLowercase,
DefaultPasswordGenerationOptions.lowercase,
DefaultPasswordGenerationOptions.minLowercase,
),
digits: process(
options.number,
options.minNumber,
DefaultPasswordGenerationOptions.number,
DefaultPasswordGenerationOptions.minNumber,
),
special: process(
options.special,
options.minSpecial,
DefaultPasswordGenerationOptions.special,
DefaultPasswordGenerationOptions.minSpecial,
),
ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous,
all: 0,
};
// engine represents character sets as "include only"; you assert how many all
// characters there can be rather than a total length. This conversion has
// the character classes win, so that the result is always consistent with policy
// minimums.
const required = sum(request.uppercase, request.lowercase, request.digits, request.special);
const remaining = (options.length ?? 0) - required;
request.all = Math.max(remaining, 0);
return request;
}
/* converts passphrase generation option sets to the eff word list request
*/
export function optionsToEffWordListRequest(options: PassphraseGenerationOptions) {
const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords;
const request = {
numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min),
capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize,
number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber,
separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator,
};
return request;
}