You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
572 lines
17 KiB
572 lines
17 KiB
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 = 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: 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; |
|
} |
|
} |
|
}
|
|
|