diff --git a/libs/tools/generator/core/src/abstractions/index.ts b/libs/tools/generator/core/src/abstractions/index.ts index 5eee745372..471ec89ea3 100644 --- a/libs/tools/generator/core/src/abstractions/index.ts +++ b/libs/tools/generator/core/src/abstractions/index.ts @@ -1,4 +1,4 @@ export { GeneratorService } from "./generator.service.abstraction"; export { GeneratorStrategy } from "./generator-strategy.abstraction"; export { PolicyEvaluator } from "./policy-evaluator.abstraction"; -export { Randomizer } from "./randomizer"; +export { Randomizer } from "../engine/abstractions"; diff --git a/libs/tools/generator/core/src/data/default-password-generation-options.ts b/libs/tools/generator/core/src/data/default-password-generation-options.ts index 00dd60c6fd..1c26fd8f95 100644 --- a/libs/tools/generator/core/src/data/default-password-generation-options.ts +++ b/libs/tools/generator/core/src/data/default-password-generation-options.ts @@ -8,7 +8,9 @@ export const DefaultPasswordGenerationOptions: Partial { + const cryptoService = mock(); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("pick", () => { + it.each([[null], [undefined], [[]]])("throws when the list is %p", async (list) => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + + await expect(() => randomizer.pick(list)).rejects.toBeInstanceOf(Error); + + expect.assertions(1); + }); + + it("picks an item from the list", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValue(1); + + const result = await randomizer.pick([0, 1]); + + expect(result).toBe(1); + }); + }); + + describe("pickWord", () => { + it.each([[null], [undefined], [[]]])("throws when the list is %p", async (list) => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + + await expect(() => randomizer.pickWord(list)).rejects.toBeInstanceOf(Error); + + expect.assertions(1); + }); + + it("picks a word from the list", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValue(1); + + const result = await randomizer.pickWord(["foo", "bar"]); + + expect(result).toBe("bar"); + }); + + it("capitalizes the word when options.titleCase is true", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValue(1); + + const result = await randomizer.pickWord(["foo", "bar"], { titleCase: true }); + + expect(result).toBe("Bar"); + }); + + it("appends a random number when options.number is true", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValueOnce(1); + cryptoService.randomNumber.mockResolvedValueOnce(2); + + const result = await randomizer.pickWord(["foo", "bar"], { number: true }); + + expect(result).toBe("bar2"); + }); + }); + + describe("shuffle", () => { + it.each([[null], [undefined], [[]]])("throws when the list is %p", async (list) => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + + await expect(() => randomizer.shuffle(list)).rejects.toBeInstanceOf(Error); + + expect.assertions(1); + }); + + it("returns a copy of the list without shuffling it when theres only one entry", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + + const result = await randomizer.shuffle(["foo"]); + + expect(result).toEqual(["foo"]); + expect(result).not.toBe(["foo"]); + expect(cryptoService.randomNumber).not.toHaveBeenCalled(); + }); + + it("shuffles the tail of the list", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValueOnce(0); + + const result = await randomizer.shuffle(["bar", "foo"]); + + expect(result).toEqual(["foo", "bar"]); + }); + + it("shuffles the list", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValueOnce(0); + cryptoService.randomNumber.mockResolvedValueOnce(1); + + const result = await randomizer.shuffle(["baz", "bar", "foo"]); + + expect(result).toEqual(["foo", "bar", "baz"]); + }); + + it("returns the input list when options.copy is false", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValueOnce(0); + + const expectedResult = ["foo"]; + const result = await randomizer.shuffle(expectedResult, { copy: false }); + + expect(result).toBe(expectedResult); + }); + }); + + describe("chars", () => { + it("returns an empty string when the length is 0", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + + const result = await randomizer.chars(0); + + expect(result).toEqual(""); + }); + + it("returns an arbitrary lowercase ascii character", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValueOnce(0); + + const result = await randomizer.chars(1); + + expect(result).toEqual("a"); + }); + + it("returns a number of ascii characters based on the length", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValue(0); + + const result = await randomizer.chars(2); + + expect(result).toEqual("aa"); + expect(cryptoService.randomNumber).toHaveBeenCalledTimes(2); + }); + + it("returns a new random character each time its called", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValueOnce(0); + cryptoService.randomNumber.mockResolvedValueOnce(1); + + const resultA = await randomizer.chars(1); + const resultB = await randomizer.chars(1); + + expect(resultA).toEqual("a"); + expect(resultB).toEqual("b"); + expect(cryptoService.randomNumber).toHaveBeenCalledTimes(2); + }); + }); + + describe("uniform", () => { + it("forwards requests to the crypto service", async () => { + const randomizer = new CryptoServiceRandomizer(cryptoService); + cryptoService.randomNumber.mockResolvedValue(5); + + const result = await randomizer.uniform(0, 5); + + expect(result).toBe(5); + expect(cryptoService.randomNumber).toHaveBeenCalledWith(0, 5); + }); + }); +}); diff --git a/libs/tools/generator/core/src/engine/crypto-service-randomizer.ts b/libs/tools/generator/core/src/engine/crypto-service-randomizer.ts index 5320fad681..9d3659139c 100644 --- a/libs/tools/generator/core/src/engine/crypto-service-randomizer.ts +++ b/libs/tools/generator/core/src/engine/crypto-service-randomizer.ts @@ -5,9 +5,17 @@ import { WordOptions } from "../types"; /** A randomizer backed by a CryptoService. */ export class CryptoServiceRandomizer implements Randomizer { + /** instantiates the type. + * @param crypto generates random numbers + */ constructor(private crypto: CryptoService) {} - async pick(list: Array) { + async pick(list: Array): Promise { + const length = list?.length ?? 0; + if (length <= 0) { + throw new Error("list must have at least one entry."); + } + const index = await this.uniform(0, list.length - 1); return list[index]; } @@ -29,6 +37,11 @@ export class CryptoServiceRandomizer implements Randomizer { // ref: https://stackoverflow.com/a/12646864/1090359 async shuffle(items: Array, options?: { copy?: boolean }) { + const length = items?.length ?? 0; + if (length <= 0) { + throw new Error("items must have at least one entry."); + } + const shuffled = options?.copy ?? true ? [...items] : items; for (let i = shuffled.length - 1; i > 0; i--) { @@ -52,11 +65,4 @@ export class CryptoServiceRandomizer implements Randomizer { async uniform(min: number, max: number) { return this.crypto.randomNumber(min, max); } - - // ref: https://stackoverflow.com/a/10073788 - private zeroPad(number: string, width: number) { - return number.length >= width - ? number - : new Array(width - number.length + 1).join("0") + number; - } } diff --git a/libs/tools/generator/core/src/engine/data.ts b/libs/tools/generator/core/src/engine/data.ts new file mode 100644 index 0000000000..d0155a49b2 --- /dev/null +++ b/libs/tools/generator/core/src/engine/data.ts @@ -0,0 +1,35 @@ +import { CharacterSet, CharacterSets } from "./types"; + +function toCharacterSet(characters: string) { + const set = characters.split(""); + + return Object.freeze(set as CharacterSet); +} + +const SpecialCharacters = toCharacterSet("!@#$%^&*"); + +/** Sets of Ascii characters used for password generation */ +export const Ascii = Object.freeze({ + /** The full set of characters available to the generator */ + Full: Object.freeze({ + Uppercase: toCharacterSet("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), + Lowercase: toCharacterSet("abcdefghijkmnopqrstuvwxyz"), + Digit: toCharacterSet("0123456789"), + Special: SpecialCharacters, + } as CharacterSets), + + /** All characters available to the generator that are not ambiguous. */ + Unmistakable: Object.freeze({ + Uppercase: toCharacterSet("ABCDEFGHJKLMNPQRSTUVWXYZ"), + Lowercase: toCharacterSet("abcdefghijklmnopqrstuvwxyz"), + Digit: toCharacterSet("23456789"), + Special: SpecialCharacters, + } as CharacterSets), +}); + +/** Splits an email into a username, subaddress, and domain named group. + * Subaddress is optional. + */ +export const SUBADDRESS_PARSER = new RegExp( + "(?[^@+]+)(?\\+.+)?(?@.+)", +); diff --git a/libs/tools/generator/core/src/engine/email-calculator.spec.ts b/libs/tools/generator/core/src/engine/email-calculator.spec.ts new file mode 100644 index 0000000000..a4aefea11a --- /dev/null +++ b/libs/tools/generator/core/src/engine/email-calculator.spec.ts @@ -0,0 +1,69 @@ +import { EmailCalculator } from "./email-calculator"; + +describe("EmailCalculator", () => { + describe("appendToSubaddress", () => { + it.each([[null], [undefined], [""]])( + "returns an empty string when the website is %p", + (website) => { + const calculator = new EmailCalculator(); + + const result = calculator.appendToSubaddress(website, null); + + expect(result).toEqual(""); + }, + ); + + it.each([["noAtSymbol"], ["has spaces"]])( + "returns the unaltered email address when it is invalid (=%p)", + (email) => { + const calculator = new EmailCalculator(); + + const result = calculator.appendToSubaddress("foo", email); + + expect(result).toEqual(email); + }, + ); + + it("creates a subadress part", () => { + const calculator = new EmailCalculator(); + + const result = calculator.appendToSubaddress("baz", "foo@example.com"); + + expect(result).toEqual("foo+baz@example.com"); + }); + + it("appends to a subaddress part", () => { + const calculator = new EmailCalculator(); + + const result = calculator.appendToSubaddress("biz", "foo+bar@example.com"); + + expect(result).toEqual("foo+barbiz@example.com"); + }); + }); + + describe("concatenate", () => { + it.each([[null], [undefined], [""]])("returns null when username is %p", (username) => { + const calculator = new EmailCalculator(); + + const result = calculator.concatenate(username, ""); + + expect(result).toEqual(null); + }); + + it.each([[null], [undefined], [""]])("returns null when domain is %p", (domain) => { + const calculator = new EmailCalculator(); + + const result = calculator.concatenate("foo", domain); + + expect(result).toEqual(null); + }); + + it("appends the username to the domain", () => { + const calculator = new EmailCalculator(); + + const result = calculator.concatenate("foo", "example.com"); + + expect(result).toEqual("foo@example.com"); + }); + }); +}); diff --git a/libs/tools/generator/core/src/engine/email-calculator.ts b/libs/tools/generator/core/src/engine/email-calculator.ts new file mode 100644 index 0000000000..36436e181a --- /dev/null +++ b/libs/tools/generator/core/src/engine/email-calculator.ts @@ -0,0 +1,56 @@ +import { SUBADDRESS_PARSER } from "./data"; + +/** Generation algorithms that produce deterministic email addresses */ +export class EmailCalculator { + /** + * Appends appendText to the subaddress of an email address. + * @param appendText The calculation fails if this is shorter than 1 character + * long, undefined, or null. + * @param email the email address to alter. + * @returns `email` with `appendText` added to its subaddress (the part + * following the "+"). If there is no subaddress, a subaddress is created. + * If the email address fails to parse, it is returned unaltered. + */ + appendToSubaddress(appendText: string, email: string) { + let result = (email ?? "").trim(); + + const suffix = (appendText ?? "").trim(); + if (suffix.length < 1) { + return result; + } + + const parsed = SUBADDRESS_PARSER.exec(result); + if (!parsed) { + return result; + } + + const subaddress = (parsed.groups.subaddress ?? "+") + suffix; + result = `${parsed.groups.username}${subaddress}${parsed.groups.domain}`; + + return result; + } + + /** + * Derives an email address from a username and domain name. + * @param username the username part of the email address. The calculation fails if this is + * shorter than 1 character long, undefined, or null. + * @param domain the domain part of the email address. The calculation fails if this is empty, + * undefined, or null. + * @returns an email address or `null` if the calculation fails. + */ + concatenate(username: string, domain: string) { + const emailDomain = domain?.startsWith("@") ? domain.substring(1, Infinity) : domain ?? ""; + if (emailDomain.length < 1) { + return null; + } + + const emailWebsite = username ?? ""; + if (emailWebsite.length < 1) { + return null; + } + + const result = `${emailWebsite}@${emailDomain}`; + + return result; + } +} diff --git a/libs/tools/generator/core/src/engine/email-randomizer.spec.ts b/libs/tools/generator/core/src/engine/email-randomizer.spec.ts new file mode 100644 index 0000000000..8670b8c176 --- /dev/null +++ b/libs/tools/generator/core/src/engine/email-randomizer.spec.ts @@ -0,0 +1,211 @@ +import { mock } from "jest-mock-extended"; + +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; + +import { Randomizer } from "./abstractions"; +import { EmailRandomizer } from "./email-randomizer"; + +describe("EmailRandomizer", () => { + const randomizer = mock(); + + beforeEach(() => { + randomizer.pickWord.mockResolvedValue("baz"); + + // set to 8 characters since that's the default + randomizer.chars.mockResolvedValue("aaaaaaaa"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("subaddress", () => { + it("returns an empty string if the generation length is 0", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiSubaddress("foo@example.com", { length: 0 }); + + expect(result).toEqual("foo@example.com"); + }); + + it("returns an empty string if the generation length is less than 0", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiSubaddress("foo@example.com", { length: -1 }); + + expect(result).toEqual("foo@example.com"); + }); + + it.each([[null], [undefined], [""]])( + "returns an empty string if the email address is %p", + async (email) => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiSubaddress(email); + + expect(result).toEqual(""); + }, + ); + + it("returns the input if the email address lacks a username", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiSubaddress("@example.com"); + + expect(result).toEqual("@example.com"); + }); + + it("returns the input if the email address lacks a domain", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiSubaddress("foo"); + + expect(result).toEqual("foo"); + }); + + it("generates an email address with a subaddress", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiSubaddress("foo@example.com"); + + expect(result).toEqual("foo+aaaaaaaa@example.com"); + }); + + it("extends the subaddress when it is provided", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiSubaddress("foo+bar@example.com"); + + expect(result).toEqual("foo+baraaaaaaaa@example.com"); + }); + + it("defaults to 8 random characters", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + await emailRandomizer.randomAsciiSubaddress("foo@example.com"); + + expect(randomizer.chars).toHaveBeenCalledWith(8); + }); + + it("overrides the default length", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + await emailRandomizer.randomAsciiSubaddress("foo@example.com", { length: 1 }); + + expect(randomizer.chars).toHaveBeenCalledWith(1); + }); + }); + + describe("randomAsciiCatchall", () => { + it.each([[null], [undefined], [""]])("returns null if the domain is %p", async (domain) => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiCatchall(domain); + + expect(result).toBeNull(); + }); + + it("returns null if the length is 0", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiCatchall("example.com", { length: 0 }); + + expect(result).toBeNull(); + }); + + it("returns null if the length is less than 0", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiCatchall("example.com", { length: -1 }); + + expect(result).toBeNull(); + }); + + it("generates a random catchall", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomAsciiCatchall("example.com"); + + expect(result).toEqual("aaaaaaaa@example.com"); + }); + + it("defaults to 8 random characters", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + await emailRandomizer.randomAsciiCatchall("example.com"); + + expect(randomizer.chars).toHaveBeenCalledWith(8); + }); + + it("overrides the default length", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + await emailRandomizer.randomAsciiCatchall("example.com", { length: 1 }); + + expect(randomizer.chars).toHaveBeenCalledWith(1); + }); + }); + + describe("randomWordsCatchall", () => { + it.each([[null], [undefined], [""]])("returns null if the domain is %p", async (domain) => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomWordsCatchall(domain); + + expect(result).toBeNull(); + }); + + it("returns null if the length is 0", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomWordsCatchall("example.com", { numberOfWords: 0 }); + + expect(result).toBeNull(); + }); + + it("returns null if the length is less than 0", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomWordsCatchall("example.com", { + numberOfWords: -1, + }); + + expect(result).toBeNull(); + }); + + it("generates a random word catchall", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const result = await emailRandomizer.randomWordsCatchall("example.com"); + + expect(result).toEqual("baz@example.com"); + }); + + it("defaults to 1 random word", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + await emailRandomizer.randomWordsCatchall("example.com"); + + expect(randomizer.pickWord).toHaveBeenCalledTimes(1); + }); + + it("requests a titleCase word for lengths greater than 1", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + randomizer.pickWord.mockResolvedValueOnce("Biz"); + + await emailRandomizer.randomWordsCatchall("example.com", { numberOfWords: 2 }); + + expect(randomizer.pickWord).toHaveBeenNthCalledWith(1, EFFLongWordList, { titleCase: false }); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(2, EFFLongWordList, { titleCase: true }); + }); + + it("overrides the eff word list", async () => { + const emailRandomizer = new EmailRandomizer(randomizer); + + const expectedWordList = ["some", "arbitrary", "words"]; + await emailRandomizer.randomWordsCatchall("example.com", { words: expectedWordList }); + + expect(randomizer.pickWord).toHaveBeenCalledWith(expectedWordList, { titleCase: false }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/engine/email-randomizer.ts b/libs/tools/generator/core/src/engine/email-randomizer.ts new file mode 100644 index 0000000000..7ea5c7e607 --- /dev/null +++ b/libs/tools/generator/core/src/engine/email-randomizer.ts @@ -0,0 +1,99 @@ +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; + +import { Randomizer } from "./abstractions"; +import { SUBADDRESS_PARSER } from "./data"; + +/** Generation algorithms that produce randomized email addresses */ +export class EmailRandomizer { + /** Instantiates the email randomizer + * @param random data source for random data + */ + constructor(private random: Randomizer) {} + + /** Appends a random set of characters as a subaddress + * @param email the email address used to generate a subaddress. If this address + * already contains a subaddress, the subaddress is extended. + * @param options.length the number of characters to append to the subaddress. Defaults to 8. If + * the length is <= 0, the function returns the input address. + * @returns a promise that resolves with the generated email address. If the provided address + * lacks a username (the part before the "@") or domain (the part after the "@"), the function + * returns the input address. + */ + async randomAsciiSubaddress(email: string, options?: { length?: number }) { + let result = email ?? ""; + + const subaddressLength = options?.length ?? 8; + if (subaddressLength < 1) { + return result; + } + + const parsed = SUBADDRESS_PARSER.exec(result); + if (!parsed) { + return result; + } + + let subaddress = parsed.groups.subaddress ?? "+"; + subaddress += await this.random.chars(subaddressLength); + result = `${parsed.groups.username}${subaddress}${parsed.groups.domain}`; + + return result; + } + + /** Creates a catchall address composed of random characters + * @param domain the domain part of the generated email address. + * @param options.length the number of characters to include in the catchall + * address. Defaults to 8. + * @returns a promise that resolves with the generated email address. If the domain + * is empty, resolves to null instead. + */ + async randomAsciiCatchall(domain: string, options?: { length?: number }) { + const emailDomain = domain?.startsWith("@") ? domain.substring(1, Infinity) : domain ?? ""; + if (emailDomain.length < 1) { + return null; + } + + const length = options?.length ?? 8; + if (length < 1) { + return null; + } + + const catchall = await this.random.chars(length); + const result = `${catchall}@${domain}`; + + return result; + } + + /** Creates a catchall address composed of random words + * @param domain the domain part of the generated email address. + * @param options.numberOfWords the number of words to include in the catchall + * address. Defaults to 1. + * @param options.words selects words from the provided wordlist. Defaults to + * the EFF "5-dice" list. + * @returns a promise that resolves with the generated email address. + */ + async randomWordsCatchall( + domain: string, + options?: { numberOfWords?: number; words?: Array }, + ) { + const emailDomain = domain?.startsWith("@") ? domain.substring(1, Infinity) : domain ?? ""; + if (emailDomain.length < 1) { + return null; + } + + const numberOfWords = options?.numberOfWords ?? 1; + if (numberOfWords < 1) { + return null; + } + + const wordList = options?.words ?? EFFLongWordList; + const words = []; + for (let i = 0; i < numberOfWords; i++) { + // camelCase the words for legibility + words[i] = await this.random.pickWord(wordList, { titleCase: i !== 0 }); + } + + const result = `${words.join("")}@${domain}`; + + return result; + } +} diff --git a/libs/tools/generator/core/src/engine/index.ts b/libs/tools/generator/core/src/engine/index.ts index 1a67384de1..38e3195b96 100644 --- a/libs/tools/generator/core/src/engine/index.ts +++ b/libs/tools/generator/core/src/engine/index.ts @@ -1 +1,5 @@ export { CryptoServiceRandomizer } from "./crypto-service-randomizer"; +export { EmailRandomizer } from "./email-randomizer"; +export { EmailCalculator } from "./email-calculator"; +export { PasswordRandomizer } from "./password-randomizer"; +export { UsernameRandomizer } from "./username-randomizer"; diff --git a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts new file mode 100644 index 0000000000..bbc31a4a29 --- /dev/null +++ b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts @@ -0,0 +1,338 @@ +import { mock } from "jest-mock-extended"; + +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; + +import { Randomizer } from "../abstractions"; + +import { Ascii } from "./data"; +import { PasswordRandomizer } from "./password-randomizer"; +import { CharacterSet, RandomAsciiRequest } from "./types"; + +describe("PasswordRandomizer", () => { + const randomizer = mock(); + + beforeEach(() => { + randomizer.shuffle.mockImplementation((items) => { + return Promise.resolve([...items]); + }); + + randomizer.pick.mockImplementation((items) => { + return Promise.resolve(items[0]); + }); + + randomizer.pickWord.mockResolvedValue("foo"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("randomAscii", () => { + it("returns the empty string when no character sets are specified", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 1, + ambiguous: true, + }); + + expect(result).toEqual(""); + }); + + it("generates an uppercase ascii password", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + uppercase: 1, + ambiguous: true, + }); + + expect(result).toEqual("A"); + expect(randomizer.pick).toHaveBeenCalledWith(Ascii.Full.Uppercase); + }); + + it("generates an uppercase ascii password without ambiguous characters", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + uppercase: 1, + ambiguous: false, + }); + + expect(result).toEqual("A"); + expect(randomizer.pick).toHaveBeenCalledWith(Ascii.Unmistakable.Uppercase); + }); + + it("generates a lowercase ascii password", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + lowercase: 1, + ambiguous: true, + }); + + expect(result).toEqual("a"); + expect(randomizer.pick).toHaveBeenCalledWith(Ascii.Full.Lowercase); + }); + + it("generates a lowercase ascii password without ambiguous characters", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + lowercase: 1, + ambiguous: false, + }); + + expect(result).toEqual("a"); + expect(randomizer.pick).toHaveBeenCalledWith(Ascii.Unmistakable.Lowercase); + }); + + it("generates a numeric ascii password", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + digits: 1, + ambiguous: true, + }); + + expect(result).toEqual("0"); + expect(randomizer.pick).toHaveBeenCalledWith(Ascii.Full.Digit); + }); + + it("generates a numeric password without ambiguous characters", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + digits: 1, + ambiguous: false, + }); + + expect(result).toEqual("2"); + expect(randomizer.pick).toHaveBeenCalledWith(Ascii.Unmistakable.Digit); + }); + + it("generates a special character password", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + special: 1, + ambiguous: true, + }); + + expect(result).toEqual("!"); + expect(randomizer.pick).toHaveBeenCalledWith(Ascii.Full.Special); + }); + + it("generates a special character password without ambiguous characters", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + special: 1, + ambiguous: false, + }); + + expect(result).toEqual("!"); + expect(randomizer.pick).toHaveBeenCalledWith(Ascii.Unmistakable.Special); + }); + + it.each([ + [2, "AA"], + [3, "AAA"], + ])("includes %p uppercase characters", async (uppercase, expected) => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + uppercase, + ambiguous: true, + }); + + expect(result).toEqual(expected); + }); + + it.each([ + [2, "aa"], + [3, "aaa"], + ])("includes %p lowercase characters", async (lowercase, expected) => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + lowercase, + ambiguous: true, + }); + + expect(result).toEqual(expected); + }); + + it.each([ + [2, "00"], + [3, "000"], + ])("includes %p digits", async (digits, expected) => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + digits, + ambiguous: true, + }); + + expect(result).toEqual(expected); + }); + + it.each([ + [2, "!!"], + [3, "!!!"], + ])("includes %p special characters", async (special, expected) => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomAscii({ + all: 0, + special, + ambiguous: true, + }); + + expect(result).toEqual(expected); + }); + + it.each([ + [{ uppercase: 0 }, Ascii.Full.Uppercase], + [{ lowercase: 0 }, Ascii.Full.Lowercase], + [{ digits: 0 }, Ascii.Full.Digit], + [{ special: 0 }, Ascii.Full.Special], + ])( + "mixes character sets for the remaining characters (=%p)", + async (setting: Partial, set: CharacterSet) => { + const password = new PasswordRandomizer(randomizer); + + await password.randomAscii({ + ...setting, + all: 1, + ambiguous: true, + }); + + expect(randomizer.pick).toHaveBeenCalledWith(set); + }, + ); + + it("shuffles the password characters", async () => { + const password = new PasswordRandomizer(randomizer); + + // Typically `shuffle` randomizes the order of the array it's been + // given. In the password generator, the array is generated from the + // options. Thus, returning a fixed set of results effectively overrides + // the randomizer's arguments. + randomizer.shuffle.mockImplementation(() => { + const results = [Ascii.Full.Uppercase, Ascii.Full.Digit]; + return Promise.resolve(results); + }); + + const result = await password.randomAscii({ + all: 0, + ambiguous: true, + }); + + expect(result).toEqual("A0"); + }); + }); + + describe("randomEffLongWords", () => { + it("generates the empty string when no words are passed", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomEffLongWords({ + numberOfWords: 0, + separator: "", + number: false, + capitalize: false, + }); + + expect(result).toEqual(""); + }); + + it.each([ + [1, "foo"], + [2, "foofoo"], + ])("generates a %i-length word list", async (words, expected) => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomEffLongWords({ + numberOfWords: words, + separator: "", + number: false, + capitalize: false, + }); + + expect(result).toEqual(expected); + expect(randomizer.pickWord).toHaveBeenCalledWith(EFFLongWordList, { + titleCase: false, + number: false, + }); + }); + + it("capitalizes the word list", async () => { + const password = new PasswordRandomizer(randomizer); + randomizer.pickWord.mockResolvedValueOnce("Foo"); + + const result = await password.randomEffLongWords({ + numberOfWords: 1, + separator: "", + number: false, + capitalize: true, + }); + + expect(result).toEqual("Foo"); + expect(randomizer.pickWord).toHaveBeenCalledWith(EFFLongWordList, { + titleCase: true, + number: false, + }); + }); + + it("includes a random number on a random word", async () => { + const password = new PasswordRandomizer(randomizer); + randomizer.pickWord.mockResolvedValueOnce("foo"); + randomizer.pickWord.mockResolvedValueOnce("foo1"); + + // chooses which word gets the number + randomizer.uniform.mockResolvedValueOnce(1); + + const result = await password.randomEffLongWords({ + numberOfWords: 2, + separator: "", + number: true, + capitalize: false, + }); + + expect(result).toEqual("foofoo1"); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(1, EFFLongWordList, { + titleCase: false, + number: false, + }); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(2, EFFLongWordList, { + titleCase: false, + number: true, + }); + }); + + it("includes a separator", async () => { + const password = new PasswordRandomizer(randomizer); + + const result = await password.randomEffLongWords({ + numberOfWords: 2, + separator: "-", + number: false, + capitalize: false, + }); + + expect(result).toEqual("foo-foo"); + }); + }); +}); diff --git a/libs/tools/generator/core/src/engine/password-randomizer.ts b/libs/tools/generator/core/src/engine/password-randomizer.ts new file mode 100644 index 0000000000..438ea8b8b4 --- /dev/null +++ b/libs/tools/generator/core/src/engine/password-randomizer.ts @@ -0,0 +1,95 @@ +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; + +import { Randomizer } from "./abstractions"; +import { Ascii } from "./data"; +import { CharacterSet, EffWordListRequest, RandomAsciiRequest } from "./types"; + +/** Generation algorithms that produce randomized secrets */ +export class PasswordRandomizer { + /** Instantiates the password randomizer + * @param random data source for random data + */ + constructor(private randomizer: Randomizer) {} + + /** create a password from ASCII codepoints + * @param request refines the generated password + * @returns a promise that completes with the generated password + */ + async randomAscii(request: RandomAsciiRequest) { + // randomize character sets + const sets = toAsciiSets(request); + const shuffled = await this.randomizer.shuffle(sets); + + // generate password + const generating = shuffled.flatMap((set) => this.randomizer.pick(set)); + const generated = await Promise.all(generating); + const result = generated.join(""); + + return result; + } + + /** create a passphrase from the EFF's "5 dice" word list + * @param request refines the generated passphrase + * @returns a promise that completes with the generated passphrase + */ + async randomEffLongWords(request: EffWordListRequest) { + // select which word gets the number, if any + let luckyNumber = -1; + if (request.number) { + luckyNumber = await this.randomizer.uniform(0, request.numberOfWords - 1); + } + + // generate the passphrase + const wordList = new Array(request.numberOfWords); + for (let i = 0; i < request.numberOfWords; i++) { + const word = await this.randomizer.pickWord(EFFLongWordList, { + titleCase: request.capitalize, + number: i === luckyNumber, + }); + + wordList[i] = word; + } + + return wordList.join(request.separator); + } +} + +// given a generator request, convert each of its `number | undefined` properties +// to an array of character sets, one for each property. The transformation is +// deterministic. +function toAsciiSets(request: RandomAsciiRequest) { + // allocate an array and initialize each cell with a fixed value + function allocate(size: number, value: T) { + const data = new Array(size > 0 ? size : 0); + data.fill(value, 0, size); + return data; + } + + const allSet: CharacterSet = []; + const active = request.ambiguous ? Ascii.Full : Ascii.Unmistakable; + const parts: Array = []; + + if (request.uppercase !== undefined) { + parts.push(...allocate(request.uppercase, active.Uppercase)); + allSet.push(...active.Uppercase); + } + + if (request.lowercase !== undefined) { + parts.push(...allocate(request.lowercase, active.Lowercase)); + allSet.push(...active.Lowercase); + } + + if (request.digits !== undefined) { + parts.push(...allocate(request.digits, active.Digit)); + allSet.push(...active.Digit); + } + + if (request.special !== undefined) { + parts.push(...allocate(request.special, active.Special)); + allSet.push(...active.Special); + } + + parts.push(...allocate(request.all, allSet)); + + return parts; +} diff --git a/libs/tools/generator/core/src/engine/types.ts b/libs/tools/generator/core/src/engine/types.ts new file mode 100644 index 0000000000..d9eee05592 --- /dev/null +++ b/libs/tools/generator/core/src/engine/types.ts @@ -0,0 +1,68 @@ +/** Each entry of a character set contains a codepoint used for password generation */ +export type CharacterSet = string[]; + +/** Well known character sets used for password generation */ +export type CharacterSets = { + /** A set of uppercase characters */ + Uppercase: CharacterSet; + + /** A set of lowercase characters */ + Lowercase: CharacterSet; + + /** A set of numeric characters (i.e., digits) */ + Digit: CharacterSet; + + /** A set of special characters (e.g. "$") */ + Special: CharacterSet; +}; + +/** Request a random password using ascii characters */ +export type RandomAsciiRequest = { + /** Number of codepoints drawn from all available character sets */ + all: number; + + /** Number of codepoints drawn from uppercase character sets */ + uppercase?: number; + + /** Number of codepoints drawn from lowercase character sets */ + lowercase?: number; + + /** Number of codepoints drawn from numeric character sets */ + digits?: number; + + /** Number of codepoints drawn from special character sets */ + special?: number; + + /** When `false`, characters with ambiguous glyphs (e.g., "I", "l", and "1") are excluded from the generated password. */ + ambiguous: boolean; +}; + +/** Request random words drawn from the EFF "5 dice" word list */ +export type EffWordListRequest = { + /** Number of words drawn from the word list */ + numberOfWords: number; + + /** Separator rendered in between each word */ + separator: string; + + /** Whether or not a word should include a random digit */ + number: boolean; + + /** Whether or not the words should be capitalized */ + capitalize: boolean; +}; + +/** request random username drawn from a word list */ +export type WordsRequest = { + /** the number of words to select. This defaults to 1. */ + numberOfWords?: number; + + /** Draw the words from a custom word list; defaults to the EFF "5 dice" word list. */ + words?: Array; + + /** The number of digits to append to the random word(s). Defaults to 0. */ + digits?: number; + + /** Expected casing of the returned words. Defaults to lowercase. */ + casing?: "lowercase" | "TitleCase" | "camelCase"; +}; diff --git a/libs/tools/generator/core/src/engine/username-randomizer.spec.ts b/libs/tools/generator/core/src/engine/username-randomizer.spec.ts new file mode 100644 index 0000000000..e30db28645 --- /dev/null +++ b/libs/tools/generator/core/src/engine/username-randomizer.spec.ts @@ -0,0 +1,105 @@ +import { mock } from "jest-mock-extended"; + +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; + +import { Randomizer } from "./abstractions"; +import { UsernameRandomizer } from "./username-randomizer"; + +describe("UsernameRandomizer", () => { + const randomizer = mock(); + + beforeEach(() => { + randomizer.pickWord.mockResolvedValue("username"); + randomizer.uniform.mockResolvedValue(0); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("randomWords", () => { + it("generates a random word", async () => { + const usernameRandomizer = new UsernameRandomizer(randomizer); + + const result = await usernameRandomizer.randomWords(); + + expect(result).toEqual("username"); + }); + + it("generates multiple random words", async () => { + const usernameRandomizer = new UsernameRandomizer(randomizer); + + const result = await usernameRandomizer.randomWords({ numberOfWords: 2 }); + + expect(result).toEqual("usernameusername"); + }); + + it("returns an empty string if length is 0", async () => { + const usernameRandomizer = new UsernameRandomizer(randomizer); + + const result = await usernameRandomizer.randomWords({ numberOfWords: 0 }); + + expect(result).toEqual(""); + }); + + it("returns an empty string if length is less than 0", async () => { + const usernameRandomizer = new UsernameRandomizer(randomizer); + + const result = await usernameRandomizer.randomWords({ numberOfWords: -1 }); + + expect(result).toEqual(""); + }); + + it("selects from a custom wordlist", async () => { + const usernameRandomizer = new UsernameRandomizer(randomizer); + + const expectedWords: string[] = []; + const result = await usernameRandomizer.randomWords({ + numberOfWords: 1, + words: expectedWords, + }); + + expect(result).toEqual("username"); + expect(randomizer.pickWord).toHaveBeenCalledWith(expectedWords, { titleCase: false }); + }); + + it("camelCases words", async () => { + const usernameRandomizer = new UsernameRandomizer(randomizer); + + const result = await usernameRandomizer.randomWords({ + numberOfWords: 2, + casing: "camelCase", + }); + + expect(result).toEqual("usernameusername"); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(1, EFFLongWordList, { titleCase: false }); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(2, EFFLongWordList, { titleCase: true }); + }); + + it("TitleCasesWords", async () => { + const usernameRandomizer = new UsernameRandomizer(randomizer); + + const result = await usernameRandomizer.randomWords({ + numberOfWords: 2, + casing: "TitleCase", + }); + + expect(result).toEqual("usernameusername"); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(1, EFFLongWordList, { titleCase: true }); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(2, EFFLongWordList, { titleCase: true }); + }); + + it("lowercases words", async () => { + const usernameRandomizer = new UsernameRandomizer(randomizer); + + const result = await usernameRandomizer.randomWords({ + numberOfWords: 2, + casing: "lowercase", + }); + + expect(result).toEqual("usernameusername"); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(1, EFFLongWordList, { titleCase: false }); + expect(randomizer.pickWord).toHaveBeenNthCalledWith(2, EFFLongWordList, { titleCase: false }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/engine/username-randomizer.ts b/libs/tools/generator/core/src/engine/username-randomizer.ts new file mode 100644 index 0000000000..4a6aa43f60 --- /dev/null +++ b/libs/tools/generator/core/src/engine/username-randomizer.ts @@ -0,0 +1,47 @@ +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; + +import { Randomizer } from "./abstractions"; +import { WordsRequest } from "./types"; + +/** Generation algorithms that produce randomized usernames */ +export class UsernameRandomizer { + /** Instantiates the username randomizer + * @param random data source for random data + */ + constructor(private random: Randomizer) {} + + /** Creates a username composed of random words + * @param request parameters to which the generated username conforms + * @returns a promise that resolves with the generated username. + */ + async randomWords(request?: WordsRequest) { + const numberOfWords = request?.numberOfWords ?? 1; + if (numberOfWords < 1) { + return ""; + } + + const digits = Math.max(request?.digits ?? 0, 0); + let selectCase = (_: number) => false; + if (request?.casing === "camelCase") { + selectCase = (i: number) => i !== 0; + } else if (request?.casing === "TitleCase") { + selectCase = (_: number) => true; + } + + const wordList = request?.words ?? EFFLongWordList; + const parts = []; + for (let i = 0; i < numberOfWords; i++) { + const word = await this.random.pickWord(wordList, { titleCase: selectCase(i) }); + parts.push(word); + } + + for (let i = 0; i < digits; i++) { + const digit = await this.random.uniform(0, 9); + parts.push(digit.toString()); + } + + const result = parts.join(""); + + return result; + } +} diff --git a/libs/tools/generator/core/src/strategies/catchall-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.spec.ts index dcb7227b1c..99f618a452 100644 --- a/libs/tools/generator/core/src/strategies/catchall-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.spec.ts @@ -8,8 +8,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { Randomizer } from "../abstractions"; import { DefaultCatchallOptions } from "../data"; +import { EmailCalculator, EmailRandomizer } from "../engine"; import { DefaultPolicyEvaluator } from "../policies"; import { CatchallGeneratorStrategy } from "./catchall-generator-strategy"; @@ -28,7 +28,7 @@ describe("Email subaddress list generation strategy", () => { it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( "should map any input (= %p) to the default policy evaluator", async (policies) => { - const strategy = new CatchallGeneratorStrategy(null, null); + const strategy = new CatchallGeneratorStrategy(null, null, null); const evaluator$ = of(policies).pipe(strategy.toEvaluator()); const evaluator = await firstValueFrom(evaluator$); @@ -41,8 +41,7 @@ describe("Email subaddress list generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const randomizer = mock(); - const strategy = new CatchallGeneratorStrategy(randomizer, provider); + const strategy = new CatchallGeneratorStrategy(null, null, provider); strategy.durableState(SomeUser); @@ -52,7 +51,7 @@ describe("Email subaddress list generation strategy", () => { describe("defaults$", () => { it("should return the default subaddress options", async () => { - const strategy = new CatchallGeneratorStrategy(null, null); + const strategy = new CatchallGeneratorStrategy(null, null, null); const result = await firstValueFrom(strategy.defaults$(SomeUser)); @@ -62,14 +61,52 @@ describe("Email subaddress list generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new CatchallGeneratorStrategy(randomizer, null); + const strategy = new CatchallGeneratorStrategy(null, null, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it.todo("generate catchall email addresses"); + it("generates a random catchall by default", async () => { + const randomizer = mock(); + randomizer.randomAsciiCatchall.mockResolvedValue("catchall@example.com"); + const strategy = new CatchallGeneratorStrategy(null, randomizer, null); + + const result = await strategy.generate({ catchallDomain: "example.com", website: "" }); + + expect(result).toEqual("catchall@example.com"); + expect(randomizer.randomAsciiCatchall).toHaveBeenCalledWith("example.com"); + }); + + it("generates random catchall email addresses", async () => { + const randomizer = mock(); + randomizer.randomAsciiCatchall.mockResolvedValue("catchall@example.com"); + const strategy = new CatchallGeneratorStrategy(null, randomizer, null); + + const result = await strategy.generate({ + catchallType: "random", + catchallDomain: "example.com", + website: "", + }); + + expect(result).toEqual("catchall@example.com"); + expect(randomizer.randomAsciiCatchall).toHaveBeenCalledWith("example.com"); + }); + + it("generates catchall email addresses from website", async () => { + const calculator = mock(); + calculator.concatenate.mockReturnValue("catchall@example.com"); + const strategy = new CatchallGeneratorStrategy(calculator, null, null); + + const result = await strategy.generate({ + catchallType: "website-name", + catchallDomain: "example.com", + website: "foo.com", + }); + + expect(result).toEqual("catchall@example.com"); + expect(calculator.concatenate).toHaveBeenCalledWith("foo.com", "example.com"); + }); }); }); diff --git a/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts index af7e2b61f4..53769dfa30 100644 --- a/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts @@ -1,8 +1,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { GeneratorStrategy, Randomizer } from "../abstractions"; +import { GeneratorStrategy } from "../abstractions"; import { DefaultCatchallOptions } from "../data"; +import { EmailCalculator, EmailRandomizer } from "../engine"; import { newDefaultEvaluator } from "../rx"; import { NoPolicy, CatchallGenerationOptions } from "../types"; import { clone$PerUserId, sharedStateByUserId } from "../util"; @@ -17,7 +18,8 @@ export class CatchallGeneratorStrategy * @param usernameService generates a catchall address for a domain */ constructor( - private random: Randomizer, + private emailCalculator: EmailCalculator, + private emailRandomizer: EmailRandomizer, private stateProvider: StateProvider, private defaultOptions: CatchallGenerationOptions = DefaultCatchallOptions, ) {} @@ -30,21 +32,14 @@ export class CatchallGeneratorStrategy // algorithm async generate(options: CatchallGenerationOptions) { - const o = Object.assign({}, DefaultCatchallOptions, options); - - if (o.catchallDomain == null || o.catchallDomain === "") { - return null; - } - if (o.catchallType == null) { - o.catchallType = "random"; + if (options.catchallType == null) { + options.catchallType = "random"; } - let startString = ""; - if (o.catchallType === "random") { - startString = await this.random.chars(8); - } else if (o.catchallType === "website-name") { - startString = o.website; + if (options.catchallType === "website-name") { + return await this.emailCalculator.concatenate(options.website, options.catchallDomain); } - return startString + "@" + o.catchallDomain; + + return this.emailRandomizer.randomAsciiCatchall(options.catchallDomain); } } diff --git a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.spec.ts index 8583664731..d3582127ad 100644 --- a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.spec.ts @@ -8,8 +8,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { Randomizer } from "../abstractions"; import { DefaultEffUsernameOptions } from "../data"; +import { UsernameRandomizer } from "../engine"; import { DefaultPolicyEvaluator } from "../policies"; import { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy"; @@ -41,8 +41,7 @@ describe("EFF long word list generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const randomizer = mock(); - const strategy = new EffUsernameGeneratorStrategy(randomizer, provider); + const strategy = new EffUsernameGeneratorStrategy(null, provider); strategy.durableState(SomeUser); @@ -62,14 +61,104 @@ describe("EFF long word list generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new EffUsernameGeneratorStrategy(randomizer, null); + const strategy = new EffUsernameGeneratorStrategy(null, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it.todo("generate username tests"); + const randomizer = mock(); + + beforeEach(() => { + randomizer.randomWords.mockResolvedValue("username"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("generates a username", async () => { + const strategy = new EffUsernameGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + wordCapitalize: false, + wordIncludeNumber: false, + website: null, + }); + + expect(result).toEqual("username"); + expect(randomizer.randomWords).toHaveBeenCalledWith({ + numberOfWords: 1, + casing: "lowercase", + digits: 0, + }); + }); + + it("includes a 4-digit number in the username", async () => { + const strategy = new EffUsernameGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + wordCapitalize: false, + wordIncludeNumber: true, + website: null, + }); + + expect(result).toEqual("username"); + expect(randomizer.randomWords).toHaveBeenCalledWith({ + numberOfWords: 1, + casing: "lowercase", + digits: 4, + }); + }); + + it("capitalizes the username", async () => { + const strategy = new EffUsernameGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + wordCapitalize: true, + wordIncludeNumber: false, + website: null, + }); + + expect(result).toEqual("username"); + expect(randomizer.randomWords).toHaveBeenCalledWith({ + numberOfWords: 1, + casing: "TitleCase", + digits: 0, + }); + }); + + it("defaults to lowercase", async () => { + const strategy = new EffUsernameGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + wordIncludeNumber: false, + website: null, + }); + + expect(result).toEqual("username"); + expect(randomizer.randomWords).toHaveBeenCalledWith({ + numberOfWords: 1, + casing: "lowercase", + digits: 0, + }); + }); + + it("defaults to a word without digits", async () => { + const strategy = new EffUsernameGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + wordCapitalize: false, + website: null, + }); + + expect(result).toEqual("username"); + expect(randomizer.randomWords).toHaveBeenCalledWith({ + numberOfWords: 1, + casing: "lowercase", + digits: 0, + }); + }); }); }); diff --git a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts index bcedfb60a7..cd802b5a5b 100644 --- a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts @@ -1,9 +1,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { GeneratorStrategy, Randomizer } from "../abstractions"; -import { DefaultEffUsernameOptions } from "../data"; +import { GeneratorStrategy } from "../abstractions"; +import { DefaultEffUsernameOptions, UsernameDigits } from "../data"; +import { UsernameRandomizer } from "../engine"; import { newDefaultEvaluator } from "../rx"; import { EffUsernameGenerationOptions, NoPolicy } from "../types"; import { clone$PerUserId, sharedStateByUserId } from "../util"; @@ -18,7 +18,7 @@ export class EffUsernameGeneratorStrategy * @param usernameService generates a username from EFF word list */ constructor( - private random: Randomizer, + private randomizer: UsernameRandomizer, private stateProvider: StateProvider, private defaultOptions: EffUsernameGenerationOptions = DefaultEffUsernameOptions, ) {} @@ -31,10 +31,15 @@ export class EffUsernameGeneratorStrategy // algorithm async generate(options: EffUsernameGenerationOptions) { - const word = await this.random.pickWord(EFFLongWordList, { - titleCase: options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize, - number: options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber, - }); + const casing = + options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize + ? "TitleCase" + : "lowercase"; + const digits = + options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber + ? UsernameDigits.enabled + : UsernameDigits.disabled; + const word = await this.randomizer.randomWords({ numberOfWords: 1, casing, digits }); return word; } } diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts index 3620fd76f2..f3a6046fac 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts @@ -8,8 +8,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { Randomizer } from "../abstractions"; import { DefaultPassphraseGenerationOptions, DisabledPassphraseGeneratorPolicy } from "../data"; +import { PasswordRandomizer } from "../engine"; import { PassphraseGeneratorOptionsEvaluator } from "../policies"; import { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy"; @@ -58,8 +58,7 @@ describe("Password generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const randomizer = mock(); - const strategy = new PassphraseGeneratorStrategy(randomizer, provider); + const strategy = new PassphraseGeneratorStrategy(null, provider); strategy.durableState(SomeUser); @@ -79,14 +78,111 @@ describe("Password generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(null, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it.todo("should generate a password using the given options"); + const randomizer = mock(); + beforeEach(() => { + randomizer.randomEffLongWords.mockResolvedValue("passphrase"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should map options", async () => { + const strategy = new PassphraseGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + numWords: 4, + capitalize: true, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual("passphrase"); + expect(randomizer.randomEffLongWords).toHaveBeenCalledWith({ + numberOfWords: 4, + capitalize: true, + number: true, + separator: "!", + }); + }); + + it("should default numWords", async () => { + const strategy = new PassphraseGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + capitalize: true, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual("passphrase"); + expect(randomizer.randomEffLongWords).toHaveBeenCalledWith({ + numberOfWords: DefaultPassphraseGenerationOptions.numWords, + capitalize: true, + number: true, + separator: "!", + }); + }); + + it("should default capitalize", async () => { + const strategy = new PassphraseGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + numWords: 4, + includeNumber: true, + wordSeparator: "!", + }); + + expect(result).toEqual("passphrase"); + expect(randomizer.randomEffLongWords).toHaveBeenCalledWith({ + numberOfWords: 4, + capitalize: DefaultPassphraseGenerationOptions.capitalize, + number: true, + separator: "!", + }); + }); + + it("should default includeNumber", async () => { + const strategy = new PassphraseGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + numWords: 4, + capitalize: true, + wordSeparator: "!", + }); + + expect(result).toEqual("passphrase"); + expect(randomizer.randomEffLongWords).toHaveBeenCalledWith({ + numberOfWords: 4, + capitalize: true, + number: DefaultPassphraseGenerationOptions.includeNumber, + separator: "!", + }); + }); + + it("should default wordSeparator", async () => { + const strategy = new PassphraseGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + numWords: 4, + capitalize: true, + includeNumber: true, + }); + + expect(result).toEqual("passphrase"); + expect(randomizer.randomEffLongWords).toHaveBeenCalledWith({ + numberOfWords: 4, + capitalize: true, + number: true, + separator: DefaultPassphraseGenerationOptions.wordSeparator, + }); + }); }); }); diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts index 7fdadaf8e2..78d7ed42a8 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts @@ -1,9 +1,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { GeneratorStrategy, Randomizer } from "../abstractions"; -import { DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { GeneratorStrategy } from "../abstractions"; +import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { PasswordRandomizer } from "../engine"; import { mapPolicyToEvaluator } from "../rx"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; import { clone$PerUserId, sharedStateByUserId } from "../util"; @@ -19,7 +19,7 @@ export class PassphraseGeneratorStrategy * @param stateProvider provides durable state */ constructor( - private randomizer: Randomizer, + private randomizer: PasswordRandomizer, private stateProvider: StateProvider, ) {} @@ -33,34 +33,14 @@ export class PassphraseGeneratorStrategy // algorithm async generate(options: PassphraseGenerationOptions): Promise { - const o = { ...DefaultPassphraseGenerationOptions, ...options }; - if (o.numWords == null || o.numWords <= 2) { - o.numWords = DefaultPassphraseGenerationOptions.numWords; - } - if (o.capitalize == null) { - o.capitalize = false; - } - if (o.includeNumber == null) { - o.includeNumber = false; - } + const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords; + const request = { + numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min), + capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize, + number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber, + separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator, + }; - // select which word gets the number, if any - let luckyNumber = -1; - if (o.includeNumber) { - luckyNumber = await this.randomizer.uniform(0, o.numWords - 1); - } - - // generate the passphrase - const wordList = new Array(o.numWords); - for (let i = 0; i < o.numWords; i++) { - const word = await this.randomizer.pickWord(EFFLongWordList, { - titleCase: o.capitalize, - number: i === luckyNumber, - }); - - wordList[i] = word; - } - - return wordList.join(o.wordSeparator); + return this.randomizer.randomEffLongWords(request); } } diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts index c1c1355d1a..536d69c9c1 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts @@ -8,8 +8,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { Randomizer } from "../abstractions"; import { DefaultPasswordGenerationOptions, DisabledPasswordGeneratorPolicy } from "../data"; +import { PasswordRandomizer } from "../engine"; import { PasswordGeneratorOptionsEvaluator } from "../policies"; import { PasswordGeneratorStrategy } from "./password-generator-strategy"; @@ -66,8 +66,7 @@ describe("Password generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const randomizer = mock(); - const strategy = new PasswordGeneratorStrategy(randomizer, provider); + const strategy = new PasswordGeneratorStrategy(null, provider); strategy.durableState(SomeUser); @@ -87,14 +86,390 @@ describe("Password generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(null, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it.todo("should generate a password using the given options"); + const randomizer = mock(); + beforeEach(() => { + randomizer.randomAscii.mockResolvedValue("password"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should map options", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 20, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 2, + minNumber: 3, + minSpecial: 4, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 10, + uppercase: 1, + lowercase: 2, + digits: 3, + special: 4, + ambiguous: true, + }); + }); + + it("should disable uppercase", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 3, + ambiguous: true, + uppercase: false, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: undefined, + lowercase: 1, + digits: 1, + special: 1, + ambiguous: true, + }); + }); + + it("should disable lowercase", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: false, + number: true, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 1, + lowercase: undefined, + digits: 1, + special: 1, + ambiguous: true, + }); + }); + + it("should disable digits", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: true, + number: false, + special: true, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 1, + lowercase: 1, + digits: undefined, + special: 1, + ambiguous: true, + }); + }); + + it("should disable special", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 3, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: false, + minUppercase: 1, + minLowercase: 1, + minNumber: 1, + minSpecial: 1, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 1, + lowercase: 1, + digits: 1, + special: undefined, + ambiguous: true, + }); + }); + + it("should override length with minimums", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 20, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 1, + minLowercase: 2, + minNumber: 3, + minSpecial: 4, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 10, + uppercase: 1, + lowercase: 2, + digits: 3, + special: 4, + ambiguous: true, + }); + }); + + it("should default uppercase", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 2, + ambiguous: true, + lowercase: true, + number: true, + special: true, + minUppercase: 2, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 2, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default lowercase", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 0, + ambiguous: true, + uppercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 2, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 0, + lowercase: 2, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default number", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 2, + minSpecial: 0, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 2, + special: 0, + ambiguous: true, + }); + }); + + it("should default special", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 0, + special: undefined, + ambiguous: true, + }); + }); + + it("should default minUppercase", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 1, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default minLowercase", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minNumber: 0, + minSpecial: 0, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 0, + lowercase: 1, + digits: 0, + special: 0, + ambiguous: true, + }); + }); + + it("should default minNumber", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minSpecial: 0, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 1, + special: 0, + ambiguous: true, + }); + }); + + it("should default minSpecial", async () => { + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + const result = await strategy.generate({ + length: 0, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + special: true, + minUppercase: 0, + minLowercase: 0, + minNumber: 0, + }); + + expect(result).toEqual("password"); + expect(randomizer.randomAscii).toHaveBeenCalledWith({ + all: 0, + uppercase: 0, + lowercase: 0, + digits: 0, + special: 0, + ambiguous: true, + }); + }); }); }); diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts index d8e59d3105..587c5609e0 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts @@ -1,11 +1,12 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { GeneratorStrategy, Randomizer } from "../abstractions"; +import { GeneratorStrategy } from "../abstractions"; import { Policies, DefaultPasswordGenerationOptions } from "../data"; +import { PasswordRandomizer } from "../engine"; import { mapPolicyToEvaluator } from "../rx"; import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types"; -import { clone$PerUserId, sharedStateByUserId } from "../util"; +import { clone$PerUserId, sharedStateByUserId, sum } from "../util"; import { PASSWORD_SETTINGS } from "./storage"; @@ -17,7 +18,7 @@ export class PasswordGeneratorStrategy * @param legacy generates the password */ constructor( - private randomizer: Randomizer, + private randomizer: PasswordRandomizer, private stateProvider: StateProvider, ) {} @@ -31,94 +32,64 @@ export class PasswordGeneratorStrategy // algorithm async generate(options: PasswordGenerationOptions): Promise { - const o = { ...DefaultPasswordGenerationOptions, ...options }; - let positions: string[] = []; - if (o.lowercase && o.minLowercase > 0) { - for (let i = 0; i < o.minLowercase; i++) { - positions.push("l"); - } - } - if (o.uppercase && o.minUppercase > 0) { - for (let i = 0; i < o.minUppercase; i++) { - positions.push("u"); - } - } - if (o.number && o.minNumber > 0) { - for (let i = 0; i < o.minNumber; i++) { - positions.push("n"); - } - } - if (o.special && o.minSpecial > 0) { - for (let i = 0; i < o.minSpecial; i++) { - positions.push("s"); - } - } - while (positions.length < o.length) { - positions.push("a"); + // converts password generation option sets, which are defined by + // an "enabled" and "quantity" parameter, to the password engine's + // parameters, which represent disabled options as `undefined` + // properties. + function process( + // values read from the options + enabled: boolean, + quantity: number, + // value used if an option is missing + defaultEnabled: boolean, + defaultQuantity: number, + ) { + const isEnabled = enabled ?? defaultEnabled; + const actualQuantity = quantity ?? defaultQuantity; + const result = isEnabled ? actualQuantity : undefined; + + return result; } - // shuffle - positions = await this.randomizer.shuffle(positions); + const request = { + uppercase: process( + options.uppercase, + options.minUppercase, + DefaultPasswordGenerationOptions.uppercase, + DefaultPasswordGenerationOptions.minUppercase, + ), + lowercase: process( + options.lowercase, + options.minLowercase, + DefaultPasswordGenerationOptions.lowercase, + DefaultPasswordGenerationOptions.minLowercase, + ), + digits: process( + options.number, + options.minNumber, + DefaultPasswordGenerationOptions.number, + DefaultPasswordGenerationOptions.minNumber, + ), + special: process( + options.special, + options.minSpecial, + DefaultPasswordGenerationOptions.special, + DefaultPasswordGenerationOptions.minSpecial, + ), + ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous, + all: 0, + }; - // build out the char sets - let allCharSet = ""; + // engine represents character sets as "include only"; you assert how many all + // characters there can be rather than a total length. This conversion has + // the character classes win, so that the result is always consistent with policy + // minimums. + const required = sum(request.uppercase, request.lowercase, request.digits, request.special); + const remaining = (options.length ?? 0) - required; + request.all = Math.max(remaining, 0); - let lowercaseCharSet = "abcdefghijkmnopqrstuvwxyz"; - if (o.ambiguous) { - lowercaseCharSet += "l"; - } - if (o.lowercase) { - allCharSet += lowercaseCharSet; - } + const result = await this.randomizer.randomAscii(request); - let uppercaseCharSet = "ABCDEFGHJKLMNPQRSTUVWXYZ"; - if (o.ambiguous) { - uppercaseCharSet += "IO"; - } - if (o.uppercase) { - allCharSet += uppercaseCharSet; - } - - let numberCharSet = "23456789"; - if (o.ambiguous) { - numberCharSet += "01"; - } - if (o.number) { - allCharSet += numberCharSet; - } - - const specialCharSet = "!@#$%^&*"; - if (o.special) { - allCharSet += specialCharSet; - } - - let password = ""; - for (let i = 0; i < o.length; i++) { - let positionChars: string; - switch (positions[i]) { - case "l": - positionChars = lowercaseCharSet; - break; - case "u": - positionChars = uppercaseCharSet; - break; - case "n": - positionChars = numberCharSet; - break; - case "s": - positionChars = specialCharSet; - break; - case "a": - positionChars = allCharSet; - break; - default: - break; - } - - const randomCharIndex = await this.randomizer.uniform(0, positionChars.length - 1); - password += positionChars.charAt(randomCharIndex); - } - - return password; + return result; } } diff --git a/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.spec.ts index e40832eb72..0be5132c67 100644 --- a/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.spec.ts @@ -8,8 +8,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { Randomizer } from "../abstractions"; import { DefaultSubaddressOptions } from "../data"; +import { EmailCalculator, EmailRandomizer } from "../engine"; import { DefaultPolicyEvaluator } from "../policies"; import { SUBADDRESS_SETTINGS } from "./storage"; @@ -28,7 +28,7 @@ describe("Email subaddress list generation strategy", () => { it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( "should map any input (= %p) to the default policy evaluator", async (policies) => { - const strategy = new SubaddressGeneratorStrategy(null, null); + const strategy = new SubaddressGeneratorStrategy(null, null, null, null); const evaluator$ = of(policies).pipe(strategy.toEvaluator()); const evaluator = await firstValueFrom(evaluator$); @@ -41,8 +41,7 @@ describe("Email subaddress list generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const randomizer = mock(); - const strategy = new SubaddressGeneratorStrategy(randomizer, provider); + const strategy = new SubaddressGeneratorStrategy(null, null, provider); strategy.durableState(SomeUser); @@ -52,7 +51,7 @@ describe("Email subaddress list generation strategy", () => { describe("defaults$", () => { it("should return the default subaddress options", async () => { - const strategy = new SubaddressGeneratorStrategy(null, null); + const strategy = new SubaddressGeneratorStrategy(null, null, null); const result = await firstValueFrom(strategy.defaults$(SomeUser)); @@ -62,14 +61,52 @@ describe("Email subaddress list generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new SubaddressGeneratorStrategy(randomizer, null); + const strategy = new SubaddressGeneratorStrategy(null, null, null); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); }); describe("generate()", () => { - it.todo("generate email subaddress tests"); + it("generates a random subaddress by default", async () => { + const randomizer = mock(); + randomizer.randomAsciiSubaddress.mockResolvedValue("subaddress@example.com"); + const strategy = new SubaddressGeneratorStrategy(null, randomizer, null); + + const result = await strategy.generate({ subaddressEmail: "foo@example.com", website: "" }); + + expect(result).toEqual("subaddress@example.com"); + expect(randomizer.randomAsciiSubaddress).toHaveBeenCalledWith("foo@example.com"); + }); + + it("generate random catchall email addresses", async () => { + const randomizer = mock(); + randomizer.randomAsciiSubaddress.mockResolvedValue("subaddress@example.com"); + const strategy = new SubaddressGeneratorStrategy(null, randomizer, null); + + const result = await strategy.generate({ + subaddressType: "random", + subaddressEmail: "foo@example.com", + website: "", + }); + + expect(result).toEqual("subaddress@example.com"); + expect(randomizer.randomAsciiSubaddress).toHaveBeenCalledWith("foo@example.com"); + }); + + it("generate catchall email addresses from website", async () => { + const calculator = mock(); + calculator.appendToSubaddress.mockReturnValue("subaddress@example.com"); + const strategy = new SubaddressGeneratorStrategy(calculator, null, null); + + const result = await strategy.generate({ + subaddressType: "website-name", + subaddressEmail: "foo@example.com", + website: "bar.com", + }); + + expect(result).toEqual("subaddress@example.com"); + expect(calculator.appendToSubaddress).toHaveBeenCalledWith("bar.com", "foo@example.com"); + }); }); }); diff --git a/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts index 51d698ea95..645b15a650 100644 --- a/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts @@ -1,8 +1,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { GeneratorStrategy, Randomizer } from "../abstractions"; +import { GeneratorStrategy } from "../abstractions"; import { DefaultSubaddressOptions } from "../data"; +import { EmailCalculator, EmailRandomizer } from "../engine"; import { newDefaultEvaluator } from "../rx"; import { SubaddressGenerationOptions, NoPolicy } from "../types"; import { clone$PerUserId, sharedStateByUserId } from "../util"; @@ -21,7 +22,8 @@ export class SubaddressGeneratorStrategy * @param usernameService generates an email subaddress from an email address */ constructor( - private random: Randomizer, + private emailCalculator: EmailCalculator, + private emailRandomizer: EmailRandomizer, private stateProvider: StateProvider, private defaultOptions: SubaddressGenerationOptions = DefaultSubaddressOptions, ) {} @@ -34,29 +36,14 @@ export class SubaddressGeneratorStrategy // algorithm async generate(options: SubaddressGenerationOptions) { - const o = Object.assign({}, DefaultSubaddressOptions, options); - - const subaddressEmail = o.subaddressEmail; - if (subaddressEmail == null || subaddressEmail.length < 3) { - return o.subaddressEmail; - } - const atIndex = subaddressEmail.indexOf("@"); - if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) { - return subaddressEmail; - } - if (o.subaddressType == null) { - o.subaddressType = "random"; + if (options.subaddressType == null) { + options.subaddressType = "random"; } - const emailBeginning = subaddressEmail.substr(0, atIndex); - const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length); - - let subaddressString = ""; - if (o.subaddressType === "random") { - subaddressString = await this.random.chars(8); - } else if (o.subaddressType === "website-name") { - subaddressString = o.website; + if (options.subaddressType === "website-name") { + return this.emailCalculator.appendToSubaddress(options.website, options.subaddressEmail); } - return emailBeginning + "+" + subaddressString + "@" + emailEnding; + + return this.emailRandomizer.randomAsciiSubaddress(options.subaddressEmail); } } diff --git a/libs/tools/generator/core/src/util.spec.ts b/libs/tools/generator/core/src/util.spec.ts new file mode 100644 index 0000000000..32bdc3ad3a --- /dev/null +++ b/libs/tools/generator/core/src/util.spec.ts @@ -0,0 +1,17 @@ +import { sum } from "./util"; + +describe("sum", () => { + it("returns 0 when the list is empty", () => { + expect(sum()).toBe(0); + }); + + it("returns its argument when there's a single number", () => { + expect(sum(1)).toBe(1); + }); + + it("adds its arguments together", () => { + expect(sum(1, 2)).toBe(3); + expect(sum(1, 3)).toBe(4); + expect(sum(1, 2, 3)).toBe(6); + }); +}); diff --git a/libs/tools/generator/core/src/util.ts b/libs/tools/generator/core/src/util.ts index db131d3b48..c62591a2ae 100644 --- a/libs/tools/generator/core/src/util.ts +++ b/libs/tools/generator/core/src/util.ts @@ -43,3 +43,7 @@ export function sharedByUserId(create: (userId: UserId) => SingleUserStat export function sharedStateByUserId(key: UserKeyDefinition, provider: StateProvider) { return (id: UserId) => provider.getUser(id, key); } + +/** returns the sum of items in the list. */ +export const sum = (...items: number[]) => + (items ?? []).reduce((sum: number, current: number) => sum + (current ?? 0), 0); diff --git a/libs/tools/generator/extensions/legacy/src/create-legacy-password-generation-service.ts b/libs/tools/generator/extensions/legacy/src/create-legacy-password-generation-service.ts index 192b566441..8ef14a3a9e 100644 --- a/libs/tools/generator/extensions/legacy/src/create-legacy-password-generation-service.ts +++ b/libs/tools/generator/extensions/legacy/src/create-legacy-password-generation-service.ts @@ -10,9 +10,9 @@ import { DefaultGeneratorNavigationService } from "@bitwarden/generator-navigati import { LegacyPasswordGenerationService } from "./legacy-password-generation.service"; import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; -const PassphraseGeneratorStrategy = strategies.PassphraseGeneratorStrategy; -const PasswordGeneratorStrategy = strategies.PasswordGeneratorStrategy; -const CryptoServiceRandomizer = engine.CryptoServiceRandomizer; +const { PassphraseGeneratorStrategy, PasswordGeneratorStrategy } = strategies; +const { CryptoServiceRandomizer, PasswordRandomizer } = engine; + const DefaultGeneratorService = services.DefaultGeneratorService; export function legacyPasswordGenerationServiceFactory( @@ -23,14 +23,15 @@ export function legacyPasswordGenerationServiceFactory( stateProvider: StateProvider, ): PasswordGenerationServiceAbstraction { const randomizer = new CryptoServiceRandomizer(cryptoService); + const passwordRandomizer = new PasswordRandomizer(randomizer); const passwords = new DefaultGeneratorService( - new PasswordGeneratorStrategy(randomizer, stateProvider), + new PasswordGeneratorStrategy(passwordRandomizer, stateProvider), policyService, ); const passphrases = new DefaultGeneratorService( - new PassphraseGeneratorStrategy(randomizer, stateProvider), + new PassphraseGeneratorStrategy(passwordRandomizer, stateProvider), policyService, ); diff --git a/libs/tools/generator/extensions/legacy/src/create-legacy-username-generation-service.ts b/libs/tools/generator/extensions/legacy/src/create-legacy-username-generation-service.ts index 956bc39263..1bcf540356 100644 --- a/libs/tools/generator/extensions/legacy/src/create-legacy-username-generation-service.ts +++ b/libs/tools/generator/extensions/legacy/src/create-legacy-username-generation-service.ts @@ -11,17 +11,19 @@ import { DefaultGeneratorNavigationService } from "@bitwarden/generator-navigati import { LegacyUsernameGenerationService } from "./legacy-username-generation.service"; import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; +const { CryptoServiceRandomizer, UsernameRandomizer, EmailRandomizer, EmailCalculator } = engine; const DefaultGeneratorService = services.DefaultGeneratorService; -const CryptoServiceRandomizer = engine.CryptoServiceRandomizer; -const CatchallGeneratorStrategy = strategies.CatchallGeneratorStrategy; -const SubaddressGeneratorStrategy = strategies.SubaddressGeneratorStrategy; -const EffUsernameGeneratorStrategy = strategies.EffUsernameGeneratorStrategy; -const AddyIoForwarder = strategies.AddyIoForwarder; -const DuckDuckGoForwarder = strategies.DuckDuckGoForwarder; -const FastmailForwarder = strategies.FastmailForwarder; -const FirefoxRelayForwarder = strategies.FirefoxRelayForwarder; -const ForwardEmailForwarder = strategies.ForwardEmailForwarder; -const SimpleLoginForwarder = strategies.SimpleLoginForwarder; +const { + CatchallGeneratorStrategy, + SubaddressGeneratorStrategy, + EffUsernameGeneratorStrategy, + AddyIoForwarder, + DuckDuckGoForwarder, + FastmailForwarder, + FirefoxRelayForwarder, + ForwardEmailForwarder, + SimpleLoginForwarder, +} = strategies; export function legacyUsernameGenerationServiceFactory( apiService: ApiService, @@ -33,19 +35,22 @@ export function legacyUsernameGenerationServiceFactory( stateProvider: StateProvider, ): UsernameGenerationServiceAbstraction { const randomizer = new CryptoServiceRandomizer(cryptoService); + const usernameRandomizer = new UsernameRandomizer(randomizer); + const emailRandomizer = new EmailRandomizer(randomizer); + const emailCalculator = new EmailCalculator(); const effUsername = new DefaultGeneratorService( - new EffUsernameGeneratorStrategy(randomizer, stateProvider), + new EffUsernameGeneratorStrategy(usernameRandomizer, stateProvider), policyService, ); const subaddress = new DefaultGeneratorService( - new SubaddressGeneratorStrategy(randomizer, stateProvider), + new SubaddressGeneratorStrategy(emailCalculator, emailRandomizer, stateProvider), policyService, ); const catchall = new DefaultGeneratorService( - new CatchallGeneratorStrategy(randomizer, stateProvider), + new CatchallGeneratorStrategy(emailCalculator, emailRandomizer, stateProvider), policyService, );