ideal-password
Version:
Password entropy test, loosely inspired by xkcd
455 lines (411 loc) • 12.6 kB
JavaScript
;
const emojiRegex = require('emoji-regex');
const ranges = require('./ranges');
const commonPasswords = require('./common-passwords')
const TOKEN_CLASS_EMOJI = 'emoji';
const TOKEN_CLASS_COMMON = 'common-password';
const DEFAULT_CONFIGURATION = {
minAcceptable: 64,
minIdeal: 96,
sets: 'all'
};
/**
* Do not processes characters in these ranges. Ranges are inclusive.
*/
const SKIP_RANGES = [
[0x0000, 0x001f],
[0x0080, 0x00a0],
[0x00ad],
[0x2000, 0x200f],
[0x2011],
[0x2028, 0x202f],
[0xdb80, 0xdfff], // high-private and low surrogates
[0xe000, 0xf8ff], // private use area
[0xfe00, 0xfe0f], // variation selectors
[0xfff0, 0xffff]
];
/**
* @constant SPECIAL_ENTROPY_CLASSES
* Entropy classes that are difficult to express as a character range. Each
* class has a test to determine if the token is part of that class. These
* are tested before the ranges are tested.
* Each character can only be a member of one class, the first matching class
* takes precedent. Each category has an entropy score (usually how may
* possible characters with some consideration for the randomness of their
* usage), a description, and either a range or a test to indicate if the
* character is in the class. Total entropy is ln(scores of all the unique
* entropy classes multiplied*length);
*/
const SPECIAL_ENTROPY_CLASSES = [
{ score: Math.log(100), name: 'emoji', test: (c) => {
return codeToChar[c] && codeToChar[c].tokenClass === TOKEN_CLASS_EMOJI;
}},
{ score: Math.log(20), name: 'common-passwords', test: (c) => {
return codeToChar[c] && codeToChar[c].tokenClass === TOKEN_CLASS_COMMON;
}}
];
const EXCEPTION_ENTROPY_CLASSES = {
unknown: { score: Math.log(100), name: 'unknown'},
illegal: { score: Math.log(20), name: 'illegal'}
};
const ENTROPY_CLASS_ALIASES = {
western: ['common-passwords', 'latin-capital', 'latin-small',
'number', 'special']
};
// suggested maximum scale for measuring entropy
// the real score goes to infinity
const ENTROPY_SCALE_MAX = 128;
// configuration properties, either default or set by user.
let _configs = {};
// maps codes to characters (easier to handle emoji this way)
let codeToChar = {};
defaults(_configs, DEFAULT_CONFIGURATION);
/**
* @method addCommonPasswords
* @param {string|Array} password
* @param {...string} [morePasswords]
*/
function addCommonPasswords(password, ...morePasswords) {
let passwordAr = Array.isArray(password)
? password
: [password];
for (let pw of passwordAr) {
commonPasswords.push(normalizeCommonPassword(pw));
}
if (morePasswords && morePasswords.length > 0) {
addCommonPasswords(morePasswords);
}
}
/**
* @method defaults
* Sets members of an object that are not already set to default values.
* @param {object} o - object to fill
* @param {object} d - default object
*/
function defaults(o, d) {
Object.keys(d).forEach(k => {
((null === o[k]) || (undefined === o[k])) && (o[k] = d[k]);
});
}
/**
* @method function entropyClassAcceptable(name) {
* Test if an entropy class is acceptable by the current config
* @param {string} name name of class to test.
* @returns {boolean}
*/
function entropyClassAcceptable(name) {
// nothing to do for 'all'
if ('all' === _configs.sets) {
return true;
}
let cccs = _configs.sets;
'string' === typeof(cccs) && (cccs = [cccs]);
for (let ccc of cccs) {
let ecac = ENTROPY_CLASS_ALIASES[ccc];
if (ecac && ecac.includes(name)) {
return true;
}
if (ccc === name) {
return true;
}
}
return false;
}
/**
* @method extend
* Copies properties of an object into another.
* @param {object} o - object to extend
* @param {object} e - extension object
*/
function extend(o, d) {
Object.keys(d).forEach(k => { o[k] = d[k]; });
}
/**
* @method firstUtf32char
* Converts first char of utf16 string to utf32
* @param str string
*/
function firstUtf32char(str) {
const HSR = [0xD800,0xDB75]; // high surrogate range
const LSR = [0xDC00,0xDFFF]; // low surrogate range
let c = str.charCodeAt(0);
let chars = 1;
// only calculate surrogate ranges if there are more than one character left
if (str.length > 1) {
if (inRange(c, HSR[0], HSR[1])) {
let cNext = str.charCodeAt(1);
if (inRange(cNext, LSR[0], LSR[1])) {
// this char is a low surrogate and the next char is a high surrogate
chars = 2;
}
}
}
// if chars == 1, just send it through, if chars == 2, calculate utf32
switch(chars) {
case 1:
return {
codePoint: c,
char: str.substring(0, 1),
remainder: str.substring(1)
};
case 2:
return {
codePoint: ((str.charCodeAt(0) & 0x399) << 10) |
(str.charCodeAt(1)&0x0399),
char: str.substring(0, 2),
remainder: str.substring(2)
};
default:
throw 'Internal error (entropy117)';
}
}
/**
* Returns `true` if `c` is between `a` and `b` inclusive.
* @param {number} c
* @param {number} a
* @param {number} b
* @returns {boolean}
*/
function inRange(c, a, b) {
return (c >= a && c <= b);
}
/**
* Tokenizes the password and returns a set of unique tokens
* @param {string} str
*/
function letterSet(str) {
var seen = new Set();
var originalLength = str.length;
while(str.length) {
let u32 = firstUtf32char(str);
let c = u32.codePoint;
let emoji = emojiRegex().exec(str);
let charOfC = u32.char;
let tokenClass = null;
let isSkippable = false;
let commonL33t = unL33t(str);
str = u32.remainder;
// skip certain unicodes
for (let skip of SKIP_RANGES) {
if ((skip.length === 1 && c == skip[0]) ||
(skip.length === 2 && inRange(c, skip[0], skip[1]))) {
isSkippable = true;
break;
}
}
if (isSkippable) {
continue;
}
// detect complex tokens. tokenClass remains null unless a complex token
// is found.
if (!tokenClass) {
// detect emoji
if (emoji && emoji.index === 0) {
charOfC = emoji[0];
c = simpleHash(emoji[0]);
// now burn off the rest of this emoji (count from 1 because the char
// at zero is already burned)
for (let i = 1; i < [...emoji[0]].length; i++) {
str = firstUtf32char(str).remainder;
}
tokenClass = TOKEN_CLASS_EMOJI;
}
}
if (!tokenClass && (originalLength < 32)) {
// detect bad passwords. Because this operation gets expensive, it is
// skipped if the password is more than 32 characters.
for (let badPw of commonPasswords) {
if (commonL33t.startsWith(badPw)) {
c = simpleHash(badPw);
str = str.substring(badPw.length-1);
tokenClass = TOKEN_CLASS_COMMON;
charOfC = badPw;
}
}
}
if (!seen.has(c)) {
seen.add(c);
codeToChar[c] = {char: charOfC, tokenClass};
}
}
return seen;
}
/**
* @method simpleHash
* Creates a simple hash value for a string. Not cryptographic, just for an
* easy lookup.
* @param {string} str
*/
function simpleHash(str) {
var hash = 0;
if (str.length == 0) {
return hash;
}
for (var i = 0; i < str.length; i++) {
var char = str.charCodeAt(i);
hash = ((hash<<5)-hash)+char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
/**
* @method normalizeCommonPassword
* @param {string} password
* @return {string} normalized password
*/
function normalizeCommonPassword(password) {
return unL33t(password).replace(/\s/, '');
}
/**
* Estimate password entropy, assigning it a score roughly equal to the log of
* the number of guesses it would take to guess the password.
* <ul><li>Repeated characters are only counted once, `000` is the same as
* `0`</li>
* <li>Every character type (upper, lower, number, special, hiragana...) that
* appears in the password increases by a certain amount, loosely based
* on the natural log of the number of characters in that class.</li>
* <li>The score for character classes is multiplied by the number of unique
* characters, so a 16 (unique) char password will have double the
* entropy of a 8 (unique) char password.</li>
* <li>If Hanzi (Chinese) characters are used, passwords with only the 100
* most common words score less than the others.</li>
* <li>Any character in any language is permitted.</li>
* </ul>
*/
function passwordEntropy(_password) {
if (undefined === _password || null === _password) {
_password='';
} else if ('number' === typeof(_password)) {
_password=new Number(_password).toString();
} else if ('string' !== typeof(_password)) {
_password=_password.toString();
}
let classTally = {};
let uniquePasswordLetters = letterSet(_password);
let result = {
classes: [],
sets: [],
length: uniquePasswordLetters.size,
entropy: 0,
max_entropy_scale: ENTROPY_SCALE_MAX
};
let i;
let foundClass = function(ecj) {
if (classTally[ecj.name]) {
classTally[ecj.name]++;
} else {
entropyClassScores[ecj.name] = ecj.score;
classTally[ecj.name] = 1;
}
};
let entropyClassScores = {};
for(let pci of uniquePasswordLetters) {
// first find the right class
let found = false;
for(let j = 0; j < SPECIAL_ENTROPY_CLASSES.length; j++) {
let ecj = SPECIAL_ENTROPY_CLASSES[j];
// only search permitted entropy classes
if (entropyClassAcceptable(ecj.name)) {
if (ecj.test(pci)) {
foundClass(ecj);
found = true;
break;
}
}
}
if (!found) {
let rs = ranges.search(pci);
if (rs.known) {
if (entropyClassAcceptable(rs.range.name)) {
foundClass(rs.range);
found = true;
}
}
}
if (!found) {
if (entropyClassAcceptable('unknown')) {
foundClass(EXCEPTION_ENTROPY_CLASSES.unknown);
} else {
foundClass(EXCEPTION_ENTROPY_CLASSES.illegal);
}
}
}
result.legal = !classTally['illegal'];
// filter class names. Right now cancelations are hard-coded because there's
// only one rule, but if the number of rules gets larger, we'll have to
// revisit this.
if (classTally['common-hanzi'] && classTally['hanzi']) {
if (classTally['common-hanzi'] >= 2 * classTally['hanzi']) {
classTally['common-hanzi'] += classTally['hanzi'];
delete classTally['hanzi'];
} else {
classTally['hanzi'] += classTally['common-hanzi'];
delete classTally['common-hanzi'];
}
}
// now calculate the total entropy of the classes represented
result.sets = Object.keys(classTally);
for (i = 0; i < result.sets.length; i++) {
result.entropy += entropyClassScores[result.sets[i]];
}
// total class entropy * number of tokens gives us the score
result.entropy *= result.length;
// get the acceptability range
if (result.entropy < _configs.minAcceptable) {
result.acceptable = false;
result.ideal = false;
} else if (result.entropy < _configs.minIdeal) {
result.acceptable = true;
result.ideal = false;
} else {
result.ideal = true;
result.acceptable = true;
}
delete result.classes;
return result;
}
/**
* @method passwordEntropy_config
* Sets a configuration parameter
* @param {object|string} key - name of configuration parameter or an object
* with key=value pairs
* @param {any} [value] - value to set parameter, or `undefined` to set key
* to default, or if `key` is actually an object
*/
function passwordEntropy_config(key, value) {
switch (typeof(key)) {
case 'string':
if ((undefined === value) || (null === value)) {
// remove config
_configs[key] = DEFAULT_CONFIGURATION[key];
} else {
// add a single config
let newConfig = {};
newConfig[key] = value;
extend(_configs, newConfig);
}
break;
case 'object':
extend(_configs, key);
break;
default:
throw 'Unexpected key type';
}
}
/**
* @method unL33t
* Use the most common l33t conversions to normalize a l33t-ized password.
* Used for detecting variants on common passwords. Converts `0` to `o`, `3`
* to `e`, and `5` to `s`.
* @param {string} str
* @return {string} normalized string.
*/
function unL33t(str) {
return str.toLowerCase()
.replace(/0/g, 'o')
.replace(/3/g, 'e')
.replace(/5/g, 's');
}
passwordEntropy.config = passwordEntropy_config;
passwordEntropy.addCommonPasswords = addCommonPasswords;
module.exports = passwordEntropy;