diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index a6aff9e455b..39b772221a4 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -268,6 +268,9 @@
"length": {
"message": "Length"
},
+ "passwordMinLength": {
+ "message": "Minimum password length"
+ },
"uppercase": {
"message": "Uppercase (A-Z)"
},
diff --git a/apps/browser/src/popup/scss/box.scss b/apps/browser/src/popup/scss/box.scss
index d98c29176da..179673279e6 100644
--- a/apps/browser/src/popup/scss/box.scss
+++ b/apps/browser/src/popup/scss/box.scss
@@ -341,7 +341,8 @@
}
}
- .img-right {
+ .img-right,
+ .txt-right {
float: right;
margin-left: 10px;
}
diff --git a/apps/browser/src/tools/popup/generator/generator.component.html b/apps/browser/src/tools/popup/generator/generator.component.html
index 5f722b661ce..3e6dfe0747f 100644
--- a/apps/browser/src/tools/popup/generator/generator.component.html
+++ b/apps/browser/src/tools/popup/generator/generator.component.html
@@ -176,7 +176,7 @@
+
+ {{ "passwordMinLength" | i18n }}
+
+ {{ passwordOptionsMinLengthForReader$ | async }}
+
+ {{ passwordOptions.minLength }}
+
@@ -232,10 +244,10 @@
@@ -249,8 +261,8 @@
type="number"
min="0"
max="9"
- (change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
+ (input)="onPasswordOptionsMinNumberInput($event)"
/>
@@ -260,8 +272,8 @@
type="number"
min="0"
max="9"
- (change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
+ (input)="onPasswordOptionsMinSpecialInput($event)"
/>
diff --git a/apps/desktop/src/app/tools/generator.component.html b/apps/desktop/src/app/tools/generator.component.html
index 0c66ebde805..aa77aafc146 100644
--- a/apps/desktop/src/app/tools/generator.component.html
+++ b/apps/desktop/src/app/tools/generator.component.html
@@ -200,7 +200,7 @@
+
+ {{ "passwordMinLength" | i18n }}
+ {{ passwordOptions.minLength }}
+
+ {{ passwordOptionsMinLengthForReader$ | async }}
+
+
@@ -258,7 +271,8 @@
type="checkbox"
(change)="savePasswordOptions()"
[disabled]="enforcedPasswordPolicyOptions?.useSpecial"
- [(ngModel)]="passwordOptions.special"
+ [ngModel]="passwordOptions.special"
+ (ngModelChange)="setPasswordOptionsSpecial($event)"
attr.aria-label="{{ 'specialCharacters' | i18n }}"
/>
@@ -275,6 +289,7 @@
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
+ (input)="onPasswordOptionsMinNumberInput($event)"
/>
@@ -286,6 +301,7 @@
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
+ (input)="onPasswordOptionsMinSpecialInput($event)"
/>
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index 28051428e7d..ca7fdf4d603 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -403,6 +403,9 @@
"length": {
"message": "Length"
},
+ "passwordMinLength": {
+ "message": "Minimum password length"
+ },
"uppercase": {
"message": "Uppercase (A-Z)"
},
diff --git a/apps/desktop/src/scss/box.scss b/apps/desktop/src/scss/box.scss
index 9466c89a7ff..0e89e9fd74e 100644
--- a/apps/desktop/src/scss/box.scss
+++ b/apps/desktop/src/scss/box.scss
@@ -217,7 +217,8 @@
}
}
- .img-right {
+ .img-right,
+ .txt-right {
float: right;
margin-left: 10px;
}
diff --git a/apps/web/src/app/tools/generator.component.html b/apps/web/src/app/tools/generator.component.html
index 2e6d6d0effd..3a079bcac5a 100644
--- a/apps/web/src/app/tools/generator.component.html
+++ b/apps/web/src/app/tools/generator.component.html
@@ -109,13 +109,31 @@
id="length"
class="form-control"
type="number"
- min="5"
+ [min]="passwordOptions.minLength"
max="128"
[(ngModel)]="passwordOptions.length"
(blur)="savePasswordOptions()"
(change)="lengthChanged()"
/>
+
+
+
+
+ {{ passwordOptionsMinLengthForReader$ | async }}
+
+
@@ -137,8 +155,8 @@
type="number"
min="0"
max="9"
- (blur)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
+ (input)="onPasswordOptionsMinSpecialInput($event)"
(change)="minSpecialChanged()"
/>
@@ -175,7 +193,8 @@
class="form-check-input"
type="checkbox"
(change)="savePasswordOptions()"
- [(ngModel)]="passwordOptions.number"
+ [ngModel]="passwordOptions.number"
+ (ngModelChange)="setPasswordOptionsNumber($event)"
[disabled]="enforcedPasswordPolicyOptions?.useNumbers"
attr.aria-label="{{ 'numbers' | i18n }}"
/>
@@ -186,8 +205,8 @@
id="special"
class="form-check-input"
type="checkbox"
- (change)="savePasswordOptions()"
- [(ngModel)]="passwordOptions.special"
+ [ngModel]="passwordOptions.special"
+ (ngModelChange)="setPasswordOptionsSpecial($event)"
[disabled]="enforcedPasswordPolicyOptions?.useSpecial"
attr.aria-label="{{ 'specialCharacters' | i18n }}"
/>
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 236ba76cfe2..0ec3b27cc38 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -1150,6 +1150,9 @@
"length": {
"message": "Length"
},
+ "passwordMinLength": {
+ "message": "Minimum password length"
+ },
"uppercase": {
"message": "Uppercase (A-Z)",
"description": "Include uppercase letters in the password generator."
diff --git a/libs/angular/src/tools/generator/components/generator.component.ts b/libs/angular/src/tools/generator/components/generator.component.ts
index 36af4868b0c..35ec0b2c5a2 100644
--- a/libs/angular/src/tools/generator/components/generator.component.ts
+++ b/libs/angular/src/tools/generator/components/generator.component.ts
@@ -1,6 +1,7 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
-import { first } from "rxjs/operators";
+import { BehaviorSubject } from "rxjs";
+import { debounceTime, first, map } from "rxjs/operators";
import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -12,6 +13,7 @@ import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/common/tools/generator/password";
+import { DefaultBoundaries } from "@bitwarden/common/tools/generator/password/password-generator-options-evaluator";
import {
UsernameGenerationServiceAbstraction,
UsernameGeneratorOptions,
@@ -40,6 +42,16 @@ export class GeneratorComponent implements OnInit {
enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions;
usernameWebsite: string = null;
+ // update screen reader minimum password length with 500ms debounce
+ // so that the user isn't flooded with status updates
+ private _passwordOptionsMinLengthForReader = new BehaviorSubject(
+ DefaultBoundaries.length.min,
+ );
+ protected passwordOptionsMinLengthForReader$ = this._passwordOptionsMinLengthForReader.pipe(
+ map((val) => val || DefaultBoundaries.length.min),
+ debounceTime(500),
+ );
+
constructor(
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected usernameGenerationService: UsernameGenerationServiceAbstraction,
@@ -144,6 +156,44 @@ export class GeneratorComponent implements OnInit {
await this.passwordGenerationService.addHistory(this.password);
}
+ async onPasswordOptionsMinNumberInput($event: Event) {
+ // `savePasswordOptions()` replaces the null
+ this.passwordOptions.number = null;
+
+ await this.savePasswordOptions();
+
+ // fixes UI desync that occurs when minNumber has a fixed value
+ // that is reset through normalization
+ ($event.target as HTMLInputElement).value = `${this.passwordOptions.minNumber}`;
+ }
+
+ async setPasswordOptionsNumber($event: boolean) {
+ this.passwordOptions.number = $event;
+ // `savePasswordOptions()` replaces the null
+ this.passwordOptions.minNumber = null;
+
+ await this.savePasswordOptions();
+ }
+
+ async onPasswordOptionsMinSpecialInput($event: Event) {
+ // `savePasswordOptions()` replaces the null
+ this.passwordOptions.special = null;
+
+ await this.savePasswordOptions();
+
+ // fixes UI desync that occurs when minSpecial has a fixed value
+ // that is reset through normalization
+ ($event.target as HTMLInputElement).value = `${this.passwordOptions.minSpecial}`;
+ }
+
+ async setPasswordOptionsSpecial($event: boolean) {
+ this.passwordOptions.special = $event;
+ // `savePasswordOptions()` replaces the null
+ this.passwordOptions.minSpecial = null;
+
+ await this.savePasswordOptions();
+ }
+
async sliderInput() {
this.normalizePasswordOptions();
this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions);
@@ -240,6 +290,8 @@ export class GeneratorComponent implements OnInit {
this.passwordOptions,
this.enforcedPasswordPolicyOptions,
);
+
+ this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength);
}
private async initForwardOptions() {
diff --git a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts
index 858b9fd8829..9d2e7eadd59 100644
--- a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts
+++ b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts
@@ -1,18 +1,73 @@
import Domain from "../../../platform/models/domain/domain-base";
+/** Enterprise policy for the password generator.
+ * @see PolicyType.PasswordGenerator
+ */
export class PasswordGeneratorPolicyOptions extends Domain {
- defaultType = "";
+ /** The default kind of credential to generate */
+ defaultType: "password" | "passphrase" | "" = "";
+
+ /** The minimum length of generated passwords.
+ * When this is less than or equal to zero, it is ignored.
+ * If this is less than the total number of characters required by
+ * the policy's other settings, then it is ignored.
+ * This field is not used for passphrases.
+ */
minLength = 0;
+
+ /** When this is true, an uppercase character must be part of
+ * the generated password.
+ * This field is not used for passphrases.
+ */
useUppercase = false;
+
+ /** When this is true, a lowercase character must be part of
+ * the generated password. This field is not used for passphrases.
+ */
useLowercase = false;
+
+ /** When this is true, at least one digit must be part of the generated
+ * password. This field is not used for passphrases.
+ */
useNumbers = false;
+
+ /** The quantity of digits to include in the generated password.
+ * When this is less than or equal to zero, it is ignored.
+ * This field is not used for passphrases.
+ */
numberCount = 0;
+
+ /** When this is true, at least one digit must be part of the generated
+ * password. This field is not used for passphrases.
+ */
useSpecial = false;
+
+ /** The quantity of special characters to include in the generated
+ * password. When this is less than or equal to zero, it is ignored.
+ * This field is not used for passphrases.
+ */
specialCount = 0;
+
+ /** The minimum number of words required by generated passphrases.
+ * This field is not used for passwords.
+ */
minNumberWords = 0;
+
+ /** When this is true, the first letter of each word in the passphrase
+ * is capitalized. This field is not used for passwords.
+ */
capitalize = false;
+
+ /** When this is true, a number is included within the passphrase.
+ * This field is not used for passwords.
+ */
includeNumber = false;
+ /** Checks whether the policy affects the password generator.
+ * @returns True if at least one password or passphrase requirement has been set.
+ * If it returns False, then no requirements have been set and the policy should
+ * not be enforced.
+ */
inEffect() {
return (
this.defaultType !== "" ||
@@ -28,4 +83,12 @@ export class PasswordGeneratorPolicyOptions extends Domain {
this.includeNumber
);
}
+
+ /** Creates a copy of the policy.
+ */
+ clone() {
+ const policy = new PasswordGeneratorPolicyOptions();
+ Object.assign(policy, this);
+ return policy;
+ }
}
diff --git a/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts b/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts
new file mode 100644
index 00000000000..c72a190f799
--- /dev/null
+++ b/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts
@@ -0,0 +1,220 @@
+import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
+
+import {
+ DefaultBoundaries,
+ PassphraseGeneratorOptionsEvaluator,
+} from "./passphrase-generator-options-evaluator";
+import { PassphraseGenerationOptions } from "./password-generator-options";
+
+describe("Password generator options builder", () => {
+ describe("constructor()", () => {
+ it("should set the policy object to a copy of the input policy", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.minLength = 10; // arbitrary change for deep equality check
+
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+
+ expect(builder.policy).toEqual(policy);
+ expect(builder.policy).not.toBe(policy);
+ });
+
+ it("should set default boundaries when a default policy is used", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+
+ expect(builder.numWords).toEqual(DefaultBoundaries.numWords);
+ });
+
+ it.each([1, 2])(
+ "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)",
+ (minNumberWords) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.minNumberWords = minNumberWords;
+
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+
+ expect(builder.numWords).toEqual(DefaultBoundaries.numWords);
+ },
+ );
+
+ it.each([8, 12, 18])(
+ "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words",
+ (minNumberWords) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.minNumberWords = minNumberWords;
+
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+
+ expect(builder.numWords.min).toEqual(minNumberWords);
+ expect(builder.numWords.max).toEqual(DefaultBoundaries.numWords.max);
+ },
+ );
+
+ it.each([150, 300, 9000])(
+ "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries",
+ (minNumberWords) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.minNumberWords = minNumberWords;
+
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+
+ expect(builder.numWords.min).toEqual(minNumberWords);
+ expect(builder.numWords.max).toEqual(minNumberWords);
+ },
+ );
+ });
+
+ describe("applyPolicy(options)", () => {
+ // All tests should freeze the options to ensure they are not modified
+
+ it("should set `capitalize` to `false` when the policy does not override it", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({});
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.capitalize).toBe(false);
+ });
+
+ it("should set `capitalize` to `true` when the policy overrides it", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.capitalize = true;
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ capitalize: false });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.capitalize).toBe(true);
+ });
+
+ it("should set `includeNumber` to false when the policy does not override it", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({});
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.includeNumber).toBe(false);
+ });
+
+ it("should set `includeNumber` to true when the policy overrides it", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.includeNumber = true;
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ includeNumber: false });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.includeNumber).toBe(true);
+ });
+
+ it("should set `numWords` to the minimum value when it isn't supplied", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({});
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
+ });
+
+ it.each([1, 2])(
+ "should set `numWords` (= %i) to the minimum value when it is less than the minimum",
+ (numWords) => {
+ expect(numWords).toBeLessThan(DefaultBoundaries.numWords.min);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ numWords });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
+ },
+ );
+
+ it.each([3, 8, 18, 20])(
+ "should set `numWords` (= %i) to the input value when it is within the boundaries",
+ (numWords) => {
+ expect(numWords).toBeGreaterThanOrEqual(DefaultBoundaries.numWords.min);
+ expect(numWords).toBeLessThanOrEqual(DefaultBoundaries.numWords.max);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ numWords });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.numWords).toBe(numWords);
+ },
+ );
+
+ it.each([21, 30, 50, 100])(
+ "should set `numWords` (= %i) to the maximum value when it is greater than the maximum",
+ (numWords) => {
+ expect(numWords).toBeGreaterThan(DefaultBoundaries.numWords.max);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ numWords });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.numWords).toBe(builder.numWords.max);
+ },
+ );
+
+ it("should preserve unknown properties", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({
+ unknown: "property",
+ another: "unknown property",
+ }) as PassphraseGenerationOptions;
+
+ const sanitizedOptions: any = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.unknown).toEqual("property");
+ expect(sanitizedOptions.another).toEqual("unknown property");
+ });
+ });
+
+ describe("sanitize(options)", () => {
+ // All tests should freeze the options to ensure they are not modified
+
+ it("should return the input options without altering them", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ wordSeparator: "%" });
+
+ const sanitizedOptions = builder.sanitize(options);
+
+ expect(sanitizedOptions).toEqual(options);
+ });
+
+ it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({});
+
+ const sanitizedOptions = builder.sanitize(options);
+
+ expect(sanitizedOptions.wordSeparator).toEqual("-");
+ });
+
+ it("should preserve unknown properties", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PassphraseGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({
+ unknown: "property",
+ another: "unknown property",
+ }) as PassphraseGenerationOptions;
+
+ const sanitizedOptions: any = builder.sanitize(options);
+
+ expect(sanitizedOptions.unknown).toEqual("property");
+ expect(sanitizedOptions.another).toEqual("unknown property");
+ });
+ });
+});
diff --git a/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts b/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts
new file mode 100644
index 00000000000..8cfdce4b094
--- /dev/null
+++ b/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts
@@ -0,0 +1,105 @@
+import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
+
+import { PassphraseGenerationOptions } from "./password-generator-options";
+
+type Boundary = {
+ readonly min: number;
+ readonly max: number;
+};
+
+function initializeBoundaries() {
+ const numWords = Object.freeze({
+ min: 3,
+ max: 20,
+ });
+
+ return Object.freeze({
+ numWords,
+ });
+}
+
+/** Immutable default boundaries for passphrase generation.
+ * These are used when the policy does not override a value.
+ */
+export const DefaultBoundaries = initializeBoundaries();
+
+/** Enforces policy for passphrase generation options.
+ */
+export class PassphraseGeneratorOptionsEvaluator {
+ // This design is not ideal, but it is a step towards a more robust passphrase
+ // generator. Ideally, `sanitize` would be implemented on an options class,
+ // and `applyPolicy` would be implemented on a policy class, "mise en place".
+ //
+ // The current design of the passphrase generator, unfortunately, would require
+ // a substantial rewrite to make this feasible. Hopefully this change can be
+ // applied when the passphrase generator is ported to rust.
+
+ /** Policy applied by the evaluator.
+ */
+ readonly policy: PasswordGeneratorPolicyOptions;
+
+ /** Boundaries for the number of words allowed in the password.
+ */
+ readonly numWords: Boundary;
+
+ /** Instantiates the evaluator.
+ * @param policy The policy applied by the evaluator. When this conflicts with
+ * the defaults, the policy takes precedence.
+ */
+ constructor(policy: PasswordGeneratorPolicyOptions) {
+ function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
+ const boundary = {
+ min: Math.max(defaultBoundary.min, value),
+ max: Math.max(defaultBoundary.max, value),
+ };
+
+ return boundary;
+ }
+
+ this.policy = policy.clone();
+ this.numWords = createBoundary(policy.minNumberWords, DefaultBoundaries.numWords);
+ }
+
+ /** Apply policy to the input options.
+ * @param options The options to build from. These options are not altered.
+ * @returns A new password generation request with policy applied.
+ */
+ applyPolicy(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
+ function fitToBounds(value: number, boundaries: Boundary) {
+ const { min, max } = boundaries;
+
+ const withUpperBound = Math.min(value ?? boundaries.min, max);
+ const withLowerBound = Math.max(withUpperBound, min);
+
+ return withLowerBound;
+ }
+
+ // apply policy overrides
+ const capitalize = this.policy.capitalize || options.capitalize || false;
+ const includeNumber = this.policy.includeNumber || options.includeNumber || false;
+
+ // apply boundaries
+ const numWords = fitToBounds(options.numWords, this.numWords);
+
+ return {
+ ...options,
+ numWords,
+ capitalize,
+ includeNumber,
+ };
+ }
+
+ /** Ensures internal options consistency.
+ * @param options The options to cascade. These options are not altered.
+ * @returns A passphrase generation request with cascade applied.
+ */
+ sanitize(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
+ // ensure words are separated by a single character
+ const wordSeparator = options.wordSeparator?.[0] ?? "-";
+
+ return {
+ ...options,
+ wordSeparator,
+ };
+ }
+}
diff --git a/libs/common/src/tools/generator/password/password-generation.service.ts b/libs/common/src/tools/generator/password/password-generation.service.ts
index aa77502f758..e497126cd30 100644
--- a/libs/common/src/tools/generator/password/password-generation.service.ts
+++ b/libs/common/src/tools/generator/password/password-generation.service.ts
@@ -7,11 +7,14 @@ import { EFFLongWordList } from "../../../platform/misc/wordlist";
import { EncString } from "../../../platform/models/domain/enc-string";
import { GeneratedPasswordHistory } from "./generated-password-history";
+import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
import { PasswordGeneratorOptions } from "./password-generator-options";
+import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
const DefaultOptions: PasswordGeneratorOptions = {
length: 14,
+ minLength: 5,
ambiguous: false,
number: true,
minNumber: 1,
@@ -28,6 +31,8 @@ const DefaultOptions: PasswordGeneratorOptions = {
includeNumber: false,
};
+const DefaultPolicy = new PasswordGeneratorPolicyOptions();
+
const MaxPasswordsInHistory = 100;
export class PasswordGenerationService implements PasswordGenerationServiceAbstraction {
@@ -38,20 +43,12 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
) {}
async generatePassword(options: PasswordGeneratorOptions): Promise {
- // overload defaults with given options
- const o = Object.assign({}, DefaultOptions, options);
-
- if (o.type === "passphrase") {
- return this.generatePassphrase(options);
+ if ((options.type ?? DefaultOptions.type) === "passphrase") {
+ return this.generatePassphrase({ ...DefaultOptions, ...options });
}
- // sanitize
- this.sanitizePasswordLength(o, true);
-
- const minLength: number = o.minUppercase + o.minLowercase + o.minNumber + o.minSpecial;
- if (o.length < minLength) {
- o.length = minLength;
- }
+ const evaluator = new PasswordGeneratorOptionsEvaluator(DefaultPolicy);
+ const o = evaluator.sanitize({ ...DefaultOptions, ...options });
const positions: string[] = [];
if (o.lowercase && o.minLowercase > 0) {
@@ -144,7 +141,8 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
}
async generatePassphrase(options: PasswordGeneratorOptions): Promise {
- const o = Object.assign({}, DefaultOptions, options);
+ const evaluator = new PassphraseGeneratorOptionsEvaluator(DefaultPolicy);
+ const o = evaluator.sanitize({ ...DefaultOptions, ...options });
if (o.numWords == null || o.numWords <= 2) {
o.numWords = DefaultOptions.numWords;
@@ -192,65 +190,25 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
async enforcePasswordGeneratorPoliciesOnOptions(
options: PasswordGeneratorOptions,
): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> {
- let enforcedPolicyOptions = await this.getPasswordGeneratorPolicyOptions();
- if (enforcedPolicyOptions != null) {
- if (options.length < enforcedPolicyOptions.minLength) {
- options.length = enforcedPolicyOptions.minLength;
- }
+ let policy = await this.getPasswordGeneratorPolicyOptions();
+ policy = policy ?? new PasswordGeneratorPolicyOptions();
- if (enforcedPolicyOptions.useUppercase) {
- options.uppercase = true;
- }
-
- if (enforcedPolicyOptions.useLowercase) {
- options.lowercase = true;
- }
-
- if (enforcedPolicyOptions.useNumbers) {
- options.number = true;
- }
-
- if (options.minNumber < enforcedPolicyOptions.numberCount) {
- options.minNumber = enforcedPolicyOptions.numberCount;
- }
-
- if (enforcedPolicyOptions.useSpecial) {
- options.special = true;
- }
-
- if (options.minSpecial < enforcedPolicyOptions.specialCount) {
- options.minSpecial = enforcedPolicyOptions.specialCount;
- }
-
- // Must normalize these fields because the receiving call expects all options to pass the current rules
- if (options.minSpecial + options.minNumber > options.length) {
- options.minSpecial = options.length - options.minNumber;
- }
-
- if (options.numWords < enforcedPolicyOptions.minNumberWords) {
- options.numWords = enforcedPolicyOptions.minNumberWords;
- }
-
- if (enforcedPolicyOptions.capitalize) {
- options.capitalize = true;
- }
-
- if (enforcedPolicyOptions.includeNumber) {
- options.includeNumber = true;
- }
-
- // Force default type if password/passphrase selected via policy
- if (
- enforcedPolicyOptions.defaultType === "password" ||
- enforcedPolicyOptions.defaultType === "passphrase"
- ) {
- options.type = enforcedPolicyOptions.defaultType;
- }
- } else {
- // UI layer expects an instantiated object to prevent more explicit null checks
- enforcedPolicyOptions = new PasswordGeneratorPolicyOptions();
+ // Force default type if password/passphrase selected via policy
+ if (policy.defaultType === "password" || policy.defaultType === "passphrase") {
+ options.type = policy.defaultType;
}
- return [options, enforcedPolicyOptions];
+
+ const evaluator = options.type
+ ? new PasswordGeneratorOptionsEvaluator(policy)
+ : new PassphraseGeneratorOptionsEvaluator(policy);
+
+ // Ensure the options to pass the current rules
+ const withPolicy = evaluator.applyPolicy(options);
+ const sanitized = evaluator.sanitize(withPolicy);
+
+ // callers assume this function updates the options parameter
+ const result = Object.assign(options, sanitized);
+ return [result, policy];
}
async getPasswordGeneratorPolicyOptions(): Promise {
@@ -389,62 +347,17 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
options: PasswordGeneratorOptions,
enforcedPolicyOptions: PasswordGeneratorPolicyOptions,
) {
- options.minLowercase = 0;
- options.minUppercase = 0;
+ const evaluator = options.type
+ ? new PasswordGeneratorOptionsEvaluator(enforcedPolicyOptions)
+ : new PassphraseGeneratorOptionsEvaluator(enforcedPolicyOptions);
- if (!options.length || options.length < 5) {
- options.length = 5;
- } else if (options.length > 128) {
- options.length = 128;
- }
+ const evaluatedOptions = evaluator.applyPolicy(options);
+ const santizedOptions = evaluator.sanitize(evaluatedOptions);
- if (options.length < enforcedPolicyOptions.minLength) {
- options.length = enforcedPolicyOptions.minLength;
- }
+ // callers assume this function updates the options parameter
+ Object.assign(options, santizedOptions);
- if (!options.minNumber) {
- options.minNumber = 0;
- } else if (options.minNumber > options.length) {
- options.minNumber = options.length;
- } else if (options.minNumber > 9) {
- options.minNumber = 9;
- }
-
- if (options.minNumber < enforcedPolicyOptions.numberCount) {
- options.minNumber = enforcedPolicyOptions.numberCount;
- }
-
- if (!options.minSpecial) {
- options.minSpecial = 0;
- } else if (options.minSpecial > options.length) {
- options.minSpecial = options.length;
- } else if (options.minSpecial > 9) {
- options.minSpecial = 9;
- }
-
- if (options.minSpecial < enforcedPolicyOptions.specialCount) {
- options.minSpecial = enforcedPolicyOptions.specialCount;
- }
-
- if (options.minSpecial + options.minNumber > options.length) {
- options.minSpecial = options.length - options.minNumber;
- }
-
- if (options.numWords == null || options.length < 3) {
- options.numWords = 3;
- } else if (options.numWords > 20) {
- options.numWords = 20;
- }
-
- if (options.numWords < enforcedPolicyOptions.minNumberWords) {
- options.numWords = enforcedPolicyOptions.minNumberWords;
- }
-
- if (options.wordSeparator != null && options.wordSeparator.length > 1) {
- options.wordSeparator = options.wordSeparator[0];
- }
-
- this.sanitizePasswordLength(options, false);
+ return options;
}
private capitalize(str: string) {
@@ -505,54 +418,4 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
[array[i], array[j]] = [array[j], array[i]];
}
}
-
- private sanitizePasswordLength(options: any, forGeneration: boolean) {
- let minUppercaseCalc = 0;
- let minLowercaseCalc = 0;
- let minNumberCalc: number = options.minNumber;
- let minSpecialCalc: number = options.minSpecial;
-
- if (options.uppercase && options.minUppercase <= 0) {
- minUppercaseCalc = 1;
- } else if (!options.uppercase) {
- minUppercaseCalc = 0;
- }
-
- if (options.lowercase && options.minLowercase <= 0) {
- minLowercaseCalc = 1;
- } else if (!options.lowercase) {
- minLowercaseCalc = 0;
- }
-
- if (options.number && options.minNumber <= 0) {
- minNumberCalc = 1;
- } else if (!options.number) {
- minNumberCalc = 0;
- }
-
- if (options.special && options.minSpecial <= 0) {
- minSpecialCalc = 1;
- } else if (!options.special) {
- minSpecialCalc = 0;
- }
-
- // This should never happen but is a final safety net
- if (!options.length || options.length < 1) {
- options.length = 10;
- }
-
- const minLength: number = minUppercaseCalc + minLowercaseCalc + minNumberCalc + minSpecialCalc;
- // Normalize and Generation both require this modification
- if (options.length < minLength) {
- options.length = minLength;
- }
-
- // Apply other changes if the options object passed in is for generation
- if (forGeneration) {
- options.minUppercase = minUppercaseCalc;
- options.minLowercase = minLowercaseCalc;
- options.minNumber = minNumberCalc;
- options.minSpecial = minSpecialCalc;
- }
- }
}
diff --git a/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts b/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts
new file mode 100644
index 00000000000..e1ca854eb90
--- /dev/null
+++ b/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts
@@ -0,0 +1,703 @@
+import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
+
+import { PasswordGenerationOptions } from "./password-generator-options";
+import {
+ DefaultBoundaries,
+ PasswordGeneratorOptionsEvaluator,
+} from "./password-generator-options-evaluator";
+
+describe("Password generator options builder", () => {
+ const defaultOptions = Object.freeze({ minLength: 0 });
+
+ describe("constructor()", () => {
+ it("should set the policy object to a copy of the input policy", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.minLength = 10; // arbitrary change for deep equality check
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.policy).toEqual(policy);
+ expect(builder.policy).not.toBe(policy);
+ });
+
+ it("should set default boundaries when a default policy is used", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.length).toEqual(DefaultBoundaries.length);
+ expect(builder.minDigits).toEqual(DefaultBoundaries.minDigits);
+ expect(builder.minSpecialCharacters).toEqual(DefaultBoundaries.minSpecialCharacters);
+ });
+
+ it.each([1, 2, 3, 4])(
+ "should use the default length boundaries when they are greater than `policy.minLength` (= %i)",
+ (minLength) => {
+ expect(minLength).toBeLessThan(DefaultBoundaries.length.min);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.minLength = minLength;
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.length).toEqual(DefaultBoundaries.length);
+ },
+ );
+
+ it.each([8, 20, 100])(
+ "should use `policy.minLength` (= %i) when it is greater than the default minimum length",
+ (expectedLength) => {
+ expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.min);
+ expect(expectedLength).toBeLessThanOrEqual(DefaultBoundaries.length.max);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.minLength = expectedLength;
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.length.min).toEqual(expectedLength);
+ expect(builder.length.max).toEqual(DefaultBoundaries.length.max);
+ },
+ );
+
+ it.each([150, 300, 9000])(
+ "should use `policy.minLength` (= %i) when it is greater than the default boundaries",
+ (expectedLength) => {
+ expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.max);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.minLength = expectedLength;
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.length.min).toEqual(expectedLength);
+ expect(builder.length.max).toEqual(expectedLength);
+ },
+ );
+
+ it.each([3, 5, 8, 9])(
+ "should use `policy.numberCount` (= %i) when it is greater than the default minimum digits",
+ (expectedMinDigits) => {
+ expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.min);
+ expect(expectedMinDigits).toBeLessThanOrEqual(DefaultBoundaries.minDigits.max);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.numberCount = expectedMinDigits;
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.minDigits.min).toEqual(expectedMinDigits);
+ expect(builder.minDigits.max).toEqual(DefaultBoundaries.minDigits.max);
+ },
+ );
+
+ it.each([10, 20, 400])(
+ "should use `policy.numberCount` (= %i) when it is greater than the default digit boundaries",
+ (expectedMinDigits) => {
+ expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.max);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.numberCount = expectedMinDigits;
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.minDigits.min).toEqual(expectedMinDigits);
+ expect(builder.minDigits.max).toEqual(expectedMinDigits);
+ },
+ );
+
+ it.each([2, 4, 6])(
+ "should use `policy.specialCount` (= %i) when it is greater than the default minimum special characters",
+ (expectedSpecialCharacters) => {
+ expect(expectedSpecialCharacters).toBeGreaterThan(
+ DefaultBoundaries.minSpecialCharacters.min,
+ );
+ expect(expectedSpecialCharacters).toBeLessThanOrEqual(
+ DefaultBoundaries.minSpecialCharacters.max,
+ );
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.specialCount = expectedSpecialCharacters;
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
+ expect(builder.minSpecialCharacters.max).toEqual(
+ DefaultBoundaries.minSpecialCharacters.max,
+ );
+ },
+ );
+
+ it.each([10, 20, 400])(
+ "should use `policy.specialCount` (= %i) when it is greater than the default special characters boundaries",
+ (expectedSpecialCharacters) => {
+ expect(expectedSpecialCharacters).toBeGreaterThan(
+ DefaultBoundaries.minSpecialCharacters.max,
+ );
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.specialCount = expectedSpecialCharacters;
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
+ expect(builder.minSpecialCharacters.max).toEqual(expectedSpecialCharacters);
+ },
+ );
+
+ it.each([
+ [8, 6, 2],
+ [6, 2, 4],
+ [16, 8, 8],
+ ])(
+ "should ensure the minimum length (= %i) is at least the sum of minimums (= %i + %i)",
+ (expectedLength, numberCount, specialCount) => {
+ expect(expectedLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.numberCount = numberCount;
+ policy.specialCount = specialCount;
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+
+ expect(builder.length.min).toBeGreaterThanOrEqual(expectedLength);
+ },
+ );
+ });
+
+ describe("applyPolicy(options)", () => {
+ // All tests should freeze the options to ensure they are not modified
+
+ it.each([
+ [false, false],
+ [true, true],
+ [false, undefined],
+ ])(
+ "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'",
+ (expectedUppercase, uppercase) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.useUppercase = false;
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, uppercase });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.uppercase).toEqual(expectedUppercase);
+ },
+ );
+
+ it.each([false, true, undefined])(
+ "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true",
+ (uppercase) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.useUppercase = true;
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, uppercase });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.uppercase).toEqual(true);
+ },
+ );
+
+ it.each([
+ [false, false],
+ [true, true],
+ [false, undefined],
+ ])(
+ "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'",
+ (expectedLowercase, lowercase) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.useLowercase = false;
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, lowercase });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.lowercase).toEqual(expectedLowercase);
+ },
+ );
+
+ it.each([false, true, undefined])(
+ "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true",
+ (lowercase) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.useLowercase = true;
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, lowercase });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.lowercase).toEqual(true);
+ },
+ );
+
+ it.each([
+ [false, false],
+ [true, true],
+ [false, undefined],
+ ])(
+ "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'",
+ (expectedNumber, number) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.useNumbers = false;
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, number });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.number).toEqual(expectedNumber);
+ },
+ );
+
+ it.each([false, true, undefined])(
+ "should set `options.number` (= %s) to true when `policy.useNumbers` is true",
+ (number) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.useNumbers = true;
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, number });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.number).toEqual(true);
+ },
+ );
+
+ it.each([
+ [false, false],
+ [true, true],
+ [false, undefined],
+ ])(
+ "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'",
+ (expectedSpecial, special) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.useSpecial = false;
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, special });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.special).toEqual(expectedSpecial);
+ },
+ );
+
+ it.each([false, true, undefined])(
+ "should set `options.special` (= %s) to true when `policy.useSpecial` is true",
+ (special) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.useSpecial = true;
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, special });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.special).toEqual(true);
+ },
+ );
+
+ it.each([1, 2, 3, 4])(
+ "should set `options.length` (= %i) to the minimum it is less than the minimum length",
+ (length) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ expect(length).toBeLessThan(builder.length.min);
+
+ const options = Object.freeze({ ...defaultOptions, length });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.length).toEqual(builder.length.min);
+ },
+ );
+
+ it.each([5, 10, 50, 100, 128])(
+ "should not change `options.length` (= %i) when it is within the boundaries",
+ (length) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ expect(length).toBeGreaterThanOrEqual(builder.length.min);
+ expect(length).toBeLessThanOrEqual(builder.length.max);
+
+ const options = Object.freeze({ ...defaultOptions, length });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.length).toEqual(length);
+ },
+ );
+
+ it.each([129, 500, 9000])(
+ "should set `options.length` (= %i) to the maximum length when it is exceeded",
+ (length) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ expect(length).toBeGreaterThan(builder.length.max);
+
+ const options = Object.freeze({ ...defaultOptions, length });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.length).toEqual(builder.length.max);
+ },
+ );
+
+ it.each([
+ [true, 1],
+ [true, 3],
+ [true, 600],
+ [false, 0],
+ [false, -2],
+ [false, -600],
+ ])(
+ "should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0",
+ (expectedNumber, minNumber) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, minNumber });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.number).toEqual(expectedNumber);
+ },
+ );
+
+ it("should set `options.minNumber` to the minimum value when `options.number` is true", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, number: true });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
+ });
+
+ it("should set `options.minNumber` to 0 when `options.number` is false", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, number: false });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minNumber).toEqual(0);
+ });
+
+ it.each([1, 2, 3, 4])(
+ "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number",
+ (minNumber) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.numberCount = 5; // arbitrary value greater than minNumber
+ expect(minNumber).toBeLessThan(policy.numberCount);
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, minNumber });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
+ },
+ );
+
+ it.each([1, 3, 5, 7, 9])(
+ "should not change `options.minNumber` (= %i) when it is within the boundaries",
+ (minNumber) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min);
+ expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max);
+
+ const options = Object.freeze({ ...defaultOptions, minNumber });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minNumber).toEqual(minNumber);
+ },
+ );
+
+ it.each([10, 20, 400])(
+ "should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded",
+ (minNumber) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ expect(minNumber).toBeGreaterThan(builder.minDigits.max);
+
+ const options = Object.freeze({ ...defaultOptions, minNumber });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.max);
+ },
+ );
+
+ it.each([
+ [true, 1],
+ [true, 3],
+ [true, 600],
+ [false, 0],
+ [false, -2],
+ [false, -600],
+ ])(
+ "should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0",
+ (expectedSpecial, minSpecial) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, minSpecial });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.special).toEqual(expectedSpecial);
+ },
+ );
+
+ it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, special: true });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minSpecial).toEqual(builder.minDigits.min);
+ });
+
+ it("should set `options.minSpecial` to 0 when `options.special` is false", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, special: false });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minSpecial).toEqual(0);
+ });
+
+ it.each([1, 2, 3, 4])(
+ "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters",
+ (minSpecial) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ policy.specialCount = 5; // arbitrary value greater than minSpecial
+ expect(minSpecial).toBeLessThan(policy.specialCount);
+
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ ...defaultOptions, minSpecial });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.min);
+ },
+ );
+
+ it.each([1, 3, 5, 7, 9])(
+ "should not change `options.minSpecial` (= %i) when it is within the boundaries",
+ (minSpecial) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min);
+ expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max);
+
+ const options = Object.freeze({ ...defaultOptions, minSpecial });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minSpecial).toEqual(minSpecial);
+ },
+ );
+
+ it.each([10, 20, 400])(
+ "should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded",
+ (minSpecial) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max);
+
+ const options = Object.freeze({ ...defaultOptions, minSpecial });
+
+ const sanitizedOptions = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.max);
+ },
+ );
+
+ it("should preserve unknown properties", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({
+ unknown: "property",
+ another: "unknown property",
+ }) as PasswordGenerationOptions;
+
+ const sanitizedOptions: any = builder.applyPolicy(options);
+
+ expect(sanitizedOptions.unknown).toEqual("property");
+ expect(sanitizedOptions.another).toEqual("unknown property");
+ });
+ });
+
+ describe("sanitize(options)", () => {
+ // All tests should freeze the options to ensure they are not modified
+
+ it.each([
+ [1, true],
+ [0, false],
+ ])(
+ "should output `options.minLowercase === %i` when `options.lowercase` is %s",
+ (expectedMinLowercase, lowercase) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ lowercase, ...defaultOptions });
+
+ const actual = builder.sanitize(options);
+
+ expect(actual.minLowercase).toEqual(expectedMinLowercase);
+ },
+ );
+
+ it.each([
+ [1, true],
+ [0, false],
+ ])(
+ "should output `options.minUppercase === %i` when `options.uppercase` is %s",
+ (expectedMinUppercase, uppercase) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ uppercase, ...defaultOptions });
+
+ const actual = builder.sanitize(options);
+
+ expect(actual.minUppercase).toEqual(expectedMinUppercase);
+ },
+ );
+
+ it.each([
+ [1, true],
+ [0, false],
+ ])(
+ "should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set",
+ (expectedMinNumber, number) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ number, ...defaultOptions });
+
+ const actual = builder.sanitize(options);
+
+ expect(actual.minNumber).toEqual(expectedMinNumber);
+ },
+ );
+
+ it.each([
+ [true, 3],
+ [true, 2],
+ [true, 1],
+ [false, 0],
+ ])(
+ "should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set",
+ (expectedNumber, minNumber) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ minNumber, ...defaultOptions });
+
+ const actual = builder.sanitize(options);
+
+ expect(actual.number).toEqual(expectedNumber);
+ },
+ );
+
+ it.each([
+ [true, 1],
+ [false, 0],
+ ])(
+ "should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set",
+ (special, expectedMinSpecial) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ special, ...defaultOptions });
+
+ const actual = builder.sanitize(options);
+
+ expect(actual.minSpecial).toEqual(expectedMinSpecial);
+ },
+ );
+
+ it.each([
+ [3, true],
+ [2, true],
+ [1, true],
+ [0, false],
+ ])(
+ "should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set",
+ (minSpecial, expectedSpecial) => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({ minSpecial, ...defaultOptions });
+
+ const actual = builder.sanitize(options);
+
+ expect(actual.special).toEqual(expectedSpecial);
+ },
+ );
+
+ it.each([
+ [0, 0, 0, 0],
+ [1, 1, 0, 0],
+ [0, 0, 1, 1],
+ [1, 1, 1, 1],
+ ])(
+ "should set `options.minLength` to the minimum boundary when the sum of minimums (%i + %i + %i + %i) is less than the default minimum length.",
+ (minLowercase, minUppercase, minNumber, minSpecial) => {
+ const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial;
+ expect(sumOfMinimums).toBeLessThan(DefaultBoundaries.length.min);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({
+ minLowercase,
+ minUppercase,
+ minNumber,
+ minSpecial,
+ ...defaultOptions,
+ });
+
+ const actual = builder.sanitize(options);
+
+ expect(actual.minLength).toEqual(builder.length.min);
+ },
+ );
+
+ it.each([
+ [12, 3, 3, 3, 3],
+ [8, 2, 2, 2, 2],
+ [9, 3, 3, 3, 0],
+ ])(
+ "should set `options.minLength === %i` to the sum of minimums (%i + %i + %i + %i) when the sum is at least the default minimum length.",
+ (expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => {
+ expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min);
+
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({
+ minLowercase,
+ minUppercase,
+ minNumber,
+ minSpecial,
+ ...defaultOptions,
+ });
+
+ const actual = builder.sanitize(options);
+
+ expect(actual.minLength).toEqual(expectedMinLength);
+ },
+ );
+
+ it("should preserve unknown properties", () => {
+ const policy = new PasswordGeneratorPolicyOptions();
+ const builder = new PasswordGeneratorOptionsEvaluator(policy);
+ const options = Object.freeze({
+ unknown: "property",
+ another: "unknown property",
+ }) as PasswordGenerationOptions;
+
+ const sanitizedOptions: any = builder.sanitize(options);
+
+ expect(sanitizedOptions.unknown).toEqual("property");
+ expect(sanitizedOptions.another).toEqual("unknown property");
+ });
+ });
+});
diff --git a/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts b/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts
new file mode 100644
index 00000000000..0b3aae57ab8
--- /dev/null
+++ b/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts
@@ -0,0 +1,179 @@
+import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
+
+import { PasswordGenerationOptions } from "./password-generator-options";
+
+function initializeBoundaries() {
+ const length = Object.freeze({
+ min: 5,
+ max: 128,
+ });
+
+ const minDigits = Object.freeze({
+ min: 0,
+ max: 9,
+ });
+
+ const minSpecialCharacters = Object.freeze({
+ min: 0,
+ max: 9,
+ });
+
+ return Object.freeze({
+ length,
+ minDigits,
+ minSpecialCharacters,
+ });
+}
+
+/** Immutable default boundaries for password generation.
+ * These are used when the policy does not override a value.
+ */
+export const DefaultBoundaries = initializeBoundaries();
+
+type Boundary = {
+ readonly min: number;
+ readonly max: number;
+};
+
+/** Enforces policy for password generation.
+ */
+export class PasswordGeneratorOptionsEvaluator {
+ // 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".
+ //
+ // The current design of the password generator, unfortunately, would require
+ // a substantial rewrite to make this feasible. Hopefully this change can be
+ // applied when the password generator is ported to rust.
+
+ /** Boundaries for the password length. This is always large enough
+ * to accommodate the minimum number of digits and special characters.
+ */
+ readonly length: Boundary;
+
+ /** Boundaries for the minimum number of digits allowed in the password.
+ */
+ readonly minDigits: Boundary;
+
+ /** Boundaries for the minimum number of special characters allowed
+ * in the password.
+ */
+ readonly minSpecialCharacters: Boundary;
+
+ /** Policy applied by the evaluator.
+ */
+ readonly policy: PasswordGeneratorPolicyOptions;
+
+ /** Instantiates the evaluator.
+ * @param policy The policy applied by the evaluator. When this conflicts with
+ * the defaults, the policy takes precedence.
+ */
+ constructor(policy: PasswordGeneratorPolicyOptions) {
+ function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
+ const boundary = {
+ min: Math.max(defaultBoundary.min, value),
+ max: Math.max(defaultBoundary.max, value),
+ };
+
+ return boundary;
+ }
+
+ this.policy = policy.clone();
+ this.minDigits = createBoundary(policy.numberCount, DefaultBoundaries.minDigits);
+ this.minSpecialCharacters = createBoundary(
+ policy.specialCount,
+ DefaultBoundaries.minSpecialCharacters,
+ );
+
+ // the overall length should be at least as long as the sum of the minimums
+ const minConsistentLength = this.minDigits.min + this.minSpecialCharacters.min;
+ const minPolicyLength = policy.minLength > 0 ? policy.minLength : DefaultBoundaries.length.min;
+ const minLength = Math.max(minPolicyLength, minConsistentLength, DefaultBoundaries.length.min);
+
+ this.length = {
+ min: minLength,
+ max: Math.max(DefaultBoundaries.length.max, minLength),
+ };
+ }
+
+ /** Apply policy to a set of options.
+ * @param options The options to build from. These options are not altered.
+ * @returns A complete password generation request with policy applied.
+ * @remarks This method only applies policy overrides.
+ * Pass the result to `sanitize` to ensure consistency.
+ */
+ applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions {
+ function fitToBounds(value: number, boundaries: Boundary) {
+ const { min, max } = boundaries;
+
+ const withUpperBound = Math.min(value || 0, max);
+ const withLowerBound = Math.max(withUpperBound, min);
+
+ return withLowerBound;
+ }
+
+ // apply policy overrides
+ const uppercase = this.policy.useUppercase || options.uppercase || false;
+ const lowercase = this.policy.useLowercase || options.lowercase || false;
+
+ // these overrides can cascade numeric fields to boolean fields
+ const number = this.policy.useNumbers || options.number || options.minNumber > 0;
+ const special = this.policy.useSpecial || options.special || options.minSpecial > 0;
+
+ // apply boundaries; the boundaries can cascade boolean fields to numeric fields
+ const length = fitToBounds(options.length, this.length);
+ const minNumber = fitToBounds(options.minNumber, this.minDigits);
+ const minSpecial = fitToBounds(options.minSpecial, this.minSpecialCharacters);
+
+ return {
+ ...options,
+ length,
+ uppercase,
+ lowercase,
+ number,
+ minNumber,
+ special,
+ minSpecial,
+ };
+ }
+
+ /** Ensures internal options consistency.
+ * @param options The options to cascade. These options are not altered.
+ * @returns A new password generation request with cascade applied.
+ * @remarks This method fills null and undefined values by looking at
+ * pairs of flags and values (e.g. `number` and `minNumber`). If the flag
+ * and value are inconsistent, the flag cascades to the value.
+ */
+ sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions {
+ function cascade(enabled: boolean, value: number): [boolean, number] {
+ const enabledResult = enabled ?? value > 0;
+ const valueResult = enabledResult ? value || 1 : 0;
+
+ return [enabledResult, valueResult];
+ }
+
+ const [lowercase, minLowercase] = cascade(options.lowercase, options.minLowercase);
+ const [uppercase, minUppercase] = cascade(options.uppercase, options.minUppercase);
+ const [number, minNumber] = cascade(options.number, options.minNumber);
+ const [special, minSpecial] = cascade(options.special, options.minSpecial);
+
+ // minimums can only increase the length
+ const minConsistentLength = minLowercase + minUppercase + minNumber + minSpecial;
+ const minLength = Math.max(minConsistentLength, this.length.min);
+ const length = Math.max(options.length ?? minLength, minLength);
+
+ return {
+ ...options,
+ length,
+ minLength,
+ lowercase,
+ minLowercase,
+ uppercase,
+ minUppercase,
+ number,
+ minNumber,
+ special,
+ minSpecial,
+ };
+ }
+}
diff --git a/libs/common/src/tools/generator/password/password-generator-options.ts b/libs/common/src/tools/generator/password/password-generator-options.ts
index 287a91659e4..0f55f8cbc7c 100644
--- a/libs/common/src/tools/generator/password/password-generator-options.ts
+++ b/libs/common/src/tools/generator/password/password-generator-options.ts
@@ -1,17 +1,105 @@
-export type PasswordGeneratorOptions = {
+/** Request format for credential generation.
+ * This type includes all properties suitable for reactive data binding.
+ */
+export type PasswordGeneratorOptions = PasswordGenerationOptions &
+ PassphraseGenerationOptions & {
+ /** The algorithm to use for credential generation.
+ * Properties on @see PasswordGenerationOptions should be processed
+ * only when `type === "password"`.
+ * Properties on @see PassphraseGenerationOptions should be processed
+ * only when `type === "passphrase"`.
+ */
+ type?: "password" | "passphrase";
+ };
+
+/** Request format for password credential generation.
+ * All members of this type may be `undefined` when the user is
+ * generating a passphrase.
+ */
+export type PasswordGenerationOptions = {
+ /** The length of the password selected by the user */
length?: number;
+
+ /** The minimum length of the password. This defaults to 5, and increases
+ * to ensure `minLength` is at least as large as the sum of the other minimums.
+ */
+ minLength?: number;
+
+ /** `true` when ambiguous characters may be included in the output.
+ * `false` when ambiguous characters should not be included in the output.
+ */
ambiguous?: boolean;
+
+ /** `true` when uppercase ASCII characters should be included in the output
+ * This value defaults to `false.
+ */
uppercase?: boolean;
+
+ /** The minimum number of uppercase characters to include in the output.
+ * The value is ignored when `uppercase` is `false`.
+ * The value defaults to 1 when `uppercase` is `true`.
+ */
minUppercase?: number;
+
+ /** `true` when lowercase ASCII characters should be included in the output.
+ * This value defaults to `false`.
+ */
lowercase?: boolean;
+
+ /** The minimum number of lowercase characters to include in the output.
+ * The value defaults to 1 when `lowercase` is `true`.
+ * The value defaults to 0 when `lowercase` is `false`.
+ */
minLowercase?: number;
+
+ /** Whether or not to include ASCII digits in the output
+ * This value defaults to `true` when `minNumber` is at least 1.
+ * This value defaults to `false` when `minNumber` is less than 1.
+ */
number?: boolean;
+
+ /** The minimum number of digits to include in the output.
+ * The value defaults to 1 when `number` is `true`.
+ * The value defaults to 0 when `number` is `false`.
+ */
minNumber?: number;
+
+ /** Whether or not to include special characters in the output.
+ * This value defaults to `true` when `minSpecial` is at least 1.
+ * This value defaults to `false` when `minSpecial` is less than 1.
+ */
special?: boolean;
+
+ /** The minimum number of special characters to include in the output.
+ * This value defaults to 1 when `special` is `true`.
+ * This value defaults to 0 when `special` is `false`.
+ */
minSpecial?: number;
- numWords?: number;
- wordSeparator?: string;
- capitalize?: boolean;
- includeNumber?: boolean;
- type?: "password" | "passphrase";
+};
+
+/** Request format for passphrase credential generation.
+ * The members of this type may be `undefined` when the user is
+ * generating a password.
+ */
+export type PassphraseGenerationOptions = {
+ /** The number of words to include in the passphrase.
+ * This value defaults to 4.
+ */
+ numWords?: number;
+
+ /** The ASCII separator character to use between words in the passphrase.
+ * This value defaults to a dash.
+ * If multiple characters appear in the string, only the first character is used.
+ */
+ wordSeparator?: string;
+
+ /** `true` when the first character of every word should be capitalized.
+ * This value defaults to `false`.
+ */
+ capitalize?: boolean;
+
+ /** `true` when a number should be included in the passphrase.
+ * This value defaults to `false`.
+ */
+ includeNumber?: boolean;
};