[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:
parent
5b5c165e10
commit
e22568f05a
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export const UsernameDigits = Object.freeze({
|
||||
enabled: 4,
|
||||
disabled: 0,
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>@.+)",
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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";
|
||||
};
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in New Issue