From ab76e3f90d38c2bb73fb24f7397553a3cdcacf92 Mon Sep 17 00:00:00 2001 From: Antelle Date: Tue, 16 Feb 2016 00:06:11 +0300 Subject: [PATCH] generator presets --- app/scripts/util/locale.js | 12 +- app/scripts/util/password-generator.js | 77 +++++- app/scripts/util/phonetic.js | 258 ++++++++++++++++++++ app/scripts/views/fields/field-view-text.js | 2 +- app/scripts/views/generator-view.js | 70 +++++- app/styles/areas/_generator.scss | 4 + app/templates/generator.hbs | 7 +- app/templates/settings/settings-about.hbs | 1 + app/templates/settings/settings-file.hbs | 2 +- 9 files changed, 421 insertions(+), 12 deletions(-) create mode 100644 app/scripts/util/phonetic.js diff --git a/app/scripts/util/locale.js b/app/scripts/util/locale.js index aa45298e..aab6c0a2 100644 --- a/app/scripts/util/locale.js +++ b/app/scripts/util/locale.js @@ -51,6 +51,16 @@ var Locale = { footerTitleLock: 'Lock', genLen: 'Length', + genPresetDefault: 'default preset', + genPresetDerived: 'like old password', + genPresetPronounceable: 'pronounceable', + genPresetMed: 'medium length', + genPresetLong: 'long', + genPresetPin4: '4-digit PIN', + genPresetMac: 'MAC address', + genPresetHash128: '128-bit hash', + genPresetHash256: '256-bit hash', + grpTitle: 'Group', grpSearch: 'Enable searching entries in this group', @@ -243,7 +253,7 @@ var Locale = { setFileHistory: 'History', setFileEnableTrash: 'Enable trash', setFileHistLen: 'History length, keep last records per entry', - resFileHistSize: 'History size, total MB per file', + setFileHistSize: 'History size, total MB per file', setFileAdvanced: 'Advanced', setFileRounds: 'Key encryption rounds', setFileUseKeyFile: 'Use key file', diff --git a/app/scripts/util/password-generator.js b/app/scripts/util/password-generator.js index 38156c70..fced16c9 100644 --- a/app/scripts/util/password-generator.js +++ b/app/scripts/util/password-generator.js @@ -1,6 +1,7 @@ 'use strict'; -var kdbxweb = require('kdbxweb'); +var kdbxweb = require('kdbxweb'), + phonetic = require('./phonetic'); var PasswordGenerator = { charRanges: { @@ -12,10 +13,21 @@ var PasswordGenerator = { high: '¡¢£¤¥¦§©ª«¬®¯°±²³´µ¶¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþ', ambiguous: 'O0oIl' }, + generate: function(opts) { if (!opts || typeof opts.length !== 'number' || opts.length < 0) { return ''; } + switch (opts.name) { + case 'Pronounceable': + return this.generatePronounceable(opts); + case 'Hash128': + return this.generateHash(32); + case 'Hash256': + return this.generateHash(64); + case 'Mac': + return this.generateMac(); + } var ranges = Object.keys(this.charRanges) .filter(function(r) { return opts[r]; }) .map(function(r) { return this.charRanges[r]; }, this); @@ -31,6 +43,69 @@ var PasswordGenerator = { } return _.shuffle(chars).join(''); }, + + generateMac: function() { + var segmentsCount = 6; + var randomBytes = kdbxweb.Random.getBytes(segmentsCount); + var result = ''; + for (var i = 0; i < segmentsCount; i++) { + var segment = randomBytes[i].toString(16).toUpperCase(); + if (segment.length < 2) { + segment = '0' + segment; + } + result += (result ? '-' : '') + segment; + } + return result; + }, + + generateHash: function(length) { + var randomBytes = kdbxweb.Random.getBytes(length); + var result = ''; + for (var i = 0; i < length; i++) { + result += randomBytes[i].toString(16)[0]; + } + return result; + }, + + generatePronounceable: function(opts) { + var pass = phonetic.generate({ length: opts.length }); + var result = ''; + var upper = []; + var i; + if (opts.upper) { + for (i = 0; i < pass.length; i += 8) { + upper.push(Math.floor(Math.random() * opts.length)); + } + } + for (i = 0; i < pass.length; i++) { + var ch = pass[i]; + if (upper.indexOf(i) >= 0) { + ch = ch.toUpperCase(); + } + result += ch; + } + return result.substr(0, opts.length); + }, + + deriveOpts: function(password) { + var opts = {}; + var length = 0; + if (password) { + var charRanges = this.charRanges; + password.forEachChar(function(ch) { + length++; + ch = String.fromCharCode(ch); + _.forEach(charRanges, function(chars, range) { + if (chars.indexOf(ch) >= 0) { + opts[range] = true; + } + }); + }); + } + opts.length = length; + return opts; + }, + present: function(length) { return new Array(length + 1).join('•'); } diff --git a/app/scripts/util/phonetic.js b/app/scripts/util/phonetic.js new file mode 100644 index 00000000..32bee7f0 --- /dev/null +++ b/app/scripts/util/phonetic.js @@ -0,0 +1,258 @@ +'use strict'; + +/* + * Phonetic + * Copyright 2013 Tom Frost + */ + +// removed node.js deps, making it available to load in browser + +/** + * Phonetics that sound best before a vowel. + * @type {Array} + */ +var PHONETIC_PRE = [ + // Simple phonetics + 'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', + 'qu', 'r', 's', 't', + // Complex phonetics + 'bl', + 'ch', 'cl', 'cr', + 'dr', + 'fl', 'fr', + 'gl', 'gr', + 'kl', 'kr', + 'ph', 'pr', 'pl', + 'sc', 'sh', 'sl', 'sn', 'sr', 'st', 'str', 'sw', + 'th', 'tr', + 'br', + 'v', 'w', 'y', 'z' +]; + +/** + * The number of simple phonetics within the 'pre' set. + * @type {number} + */ +var PHONETIC_PRE_SIMPLE_LENGTH = 16; + +/** + * Vowel sound phonetics. + * @type {Array} + */ +var PHONETIC_MID = [ + // Simple phonetics + 'a', 'e', 'i', 'o', 'u', + // Complex phonetics + 'ee', 'ie', 'oo', 'ou', 'ue' +]; + +/** + * The number of simple phonetics within the 'mid' set. + * @type {number} + */ +var PHONETIC_MID_SIMPLE_LENGTH = 5; + +/** + * Phonetics that sound best after a vowel. + * @type {Array} + */ +var PHONETIC_POST = [ + // Simple phonetics + 'b', 'd', 'f', 'g', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'y', + // Complex phonetics + 'ch', 'ck', + 'ln', + 'nk', 'ng', + 'rn', + 'sh', 'sk', 'st', + 'th', + 'x', 'z' +]; + +/** + * The number of simple phonetics within the 'post' set. + * @type {number} + */ +var PHONETIC_POST_SIMPLE_LENGTH = 13; + +/** + * A mapping of regular expressions to replacements, which will be run on the + * resulting word before it gets returned. The purpose of replacements is to + * address language subtleties that the phonetic builder is incapable of + * understanding, such as 've' more pronounceable than just 'v' at the end of + * a word, 'ey' more pronounceable than 'iy', etc. + * @type {{}} + */ +var REPLACEMENTS = { + 'quu': 'que', + 'qu([aeiou]){2}': 'qu$1', + '[iu]y': 'ey', + 'eye': 'ye', + '(.)ye$': '$1y', + '(^|e)cie(?!$)': '$1cei', + '([vz])$': '$1e', + '[iu]w': 'ow' +}; + +/** + * Adds a single syllable to the word contained in the wordObj. A syllable + * contains, at maximum, a phonetic from each the PRE, MID, and POST phonetic + * sets. Some syllables will omit pre or post based on the + * options.compoundSimplicity. + * + * @param {{word, numeric, lastSkippedPre, lastSkippedPost, opts}} wordObj The + * word object on which to operate. + */ +function addSyllable(wordObj) { + var deriv = getDerivative(wordObj.numeric), + compound = deriv % wordObj.opts.compoundSimplicity === 0, + first = wordObj.word === '', + preOnFirst = deriv % 6 > 0; + if ((first && preOnFirst) || wordObj.lastSkippedPost || compound) { + wordObj.word += getNextPhonetic(PHONETIC_PRE, + PHONETIC_PRE_SIMPLE_LENGTH, wordObj); + wordObj.lastSkippedPre = false; + } else { + wordObj.lastSkippedPre = true; + } + wordObj.word += getNextPhonetic(PHONETIC_MID, PHONETIC_MID_SIMPLE_LENGTH, + wordObj, first && wordObj.lastSkippedPre); + if (wordObj.lastSkippedPre || compound) { + wordObj.word += getNextPhonetic(PHONETIC_POST, + PHONETIC_POST_SIMPLE_LENGTH, wordObj); + wordObj.lastSkippedPost = false; + } else { + wordObj.lastSkippedPost = true; + } +} + +/** + * Gets a derivative of a number by repeatedly dividing it by 7 and adding the + * remainders together. It's useful to base decisions on a derivative rather + * than the wordObj's current numeric, as it avoids making the same decisions + * around the same phonetics. + * + * @param {number} num A number from which a derivative should be calculated + * @returns {number} The derivative. + */ +function getDerivative(num) { + var derivative = 1; + while (num) { + derivative += num % 7; + num = Math.floor(num / 7); + } + return derivative; +} + +/** + * Combines the option defaults with the provided overrides. Available + * options are: + * - seed: A string or number with which to seed the generator. Using the + * same seed (with the same other options) will coerce the generator + * into producing the same word. Default is random. + * - phoneticSimplicity: The greater this number, the simpler the phonetics. + * For example, 1 might produce 'str' while 5 might produce 's' for + * the same syllable. Minimum is 1, default is 5. + * - compoundSimplicity: The greater this number, the less likely the + * resulting word will sound "compound", such as "ripkuth" instead of + * "riputh". Minimum is 1, default is 5. + * + * @param {{}} overrides A set of options and values with which to override + * the defaults. + * @returns {{seed, phoneticSimplicity, compoundSimplicity}} + * An options object. + */ +function getOptions(overrides) { + var options = {}; + overrides = overrides || {}; + options.length = overrides.length || 16; + options.seed = overrides.seed || Math.random(); + options.phoneticSimplicity = overrides.phoneticSimplicity ? + Math.max(overrides.phoneticSimplicity, 1) : 5; + options.compoundSimplicity = overrides.compoundSimplicity ? + Math.max(overrides.compoundSimplicity, 1) : 5; + return options; +} + +/** + * Gets the next pseudo-random phonetic from a given phonetic set, + * intelligently determining whether to include "complex" phonetics in that + * set based on the options.phoneticSimplicity. + * + * @param {Array} phoneticSet The array of phonetics from which to choose + * @param {number} simpleCap The number of 'simple' phonetics at the beginning + * of the phoneticSet + * @param {{word, numeric, lastSkippedPre, lastSkippedPost, opts}} wordObj The + * wordObj for which the phonetic is being chosen + * @param {boolean} [forceSimple] true to force a simple phonetic to be + * chosen; otherwise, the function will choose whether to include complex + * phonetics based on the derivative of wordObj.numeric. + * @returns {string} The chosen phonetic. + */ +function getNextPhonetic(phoneticSet, simpleCap, wordObj, forceSimple) { + var deriv = getDerivative(wordObj.numeric), + simple = (wordObj.numeric + deriv) % wordObj.opts.phoneticSimplicity > 0, + cap = simple || forceSimple ? simpleCap : phoneticSet.length, + phonetic = phoneticSet[wordObj.numeric % cap]; + wordObj.numeric = getNumericHash(wordObj.numeric + wordObj.word); + return phonetic; +} + +/** + * Generates a numeric hash based on the input data. The hash is an md5, with + * each block of 32 bits converted to an integer and added together. + * + * @param {string|number} data The string or number to be hashed. + * @returns {number} + */ +function getNumericHash(data) { + var numeric = 0; + data += '-Phonetic'; + for (var i = 0; i < data.length; i++) { + numeric += data.charCodeAt(i); + } + return numeric; +} + +/** + * Applies post-processing to a word after it has already been generated. In + * this phase, the REPLACEMENTS are executed, applying language intelligence + * that can make generated words more pronounceable. The first letter is + * also capitalized. + * + * @param {{word, numeric, lastSkippedPre, lastSkippedPost, opts}} wordObj The + * word object to be processed. + * @returns {string} The processed word. + */ +function postProcess(wordObj) { + var regex; + for (var i in REPLACEMENTS) { + if (REPLACEMENTS.hasOwnProperty(i)) { + regex = new RegExp(i); + wordObj.word = wordObj.word.replace(regex, REPLACEMENTS[i]); + } + } + return wordObj.word; +} + +/** + * Generates a new word based on the given options. For available options, + * see getOptions. + * + * @param {*} [options] A collection of options to control the word generator. + * @returns {string} A generated word. + */ +module.exports.generate = function(options) { + options = getOptions(options); + var length = options.length, + wordObj = { + numeric: getNumericHash(options.seed), + lastSkippedPost: false, + word: '', + opts: options + }; + while (wordObj.word.length < length) { + addSyllable(wordObj); + } + return postProcess(wordObj).substr(0, length); +}; diff --git a/app/scripts/views/fields/field-view-text.js b/app/scripts/views/fields/field-view-text.js index 967c535c..b6cb1f6b 100644 --- a/app/scripts/views/fields/field-view-text.js +++ b/app/scripts/views/fields/field-view-text.js @@ -56,7 +56,7 @@ var FieldViewText = FieldView.extend({ this.hideGenerator(); } else { var fieldRect = this.input[0].getBoundingClientRect(); - this.gen = new GeneratorView({model: {pos: {left: fieldRect.left, top: fieldRect.bottom}}}).render(); + this.gen = new GeneratorView({model: {pos: {left: fieldRect.left, top: fieldRect.bottom}, password: this.value}}).render(); this.gen.once('remove', this.generatorClosed.bind(this)); this.gen.once('result', this.generatorResult.bind(this)); } diff --git a/app/scripts/views/generator-view.js b/app/scripts/views/generator-view.js index bff44b8e..bb51911c 100644 --- a/app/scripts/views/generator-view.js +++ b/app/scripts/views/generator-view.js @@ -5,10 +5,6 @@ var Backbone = require('backbone'), CopyPaste = require('../comp/copy-paste'), Locale = require('../util/locale'); -var DefaultGenOpts = { - length: 16, upper: true, lower: true, digits: true, special: false, brackets: false, high: false, ambiguous: false -}; - var GeneratorView = Backbone.View.extend({ el: 'body', @@ -20,26 +16,67 @@ var GeneratorView = Backbone.View.extend({ 'mousemove .gen__length-range': 'lengthChange', 'change .gen__length-range': 'lengthChange', 'change .gen__check input[type=checkbox]': 'checkChange', - 'click .gen__btn-ok': 'btnOkClick' + 'click .gen__btn-ok': 'btnOkClick', + 'change .gen__sel-tpl': 'templateChange' }, valuesMap: [3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,22,24,26,28,30,32,48,64], + presets: null, + preset: null, + initialize: function () { + this.createPresets(); + var preset = this.preset; + this.gen = _.clone(_.find(this.presets, function(pr) { return pr.name === preset; })); + this.gen = _.clone(this.presets[1]); $('body').one('click', this.remove.bind(this)); - this.gen = _.clone(DefaultGenOpts); }, render: function() { var canCopy = document.queryCommandSupported('copy'); var btnTitle = this.model.copy ? canCopy ? Locale.alertCopy : Locale.alertClose : Locale.alertOk; - this.renderTemplate({ btnTitle: btnTitle, opt: this.gen }); + this.renderTemplate({ btnTitle: btnTitle, opt: this.gen, presets: this.presets, preset: this.preset }); this.resultEl = this.$el.find('.gen__result'); this.$el.css(this.model.pos); this.generate(); return this; }, + createPresets: function() { + this.presets = [ + { name: 'Default', length: 16, upper: true, lower: true, digits: true }, + { name: 'Pronounceable', length: 10, lower: true, upper: true }, + { name: 'Med', length: 16, upper: true, lower: true, digits: true, special: true, brackets: true, ambiguous: true }, + { name: 'Long', length: 32, upper: true, lower: true, digits: true }, + { name: 'Pin4', length: 4, digits: true }, + { name: 'Mac', length: 17, upper: true, digits: true, special: true }, + { name: 'Hash128', length: 32, lower: true, digits: true }, + { name: 'Hash256', length: 64, lower: true, digits: true } + ]; + if (this.model.password) { + var derivedPreset = { name: 'Derived' }; + _.extend(derivedPreset, PasswordGenerator.deriveOpts(this.model.password)); + for (var i = 0; i < this.valuesMap.length; i++) { + if (this.valuesMap[i] >= derivedPreset.length) { + derivedPreset.length = this.valuesMap[i]; + break; + } + } + if (derivedPreset.length > this.valuesMap[this.valuesMap.length - 1]) { + derivedPreset.length = this.valuesMap[this.valuesMap.length - 1]; + } + this.presets.splice(1, 0, derivedPreset); + this.preset = 'Derived'; + } else { + this.preset = 'Default'; + } + this.presets.forEach(function(pr) { + pr.pseudoLength = this.valuesMap.indexOf(pr.length); + pr.title = Locale['genPreset' + pr.name]; + }, this); + }, + click: function(e) { e.stopPropagation(); }, @@ -49,6 +86,7 @@ var GeneratorView = Backbone.View.extend({ if (val !== this.gen.length) { this.gen.length = val; this.$el.find('.gen__length-range-val').html(val); + this.optionChanged('length'); this.generate(); } }, @@ -58,9 +96,19 @@ var GeneratorView = Backbone.View.extend({ if (id) { this.gen[id] = e.target.checked; } + this.optionChanged(id); this.generate(); }, + optionChanged: function(option) { + if (this.preset === 'Custom' || + this.preset === 'Pronounceable' && ['length', 'lower', 'upper'].indexOf(option) >= 0) { + return; + } + this.preset = this.gen.name = 'Custom'; + this.$el.find('.gen__sel-tpl').val(''); + }, + generate: function() { this.password = PasswordGenerator.generate(this.gen); this.resultEl.text(this.password); @@ -77,6 +125,14 @@ var GeneratorView = Backbone.View.extend({ CopyPaste.copy(this.password); this.trigger('result', this.password); this.remove(); + }, + + templateChange: function(e) { + var name = e.target.value; + this.preset = name; + var preset = _.find(this.presets, function(t) { return t.name === name; }); + this.gen = _.clone(preset); + this.render(); } }); diff --git a/app/styles/areas/_generator.scss b/app/styles/areas/_generator.scss index 76783208..74622d29 100644 --- a/app/styles/areas/_generator.scss +++ b/app/styles/areas/_generator.scss @@ -5,6 +5,10 @@ width: 11em; &__length-range { } + &__sel-tpl { + width: 100%; + margin-top: $base-padding-v; + } &__check { width: 40%; display: inline-block; diff --git a/app/templates/generator.hbs b/app/templates/generator.hbs index d7dbfecf..1f282a6b 100644 --- a/app/templates/generator.hbs +++ b/app/templates/generator.hbs @@ -1,6 +1,11 @@
{{res 'genLen'}}: {{opt.length}}
- + +
diff --git a/app/templates/settings/settings-about.hbs b/app/templates/settings/settings-about.hbs index 170c3db4..f25d8043 100644 --- a/app/templates/settings/settings-about.hbs +++ b/app/templates/settings/settings-about.hbs @@ -30,6 +30,7 @@
  • baron, native scroll with custom scrollbar
  • pikaday, a refreshing JavaScript datepicker
  • filesaver.js, HTML5 saveAs FileSaver implementation
  • +
  • node-phonetic, generates unique, pronounceable names
  • Desktop modules

    diff --git a/app/templates/settings/settings-file.hbs b/app/templates/settings/settings-file.hbs index 4389bf3c..02de8f52 100644 --- a/app/templates/settings/settings-file.hbs +++ b/app/templates/settings/settings-file.hbs @@ -50,7 +50,7 @@
    - +

    {{res 'setFileAdvanced'}}