1
0
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:
✨ Audrey ✨
2024-02-02 10:49:38 -05:00
committed by GitHub
parent 4e4e39e9f7
commit e8d0d56c5f
11 changed files with 292 additions and 56 deletions

View File

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

View File

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

View File

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

View File

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

View File

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