diff --git a/libs/common/src/tools/generator/key-definitions.ts b/libs/common/src/tools/generator/key-definitions.ts index 8e9ed24fcb..f5856fc4ac 100644 --- a/libs/common/src/tools/generator/key-definitions.ts +++ b/libs/common/src/tools/generator/key-definitions.ts @@ -1,5 +1,6 @@ import { GENERATOR_DISK, GENERATOR_MEMORY, KeyDefinition } from "../../platform/state"; +import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options"; import { GeneratedPasswordHistory } from "./password/generated-password-history"; import { PasswordGenerationOptions } from "./password/password-generation-options"; @@ -13,7 +14,7 @@ export const PASSWORD_SETTINGS = new KeyDefinition( ); /** plaintext passphrase generation options */ -export const PASSPHRASE_SETTINGS = new KeyDefinition( +export const PASSPHRASE_SETTINGS = new KeyDefinition( GENERATOR_DISK, "passphraseGeneratorSettings", { diff --git a/libs/common/src/tools/generator/passphrase/index.ts b/libs/common/src/tools/generator/passphrase/index.ts new file mode 100644 index 0000000000..a14ead1e0f --- /dev/null +++ b/libs/common/src/tools/generator/passphrase/index.ts @@ -0,0 +1,4 @@ +// password generator "v2" interfaces +export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; +export { PassphraseGeneratorPolicy } from "./passphrase-generator-policy"; +export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy"; diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generation-options.ts b/libs/common/src/tools/generator/passphrase/passphrase-generation-options.ts new file mode 100644 index 0000000000..8d2ac78ace --- /dev/null +++ b/libs/common/src/tools/generator/passphrase/passphrase-generation-options.ts @@ -0,0 +1,26 @@ +/** 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; +}; diff --git a/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.spec.ts similarity index 70% rename from libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts rename to libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.spec.ts index c72a190f79..c939fa9510 100644 --- a/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.spec.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.spec.ts @@ -1,16 +1,19 @@ -import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; - +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ +import { PassphraseGenerationOptions } from "./passphrase-generation-options"; import { DefaultBoundaries, PassphraseGeneratorOptionsEvaluator, } from "./passphrase-generator-options-evaluator"; -import { PassphraseGenerationOptions } from "./password-generator-options"; +import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy"; 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 policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.minNumberWords = 10; // arbitrary change for deep equality check const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -19,7 +22,7 @@ describe("Password generator options builder", () => { }); it("should set default boundaries when a default policy is used", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); expect(builder.numWords).toEqual(DefaultBoundaries.numWords); @@ -28,7 +31,7 @@ describe("Password generator options builder", () => { it.each([1, 2])( "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", (minNumberWords) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -40,7 +43,7 @@ describe("Password generator options builder", () => { it.each([8, 12, 18])( "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", (minNumberWords) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -53,7 +56,7 @@ describe("Password generator options builder", () => { it.each([150, 300, 9000])( "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", (minNumberWords) => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -64,11 +67,44 @@ describe("Password generator options builder", () => { ); }); + describe("policyInEffect", () => { + it("should return false when the policy has no effect", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(false); + }); + + it("should return true when the policy has a numWords greater than the default boundary", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.minNumberWords = DefaultBoundaries.numWords.min + 1; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has capitalize enabled", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.capitalize = true; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has includeNumber enabled", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.includeNumber = true; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + }); + 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 policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -78,7 +114,7 @@ describe("Password generator options builder", () => { }); it("should set `capitalize` to `true` when the policy overrides it", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ capitalize: false }); @@ -89,7 +125,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to false when the policy does not override it", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -99,7 +135,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to true when the policy overrides it", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ includeNumber: false }); @@ -110,7 +146,7 @@ describe("Password generator options builder", () => { }); it("should set `numWords` to the minimum value when it isn't supplied", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -124,7 +160,7 @@ describe("Password generator options builder", () => { (numWords) => { expect(numWords).toBeLessThan(DefaultBoundaries.numWords.min); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -140,7 +176,7 @@ describe("Password generator options builder", () => { expect(numWords).toBeGreaterThanOrEqual(DefaultBoundaries.numWords.min); expect(numWords).toBeLessThanOrEqual(DefaultBoundaries.numWords.max); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -155,7 +191,7 @@ describe("Password generator options builder", () => { (numWords) => { expect(numWords).toBeGreaterThan(DefaultBoundaries.numWords.max); - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -166,7 +202,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", @@ -184,7 +220,7 @@ describe("Password generator options builder", () => { // 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 policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ wordSeparator: "%" }); @@ -194,7 +230,7 @@ describe("Password generator options builder", () => { }); it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -204,7 +240,7 @@ describe("Password generator options builder", () => { }); it("should preserve unknown properties", () => { - const policy = new PasswordGeneratorPolicyOptions(); + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", diff --git a/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.ts similarity index 79% rename from libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts rename to libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.ts index 8cfdce4b09..c4ce2fd226 100644 --- a/libs/common/src/tools/generator/password/passphrase-generator-options-evaluator.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.ts @@ -1,6 +1,7 @@ -import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; +import { PolicyEvaluator } from "../abstractions/policy-evaluator.abstraction"; -import { PassphraseGenerationOptions } from "./password-generator-options"; +import { PassphraseGenerationOptions } from "./passphrase-generation-options"; +import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy"; type Boundary = { readonly min: number; @@ -25,7 +26,9 @@ export const DefaultBoundaries = initializeBoundaries(); /** Enforces policy for passphrase generation options. */ -export class PassphraseGeneratorOptionsEvaluator { +export class PassphraseGeneratorOptionsEvaluator + implements PolicyEvaluator +{ // 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". @@ -36,7 +39,7 @@ export class PassphraseGeneratorOptionsEvaluator { /** Policy applied by the evaluator. */ - readonly policy: PasswordGeneratorPolicyOptions; + readonly policy: PassphraseGeneratorPolicy; /** Boundaries for the number of words allowed in the password. */ @@ -46,7 +49,7 @@ export class PassphraseGeneratorOptionsEvaluator { * @param policy The policy applied by the evaluator. When this conflicts with * the defaults, the policy takes precedence. */ - constructor(policy: PasswordGeneratorPolicyOptions) { + constructor(policy: PassphraseGeneratorPolicy) { function createBoundary(value: number, defaultBoundary: Boundary): Boundary { const boundary = { min: Math.max(defaultBoundary.min, value), @@ -56,10 +59,21 @@ export class PassphraseGeneratorOptionsEvaluator { return boundary; } - this.policy = policy.clone(); + this.policy = structuredClone(policy); this.numWords = createBoundary(policy.minNumberWords, DefaultBoundaries.numWords); } + /** {@link PolicyEvaluator.policyInEffect} */ + get policyInEffect(): boolean { + const policies = [ + this.policy.capitalize, + this.policy.includeNumber, + this.policy.minNumberWords > DefaultBoundaries.numWords.min, + ]; + + return policies.includes(true); + } + /** 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. diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts new file mode 100644 index 0000000000..ca54184d16 --- /dev/null +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts @@ -0,0 +1,13 @@ +/** Policy options enforced during passphrase generation. */ +export type PassphraseGeneratorPolicy = { + minNumberWords: number; + capitalize: boolean; + includeNumber: boolean; +}; + +/** The default options for password generation policy. */ +export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Object.freeze({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, +}); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts new file mode 100644 index 0000000000..d355d2316d --- /dev/null +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts @@ -0,0 +1,102 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ + +import { mock } from "jest-mock-extended"; + +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PASSPHRASE_SETTINGS } from "../key-definitions"; +import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction"; + +import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorStrategy } from "."; + +describe("Password generation strategy", () => { + describe("evaluator()", () => { + it("should throw if the policy type is incorrect", () => { + const strategy = new PassphraseGeneratorStrategy(null); + const policy = mock({ + type: PolicyType.DisableSend, + }); + + expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); + }); + + it("should map to the policy evaluator", () => { + const strategy = new PassphraseGeneratorStrategy(null); + const policy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minNumberWords: 10, + capitalize: true, + includeNumber: true, + }, + }); + + const evaluator = strategy.evaluator(policy); + + expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject({ + minNumberWords: 10, + capitalize: true, + includeNumber: true, + }); + }); + }); + + describe("disk", () => { + it("should use password settings key", () => { + const legacy = mock(); + const strategy = new PassphraseGeneratorStrategy(legacy); + + expect(strategy.disk).toBe(PASSPHRASE_SETTINGS); + }); + }); + + describe("cache_ms", () => { + it("should be a positive non-zero number", () => { + const legacy = mock(); + const strategy = new PassphraseGeneratorStrategy(legacy); + + expect(strategy.cache_ms).toBeGreaterThan(0); + }); + }); + + describe("policy", () => { + it("should use password generator policy", () => { + const legacy = mock(); + const strategy = new PassphraseGeneratorStrategy(legacy); + + expect(strategy.policy).toBe(PolicyType.PasswordGenerator); + }); + }); + + describe("generate()", () => { + it("should call the legacy service with the given options", async () => { + const legacy = mock(); + const strategy = new PassphraseGeneratorStrategy(legacy); + const options = { + type: "passphrase", + minNumberWords: 1, + capitalize: true, + includeNumber: true, + }; + + await strategy.generate(options); + + expect(legacy.generatePassphrase).toHaveBeenCalledWith(options); + }); + + it("should set the generation type to passphrase", async () => { + const legacy = mock(); + const strategy = new PassphraseGeneratorStrategy(legacy); + + await strategy.generate({ type: "foo" } as any); + + expect(legacy.generatePassphrase).toHaveBeenCalledWith({ type: "passphrase" }); + }); + }); +}); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts new file mode 100644 index 0000000000..8e1d0d4598 --- /dev/null +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts @@ -0,0 +1,56 @@ +import { GeneratorStrategy } from ".."; +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PASSPHRASE_SETTINGS } from "../key-definitions"; +import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction"; + +import { PassphraseGenerationOptions } from "./passphrase-generation-options"; +import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; +import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy"; + +const ONE_MINUTE = 60 * 1000; + +/** {@link GeneratorStrategy} */ +export class PassphraseGeneratorStrategy + implements GeneratorStrategy +{ + /** instantiates the password generator strategy. + * @param legacy generates the passphrase + */ + constructor(private legacy: PasswordGenerationServiceAbstraction) {} + + /** {@link GeneratorStrategy.disk} */ + get disk() { + return PASSPHRASE_SETTINGS; + } + + /** {@link GeneratorStrategy.policy} */ + get policy() { + return PolicyType.PasswordGenerator; + } + + get cache_ms() { + return ONE_MINUTE; + } + + /** {@link GeneratorStrategy.evaluator} */ + evaluator(policy: Policy): PassphraseGeneratorOptionsEvaluator { + if (policy.type !== this.policy) { + const details = `Expected: ${this.policy}. Received: ${policy.type}`; + throw Error("Mismatched policy type. " + details); + } + + return new PassphraseGeneratorOptionsEvaluator({ + minNumberWords: policy.data.minNumberWords, + capitalize: policy.data.capitalize, + includeNumber: policy.data.includeNumber, + }); + } + + /** {@link GeneratorStrategy.generate} */ + generate(options: PassphraseGenerationOptions): Promise { + return this.legacy.generatePassphrase({ ...options, type: "passphrase" }); + } +} 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 e497126cd3..a66c6d7201 100644 --- a/libs/common/src/tools/generator/password/password-generation.service.ts +++ b/libs/common/src/tools/generator/password/password-generation.service.ts @@ -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"; 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 f53fffa199..a0b42b3032 100644 --- a/libs/common/src/tools/generator/password/password-generator-options.ts +++ b/libs/common/src/tools/generator/password/password-generator-options.ts @@ -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; -}; diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts index 7340ae8fb8..e49d1d5671 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts @@ -67,6 +67,15 @@ describe("Password generation strategy", () => { }); }); + describe("cache_ms", () => { + it("should be a positive non-zero number", () => { + const legacy = mock(); + const strategy = new PasswordGeneratorStrategy(legacy); + + expect(strategy.cache_ms).toBeGreaterThan(0); + }); + }); + describe("policy", () => { it("should use password generator policy", () => { const legacy = mock();