mirror of
https://github.com/bitwarden/browser
synced 2025-12-21 18:53:29 +00:00
[PM-5608] introduce passphrase generator strategy (#7690)
This commit is contained in:
@@ -1,220 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { EFFLongWordList } from "../../../platform/misc/wordlist";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { PassphraseGeneratorOptionsEvaluator } from "../passphrase/passphrase-generator-options-evaluator";
|
||||
|
||||
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";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PassphraseGenerationOptions } from "../passphrase/passphrase-generation-options";
|
||||
|
||||
import { PasswordGenerationOptions } from "./password-generation-options";
|
||||
|
||||
/** Request format for credential generation.
|
||||
@@ -13,30 +15,3 @@ export type PasswordGeneratorOptions = PasswordGenerationOptions &
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -67,6 +67,15 @@ describe("Password generation strategy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache_ms", () => {
|
||||
it("should be a positive non-zero number", () => {
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
const strategy = new PasswordGeneratorStrategy(legacy);
|
||||
|
||||
expect(strategy.cache_ms).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const legacy = mock<PasswordGenerationServiceAbstraction>();
|
||||
|
||||
Reference in New Issue
Block a user