1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[PM-328] Move generator to tools (#4980)

* Move generator to tools

libs/angular:
- Move generator.component to tools
libs/common:
- Move password generation to tools
- Move username generation including email-forwarders to tools

apps/*
- create tools-subfolder and move files regarding generator functionality
- Update all the imports

.github/:
- Cleaned up whitelist-capital-letters.txt
- Added team-tools-dev folders to CODEOWNERS

* Remove unused barrel file
This commit is contained in:
Daniel James Smith
2023-03-10 21:39:46 +01:00
committed by GitHub
parent e9d0f75b8a
commit d4c812160f
92 changed files with 238 additions and 202 deletions

View File

@@ -0,0 +1,9 @@
export class GeneratedPasswordHistory {
password: string;
date: number;
constructor(password: string, date: number) {
this.password = password;
this.date = date;
}
}

View File

@@ -0,0 +1,3 @@
export { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
export { PasswordGenerationService } from "./password-generation.service";
export { GeneratedPasswordHistory } from "./generated-password-history";

View File

@@ -0,0 +1,25 @@
import * as zxcvbn from "zxcvbn";
import { PasswordGeneratorPolicyOptions } from "../../../models/domain/password-generator-policy-options";
import { GeneratedPasswordHistory } from "./generated-password-history";
import { PasswordGeneratorOptions } from "./password-generator-options";
export abstract class PasswordGenerationServiceAbstraction {
generatePassword: (options: PasswordGeneratorOptions) => Promise<string>;
generatePassphrase: (options: PasswordGeneratorOptions) => Promise<string>;
getOptions: () => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>;
enforcePasswordGeneratorPoliciesOnOptions: (
options: PasswordGeneratorOptions
) => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>;
getPasswordGeneratorPolicyOptions: () => Promise<PasswordGeneratorPolicyOptions>;
saveOptions: (options: PasswordGeneratorOptions) => Promise<void>;
getHistory: () => Promise<GeneratedPasswordHistory[]>;
addHistory: (password: string) => Promise<void>;
clear: (userId?: string) => Promise<void>;
passwordStrength: (password: string, userInputs?: string[]) => zxcvbn.ZXCVBNResult;
normalizeOptions: (
options: PasswordGeneratorOptions,
enforcedPolicyOptions: PasswordGeneratorPolicyOptions
) => void;
}

View File

@@ -0,0 +1,574 @@
import * as zxcvbn from "zxcvbn";
import { CryptoService } from "../../../abstractions/crypto.service";
import { PolicyService } from "../../../abstractions/policy/policy.service.abstraction";
import { StateService } from "../../../abstractions/state.service";
import { PolicyType } from "../../../enums/policyType";
import { EFFLongWordList } from "../../../misc/wordlist";
import { EncString } from "../../../models/domain/enc-string";
import { PasswordGeneratorPolicyOptions } from "../../../models/domain/password-generator-policy-options";
import { GeneratedPasswordHistory } from "./generated-password-history";
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
import { PasswordGeneratorOptions } from "./password-generator-options";
const DefaultOptions: PasswordGeneratorOptions = {
length: 14,
ambiguous: false,
number: true,
minNumber: 1,
uppercase: true,
minUppercase: 0,
lowercase: true,
minLowercase: 0,
special: false,
minSpecial: 1,
type: "password",
numWords: 3,
wordSeparator: "-",
capitalize: false,
includeNumber: false,
};
const MaxPasswordsInHistory = 100;
export class PasswordGenerationService implements PasswordGenerationServiceAbstraction {
constructor(
private cryptoService: CryptoService,
private policyService: PolicyService,
private stateService: StateService
) {}
async generatePassword(options: PasswordGeneratorOptions): Promise<string> {
// overload defaults with given options
const o = Object.assign({}, DefaultOptions, options);
if (o.type === "passphrase") {
return this.generatePassphrase(options);
}
// sanitize
this.sanitizePasswordLength(o, true);
const minLength: number = o.minUppercase + o.minLowercase + o.minNumber + o.minSpecial;
if (o.length < minLength) {
o.length = minLength;
}
const 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");
}
// shuffle
await this.shuffleArray(positions);
// build out the char sets
let allCharSet = "";
let lowercaseCharSet = "abcdefghijkmnopqrstuvwxyz";
if (o.ambiguous) {
lowercaseCharSet += "l";
}
if (o.lowercase) {
allCharSet += lowercaseCharSet;
}
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.cryptoService.randomNumber(0, positionChars.length - 1);
password += positionChars.charAt(randomCharIndex);
}
return password;
}
async generatePassphrase(options: PasswordGeneratorOptions): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
if (o.numWords == null || o.numWords <= 2) {
o.numWords = DefaultOptions.numWords;
}
if (o.wordSeparator == null || o.wordSeparator.length === 0 || o.wordSeparator.length > 1) {
o.wordSeparator = " ";
}
if (o.capitalize == null) {
o.capitalize = false;
}
if (o.includeNumber == null) {
o.includeNumber = false;
}
const listLength = EFFLongWordList.length - 1;
const wordList = new Array(o.numWords);
for (let i = 0; i < o.numWords; i++) {
const wordIndex = await this.cryptoService.randomNumber(0, listLength);
if (o.capitalize) {
wordList[i] = this.capitalize(EFFLongWordList[wordIndex]);
} else {
wordList[i] = EFFLongWordList[wordIndex];
}
}
if (o.includeNumber) {
await this.appendRandomNumberToRandomWord(wordList);
}
return wordList.join(o.wordSeparator);
}
async getOptions(): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> {
let options = await this.stateService.getPasswordGenerationOptions();
if (options == null) {
options = Object.assign({}, DefaultOptions);
} else {
options = Object.assign({}, DefaultOptions, options);
}
await this.stateService.setPasswordGenerationOptions(options);
const enforcedOptions = await this.enforcePasswordGeneratorPoliciesOnOptions(options);
options = enforcedOptions[0];
return [options, enforcedOptions[1]];
}
async enforcePasswordGeneratorPoliciesOnOptions(
options: PasswordGeneratorOptions
): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> {
let enforcedPolicyOptions = await this.getPasswordGeneratorPolicyOptions();
if (enforcedPolicyOptions != null) {
if (options.length < enforcedPolicyOptions.minLength) {
options.length = enforcedPolicyOptions.minLength;
}
if (enforcedPolicyOptions.useUppercase) {
options.uppercase = true;
}
if (enforcedPolicyOptions.useLowercase) {
options.lowercase = true;
}
if (enforcedPolicyOptions.useNumbers) {
options.number = true;
}
if (options.minNumber < enforcedPolicyOptions.numberCount) {
options.minNumber = enforcedPolicyOptions.numberCount;
}
if (enforcedPolicyOptions.useSpecial) {
options.special = true;
}
if (options.minSpecial < enforcedPolicyOptions.specialCount) {
options.minSpecial = enforcedPolicyOptions.specialCount;
}
// Must normalize these fields because the receiving call expects all options to pass the current rules
if (options.minSpecial + options.minNumber > options.length) {
options.minSpecial = options.length - options.minNumber;
}
if (options.numWords < enforcedPolicyOptions.minNumberWords) {
options.numWords = enforcedPolicyOptions.minNumberWords;
}
if (enforcedPolicyOptions.capitalize) {
options.capitalize = true;
}
if (enforcedPolicyOptions.includeNumber) {
options.includeNumber = true;
}
// Force default type if password/passphrase selected via policy
if (
enforcedPolicyOptions.defaultType === "password" ||
enforcedPolicyOptions.defaultType === "passphrase"
) {
options.type = enforcedPolicyOptions.defaultType;
}
} else {
// UI layer expects an instantiated object to prevent more explicit null checks
enforcedPolicyOptions = new PasswordGeneratorPolicyOptions();
}
return [options, enforcedPolicyOptions];
}
async getPasswordGeneratorPolicyOptions(): Promise<PasswordGeneratorPolicyOptions> {
const policies = await this.policyService?.getAll(PolicyType.PasswordGenerator);
let enforcedOptions: PasswordGeneratorPolicyOptions = null;
if (policies == null || policies.length === 0) {
return enforcedOptions;
}
policies.forEach((currentPolicy) => {
if (!currentPolicy.enabled || currentPolicy.data == null) {
return;
}
if (enforcedOptions == null) {
enforcedOptions = new PasswordGeneratorPolicyOptions();
}
// Password wins in multi-org collisions
if (currentPolicy.data.defaultType != null && enforcedOptions.defaultType !== "password") {
enforcedOptions.defaultType = currentPolicy.data.defaultType;
}
if (
currentPolicy.data.minLength != null &&
currentPolicy.data.minLength > enforcedOptions.minLength
) {
enforcedOptions.minLength = currentPolicy.data.minLength;
}
if (currentPolicy.data.useUpper) {
enforcedOptions.useUppercase = true;
}
if (currentPolicy.data.useLower) {
enforcedOptions.useLowercase = true;
}
if (currentPolicy.data.useNumbers) {
enforcedOptions.useNumbers = true;
}
if (
currentPolicy.data.minNumbers != null &&
currentPolicy.data.minNumbers > enforcedOptions.numberCount
) {
enforcedOptions.numberCount = currentPolicy.data.minNumbers;
}
if (currentPolicy.data.useSpecial) {
enforcedOptions.useSpecial = true;
}
if (
currentPolicy.data.minSpecial != null &&
currentPolicy.data.minSpecial > enforcedOptions.specialCount
) {
enforcedOptions.specialCount = currentPolicy.data.minSpecial;
}
if (
currentPolicy.data.minNumberWords != null &&
currentPolicy.data.minNumberWords > enforcedOptions.minNumberWords
) {
enforcedOptions.minNumberWords = currentPolicy.data.minNumberWords;
}
if (currentPolicy.data.capitalize) {
enforcedOptions.capitalize = true;
}
if (currentPolicy.data.includeNumber) {
enforcedOptions.includeNumber = true;
}
});
return enforcedOptions;
}
async saveOptions(options: PasswordGeneratorOptions) {
await this.stateService.setPasswordGenerationOptions(options);
}
async getHistory(): Promise<GeneratedPasswordHistory[]> {
const hasKey = await this.cryptoService.hasKey();
if (!hasKey) {
return new Array<GeneratedPasswordHistory>();
}
if ((await this.stateService.getDecryptedPasswordGenerationHistory()) == null) {
const encrypted = await this.stateService.getEncryptedPasswordGenerationHistory();
const decrypted = await this.decryptHistory(encrypted);
await this.stateService.setDecryptedPasswordGenerationHistory(decrypted);
}
const passwordGenerationHistory =
await this.stateService.getDecryptedPasswordGenerationHistory();
return passwordGenerationHistory != null
? passwordGenerationHistory
: new Array<GeneratedPasswordHistory>();
}
async addHistory(password: string): Promise<void> {
// Cannot add new history if no key is available
const hasKey = await this.cryptoService.hasKey();
if (!hasKey) {
return;
}
const currentHistory = await this.getHistory();
// Prevent duplicates
if (this.matchesPrevious(password, currentHistory)) {
return;
}
currentHistory.unshift(new GeneratedPasswordHistory(password, Date.now()));
// Remove old items.
if (currentHistory.length > MaxPasswordsInHistory) {
currentHistory.pop();
}
const newHistory = await this.encryptHistory(currentHistory);
await this.stateService.setDecryptedPasswordGenerationHistory(currentHistory);
return await this.stateService.setEncryptedPasswordGenerationHistory(newHistory);
}
async clear(userId?: string): Promise<void> {
await this.stateService.setEncryptedPasswordGenerationHistory(null, { userId: userId });
await this.stateService.setDecryptedPasswordGenerationHistory(null, { userId: userId });
}
passwordStrength(password: string, userInputs: string[] = null): zxcvbn.ZXCVBNResult {
if (password == null || password.length === 0) {
return null;
}
let globalUserInputs = ["bitwarden", "bit", "warden"];
if (userInputs != null && userInputs.length > 0) {
globalUserInputs = globalUserInputs.concat(userInputs);
}
// Use a hash set to get rid of any duplicate user inputs
const finalUserInputs = Array.from(new Set(globalUserInputs));
const result = zxcvbn(password, finalUserInputs);
return result;
}
normalizeOptions(
options: PasswordGeneratorOptions,
enforcedPolicyOptions: PasswordGeneratorPolicyOptions
) {
options.minLowercase = 0;
options.minUppercase = 0;
if (!options.length || options.length < 5) {
options.length = 5;
} else if (options.length > 128) {
options.length = 128;
}
if (options.length < enforcedPolicyOptions.minLength) {
options.length = enforcedPolicyOptions.minLength;
}
if (!options.minNumber) {
options.minNumber = 0;
} else if (options.minNumber > options.length) {
options.minNumber = options.length;
} else if (options.minNumber > 9) {
options.minNumber = 9;
}
if (options.minNumber < enforcedPolicyOptions.numberCount) {
options.minNumber = enforcedPolicyOptions.numberCount;
}
if (!options.minSpecial) {
options.minSpecial = 0;
} else if (options.minSpecial > options.length) {
options.minSpecial = options.length;
} else if (options.minSpecial > 9) {
options.minSpecial = 9;
}
if (options.minSpecial < enforcedPolicyOptions.specialCount) {
options.minSpecial = enforcedPolicyOptions.specialCount;
}
if (options.minSpecial + options.minNumber > options.length) {
options.minSpecial = options.length - options.minNumber;
}
if (options.numWords == null || options.length < 3) {
options.numWords = 3;
} else if (options.numWords > 20) {
options.numWords = 20;
}
if (options.numWords < enforcedPolicyOptions.minNumberWords) {
options.numWords = enforcedPolicyOptions.minNumberWords;
}
if (options.wordSeparator != null && options.wordSeparator.length > 1) {
options.wordSeparator = options.wordSeparator[0];
}
this.sanitizePasswordLength(options, false);
}
private capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
private async appendRandomNumberToRandomWord(wordList: string[]) {
if (wordList == null || wordList.length <= 0) {
return;
}
const index = await this.cryptoService.randomNumber(0, wordList.length - 1);
const num = await this.cryptoService.randomNumber(0, 9);
wordList[index] = wordList[index] + num;
}
private async encryptHistory(
history: GeneratedPasswordHistory[]
): Promise<GeneratedPasswordHistory[]> {
if (history == null || history.length === 0) {
return Promise.resolve([]);
}
const promises = history.map(async (item) => {
const encrypted = await this.cryptoService.encrypt(item.password);
return new GeneratedPasswordHistory(encrypted.encryptedString, item.date);
});
return await Promise.all(promises);
}
private async decryptHistory(
history: GeneratedPasswordHistory[]
): Promise<GeneratedPasswordHistory[]> {
if (history == null || history.length === 0) {
return Promise.resolve([]);
}
const promises = history.map(async (item) => {
const decrypted = await this.cryptoService.decryptToUtf8(new EncString(item.password));
return new GeneratedPasswordHistory(decrypted, item.date);
});
return await Promise.all(promises);
}
private matchesPrevious(password: string, history: GeneratedPasswordHistory[]): boolean {
if (history == null || history.length === 0) {
return false;
}
return history[history.length - 1].password === password;
}
// ref: https://stackoverflow.com/a/12646864/1090359
private async shuffleArray(array: string[]) {
for (let i = array.length - 1; i > 0; i--) {
const j = await this.cryptoService.randomNumber(0, i);
[array[i], array[j]] = [array[j], array[i]];
}
}
private sanitizePasswordLength(options: any, forGeneration: boolean) {
let minUppercaseCalc = 0;
let minLowercaseCalc = 0;
let minNumberCalc: number = options.minNumber;
let minSpecialCalc: number = options.minSpecial;
if (options.uppercase && options.minUppercase <= 0) {
minUppercaseCalc = 1;
} else if (!options.uppercase) {
minUppercaseCalc = 0;
}
if (options.lowercase && options.minLowercase <= 0) {
minLowercaseCalc = 1;
} else if (!options.lowercase) {
minLowercaseCalc = 0;
}
if (options.number && options.minNumber <= 0) {
minNumberCalc = 1;
} else if (!options.number) {
minNumberCalc = 0;
}
if (options.special && options.minSpecial <= 0) {
minSpecialCalc = 1;
} else if (!options.special) {
minSpecialCalc = 0;
}
// This should never happen but is a final safety net
if (!options.length || options.length < 1) {
options.length = 10;
}
const minLength: number = minUppercaseCalc + minLowercaseCalc + minNumberCalc + minSpecialCalc;
// Normalize and Generation both require this modification
if (options.length < minLength) {
options.length = minLength;
}
// Apply other changes if the options object passed in is for generation
if (forGeneration) {
options.minUppercase = minUppercaseCalc;
options.minLowercase = minLowercaseCalc;
options.minNumber = minNumberCalc;
options.minSpecial = minSpecialCalc;
}
}
}

View File

@@ -0,0 +1,17 @@
export type PasswordGeneratorOptions = {
length?: number;
ambiguous?: boolean;
uppercase?: boolean;
minUppercase?: number;
lowercase?: boolean;
minLowercase?: number;
number?: boolean;
minNumber?: number;
special?: boolean;
minSpecial?: number;
numWords?: number;
wordSeparator?: string;
capitalize?: boolean;
includeNumber?: boolean;
type?: "password" | "passphrase";
};

View File

@@ -0,0 +1,41 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class AnonAddyForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid AnonAddy API token.";
}
if (options.anonaddy?.domain == null || options.anonaddy.domain === "") {
throw "Invalid AnonAddy domain.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.apiKey,
"Content-Type": "application/json",
}),
};
const url = "https://app.anonaddy.com/api/v1/aliases";
requestInit.body = JSON.stringify({
domain: options.anonaddy.domain,
description:
(options.website != null ? "Website: " + options.website + ". " : "") +
"Generated by Bitwarden.",
});
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json?.data?.email;
}
if (response.status === 401) {
throw "Invalid AnonAddy API token.";
}
throw "Unknown AnonAddy error occurred.";
}
}

View File

@@ -0,0 +1,33 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class DuckDuckGoForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid DuckDuckGo API token.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.apiKey,
"Content-Type": "application/json",
}),
};
const url = "https://quack.duckduckgo.com/api/email/addresses";
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
if (json.address) {
return `${json.address}@duck.com`;
}
} else if (response.status === 401) {
throw "Invalid DuckDuckGo API token.";
}
throw "Unknown DuckDuckGo error occurred.";
}
}

View File

@@ -0,0 +1,96 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class FastmailForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid Fastmail API token.";
}
const accountId = await this.getAccountId(apiService, options);
if (accountId == null || accountId === "") {
throw "Unable to obtain Fastmail masked email account ID.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.apiKey,
"Content-Type": "application/json",
}),
};
const url = "https://api.fastmail.com/jmap/api/";
requestInit.body = JSON.stringify({
using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"],
methodCalls: [
[
"MaskedEmail/set",
{
accountId: accountId,
create: {
"new-masked-email": {
state: "enabled",
description: "",
url: options.website,
emailPrefix: options.fastmail.prefix,
},
},
},
"0",
],
],
});
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200) {
const json = await response.json();
if (
json.methodResponses != null &&
json.methodResponses.length > 0 &&
json.methodResponses[0].length > 0
) {
if (json.methodResponses[0][0] === "MaskedEmail/set") {
if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) {
return json.methodResponses[0][1]?.created?.["new-masked-email"]?.email;
}
if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) {
throw (
"Fastmail error: " +
json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description
);
}
} else if (json.methodResponses[0][0] === "error") {
throw "Fastmail error: " + json.methodResponses[0][1]?.description;
}
}
}
if (response.status === 401 || response.status === 403) {
throw "Invalid Fastmail API token.";
}
throw "Unknown Fastmail error occurred.";
}
private async getAccountId(apiService: ApiService, options: ForwarderOptions): Promise<string> {
const requestInit: RequestInit = {
cache: "no-store",
method: "GET",
headers: new Headers({
Authorization: "Bearer " + options.apiKey,
}),
};
const url = "https://api.fastmail.com/.well-known/jmap";
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200) {
const json = await response.json();
if (json.primaryAccounts != null) {
return json.primaryAccounts["https://www.fastmail.com/dev/maskedemail"];
}
}
return null;
}
}

View File

@@ -0,0 +1,38 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class FirefoxRelayForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid Firefox Relay API token.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Token " + options.apiKey,
"Content-Type": "application/json",
}),
};
const url = "https://relay.firefox.com/api/v1/relayaddresses/";
requestInit.body = JSON.stringify({
enabled: true,
generated_for: options.website,
description:
(options.website != null ? options.website + " - " : "") + "Generated by Bitwarden.",
});
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json?.full_address;
}
if (response.status === 401) {
throw "Invalid Firefox Relay API token.";
}
throw "Unknown Firefox Relay error occurred.";
}
}

View File

@@ -0,0 +1,14 @@
export class ForwarderOptions {
apiKey: string;
website: string;
fastmail = new FastmailForwarderOptions();
anonaddy = new AnonAddyForwarderOptions();
}
export class FastmailForwarderOptions {
prefix: string;
}
export class AnonAddyForwarderOptions {
domain: string;
}

View File

@@ -0,0 +1,7 @@
import { ApiService } from "../../../../abstractions/api.service";
import { ForwarderOptions } from "./forwarder-options";
export interface Forwarder {
generate(apiService: ApiService, options: ForwarderOptions): Promise<string>;
}

View File

@@ -0,0 +1,7 @@
export { AnonAddyForwarder } from "./anon-addy-forwarder";
export { DuckDuckGoForwarder } from "./duck-duck-go-forwarder";
export { FastmailForwarder } from "./fastmail-forwarder";
export { FirefoxRelayForwarder } from "./firefox-relay-forwarder";
export { Forwarder } from "./forwarder";
export { ForwarderOptions } from "./forwarder-options";
export { SimpleLoginForwarder } from "./simple-login-forwarder";

View File

@@ -0,0 +1,48 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class SimpleLoginForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid SimpleLogin API key.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authentication: options.apiKey,
"Content-Type": "application/json",
}),
};
let url = "https://app.simplelogin.io/api/alias/random/new";
if (options.website != null) {
url += "?hostname=" + options.website;
}
requestInit.body = JSON.stringify({
note:
(options.website != null ? "Website: " + options.website + ". " : "") +
"Generated by Bitwarden.",
});
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json.alias;
}
if (response.status === 401) {
throw "Invalid SimpleLogin API key.";
}
try {
const json = await response.json();
if (json?.error != null) {
throw "SimpleLogin error:" + json.error;
}
} catch {
// Do nothing...
}
throw "Unknown SimpleLogin error occurred.";
}
}

View File

@@ -0,0 +1,2 @@
export { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction";
export { UsernameGenerationService } from "./username-generation.service";

View File

@@ -0,0 +1,9 @@
export abstract class UsernameGenerationServiceAbstraction {
generateUsername: (options: any) => Promise<string>;
generateWord: (options: any) => Promise<string>;
generateSubaddress: (options: any) => Promise<string>;
generateCatchall: (options: any) => Promise<string>;
generateForwarded: (options: any) => Promise<string>;
getOptions: () => Promise<any>;
saveOptions: (options: any) => Promise<void>;
}

View File

@@ -0,0 +1,180 @@
import { ApiService } from "../../../abstractions/api.service";
import { CryptoService } from "../../../abstractions/crypto.service";
import { StateService } from "../../../abstractions/state.service";
import { EFFLongWordList } from "../../../misc/wordlist";
import {
AnonAddyForwarder,
DuckDuckGoForwarder,
FastmailForwarder,
FirefoxRelayForwarder,
Forwarder,
ForwarderOptions,
SimpleLoginForwarder,
} from "./email-forwarders";
import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction";
const DefaultOptions = {
type: "word",
wordCapitalize: true,
wordIncludeNumber: true,
subaddressType: "random",
catchallType: "random",
forwardedService: "",
forwardedAnonAddyDomain: "anonaddy.me",
};
export class UsernameGenerationService implements UsernameGenerationServiceAbstraction {
constructor(
private cryptoService: CryptoService,
private stateService: StateService,
private apiService: ApiService
) {}
generateUsername(options: any): Promise<string> {
if (options.type === "catchall") {
return this.generateCatchall(options);
} else if (options.type === "subaddress") {
return this.generateSubaddress(options);
} else if (options.type === "forwarded") {
return this.generateForwarded(options);
} else {
return this.generateWord(options);
}
}
async generateWord(options: any): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
if (o.wordCapitalize == null) {
o.wordCapitalize = true;
}
if (o.wordIncludeNumber == null) {
o.wordIncludeNumber = true;
}
const wordIndex = await this.cryptoService.randomNumber(0, EFFLongWordList.length - 1);
let word = EFFLongWordList[wordIndex];
if (o.wordCapitalize) {
word = word.charAt(0).toUpperCase() + word.slice(1);
}
if (o.wordIncludeNumber) {
const num = await this.cryptoService.randomNumber(1, 9999);
word = word + this.zeroPad(num.toString(), 4);
}
return word;
}
async generateSubaddress(options: any): Promise<string> {
const o = Object.assign({}, DefaultOptions, 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";
}
const emailBeginning = subaddressEmail.substr(0, atIndex);
const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length);
let subaddressString = "";
if (o.subaddressType === "random") {
subaddressString = await this.randomString(8);
} else if (o.subaddressType === "website-name") {
subaddressString = o.website;
}
return emailBeginning + "+" + subaddressString + "@" + emailEnding;
}
async generateCatchall(options: any): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
if (o.catchallDomain == null || o.catchallDomain === "") {
return null;
}
if (o.catchallType == null) {
o.catchallType = "random";
}
let startString = "";
if (o.catchallType === "random") {
startString = await this.randomString(8);
} else if (o.catchallType === "website-name") {
startString = o.website;
}
return startString + "@" + o.catchallDomain;
}
async generateForwarded(options: any): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
if (o.forwardedService == null) {
return null;
}
let forwarder: Forwarder = null;
const forwarderOptions = new ForwarderOptions();
forwarderOptions.website = o.website;
if (o.forwardedService === "simplelogin") {
forwarder = new SimpleLoginForwarder();
forwarderOptions.apiKey = o.forwardedSimpleLoginApiKey;
} else if (o.forwardedService === "anonaddy") {
forwarder = new AnonAddyForwarder();
forwarderOptions.apiKey = o.forwardedAnonAddyApiToken;
forwarderOptions.anonaddy.domain = o.forwardedAnonAddyDomain;
} else if (o.forwardedService === "firefoxrelay") {
forwarder = new FirefoxRelayForwarder();
forwarderOptions.apiKey = o.forwardedFirefoxApiToken;
} else if (o.forwardedService === "fastmail") {
forwarder = new FastmailForwarder();
forwarderOptions.apiKey = o.forwardedFastmailApiToken;
} else if (o.forwardedService === "duckduckgo") {
forwarder = new DuckDuckGoForwarder();
forwarderOptions.apiKey = o.forwardedDuckDuckGoToken;
}
if (forwarder == null) {
return null;
}
return forwarder.generate(this.apiService, forwarderOptions);
}
async getOptions(): Promise<any> {
let options = await this.stateService.getUsernameGenerationOptions();
if (options == null) {
options = Object.assign({}, DefaultOptions);
} else {
options = Object.assign({}, DefaultOptions, options);
}
await this.stateService.setUsernameGenerationOptions(options);
return options;
}
async saveOptions(options: any) {
await this.stateService.setUsernameGenerationOptions(options);
}
private async randomString(length: number) {
let str = "";
const charSet = "abcdefghijklmnopqrstuvwxyz1234567890";
for (let i = 0; i < length; i++) {
const randomCharIndex = await this.cryptoService.randomNumber(0, charSet.length - 1);
str += charSet.charAt(randomCharIndex);
}
return str;
}
// 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;
}
}