[PM-9422] generator engines (#10032)

* introduce email-randomizer
* introduce email-calculator
* introduce password-randomizer
* introduce username-randomizer
* move randomizer abstraction
This commit is contained in:
✨ Audrey ✨ 2024-07-19 16:34:39 -04:00 committed by GitHub
parent 5b5c165e10
commit e22568f05a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2138 additions and 226 deletions

View File

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

View File

@ -8,7 +8,9 @@ export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions
minLength: DefaultPasswordBoundaries.length.min,
ambiguous: true,
uppercase: true,
minUppercase: 1,
lowercase: true,
minLowercase: 1,
number: true,
minNumber: 1,
special: false,

View File

@ -15,3 +15,4 @@ export * from "./disabled-passphrase-generator-policy";
export * from "./disabled-password-generator-policy";
export * from "./forwarders";
export * from "./policies";
export * from "./username-digits";

View File

@ -0,0 +1,4 @@
export const UsernameDigits = Object.freeze({
enabled: 4,
disabled: 0,
});

View File

@ -0,0 +1,173 @@
import { mock } from "jest-mock-extended";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { CryptoServiceRandomizer } from "./crypto-service-randomizer";
describe("CryptoServiceRandomizer", () => {
const cryptoService = mock<CryptoService>();
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);
});
});
});

View File

@ -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<Entry>(list: Array<Entry>) {
async pick<Entry>(list: Array<Entry>): Promise<Entry> {
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<T>(items: Array<T>, 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;
}
}

View File

@ -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(
"(?<username>[^@+]+)(?<subaddress>\\+.+)?(?<domain>@.+)",
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Randomizer>();
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<RandomAsciiRequest>, 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");
});
});
});

View File

@ -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<T>(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<CharacterSet> = [];
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;
}

View File

@ -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<string>;
/** 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";
};

View File

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

View File

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

View File

@ -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<StateProvider>();
const randomizer = mock<Randomizer>();
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<Randomizer>();
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<EmailRandomizer>();
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<EmailRandomizer>();
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<EmailCalculator>();
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");
});
});
});

View File

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

View File

@ -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<StateProvider>();
const randomizer = mock<Randomizer>();
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<Randomizer>();
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<UsernameRandomizer>();
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,
});
});
});
});

View File

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

View File

@ -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<StateProvider>();
const randomizer = mock<Randomizer>();
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<Randomizer>();
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<PasswordRandomizer>();
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,
});
});
});
});

View File

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

View File

@ -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<StateProvider>();
const randomizer = mock<Randomizer>();
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<Randomizer>();
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<PasswordRandomizer>();
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,
});
});
});
});

View File

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

View File

@ -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<StateProvider>();
const randomizer = mock<Randomizer>();
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<Randomizer>();
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<EmailRandomizer>();
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<EmailRandomizer>();
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<EmailCalculator>();
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");
});
});
});

View File

@ -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);
if (options.subaddressType === "website-name") {
return this.emailCalculator.appendToSubaddress(options.website, options.subaddressEmail);
}
let subaddressString = "";
if (o.subaddressType === "random") {
subaddressString = await this.random.chars(8);
} else if (o.subaddressType === "website-name") {
subaddressString = o.website;
}
return emailBeginning + "+" + subaddressString + "@" + emailEnding;
return this.emailRandomizer.randomAsciiSubaddress(options.subaddressEmail);
}
}

View File

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

View File

@ -43,3 +43,7 @@ export function sharedByUserId<Value>(create: (userId: UserId) => SingleUserStat
export function sharedStateByUserId<Value>(key: UserKeyDefinition<Value>, provider: StateProvider) {
return (id: UserId) => provider.getUser<Value>(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);

View File

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

View File

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