diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4f1696461a..29396a8bcf 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -384,16 +384,56 @@ "message": "Minimum password length" }, "uppercase": { - "message": "Uppercase (A-Z)" + "message": "Uppercase (A-Z)", + "description": "deprecated. Use uppercaseLabel instead." }, "lowercase": { - "message": "Lowercase (a-z)" + "message": "Lowercase (a-z)", + "description": "deprecated. Use lowercaseLabel instead." }, "numbers": { - "message": "Numbers (0-9)" + "message": "Numbers (0-9)", + "description": "deprecated. Use numbersLabel instead." }, "specialCharacters": { - "message": "Special characters (!@#$%^&*)" + "message": "Special characters (!@#$%^&*)", + "description": "deprecated. Use specialCharactersLabel instead." + }, + "include": { + "message": "Include", + "description": "Card header for password generator include block" + }, + "uppercaseDescription": { + "message": "Include uppercase characters", + "description": "Tooltip for the password generator uppercase character checkbox" + }, + "uppercaseLabel": { + "message": "A-Z", + "description": "Label for the password generator uppercase character checkbox" + }, + "lowercaseDescription": { + "message": "Include lowercase characters", + "description": "Full description for the password generator lowercase character checkbox" + }, + "lowercaseLabel": { + "message": "a-z", + "description": "Label for the password generator lowercase character checkbox" + }, + "numbersDescription": { + "message": "Include numbers", + "description": "Full description for the password generator numbers checkbox" + }, + "numbersLabel": { + "message": "0-9", + "description": "Label for the password generator numbers checkbox" + }, + "specialCharactersDescription": { + "message": "Include special characters", + "description": "Full description for the password generator special characters checkbox" + }, + "specialCharactersLabel": { + "message": "!@#$%^&*", + "description": "Label for the password generator special characters checkbox" }, "numWords": { "message": "Number of words" @@ -415,7 +455,12 @@ "message": "Minimum special" }, "avoidAmbChar": { - "message": "Avoid ambiguous characters" + "message": "Avoid ambiguous characters", + "description": "deprecated. Use avoidAmbiguous instead." + }, + "avoidAmbiguous": { + "message": "Avoid ambiguous characters", + "description": "Label for the avoid ambiguous characters checkbox." }, "searchVault": { "message": "Search vault" diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.html b/apps/browser/src/tools/popup/generator/credential-generator.component.html index 1bb626e3e8..0b43b0e257 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.html +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.html @@ -1 +1 @@ - + diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.ts b/apps/browser/src/tools/popup/generator/credential-generator.component.ts index 91a17ab2d3..f07affd237 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -1,11 +1,14 @@ import { Component } from "@angular/core"; -import { PassphraseSettingsComponent } from "@bitwarden/generator-components"; +import { + PassphraseSettingsComponent, + PasswordSettingsComponent, +} from "@bitwarden/generator-components"; @Component({ standalone: true, selector: "credential-generator", templateUrl: "credential-generator.component.html", - imports: [PassphraseSettingsComponent], + imports: [PassphraseSettingsComponent, PasswordSettingsComponent], }) export class CredentialGeneratorComponent {} diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index ae631f7137..0fc0655a02 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -1 +1,2 @@ export { PassphraseSettingsComponent } from "./passphrase-settings.component"; +export { PasswordSettingsComponent } from "./password-settings.component"; diff --git a/libs/tools/generator/components/src/password-settings.component.html b/libs/tools/generator/components/src/password-settings.component.html new file mode 100644 index 0000000000..dd3f77431b --- /dev/null +++ b/libs/tools/generator/components/src/password-settings.component.html @@ -0,0 +1,86 @@ + + + {{ "options" | i18n }} + + + + + + {{ "length" | i18n }} + + + + + + + {{ "include" | i18n }} + + + + {{ "uppercaseLabel" | i18n }} + + + + {{ "lowercaseLabel" | i18n }} + + + + {{ "numbersLabel" | i18n }} + + + + {{ "specialCharactersLabel" | i18n }} + + + + + {{ "minNumbers" | i18n }} + + + + {{ "minSpecial" | i18n }} + + + + + + {{ "avoidAmbiguous" | i18n }} + + + + + diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts new file mode 100644 index 0000000000..e4f2bb57b8 --- /dev/null +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -0,0 +1,202 @@ +import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { BehaviorSubject, skip, takeUntil, Subject, map } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + Generators, + CredentialGeneratorService, + PasswordGenerationOptions, +} from "@bitwarden/generator-core"; + +import { DependenciesModule } from "./dependencies"; +import { completeOnAccountSwitch, toValidators } from "./util"; + +const Controls = Object.freeze({ + length: "length", + uppercase: "uppercase", + lowercase: "lowercase", + numbers: "numbers", + special: "special", + minNumber: "minNumber", + minSpecial: "minSpecial", + avoidAmbiguous: "avoidAmbiguous", +}); + +/** Options group for passwords */ +@Component({ + standalone: true, + selector: "bit-password-settings", + templateUrl: "password-settings.component.html", + imports: [DependenciesModule], +}) +export class PasswordSettingsComponent 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 password component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + /** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */ + @Input() + showHeader: boolean = true; + + /** 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(); + + protected settings = this.formBuilder.group({ + [Controls.length]: [Generators.Password.settings.initial.length], + [Controls.uppercase]: [Generators.Password.settings.initial.uppercase], + [Controls.lowercase]: [Generators.Password.settings.initial.lowercase], + [Controls.numbers]: [Generators.Password.settings.initial.number], + [Controls.special]: [Generators.Password.settings.initial.special], + [Controls.minNumber]: [Generators.Password.settings.initial.minNumber], + [Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial], + [Controls.avoidAmbiguous]: [!Generators.Password.settings.initial.ambiguous], + }); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + const settings = await this.generatorService.settings(Generators.Password, { singleUserId$ }); + + settings + .pipe( + map((settings) => { + // interface is "avoid" while storage is "include" + const s: any = { ...settings }; + s.avoidAmbiguous = s.ambiguous; + delete s.ambiguous; + return s; + }), + takeUntil(this.destroyed$), + ) + .subscribe((s) => { + // skips reactive event emissions to break a subscription cycle + 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.generatorService + .policy$(Generators.Password, { userId$: singleUserId$ }) + .pipe(takeUntil(this.destroyed$)) + .subscribe((policy) => { + this.settings + .get(Controls.length) + .setValidators(toValidators(Controls.length, Generators.Password, policy)); + + this.settings + .get(Controls.minNumber) + .setValidators(toValidators(Controls.minNumber, Generators.Password, policy)); + + this.settings + .get(Controls.minSpecial) + .setValidators(toValidators(Controls.minSpecial, Generators.Password, policy)); + + // forward word boundaries to the template (can't do it through the rx form) + // FIXME: move the boundary logic fully into the policy evaluator + this.minLength = policy.length?.min ?? Generators.Password.settings.constraints.length.min; + this.maxLength = policy.length?.max ?? Generators.Password.settings.constraints.length.max; + this.minMinNumber = + policy.minNumber?.min ?? Generators.Password.settings.constraints.minNumber.min; + this.maxMinNumber = + policy.minNumber?.max ?? Generators.Password.settings.constraints.minNumber.max; + this.minMinSpecial = + policy.minSpecial?.min ?? Generators.Password.settings.constraints.minSpecial.min; + this.maxMinSpecial = + policy.minSpecial?.max ?? Generators.Password.settings.constraints.minSpecial.max; + + const toggles = [ + [Controls.length, policy.length.min < policy.length.max], + [Controls.uppercase, !policy.policy.useUppercase], + [Controls.lowercase, !policy.policy.useLowercase], + [Controls.numbers, !policy.policy.useNumbers], + [Controls.special, !policy.policy.useSpecial], + [Controls.minNumber, policy.minNumber.min < policy.minNumber.max], + [Controls.minSpecial, policy.minSpecial.min < policy.minSpecial.max], + ] as [keyof typeof Controls, boolean][]; + + for (const [control, enabled] of toggles) { + this.toggleEnabled(control, enabled); + } + }); + + // now that outputs are set up, connect inputs + this.settings.valueChanges + .pipe( + map((settings) => { + // interface is "avoid" while storage is "include" + const s: any = { ...settings }; + s.ambiguous = s.avoidAmbiguous; + delete s.avoidAmbiguous; + return s; + }), + takeUntil(this.destroyed$), + ) + .subscribe(settings); + } + + /** attribute binding for length[min] */ + protected minLength: number; + + /** attribute binding for length[max] */ + protected maxLength: number; + + /** attribute binding for minNumber[min] */ + protected minMinNumber: number; + + /** attribute binding for minNumber[max] */ + protected maxMinNumber: number; + + /** attribute binding for minSpecial[min] */ + protected minMinSpecial: number; + + /** attribute binding for minSpecial[max] */ + protected maxMinSpecial: number; + + private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { + if (enabled) { + this.settings.get(setting).enable(); + } else { + this.settings.get(setting).disable(); + } + } + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed$), + ); + } + + private readonly destroyed$ = new Subject(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index dadba41f42..94e289be03 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,9 +1,16 @@ -import { PASSPHRASE_SETTINGS } from "../strategies/storage"; -import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; +import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "../strategies/storage"; +import { + PassphraseGenerationOptions, + PassphraseGeneratorPolicy, + PasswordGenerationOptions, + PasswordGeneratorPolicy, +} from "../types"; import { CredentialGeneratorConfiguration } from "../types/credential-generator-configuration"; import { DefaultPassphraseBoundaries } from "./default-passphrase-boundaries"; import { DefaultPassphraseGenerationOptions } from "./default-passphrase-generation-options"; +import { DefaultPasswordBoundaries } from "./default-password-boundaries"; +import { DefaultPasswordGenerationOptions } from "./default-password-generation-options"; import { Policies } from "./policies"; const PASSPHRASE = Object.freeze({ @@ -24,8 +31,33 @@ const PASSPHRASE = Object.freeze({ PassphraseGeneratorPolicy >); +const PASSWORD = Object.freeze({ + settings: { + initial: DefaultPasswordGenerationOptions, + constraints: { + length: { + min: DefaultPasswordBoundaries.length.min, + max: DefaultPasswordBoundaries.length.max, + }, + minNumber: { + min: DefaultPasswordBoundaries.minDigits.min, + max: DefaultPasswordBoundaries.minDigits.max, + }, + minSpecial: { + min: DefaultPasswordBoundaries.minSpecialCharacters.min, + max: DefaultPasswordBoundaries.minSpecialCharacters.max, + }, + }, + account: PASSWORD_SETTINGS, + }, + policy: Policies.Password, +} satisfies CredentialGeneratorConfiguration); + /** Generator configurations */ export const Generators = Object.freeze({ /** Passphrase generator configuration */ Passphrase: PASSPHRASE, + + /** Password generator configuration */ + Password: PASSWORD, }); diff --git a/libs/tools/generator/core/src/data/policies.ts b/libs/tools/generator/core/src/data/policies.ts index 7df1f60360..ed5e6c4e5a 100644 --- a/libs/tools/generator/core/src/data/policies.ts +++ b/libs/tools/generator/core/src/data/policies.ts @@ -39,6 +39,7 @@ const PASSWORD = Object.freeze({ }), combine: passwordLeastPrivilege, createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), + createEvaluatorV2: (policy) => new PasswordGeneratorOptionsEvaluator(policy), } as PolicyConfiguration); /** Policy configurations */ diff --git a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.ts b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.ts index e0cc8f25d3..df123daef0 100644 --- a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.ts +++ b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.ts @@ -1,3 +1,5 @@ +import { Constraints } from "@bitwarden/common/tools/types"; + import { PolicyEvaluator } from "../abstractions"; import { DefaultPasswordBoundaries } from "../data"; import { Boundary, PasswordGeneratorPolicy, PasswordGenerationOptions } from "../types"; @@ -5,8 +7,19 @@ import { Boundary, PasswordGeneratorPolicy, PasswordGenerationOptions } from ".. /** Enforces policy for password generation. */ export class PasswordGeneratorOptionsEvaluator - implements PolicyEvaluator + implements + PolicyEvaluator, + Constraints { + // Constraints compatibility + get minNumber() { + return this.minDigits; + } + + get minSpecial() { + return this.minSpecialCharacters; + } + // This design is not ideal, but it is a step towards a more robust password // generator. Ideally, `sanitize` would be implemented on an options class, // and `applyPolicy` would be implemented on a policy class, "mise en place".