From b9de642c428aaa457e4ca5b0122ee6ab71631a82 Mon Sep 17 00:00:00 2001 From: leolivier Date: Thu, 26 Apr 2018 18:20:19 +0200 Subject: [PATCH] Major refactoring #2: - split code in 2 classes: HIBPUtils and HIBP - move most code to HIBP class - throttling of calling to HIBP to avoid HIBP rate limits and cloudfare ddos protection - storage cache (based on IOBrowser) - documentation --- docs/plugins/haveibeenpwned/manifest.json | 4 +- docs/plugins/haveibeenpwned/plugin.js | 775 ++++++++++++++-------- 2 files changed, 509 insertions(+), 270 deletions(-) diff --git a/docs/plugins/haveibeenpwned/manifest.json b/docs/plugins/haveibeenpwned/manifest.json index b8f89f0..d73ea95 100644 --- a/docs/plugins/haveibeenpwned/manifest.json +++ b/docs/plugins/haveibeenpwned/manifest.json @@ -1,5 +1,5 @@ { - "version": "0.4.0", + "version": "0.6.0", "manifestVersion": "0.1.0", "name": "haveibeenpwned", "description": "Check HaveIBeenPwned password database", @@ -9,7 +9,7 @@ "url": "https://github.com/leolivier" }, "resources": { - "js": "i9hmfSZ7Lj+bO/+aBMjdv7UH1AwlJo2exE6rEVvxqkaZ6JHP2ie/YhiY49KwdKdNZQc88iDUuZgA9Vr7XYVGP0Z/xH4q/659tB+veeX4Rht5FvOLbUX8bzNb2DzCVEFg8TnZogPAw6wPPWkccMcavaX8S9UgFKrp6N4gYWYRqIOh8gIrYf3IFVC4WSSG0XO2fBoTUgNG1iRKGjJ5mb40CLjiW8a3IpM+T3P7XT5RfgzHcWj2KugQV+gc59f97qxmjM+mZldrN0M4SKOAaSxvMJI453rrKgZdk6NPjW08d28ZE7V4ukJXa8wWNgNEbxtwCIT8P0eDeSUFtlQhyWrEZw==", + "js": "KLD9KlvagbiBTivQ5UQcTYscXlTTeEESOD3ZP7r8IKuTEVg254+FXcqwbvI8hQN/H1n1k50k+dPtzmOH9Ln2wAEyTtXSaGkQOkpOXFtCSvisnML0wzgff+KeAmt6wmIR+weyh4S5JE0Bh/FeekWAFTp3QDRp+Mp29bWYAclr6z9feEpA25DUJKk++qyivES7pTz4JVyvnTXopueyic+d935FuKBLNfMtI4VmP7X56x2xa6XRrosGEs+mv1IxwS/0rgHInM9zXwpGozJ5ywDAJ+PLN3Gfx1l/KND+rcXUG7hb7sccbz6hny7BH2MYn5vsG6Uz6Agg1EkVsixOA8lM5w==", "css": "hL9Bv6NIxyP1vsHPZftppZSoM2zvP+H8ZxaEP4NwFZPoWlWZvn6l7f/x8FJly7mv7mFqpygMP4qZguPk9ZAcE5C5wj6mI9RloUg3f+500rN1mEVTpd+wH7AxR0eh/tjrYK0oq9BKpM099NknZ/t0J+aWODlk7HHbo9R2HUuvG2cpY2R137z0vkI8NPl2KQWi5vwIzKa+/EUwVIxoRApg603XzUgK8FFvAcrCsNdrXjoKEuQpZfEz8XBMjp62SuKS8KxdtyyJdsGbNrPkR8gc52ZBY0yFPC+FrSVhl6sxvu6UAMXW5xVtN5pE2Pn7+jnaVnaCafPYeqpDk5brf8G3JA==" }, "url": "https://plugins.keeweb.info/plugins/haveibeenpwned", diff --git a/docs/plugins/haveibeenpwned/plugin.js b/docs/plugins/haveibeenpwned/plugin.js index 861eef1..ac77fbe 100644 --- a/docs/plugins/haveibeenpwned/plugin.js +++ b/docs/plugins/haveibeenpwned/plugin.js @@ -5,10 +5,22 @@ */ const Logger = require('util/logger'); -// change log level here. Should be changed to Info when issue #893 fixed on keeweb -const LogLevel = Logger.Level.Debug; +/** + * local logger + * @type {Logger} + */ +const hLogger = new Logger('HaveIBeenPwned'); +/** change log level here. Should be changed to Info when issue #893 fixed on keeweb */ +hLogger.setLevel(Logger.Level.Debug); -// Strings that should be localized +/** + * Cache time to live + * Set to 14 days (in milliseconds) + * @type {integer} + */ +const CacheTTL = 1000 * 3600 * 24 * 14; + +/** Strings that should be localized */ const HIBPLocale = { hibpCheckPwnedPwd: 'Should I check passwords against HaveIBeenPwned list?', hibpCheckPwnedName: 'Should I check user name against HaveIBeenPwned list?', @@ -23,12 +35,18 @@ const HIBPLocale = { hibpApiError: 'HaveIBeenPwned API error' }; +/** What chcking level to use + * None: no checking + * Alert: Draw an alert near the pawned item + * AskMe: Interactively ask me to revert to the previous value if pawned + */ const HIBPCheckLevel = { None: 'none', Alert: 'alert', AskMe: 'askme' }; +/** Required modules */ const DetailsView = require('views/details/details-view'); const ListView = require('views/list-view'); const AppModel = require('models/app-model'); @@ -37,59 +55,85 @@ const Kdbxweb = require('kdbxweb'); const _ = require('_'); const Tip = require('util/tip'); const Alerts = require('comp/alerts'); +const StorageBase = require('storage/storage-base'); +const IoBrowserCache = require('storage/io-browser-cache'); +/** Keeps track of 4 replaced methods */ const detailsViewFieldChanged = DetailsView.prototype.fieldChanged; const detailsViewAddFieldViews = DetailsView.prototype.addFieldViews; const listViewRender = ListView.prototype.render; const appModelGetEntriesByFilter = AppModel.prototype.getEntriesByFilter; -let _seen = []; -class HIBPUtils { +/** + * Storage cache based on IoBrowserCache. + * Inspired from storage-cache.js with a specific config + * TODO: test this cache in Desktop app + */ +class StorageCache extends StorageBase { constructor() { - _seen = []; - // the 3 options with their default values - this.checkPwnedPwd = HIBPCheckLevel.Alert; - this.checkPwnedName = HIBPCheckLevel.Alert; - this.checkPwnedList = false; - // cache variables - this._pwnedNamesCache = {}; - this._pwnedPwdsCache = {}; - // local logger - this.logger = new Logger('HaveIBeenPwned'); - this.logger.setLevel(LogLevel); + super(); + this.name = 'cache'; + this.enabled = IoBrowserCache.enabled; + this.system = true; + this.init(); // storage base + this.io = new IoBrowserCache({ + cacheName: 'HIBPCache', + logger: hLogger + }); }; - // used for cyclic stringifier - _replacer(key, value) { - if (value != null && typeof value === 'object') { - if (_seen.indexOf(value) >= 0) { - return; + + save(id, data, callback) { + this.io.save(id, data, callback); + }; + load(id, callback) { + this.io.load(id, callback); + }; + remove(id, callback) { + this.io.remove(id, callback); + } +}; + +let _seen = []; +const HIBPUtils = { + /** + * cyclic objects enabled stringifier + * @param {object} obj the object to be stringified + * @returns {string} the stringified object + */ + stringify: (obj) => { + const ret = JSON.stringify(obj, (key, value) => { + if (value != null && typeof value === 'object') { + if (_seen.indexOf(value) >= 0) { + return; + } + _seen.push(value); } - _seen.push(value); - } - return value; - }; - // cyclic objects enabled stringifier - stringify(obj) { - const ret = JSON.stringify(obj, hibp._replacer); + return value; + }); _seen = []; return ret; - }; - // prints a stack trace in debug mode - stackTrace() { + }, + /** + * writes the stingified object on the console if in debug mode + * @param {object} obj the object to be dumped + */ + dump: (obj) => { + hLogger.debug(HIBPUtils.stringify(obj)); + }, + /** + * Prints a stack trace in debug mode + */ + stackTrace: () => { const err = new Error(); - hibp.logger.debug(err.stack); - } - // show the details of an entry in debug mode - showItem(model) { - if (model) { - hibp.logger.debug('show entry ' + model.title + - ': name=' + model.user + ', pwd=' + (model.password ? model.password.getText() : 'undefined') + - ', namePwned=' + model.namePwned + ', pwdPwned=' + model.pwdPwned - ); - } - } - // XML HTTP Request with Promises, modified from StorageBase - _xhrpromise(config) { + hLogger.debug(err.stack); + }, + /** + * XML HTTP Request with Promises, + * modified from StorageBase + * @param {object} config the XML HTTP Request configuration. Same as StorageBase + * @returns {Promise} + */ + xhrpromise: (config) => { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); if (config.responseType) { @@ -106,63 +150,95 @@ class HIBPUtils { if (statuses.indexOf(xhr.status) >= 0) { resolve(xhr.response); } else { + hLogger.error(HIBPLocale.hibpApiError, 'GET', xhr.status, xhr.statusText); reject(xhr.statusText); } }); xhr.addEventListener('error', () => { const err = xhr.response && xhr.response.error || new Error('Network error'); - hibp.logger.error(HIBPLocale.hibpApiError, 'GET', xhr.status, err); + hLogger.error(HIBPLocale.hibpApiError, 'GET', xhr.status, err); reject(xhr.statusText); }); xhr.send(config.data); }); - } - // transforms a byte array into an hex string - _hex(buffer) { - const hexCodes = []; - const view = new DataView(buffer); - for (let i = 0; i < view.byteLength; i += 4) { - // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time) - const value = view.getUint32(i); - // toString(16) will give the hex representation of the number without padding - const stringValue = value.toString(16); - // We use concatenation and slice for padding - const padding = '00000000'; - const paddedValue = (padding + stringValue).slice(-padding.length); - hexCodes.push(paddedValue); - } - // Join all the hex strings into one - return hexCodes.join(''); - }; - // applies a digest algorithm and returns the corresponding hex string - _digest(algo, str) { + }, + /** + * Applies a digest algorithm on input string + * @param {algo} algorithm to be applied (e.g. 'SHA-1' 'SHA-2456' + * @returns {string} the "digested" hex string + */ + digest: (algo, str) => { const buffer = Kdbxweb.ByteUtils.stringToBytes(str); const subtle = window.crypto.subtle || window.crypto.webkitSubtle; - return subtle.digest(algo, buffer).then(hash => { - return hibp._hex(hash); + return subtle.digest(algo, buffer).then(buffer => { + // transforms the buffer into an hex string + const hexCodes = []; + const view = new DataView(buffer); + for (let i = 0; i < view.byteLength; i += 4) { + // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time) + const value = view.getUint32(i); + // toString(16) will give the hex representation of the number without padding + const stringValue = value.toString(16); + // We use concatenation and slice for padding + const padding = '00000000'; + const paddedValue = (padding + stringValue).slice(-padding.length); + hexCodes.push(paddedValue); + } + // Join all the hex strings into one + return hexCodes.join(''); }); - }; - // returns the SHA-1 hex string of the input string - sha1(str) { - return hibp._digest('SHA-1', str); - }; - // returns the SHA-256 hex string of the input string - sha256(str) { - return hibp._digest('SHA-256', str); - }; - // add css stuff + tip on fields to show an alert on pawned fields + } +}; + +/** + * This is were most HaveIBeenPwned stuff lies. + */ +class HIBP { + constructor() { + // the 3 options with their default values + this.checkPwnedPwd = HIBPCheckLevel.Alert; + this.checkPwnedName = HIBPCheckLevel.Alert; + this.checkPwnedList = false; + // cache variables + this._pwnedNamesCache = {}; + this._pwnedPwdsCache = {}; + // cache manager + this.cache = new StorageCache(); + this.loadCache('_pwnedNamesCache'); + this.loadCache('_pwnedPwdsCache'); + } + /** + * Show the details of an entry in debug mode + * @param {Entry} entry the entry to be shown + */ + showItem(entry) { + if (entry) { + hLogger.debug('show entry ' + entry.title + + ': name=' + entry.user + ', pwd=' + (entry.password ? entry.password.getText() : 'undefined') + + ', namePwned=' + entry.namePwned + ', pwdPwned=' + entry.pwdPwned + ); + } + } + /** + * Add css stuff + tip on fields to show an alert on pawned fields + * @param {Element} el the HTML element of the field + * @param {string} msg the message to print in the tip + */ alert(el, msg) { - hibp.logger.info(msg); + hLogger.info(msg); el.focus(); el.addClass('input--error'); el.find('.details__field-value').addClass('hibp-pwned'); Tip.createTip(el, { title: msg, placement: 'bottom' }); InputFx.shake(el); - // hibp.stackTrace(); }; - // reset css stuff and tip on fields to remove alerts on pawned fields + /** + * Reset css stuff and tip on fields to remove alerts on pawned fields + * @param {Element} el the HTML element of the field + * @param {string} msg the message to print in the console + */ passed(el, msg) { - hibp.logger.info(msg); + hLogger.info(msg); el.removeClass('input--error'); el.find('.details__field-value').removeClass('hibp-pwned'); const tip = el._tip; @@ -171,22 +247,83 @@ class HIBPUtils { tip.title = null; } } - // store the cache variable name cacheName in local storage - storeCache(cacheName) { - // TODO: implement this method + /** + * Store the desired cache in local storage + * @param {string} cacheVarName the name of the cache variable ('_pwnedNamesCache' or '_pwnedPwdsCache') + */ + storeCache(cacheVarName) { + this.cache.save(cacheVarName, this[cacheVarName], (err) => { + if (err) { + hLogger.error('can\'t store cache ' + cacheVarName + ': ' + err); + } + }); } - // checks if the input name is pawned in breaches on haveibeenpwned. - // Uses a cache to avoid calling hibp again and again with the same values - // Returns a promise resolving to an html string containing a list of breaches names if pwned or null - checkNamePwned (name) { - hibp.logger.info('check hibp name ' + name); - if (hibp._pwnedNamesCache[name]) { - return Promise.resolve(hibp._pwnedNamesCache[name] !== '' ? hibp._pwnedNamesCache[name] : null); + /** + * Load desired cache from local storage + * Values older than CacheTTL are discarded + * @param {string} cacheVarName the name of the cache variable ('_pwnedNamesCache' or '_pwnedPwdsCache') + */ + loadCache(cacheVarName) { + this.cache.load(cacheVarName, (err, res) => { + if (err) { + hLogger.info('can\'t load cache ' + cacheVarName + ': ' + err); + } else { + // check each element to remove too old ones + for (const key in res) { + const value = res[key]; + const diff = Date.now() - value.date; // cache age in millisec + if (diff < CacheTTL) { + this[cacheVarName][key] = value; + } else { + hLogger.info('cache too old for ' + cacheVarName + '.' + key); + this[cacheVarName][key] = undefined; + } + // HIBPUtils.dump(this); + } + } + }); + } + /** + * true if cached element exist and is not too old + * @param {string} cachedElem the cache element to check + * @returns {boolean} + */ + checkCache(cachedElem) { + return (cachedElem && (Date.now() - cachedElem.date) < CacheTTL); + } + /** + * Computes and returns the SHA1 hash of a string + * @param {string} str the input string + * @returns {string} the SHA-1 hex string of the input string + */ + sha1(str) { + return HIBPUtils.digest('SHA-1', str); + }; + /** + * Computes and returns the SHA256 hash of a string + * @param {string} str the input string + * @returns {string} the SHA-256 hex string of the input string + */ + sha256(str) { + return HIBPUtils.digest('SHA-256', str); + }; + /** + * Checks if the input name is pawned in breaches on haveibeenpwned. + * Uses a cache to avoid calling hibp again and again with the same values + * @param {string} name the name to check + * @returns {Promise} a promise resolving to an html string containing a list of breaches names if pwned, or null + */ + checkNamePwned (uname) { + hLogger.debug('checking user name ' + uname); + const name = encodeURIComponent(uname); + if (this.checkCache(this._pwnedNamesCache[name])) { + hLogger.debug(' user name found in cache: ' + name); + return Promise.resolve(this._pwnedNamesCache[name].val); } else { - name = encodeURIComponent(name); + hLogger.debug('USER NAME NOT FOUND in cache: ' + name); // + ' cache=' + this.stringify(this._pwnedNamesCache)); const url = `https://haveibeenpwned.com/api/v2/breachedaccount/${name}?truncateResponse=true`; - // hibp.logger.debug('url ' + url); - return hibp._xhrpromise({ + // hLogger.debug('url ' + url); + return HIBPUtils.xhrpromise({ url: url, method: 'GET', responseType: 'json', @@ -194,34 +331,36 @@ class HIBPUtils { data: null, statuses: [200, 404] }).then(data => { + let breaches = null; if (data && data.length > 0) { - hibp.logger.debug('found breaches ' + JSON.stringify(data)); - let breaches = ''; + // hLogger.debug('found breaches ' + JSON.stringify(data)); + breaches = ''; data.forEach(breach => { breaches += '
  • ' + _.escape(breach.Name) + '
  • \n'; }); - hibp._pwnedNamesCache[name] = breaches || ''; - if (breaches) hibp.logger.debug(`name ${name} pwned in ${breaches}`); - hibp.storeCache('_pwnedNamesCache'); - return breaches; - } else { - hibp._pwnedNamesCache[name] = ''; - hibp.storeCache('_pwnedNamesCache'); - return null; } + this._pwnedNamesCache[name] = { date: Date.now(), val: breaches }; + if (breaches) hLogger.info(`name ${name} pwned in ${breaches}`); + this.storeCache('_pwnedNamesCache'); + return breaches; }); } }; - // checks if the input password (hashed in sha-1) is pawned in breaches on haveibeenpwned. - // Uses a cache to avoid calling hibp again and again with the same values - // Returns a promise resolving to a string containing the number of pwnages if pwned or null + /** + * Checks if the input password (hashed in sha-1) is pawned in breaches on haveibeenpwned. + * Uses a cache to avoid calling hibp again and again with the same values + * @param { string } pwd the sha1 hashed password to check + * @returns { Promise } a promise resolving to the number of pwnages if pwned or null + */ checkPwdPwned (passwordHash) { passwordHash = passwordHash.toUpperCase(); - hibp.logger.info('check hibp pwd (hash) ' + passwordHash); + hLogger.debug('checking pwd (hashed) ' + passwordHash); const prefix = passwordHash.substring(0, 5); - if (hibp._pwnedPwdsCache[passwordHash]) { - return (hibp._pwnedPwdsCache[passwordHash] !== '' - ? hibp._pwnedPwdsCache[passwordHash] : null); + if (this.checkCache(this._pwnedPwdsCache[passwordHash])) { + const val = this._pwnedPwdsCache[passwordHash].val ? this._pwnedPwdsCache[passwordHash].val : null; + hLogger.debug('found pwd in cache: ' + passwordHash + ' val:' + val); + return Promise.resolve(val); } else { - return hibp._xhrpromise({ + hLogger.debug('PWD NOT FOUND in cache: ' + passwordHash); // + ' cache=' + this.stringify(this._pwnedPwdsCache)); + return HIBPUtils.xhrpromise({ url: `https://api.pwnedpasswords.com/range/${prefix}`, method: 'GET', responseType: 'text', @@ -231,28 +370,39 @@ class HIBPUtils { }).then(data => { let nb = null; if (data) { - // hibp.logger.debug('found breaches ' + JSON.stringify(data)); + // hLogger.debug('found breaches ' + JSON.stringify(data)); data.split('\r\n').some(line => { const h = line.split(':'); const suffix = h[0]; if (prefix + suffix === passwordHash) { nb = _.escape(h[1]); - // hibp.logger.debug('matching breach ' + suffix); + // hLogger.debug('matching breach ' + suffix); return true; } }); } - hibp._pwnedPwdsCache[passwordHash] = nb || ''; - if (nb) hibp.logger.debug(`password ${passwordHash} pawned ${nb} times`); - hibp.storeCache('_pwnedPwdsCache'); + this._pwnedPwdsCache[passwordHash] = { date: Date.now(), val: nb }; + if (nb) hLogger.info(`password ${passwordHash} pawned ${nb} times`); + this.storeCache('_pwnedPwdsCache'); return nb; }); } }; - // returns true if the pwd can be checked + /** + * filter passwords needing to be checked + * @param {string} pwd the password to check + * @returns {boolean} true if the pwd can be checked + */ elligiblePwd (pwd) { return (pwd && pwd.replace(/\s/, '') !== '' && !pwd.startsWith('{REF:')); } + /** + * Change the password field to display an alert or reset it depending on npwned value + * @param {View} dview the details view + * @param {integer} npwned the number of times the password has been pawned (or null or 0 if none) + * @param {string} warning the warning to display + * @param {...} args the arguments to be passed to the original 'fieldChanged' function + */ alertPwdPwned (dview, npwned, warning, args) { if (npwned) { // pwned // record pawnage in the model to be able to show it in list view @@ -260,16 +410,31 @@ class HIBPUtils { // calls original function detailsViewFieldChanged.apply(dview, args); // sets the alert - hibp.alert(dview.passEditView.$el, warning); + this.alert(dview.passEditView.$el, warning); } else { // not pwned // reset css and tip - hibp.passed(dview.passEditView.$el, 'check pwned password passed...'); + this.passed(dview.passEditView.$el, 'check pwned password passed...'); // reset pawnage in the model dview.model.pwdPwned = null; // call initial function detailsViewFieldChanged.apply(dview, args); } }; + /** + * filter names needing to be checked + * @param {string} name the name to check + * @returns {boolean} true if the name can be checked + */ + elligibleName(name) { + return (name && name.replace(/\s/, '') !== ''); + } + /** + * Change the name field to display an alert or reset it depending on breaches value + * @param {View} dview the details view + * @param {string} breaches the breaches in which the name has been pawned (or null or '' if none) + * @param {string} warning the warning to display + * @param {...} args the arguments to be passed to the original 'fieldChanged' function + */ alertNamePwned (dview, breaches, warning, args) { if (breaches) { // pwned // remember breaches in the model to be able to show it in list view @@ -277,148 +442,256 @@ class HIBPUtils { // call initial function detailsViewFieldChanged.apply(dview, args); // adds an alert - hibp.alert(dview.userEditView.$el, warning); + this.alert(dview.userEditView.$el, warning); } else { // not pwned // reset alert - hibp.passed(dview.userEditView.$el, 'check pwned user name passed...'); + this.passed(dview.userEditView.$el, 'check pwned user name passed...'); // reset the model dview.model.namePwned = null; // call initial function detailsViewFieldChanged.apply(dview, args); } }; + /** + * Looks up the password on HaveIBeenPwned and handle the results + * If the password is pawned, depending on the check level, puts some icon warning, or asks to revert to the previous one + * @param {DetailedView} dview the detailed view containing the password + * @param {string} pwd the pwd to check + */ + handlePasswordChange(dview, pwd, args) { + pwd = pwd ? pwd.getText() : null; + if (hibp.elligiblePwd(pwd)) { + // hLogger.debug('pwd:>>>' + pwd + '<<<'); + this.sha1(pwd) + .then(hpwd => { + return this.checkPwdPwned(hpwd); + }) + .then(npwned => { + const warning = HIBPLocale.hibpPwdWarning.replace('{}', npwned); + if (npwned) { // pawned + if (this.checkPwnedPwd === HIBPCheckLevel.AskMe) { + // ask before taking the field change into account + Alerts.yesno({ + header: HIBPLocale.hibpChangePwd, + body: warning, + icon: 'exclamation-triangle', + success: () => { // keep password, just set an alert + this.alertPwdPwned(dview, npwned, warning, args); + }, + cancel: () => { // reset password by not registering change + hLogger.info('keeping old passwd'); + } + }); + } else { // check level = alert, keep pwd, set an alert + this.alertPwdPwned(dview, npwned, warning, args); + } + } else { // not pawned + this.alertPwdPwned(dview, null, null, args); + } + }).catch(error => { + hLogger.error('check pwned password error: ' + error.message); + }); + } else { + this.alertPwdPwned(dview, null, null, args); + } + } + /** + * Looks up the user name on HaveIBeenPwned and handle the results + * If the name is pawned, depending on the check level, puts some icon warning, or asks to revert to the previous one + * @param {DetailedView} dview the detailed view containing the user name + * @param {string} name the user name to check + */ + handleNameChange(dview, name, args) { + if (this.elligibleName(name)) { + this.checkNamePwned(name) + .then(breaches => { + if (breaches) { // pawned + name = _.escape(name); // breaches already escaped + const warning = HIBPLocale.hibpNameWarning.replace('{name}', name).replace('{breaches}', breaches); + if (this.checkPwnedName === HIBPCheckLevel.AskMe) { + // ask before taking the field change into account + Alerts.yesno({ + header: HIBPLocale.hibpChangeName, + body: warning, + icon: 'exclamation-triangle', + success: () => { // keep name, but set an alert + this.alertNamePwned(dview, breaches, warning, args); + }, + cancel: () => { // reset name by not registering change + hLogger.info('reverting to previous user name'); + } + }); + } else { // check level = alert, keep new name but sets an alert + this.alertNamePwned(dview, breaches, warning, args); + } + } else { // not pawned + this.alertNamePwned(dview, null, null, args); + } + }).catch(error => { + hLogger.error('check pwned name error: ' + error.message); + }); + } else { + hibp.alertNamePwned(this, null, null, args); + } + } + displayFields(dview) { + // check password + const pwd = dview.model.password ? dview.model.password.getText() : null; + // hLogger.debug('addfv pwd:>>>' + pwd + '<<<'); + if (this.checkPwnedPwd !== HIBPCheckLevel.None && this.elligiblePwd(pwd)) { + this.sha1(pwd) + .then(hpwd => { + return this.checkPwdPwned(hpwd); + }) + .then(nb => { + // hLogger.debug(pwd + ' pppwand: ' + nb); + dview.model.pwdPwned = nb; + if (nb) { // pawned + const warning = HIBPLocale.hibpPwdWarning.replace('{}', nb); + this.alert(dview.passEditView.$el, warning); + } else { // not pawned + this.passed(dview.passEditView.$el, 'check pwned password passed...'); + } + }).catch(error => { + hLogger.error('check pwned pwd error: ' + error); + }); + } + // check user name + let name = dview.userEditView.value; + // hLogger.debug('addfv name:>>>' + name + '<<<'); + if (this.elligibleName(name) && this.checkPwnedName !== HIBPCheckLevel.None) { + this.checkNamePwned(name) + .then(breaches => { + dview.model.namePwned = breaches; + if (breaches) { // pawned + name = _.escape(name); // breaches already escaped + const warning = HIBPLocale.hibpNameWarning.replace('{name}', name).replace('{breaches}', breaches); + this.alert(dview.userEditView.$el, warning); + } else { // not pawned + this.passed(dview.userEditView.$el, 'check pwned user name passed...'); + } + }).catch(error => { + hLogger.error('check pwned name error: ' + HIBPUtils.stringify(error)); + }); + } + }; + filterEntries(app, entries) { + // hLogger.debug('getEntriesByFilter: entries = ' + JSON.stringify(entries)); + if (this.checkPwnedList && entries && entries.length) { + // get all different names and pwds to reduce the number of calls to the HIBP API + const names = []; + const pwds = []; + entries.forEach(item => { + // hLogger.debug('getEntriesByFilter: item = ' + item.title); + // get different user names and pwds to optimize queries + const name = item.user; + if (this.elligibleName(name)) { + const fname = names.find(elem => elem.name === name); + // hLogger.debug("fname=" + JSON.stringify(fname)); + if (fname) fname.items.push(item); + else names.push({ name: name, items: [item] }); + } + let pwd = item.password; + if (pwd) { + pwd = pwd.getText(); + if (this.elligiblePwd(pwd)) { + const fpwd = pwds.find(elem => elem.pwd === pwd); + if (fpwd) fpwd.items.push(item); + else pwds.push({ pwd: pwd, items: [item] }); + } + } + }); + + // asynchronously look for pawned names and pwds + // do somme throttling on names as HIBP does not allow more than one call every 1500 millisecs + let throttle = 2000; // millisecs betwwen 2 calls + names.forEach((elem, index) => { + // hLogger.debug('getEntriesByFilter: check item ' + item.title); + setTimeout(() => { + this.checkNamePwned(elem.name) + .then(breaches => { + let refresh = false; + elem.items.forEach(item => { + const itemPwned = item.namePwned; + item.namePwned = breaches; + refresh = refresh || (!breaches !== !itemPwned); // XOR + }); + refresh && app.refresh(); + }) + .catch(err => { + hLogger.error('error in checking name ' + elem.name + 'in get entries by filter: ' + err); + }); + }, index * throttle); + }); + // no need of throttling for passwords on HIBP, use a low throttle value + throttle = 100; // millisecs + pwds.forEach((elem, index) => { + setTimeout(() => { + this.sha1(elem.pwd) + .then(hpwd => { + return this.checkPwdPwned(hpwd); + }) + .then(nb => { + let refresh = false; + elem.items.forEach(item => { + const itemPwned = item.pwdPwned; + item.pwdPwned = nb; + refresh = refresh || (!nb !== !itemPwned); // XOR + }); + refresh && app.refresh(); + }) + .catch(err => { + hLogger.error('error in checking pwd ' + elem.pwd + ' in get entries by filter: ' + err); + }); + }, index * throttle); + }); + } + } }; -const hibp = new HIBPUtils(); +/** the HIBP singleton + * @type {HIBP} + */ +const hibp = new HIBP(); -// Replaces the fiedChanged function of DetailsView to add checks on user names and passwords +/** + * Replaces the fiedChanged function of DetailsView to add checks on user names and passwords + * @param {Event} e the event that triggered the change + */ DetailsView.prototype.fieldChanged = function (e) { if (e.field) { - // hibp.logger.debug('field changed ' + hibp.stringify(e)); + // hLogger.debug('field changed ' + hibp.stringify(e)); // first check password if (e.field === '$Password' && hibp.checkPwnedPwd !== HIBPCheckLevel.None && this.passEditView.value) { - const pwd = e.val ? e.val.getText() : null; - if (hibp.elligiblePwd(pwd)) { - hibp.logger.debug('pwd:>>>' + pwd + '<<<'); - hibp.sha1(pwd) - .then(hibp.checkPwdPwned) - .then(npwned => { - const warning = HIBPLocale.hibpPwdWarning.replace('{}', npwned); - if (npwned) { // pawned - if (hibp.checkPwnedPwd === HIBPCheckLevel.AskMe) { - // ask before taking the field change into account - Alerts.yesno({ - header: HIBPLocale.hibpChangePwd, - body: warning, - icon: 'exclamation-triangle', - success: () => { // keep password, just set an alert - hibp.alertPwdPwned(this, npwned, warning, arguments); - }, - cancel: () => { // reset password by not registering change - hibp.logger.info('keeping old passwd'); - } - }); - } else { // check level = alert, keep pwd, set an alert - hibp.alertPwdPwned(this, npwned, warning, arguments); - } - } else { // not pawned - hibp.alertPwdPwned(this, null, null, arguments); - } - }).catch(error => { - hibp.logger.info('check pwned password error: ' + error.message); - }); - } else { - hibp.alertPwdPwned(this, null, null, arguments); - } + hibp.handlePasswordChange(this, e.val, arguments); // second, check user name } else if (e.field === '$UserName' && hibp.checkPwnedName !== HIBPCheckLevel.None) { - let name = e.val; - if (name && name.replace(/\s/, '') !== '') { - hibp.checkNamePwned(name) - .then(breaches => { - if (breaches) { // pawned - name = _.escape(name); // breaches already escaped - const warning = HIBPLocale.hibpNameWarning.replace('{name}', name).replace('{breaches}', breaches); - if (hibp.checkPwnedName === HIBPCheckLevel.AskMe) { - // ask before taking the field change into account - Alerts.yesno({ - header: HIBPLocale.hibpChangeName, - body: warning, - icon: 'exclamation-triangle', - success: () => { // keep name, but set an alert - hibp.alertNamePwned(this, breaches, warning, arguments); - }, - cancel: () => { // reset name by not registering change - hibp.logger.info('keeping old user name'); - } - }); - } else { // check level = alert, keep new name but sets an alert - hibp.alertNamePwned(this, breaches, warning, arguments); - } - } else { // not pawned - hibp.alertNamePwned(this, null, null, arguments); - } - }).catch(error => { - hibp.logger.info('check pwned name error: ' + error.message); - }); - } - } else { - hibp.alertNamePwned(this, null, null, arguments); + hibp.handleNameChange(this, e.val, arguments); } - } else { + } else { // not name, not password detailsViewFieldChanged.apply(this, arguments); } }; -// replaces initial addFieldViews in DetailsView -// Allows showing pwned fields when displaying entry details +/** + * Replaces initial addFieldViews function in DetailsView + * Allows showing pawned fields when displaying entry details + */ DetailsView.prototype.addFieldViews = function () { // call initial function detailsViewAddFieldViews.apply(this, arguments); - // check password - const pwd = this.model.password ? this.model.password.getText() : null; - // hibp.logger.debug('addfv pwd:>>>' + pwd + '<<<'); - if (hibp.checkPwnedPwd !== HIBPCheckLevel.None && hibp.elligiblePwd(pwd)) { - hibp.sha1(pwd) - .then(hibp.checkPwdPwned) - .then(npwned => { - this.model.pwdPwned = npwned; - if (npwned) { // pawned - const warning = HIBPLocale.hibpPwdWarning.replace('{}', npwned); - hibp.alert(this.passEditView.$el, warning); - } else { // not pawned - hibp.passed(this.passEditView.$el, 'check pwned password passed...'); - } - }).catch(error => { - hibp.logger.info('check pwned pwd error: ' + error); - }); - } - // check user name - let name = this.userEditView.value; - // hibp.logger.debug('addfv name:>>>' + name + '<<<'); - if (name && name.replace(/\s/, '') !== '' && hibp.checkPwnedName !== HIBPCheckLevel.None) { - hibp.checkNamePwned(name) - .then(breaches => { - this.model.namePwned = breaches; - if (breaches) { // pawned - name = _.escape(name); // breaches already escaped - const warning = HIBPLocale.hibpNameWarning.replace('{name}', name).replace('{breaches}', breaches); - hibp.alert(this.userEditView.$el, warning); - } else { // not pawned - hibp.passed(this.userEditView.$el, 'check pwned user name passed...'); - } - }).catch(error => { - hibp.logger.info('check pwned name error: ' + hibp.stringify(error)); - }); - } + hibp.displayFields(this); }; +/** + * Replaces initial render function in ListView + * + */ ListView.prototype.render = function () { listViewRender.apply(this, arguments); - hibp.logger.debug('rendering list in hibp'); - // this.items.forEach(hibp.showItem); + hLogger.debug('rendering list in hibp'); this.items.filter(item => item.namePwned || item.pwdPwned).forEach(item => { - hibp.logger.debug('list pwned ' + item.title); + hLogger.debug('list pwned item "' + item.title + '"'); const itemEl = document.getElementById(item.id); if (itemEl) { itemEl.classList.add('hibp-pwned'); } }); @@ -426,44 +699,10 @@ ListView.prototype.render = function () { AppModel.prototype.getEntriesByFilter = function (filter) { const entries = appModelGetEntriesByFilter.apply(this, arguments); - if (hibp.checkPwnedList && entries && entries.length) { - // asynchronously look for pawned names and pwds - setTimeout(() => { - entries.forEach(item => { - hibp.logger.debug('getEntriesByFilter: check item ' + item.title); - hibp.checkNamePwned(item.user) - .then(breaches => { - const itemPwned = item.namePwned; - item.namePwned = breaches; - if (!breaches !== !itemPwned) { // XOR - this.refresh(); - } - }); - const pwd = item.password ? item.password.getText() : null; - if (hibp.elligiblePwd(pwd)) { - hibp.sha1(pwd) - .then(hibp.checkPwdPwned) - .then(nb => { - const itemPwned = item.pwdPwned; - item.pwdPwned = nb; - if (!nb !== !itemPwned) { // XOR - this.refresh(); - } - }); - } - }); - }, 20); - } + hibp.filterEntries(this, entries); return entries; }; -// for debug purpose -// const dvrender = DetailsView.prototype.render; -// DetailsView.prototype.render = function () { -// dvrender.apply(this, arguments); -// hibp.showItem(this.model); -// }, - module.exports.getSettings = function () { const options = [ { value: HIBPCheckLevel.None, label: HIBPLocale.hibpCheckLevelNone }, @@ -498,7 +737,7 @@ module.exports.setSettings = function (changes) { const ccfield = field.substr(0, 1).toLowerCase() + field.substring(1); hibp[ccfield] = changes[field]; } - hibp.logger.debug(hibp.stringify(hibp)); + HIBPUtils.dump(hibp); }; module.exports.uninstall = function () {