573 lines
17 KiB
TypeScript
573 lines
17 KiB
TypeScript
import * as zxcvbn from "zxcvbn";
|
|
|
|
import { CryptoService } from "../abstractions/crypto.service";
|
|
import { PasswordGenerationService as PasswordGenerationServiceAbstraction } from "../abstractions/passwordGeneration.service";
|
|
import { PolicyService } from "../abstractions/policy.service";
|
|
import { StateService } from "../abstractions/state.service";
|
|
import { PolicyType } from "../enums/policyType";
|
|
import { EEFLongWordList } from "../misc/wordlist";
|
|
import { EncString } from "../models/domain/encString";
|
|
import { GeneratedPasswordHistory } from "../models/domain/generatedPasswordHistory";
|
|
import { PasswordGeneratorPolicyOptions } from "../models/domain/passwordGeneratorPolicyOptions";
|
|
import { Policy } from "../models/domain/policy";
|
|
|
|
const DefaultOptions = {
|
|
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: any): 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: any): 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 = EEFLongWordList.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(EEFLongWordList[wordIndex]);
|
|
} else {
|
|
wordList[i] = EEFLongWordList[wordIndex];
|
|
}
|
|
}
|
|
|
|
if (o.includeNumber) {
|
|
await this.appendRandomNumberToRandomWord(wordList);
|
|
}
|
|
return wordList.join(o.wordSeparator);
|
|
}
|
|
|
|
async getOptions(): Promise<[any, PasswordGeneratorPolicyOptions]> {
|
|
let options = await this.stateService.getPasswordGenerationOptions();
|
|
if (options == null) {
|
|
options = 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: any
|
|
): Promise<[any, 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: Policy[] =
|
|
this.policyService == null
|
|
? null
|
|
: 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: any) {
|
|
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<any> {
|
|
// 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);
|
|
return await this.stateService.setEncryptedPasswordGenerationHistory(newHistory);
|
|
}
|
|
|
|
async clear(userId?: string): Promise<any> {
|
|
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: any, 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;
|
|
}
|
|
}
|
|
}
|