diff --git a/passmanager/init.lua b/passmanager/init.lua new file mode 100644 index 0000000..02553bf --- /dev/null +++ b/passmanager/init.lua @@ -0,0 +1,180 @@ +--- passmanager module. +-- +-- This module allows to use pass (www.passwordstore.org) as password manager in luakit +-- plus it have integrated passhash generator (slightly modified version of +-- https://milliways.cryptomilk.org/passhash.html) +-- +-- # Capabilities +-- +-- Plugin assumes that first line in pass record is password and second line is login. +-- If your pass records have different structure then don't use this plugin because it will overwrite +-- first 2 line unconditionally during password update. +-- +-- # Usage +-- +-- * Import your existing passwords from firefox to pass using https://github.com/Unode/firefox_decrypt/#readme +-- or setup pass without importing password. +-- +-- * Add `require "passmanager"` to your `config.rc`. +-- +-- * After loading of any page it will be inspected and if +-- page has form(s) with login/password fields plugin query for all existing credentials +-- registered for this site in pass manager (query like 'pass web/site/login1', 'pass web/site/login1' +-- and 'pass web/site'). and attachs several callbacks to login/password fields. +-- +-- Login/passwords fields will be prefilled with first (shortest) login and corresponding password +-- +-- In login field you can use Up and Down keys for scrolling over all know logins +-- +-- When login field lost focus then password field(s) will be filled with corresponding values +-- (if there's password for entered login) +-- +-- In password filed you can press: +-- *) Alt-s -- shows/hides content of password field +-- *) Alt-g -- opens passhash generator +-- +-- * Before submitting form plugin checks entered login and password and if: +-- *) there's no yet such login registered for this site then new pass record created for it +-- *) password is different from existing - then updating pass record with new password for login +-- both steps require interactive confirmation from user +-- *) forms with empty login and/or password are ignored and pass record doesn't updated/created +-- +-- # Troubleshooting +-- +-- tricky login forms may not work +-- probably a lot of bug but feel free to report them :) +-- +-- # Files and Directories +-- +-- @module passmanager +-- @author Serg Kozhemyakin +-- @copyright 2017-2018 Serg Kozhemyakin + +local window = require("window") +local new_mode = require("modes").new_mode +local modes = require("modes") +local lousy = require("lousy") +local wm = require_web_module('plugins/passmanager/passmanager_wm') +local lfs = require("lfs") + +local web_root = 'web' + +local function fetch_credentials_for_domain(domain) + local credentials = {} + local pwd_store = os.getenv('PASSWORD_STORE_DIR') or os.getenv('HOME')..'/.password-store' + local pwd = (web_root and web_root..'/' or '')..domain + local path = pwd_store..'/'..pwd + local attr = lfs.attributes(path) + if attr then + if attr.mode == 'directory' then + for f in lfs.dir(path) do + if string.sub(f, 1, 1) ~= '.' then + f = string.gsub(f, '%.gpg$', '') + local p = io.popen('pass '..pwd..'/'..f..' 2>/dev/null') + local password = p:read() + local login = p:read() + if login and password then + table.insert(credentials, {password = password, login = login, file = pwd..'/'..f}) + end + p:close() + end + end + end + else + attr = lfs.attributes(path..'.gpg') + if attr and attr.mode == 'file' then + local p = io.popen('pass '..pwd) + local password = p:read() + local login = p:read() + if login and password then + table.insert(credentials, {password = password, login = login, file = pwd}) + end + p:close() + end + end + -- if no credentials for this domain then let's try to fetch + -- them for parent one + if not #credentials then + local dot = string.find(domain, '%.') + if dot then + credentials = fetch_credentials_for_domain(string.sub(domain, dot+1)) + end + end + table.sort(credentials, function(a,b) return #a.login < #b.login end) + return credentials +end + +local function update_pass_record(login, password, file, force) + -- first let's read existing pass entry, if it exists + local p = io.popen('pass '..file..' 2>/dev/null') + local entry = {} + for line in p:lines() do + table.insert(entry, line) + end + p:close() + + -- update login/password entries and preserve all other entries + entry[1] = password + entry[2] = login + + -- write updated pass entry + p = io.popen('pass insert -m '..(force and '-f ' or '')..file..' >/dev/null 2>&1', 'w') + for _, line in ipairs(entry) do + p:write(line.."\n") + end + p:close() +end + +local _op = setmetatable({}, {__mode = 'k'}); +new_mode("passmanager-ask-confirmation", { + enter = function (w, confirmation_msg, lock_file, login, password, file, force) + w:warning(confirmation_msg..' (y/n)', false) + _op[w] = {lock_file = lock_file, login = login, password = password, file = file, force = force} + end, + + leave = function (w) + os.remove(_op[w].lock_file) + _op[w] = nil + end, +}) + +local function w_from_view_id(view_id) + assert(type(view_id) == "number", type(view_id)) + for _, w in pairs(window.bywidget) do + if w.view.id == view_id then return w end + end +end + +modes.add_binds("passmanager-ask-confirmation", { + { "y", "Answer 'Yes' on confirmation.", function (w) + update_pass_record(_op[w].login, _op[w].password, _op[w].file, _op[w].force) + w:set_mode() + end }, + { "n", "Answer 'No' on confirmation.", function (w) w:set_mode() end }, + { "", "Answer 'No' on confirmation.", function (w) w:set_mode() end }, +}) + +wm:add_signal('update-pass-for-login', function (_, page, lock_file, domain, login, password, file) + local w = w_from_view_id(page) + w:set_mode("passmanager-ask-confirmation", '**'..domain..'**: update this login: "'..login..'" with password: "'..password..'"?', + lock_file, login, password, file, true) +end) + +wm:add_signal('create-pass-for-login', function (_, page, lock_file, domain, login, password) + local w = w_from_view_id(page) + local file = (web_root and web_root..'/' or '')..domain..'/'..login + w:set_mode("passmanager-ask-confirmation", '**'..domain..'**: remember this login: "'..login..'" and password: "'..password..'"?', + lock_file, login, password, file, false) +end) + +wm:add_signal('get-credentials-for-uri', function (_, page, domain) + local credentials = fetch_credentials_for_domain(domain) + wm:emit_signal(page, 'get-credentials-for-uri-reply', {credentials, domain}) +end) + +local plugin_location = luakit.config_dir..'/plugins/passmanager' +wm:add_signal('get-plugin-configuration', function (_, page) + wm:emit_signal(page, 'get-plugin-configuration-reply', plugin_location) +end) + +-- vim: et:sw=4:ts=8:sts=4:tw=80 diff --git a/passmanager/passhash/modal.css b/passmanager/passhash/modal.css new file mode 100644 index 0000000..ee84c76 --- /dev/null +++ b/passmanager/passhash/modal.css @@ -0,0 +1,58 @@ +.passhash-popup { + display: none; + position: fixed; + z-index: 1; + padding-top: 100px; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0,0,0); + background-color: rgba(0,0,0,0.4); +} +.passhash-popup-content { + position: relative; + background-color: #fefefe; + margin: auto; + padding: 0; + width: max-content; + box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); +} + +.passhash-popup-header { + padding: 2px 16px; + background-color: #0000ff; + color: white; +} +.passhash-popup-main {padding: 2px 16px;} + +.passhash-popup-close { + color: white; + float: right; + font-size: 28px; + font-weight: bold; +} +.passhash-popup-close:hover, .passhash-popup-close:focus { + color: #000; + text-decoration: none; + cursor: pointer; +} + +.passhash-popup-button { + padding: 0.5em 1em; + color: #444; /* rgba not supported (IE 8) */ + color: rgba(0, 0, 0, 0.80); /* rgba supported */ + border: 1px solid #999; + border: none rgba(0, 0, 0, 0); + background-color: #E6E6E6; + text-decoration: none; + border-radius: 2px; + display: inline-block; + zoom: 1; + line-height: normal; + white-space: nowrap; + vertical-align: middle; + text-align: center; + cursor: pointer; +} diff --git a/passmanager/passhash/passhash.html b/passmanager/passhash/passhash.html new file mode 100644 index 0000000..9cfb84b --- /dev/null +++ b/passmanager/passhash/passhash.html @@ -0,0 +1,102 @@ +
+
+ +

Password Hasher

+
+ +
+
+ +
+ +
+ + Bump +
+ + +
+ + Reveal +
+ + +
+ + OK +
+
+ + +
+ Requirements + +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+ +
diff --git a/passmanager/passhash/passhash.js b/passmanager/passhash/passhash.js new file mode 100644 index 0000000..d2ecc49 --- /dev/null +++ b/passmanager/passhash/passhash.js @@ -0,0 +1,1216 @@ +var browser = new Object(); +browser.version = parseInt(navigator.appVersion); +browser.isNetscape = false; +browser.isMicrosoft = false; +if (navigator.appName.indexOf("Netscape") != -1) + browser.isNetscape = true; +else if (navigator.appName.indexOf("Microsoft") != -1) + browser.isMicrosoft = true; + +var siteTagLast = ''; +var masterKeyLast = ''; + +function passhash_onLoad() +{ + if (browser.isMicrosoft) + { + document.getElementById('reveal').disabled = true; + document.getElementById('reveal-text').disabled = true; + } + document.getElementById('site-tag').focus(); + setTimeout('passhash_checkChange()',1000); +} + +function passhash_validate(form) +{ + var siteTag = document.getElementById('site-tag'); + var masterKey = document.getElementById('master-key'); + if (!siteTag.value) + { + siteTag.focus(); + return false; + } + if (!masterKey.value) + { + masterKey.focus(); + return false; + } + return true; +} + +function passhash_update() +{ + var siteTag = document.getElementById('site-tag'); + var masterKey = document.getElementById('master-key'); + var hashWord = document.getElementById('hash-word'); + //var hashapass = b64_hmac_sha1(masterKey.value, siteTag.value).substr(0,8); + var hashWordSize = 8; + var requireDigit = document.getElementById("digit").checked; + var requirePunctuation = document.getElementById("punctuation").checked; + var requireMixedCase = document.getElementById("mixedCase").checked; + var restrictSpecial = document.getElementById("noSpecial").checked; + var restrictDigits = document.getElementById("digitsOnly").checked; + hashWordSize = document.getElementById("passhash-password-size" ).value; + hashWord.value = PassHashCommon.generateHashWord( + siteTag.value, + masterKey.value, + hashWordSize, + requireDigit, + requirePunctuation, + requireMixedCase, + restrictSpecial, + restrictDigits); + masterKey.focus(); + var hashOptions = document.getElementById('passhash-options'); + if (hashOptions) { + hashOptions.value=siteTag+'/'+ + (requireDigit?'d':'')+ + (requirePunctuation?'p':'')+ + (requireMixedCase?'m':'')+ + (restrictSpecial?'r':'')+ + (restrictDigits?'g':'')+ + hashWordSize + } + siteTagLast = siteTag.value; + masterKeyLast = masterKey.value; +} + +function passhash_showHideSection(id, sectionName, fld) { + var div = document.getElementById(id); + if (div.style.display == 'none') { + div.style.display = 'block'; + fld.innerHTML="↓ "+sectionName; + } else { + div.style.display = 'none'; + fld.innerHTML="→ "+sectionName; + } +} + +function passhash_onUpdateMaster() { + var masterKey = document.getElementById('master-key'); + passhash_update(); + masterKey.focus(); + masterKey.selectionStart = masterKey.value.length; +} + +function passhash_checkChange() +{ + var siteTag = document.getElementById('site-tag'); + var masterKey = document.getElementById('master-key'); + var hashWord = document.getElementById('hash-word'); + if (siteTag.value != siteTagLast || masterKey.value != masterKeyLast) + { + hashWord.value = ''; + siteTagLast = siteTag.value; + masterKeyLast = masterKey.value; + } + setTimeout('passhash_checkChange()', 1000); +} + +function passhash_onLeaveResultField(hashWord) +{ + var submit = document.getElementById('submit'); + submit.value = 'OK'; +// hashWord.value = ''; + document.getElementById('prompt').innerHTML = ''; +} + +function passhash_onReveal(fld) +{ + var masterKey = document.getElementById('master-key'); + var hashWord = document.getElementById('hash-word'); + var revealButton = document.getElementById('reveal'); + try + { + if (masterKey.getAttribute("type") == "password") { + masterKey.setAttribute("type", ""); + hashWord.setAttribute("type", ""); + revealButton.innerHTML = "Hide"; + } else { + masterKey.setAttribute("type", "password"); + hashWord.setAttribute("type", "password"); + revealButton.innerHTML = "Reveal"; + } + } catch (ex) {} + + masterKey.focus(); +} + +function passhash_onNoSpecial(fld) +{ + document.getElementById('punctuation').disabled = fld.checked; + passhash_update(); +} + +function passhash_onDigitsOnly(fld) +{ + document.getElementById('punctuation').disabled = fld.checked; + document.getElementById("digit" ).disabled = fld.checked; + document.getElementById("punctuation").disabled = fld.checked; + document.getElementById("mixedCase" ).disabled = fld.checked; + document.getElementById("noSpecial" ).disabled = fld.checked; + passhash_update(); +} + +function passhash_onBump() +{ + var siteTag = document.getElementById("site-tag"); + siteTag.value = PassHashCommon.bumpSiteTag(siteTag.value); + passhash_update(); +} + +function onSelectSiteTag(fld) +{ + var siteTag = document.getElementById('site-tag'); + siteTag.value = fld[fld.selectedIndex].text; + var options = fld[fld.selectedIndex].value; + document.getElementById("digit" ).checked = (options.search(/d/i) >= 0); + document.getElementById("punctuation").checked = (options.search(/p/i) >= 0); + document.getElementById("mixedCase" ).checked = (options.search(/m/i) >= 0); + document.getElementById("noSpecial" ).checked = (options.search(/r/i) >= 0); + document.getElementById("digitsOnly" ).checked = (options.search(/g/i) >= 0); + document.getElementById('punctuation').disabled = (options.search(/[rg]/i) >= 0); + document.getElementById("digit" ).disabled = (options.search(/g/i) >= 0); + document.getElementById("punctuation").disabled = (options.search(/g/i) >= 0); + document.getElementById("mixedCase" ).disabled = (options.search(/g/i) >= 0); + document.getElementById("noSpecial" ).disabled = (options.search(/g/i) >= 0); + var sizeMatch = options.match(/[0-9]+/); + var hashWordSize = (sizeMatch != null && sizeMatch.length > 0 + ? parseInt(sizeMatch[0]) + : 16); + document.getElementById("s6" ).checked = (hashWordSize == 6 ); + document.getElementById("s8" ).checked = (hashWordSize == 8 ); + document.getElementById("s10").checked = (hashWordSize == 10); + document.getElementById("s12").checked = (hashWordSize == 12); + document.getElementById("s14").checked = (hashWordSize == 14); + document.getElementById("s16").checked = (hashWordSize == 16); + document.getElementById("s18").checked = (hashWordSize == 18); + document.getElementById("s20").checked = (hashWordSize == 20); + document.getElementById("s22").checked = (hashWordSize == 22); + document.getElementById("s24").checked = (hashWordSize == 24); + document.getElementById("s26").checked = (hashWordSize == 26); + if (passhash_validate()) + passhash_update(); +} + +function onLeaveSelectSiteTag(fld) +{ + // Remove the prompt + document.getElementById('prompt').innerHTML = ''; +} + +/* + * A JavaScript implementation of the Secure Hash Algorithm, SHA-1, as defined + * in FIPS PUB 180-1 + * Version 2.1a Copyright Paul Johnston 2000 - 2002. + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * Distributed under the BSD License + * See http://pajhome.org.uk/crypt/md5 for details. + */ + +/* + * Configurable variables. You may need to tweak these to be compatible with + * the server-side, but the defaults work in most cases. + */ +var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */ +var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */ +var chrsz = 8; /* bits per input character. 8 - ASCII; 16 - Unicode */ + +/* + * These are the functions you'll usually want to call + * They take string arguments and return either hex or base-64 encoded strings + */ +function hex_sha1(s){return binb2hex(core_sha1(str2binb(s),s.length * chrsz));} +function b64_sha1(s){return binb2b64(core_sha1(str2binb(s),s.length * chrsz));} +function str_sha1(s){return binb2str(core_sha1(str2binb(s),s.length * chrsz));} +function hex_hmac_sha1(key, data){ return binb2hex(core_hmac_sha1(key, data));} +function b64_hmac_sha1(key, data){ return binb2b64(core_hmac_sha1(key, data));} +function str_hmac_sha1(key, data){ return binb2str(core_hmac_sha1(key, data));} + +/* + * Perform a simple self-test to see if the VM is working + */ +function sha1_vm_test() +{ + return hex_sha1("abc") == "a9993e364706816aba3e25717850c26c9cd0d89d"; +} + +/* + * Calculate the SHA-1 of an array of big-endian words, and a bit length + */ +function core_sha1(x, len) +{ + /* append padding */ + /* SC - Get rid of warning */ + var i = (len >> 5); + if (x[i] == undefined) + x[i] = 0x80 << (24 - len % 32); + else + x[i] |= 0x80 << (24 - len % 32); + /*x[len >> 5] |= 0x80 << (24 - len % 32);*/ + x[((len + 64 >> 9) << 4) + 15] = len; + + var w = Array(80); + var a = 1732584193; + var b = -271733879; + var c = -1732584194; + var d = 271733878; + var e = -1009589776; + + for(var i = 0; i < x.length; i += 16) + { + var olda = a; + var oldb = b; + var oldc = c; + var oldd = d; + var olde = e; + + for(var j = 0; j < 80; j++) + { + if(j < 16) w[j] = x[i + j]; + else w[j] = rol(w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16], 1); + var t = safe_add(safe_add(rol(a, 5), sha1_ft(j, b, c, d)), + safe_add(safe_add(e, w[j]), sha1_kt(j))); + e = d; + d = c; + c = rol(b, 30); + b = a; + a = t; + } + + a = safe_add(a, olda); + b = safe_add(b, oldb); + c = safe_add(c, oldc); + d = safe_add(d, oldd); + e = safe_add(e, olde); + } + return Array(a, b, c, d, e); + +} + +/* + * Perform the appropriate triplet combination function for the current + * iteration + */ +function sha1_ft(t, b, c, d) +{ + if(t < 20) return (b & c) | ((~b) & d); + if(t < 40) return b ^ c ^ d; + if(t < 60) return (b & c) | (b & d) | (c & d); + return b ^ c ^ d; +} + +/* + * Determine the appropriate additive constant for the current iteration + */ +function sha1_kt(t) +{ + return (t < 20) ? 1518500249 : (t < 40) ? 1859775393 : + (t < 60) ? -1894007588 : -899497514; +} + +/* + * Calculate the HMAC-SHA1 of a key and some data + */ +function core_hmac_sha1(key, data) +{ + var bkey = str2binb(key); + if(bkey.length > 16) bkey = core_sha1(bkey, key.length * chrsz); + + var ipad = Array(16), opad = Array(16); + for(var i = 0; i < 16; i++) + { + /* SC - Get rid of warning */ + var k = (bkey[i] != undefined ? bkey[i] : 0); + ipad[i] = k ^ 0x36363636; + opad[i] = k ^ 0x5C5C5C5C; +/* ipad[i] = bkey[i] ^ 0x36363636; + opad[i] = bkey[i] ^ 0x5C5C5C5C;*/ + } + + var hash = core_sha1(ipad.concat(str2binb(data)), 512 + data.length * chrsz); + return core_sha1(opad.concat(hash), 512 + 160); +} + +/* + * Add integers, wrapping at 2^32. This uses 16-bit operations internally + * to work around bugs in some JS interpreters. + */ +function safe_add(x, y) +{ + var lsw = (x & 0xFFFF) + (y & 0xFFFF); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xFFFF); +} + +/* + * Bitwise rotate a 32-bit number to the left. + */ +function rol(num, cnt) +{ + return (num << cnt) | (num >>> (32 - cnt)); +} + +/* + * Convert an 8-bit or 16-bit string to an array of big-endian words + * In 8-bit function, characters >255 have their hi-byte silently ignored. + */ +function str2binb(str) +{ + var bin = Array(); + var mask = (1 << chrsz) - 1; + /* SC - Get rid of warnings */ + for(var i = 0; i < str.length * chrsz; i += chrsz) + { + if (bin[i>>5] != undefined) + bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (32 - chrsz - i%32); + else + bin[i>>5] = (str.charCodeAt(i / chrsz) & mask) << (32 - chrsz - i%32); + } + /*for(var i = 0; i < str.length * chrsz; i += chrsz) + bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (32 - chrsz - i%32);*/ + return bin; +} + +/* + * Convert an array of big-endian words to a string + */ +function binb2str(bin) +{ + var str = ""; + var mask = (1 << chrsz) - 1; + for(var i = 0; i < bin.length * 32; i += chrsz) + str += String.fromCharCode((bin[i>>5] >>> (32 - chrsz - i%32)) & mask); + return str; +} + +/* + * Convert an array of big-endian words to a hex string. + */ +function binb2hex(binarray) +{ + var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; + var str = ""; + for(var i = 0; i < binarray.length * 4; i++) + { + str += hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8+4)) & 0xF) + + hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8 )) & 0xF); + } + return str; +} + +/* + * Convert an array of big-endian words to a base-64 string + */ +function binb2b64(binarray) +{ + var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + var str = ""; + for(var i = 0; i < binarray.length * 4; i += 3) + { + /* SC - Get rid of warning */ + var b1 = binarray[i >> 2] != undefined ? ((binarray[i >> 2] >> 8 * (3 - i %4)) & 0xFF) << 16 : 0; + var b2 = binarray[i+1 >> 2] != undefined ? ((binarray[i+1 >> 2] >> 8 * (3 - (i+1)%4)) & 0xFF) << 8 : 0; + var b3 = binarray[i+2 >> 2] != undefined ? ((binarray[i+2 >> 2] >> 8 * (3 - (i+2)%4)) & 0xFF) : 0; + var triplet = b1 | b2 | b3; + /*var triplet = (((binarray[i >> 2] >> 8 * (3 - i %4)) & 0xFF) << 16) + | (((binarray[i+1 >> 2] >> 8 * (3 - (i+1)%4)) & 0xFF) << 8 ) + | ((binarray[i+2 >> 2] >> 8 * (3 - (i+2)%4)) & 0xFF);*/ + for(var j = 0; j < 4; j++) + { + if(i * 8 + j * 6 > binarray.length * 32) str += b64pad; + else str += tab.charAt((triplet >> 6*(3-j)) & 0x3F); + } + } + return str; +} + +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1/GPL 2.0/LGPL 2.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is Password Hasher + * + * The Initial Developer of the Original Code is Steve Cooper. + * Portions created by the Initial Developer are Copyright (C) 2006 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): (none) + * + * Alternatively, the contents of this file may be used under the terms of + * either the GNU General Public License Version 2 or later (the "GPL"), or + * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), + * in which case the provisions of the GPL or the LGPL are applicable instead + * of those above. If you wish to allow use of your version of this file only + * under the terms of either the GPL or the LGPL, and not to allow others to + * use your version of this file under the terms of the MPL, indicate your + * decision by deleting the provisions above and replace them with the notice + * and other provisions required by the GPL or the LGPL. If you do not delete + * the provisions above, a recipient may use your version of this file under + * the terms of any one of the MPL, the GPL or the LGPL. + * + * ***** END LICENSE BLOCK ***** */ + +var PassHashCommon = +{ + // Artificial host name used for for saving to the password database + host: "passhash.passhash", + + log: function(msg) + { + var consoleService = Components.classes["@mozilla.org/consoleservice;1"] + .getService(Components.interfaces.nsIConsoleService); + consoleService.logStringMessage(msg); + }, + + loadOptions: function() + { + var opts = this.createOptions(); + var prefs = Components.classes["@mozilla.org/preferences-service;1"]. + getService(Components.interfaces.nsIPrefService).getBranch("passhash."); + var forceSave = false; + if (prefs.prefHasUserValue("optSecurityLevel")) + { + opts.securityLevel = prefs.getIntPref("optSecurityLevel"); + opts.firstTime = false; + forceSave = true; + } + if (prefs.prefHasUserValue("optGuessSiteTag")) + opts.guessSiteTag = prefs.getBoolPref("optGuessSiteTag"); + if (prefs.prefHasUserValue("optRememberSiteTag")) + opts.rememberSiteTag = prefs.getBoolPref("optRememberSiteTag"); + if (prefs.prefHasUserValue("optRememberMasterKey")) + opts.rememberMasterKey = prefs.getBoolPref("optRememberMasterKey"); + if (prefs.prefHasUserValue("optRevealSiteTag")) + opts.revealSiteTag = prefs.getBoolPref("optRevealSiteTag"); + if (prefs.prefHasUserValue("optRevealHashWord")) + opts.revealHashWord = prefs.getBoolPref("optRevealHashWord"); + if (prefs.prefHasUserValue("optShowMarker")) + opts.showMarker = prefs.getBoolPref("optShowMarker"); + if (prefs.prefHasUserValue("optUnmaskMarker")) + opts.unmaskMarker = prefs.getBoolPref("optUnmaskMarker"); + if (prefs.prefHasUserValue("optGuessFullDomain")) + opts.guessFullDomain = prefs.getBoolPref("optGuessFullDomain"); + if (prefs.prefHasUserValue("optDigitDefault")) + opts.digitDefault = prefs.getBoolPref("optDigitDefault"); + if (prefs.prefHasUserValue("optPunctuationDefault")) + opts.punctuationDefault = prefs.getBoolPref("optPunctuationDefault"); + if (prefs.prefHasUserValue("optMixedCaseDefault")) + opts.mixedCaseDefault = prefs.getBoolPref("optMixedCaseDefault"); + if (prefs.prefHasUserValue("optHashWordSizeDefault")) + opts.hashWordSizeDefault = prefs.getIntPref("optHashWordSizeDefault"); + if (prefs.prefHasUserValue("optShortcutKeyCode")) + opts.shortcutKeyCode = prefs.getCharPref("optShortcutKeyCode"); + if (!opts.shortcutKeyCode) + { + // Set shortcut key to XUL-defined default. + forceSave = true; + var elementKey = document.getElementById("key_passhash"); + if (elementKey != null) + { + opts.shortcutKeyCode = elementKey.getAttribute("key"); + if (!opts.shortcutKeyCode) + opts.shortcutKeyCode = elementKey.getAttribute("keycode"); + } + } + if (prefs.prefHasUserValue("optShortcutKeyMods")) + opts.shortcutKeyMods = prefs.getCharPref("optShortcutKeyMods"); + if (!opts.shortcutKeyMods) + { + // Set shortcut modifiers to XUL-defined default. + forceSave = true; + var elementKey = document.getElementById("key_passhash"); + if (elementKey != null) + opts.shortcutKeyMods = elementKey.getAttribute("modifiers"); + } + // Force saving options if the key options are not present to give them visibility + if (forceSave) + this.saveOptions(opts); + return opts; + }, + + createOptions: function() + { + var opts = new Object(); + opts.securityLevel = 2; + opts.guessSiteTag = true; + opts.rememberSiteTag = true; + opts.rememberMasterKey = false; + opts.revealSiteTag = true; + opts.revealHashWord = false; + opts.showMarker = true; + opts.unmaskMarker = false; + opts.guessFullDomain = false; + opts.digitDefault = true; + opts.punctuationDefault = true; + opts.mixedCaseDefault = true; + opts.hashWordSizeDefault = 8; + opts.firstTime = true; + opts.shortcutKeyCode = ""; + opts.shortcutKeyMods = ""; + return opts; + }, + + saveOptions: function(opts) + { + var prefs = Components.classes["@mozilla.org/preferences-service;1"]. + getService(Components.interfaces.nsIPrefService).getBranch("passhash."); + prefs.setIntPref( "optSecurityLevel", opts.securityLevel); + prefs.setBoolPref("optGuessSiteTag", opts.guessSiteTag); + prefs.setBoolPref("optRememberSiteTag", opts.rememberSiteTag); + prefs.setBoolPref("optRememberMasterKey", opts.rememberMasterKey); + prefs.setBoolPref("optRevealSiteTag", opts.revealSiteTag); + prefs.setBoolPref("optRevealHashWord", opts.revealHashWord); + prefs.setBoolPref("optShowMarker", opts.showMarker); + prefs.setBoolPref("optUnmaskMarker", opts.unmaskMarker); + prefs.setBoolPref("optGuessFullDomain", opts.guessFullDomain); + prefs.setBoolPref("optDigitDefault", opts.digitDefault); + prefs.setBoolPref("optPunctuationDefault", opts.punctuationDefault); + prefs.setBoolPref("optMixedCaseDefault", opts.mixedCaseDefault); + prefs.setIntPref( "optHashWordSizeDefault", opts.hashWordSizeDefault); + prefs.setCharPref("optShortcutKeyCode", opts.shortcutKeyCode); + prefs.setCharPref("optShortcutKeyMods", opts.shortcutKeyMods); + }, + + loadSecureValue: function(option, name, suffix, valueDefault) + { + return (this.hasLoginManager() + ? this.loadLoginManagerValue(option, name, suffix, valueDefault) + : this.loadPasswordManagerValue(option, name, suffix, valueDefault)); + }, + + loadLoginManagerValue: function(option, name, suffix, valueDefault) + { + var user = (suffix ? name + "-" + suffix : name); + var value = valueDefault; + if (option && suffix != null) + { + var login = this.findLoginManagerUserLogin(user); + if (login != null && login.password != "" && login.password != "n/a") + value = login.password; + } + return value; + }, + + loadPasswordManagerValue: function(option, name, suffix, valueDefault) + { + var user = (suffix ? name + "-" + suffix : name); + var value = valueDefault; + var found = false; + if (option && suffix != null) + { + var passwordManager = Components.classes["@mozilla.org/passwordmanager;1"] + .getService(Components.interfaces.nsIPasswordManager); + var e = passwordManager.enumerator; + while (!found && e.hasMoreElements()) + { + try + { + var pass = e.getNext().QueryInterface(Components.interfaces.nsIPassword); + if (pass.host == this.host && pass.user == user) + { + value = pass.password; + found = true; + } + } + catch (ex) {} + } + } + return value; + }, + + saveSecureValue: function(option, name, suffix, value) + { + return (this.hasLoginManager() + ? this.saveLoginManagerValue(option, name, suffix, value) + : this.savePasswordManagerValue(option, name, suffix, value)); + }, + + saveLoginManagerValue: function(option, name, suffix, value) + { + if (!value || suffix == null) + return false; + var valueSave = (option ? value : "n/a"); + var user = (suffix ? name + "-" + suffix : name); + + var loginManager = Components.classes["@mozilla.org/login-manager;1"]. + getService(Components.interfaces.nsILoginManager); + + var newLogin = Components.classes["@mozilla.org/login-manager/loginInfo;1"]. + createInstance(Components.interfaces.nsILoginInfo); + + newLogin.init(this.host, 'passhash', null, user, valueSave, "", ""); + + var currentLogin = this.findLoginManagerUserLogin(user); + + if ( currentLogin == null) + loginManager.addLogin(newLogin); + else + loginManager.modifyLogin(currentLogin, newLogin); + return true; + }, + + savePasswordManagerValue: function(option, name, suffix, value) + { + if (!value || suffix == null) + return false; + var valueSave = (option ? value : ""); + var user = (suffix ? name + "-" + suffix : name); + var passwordManager = Components.classes["@mozilla.org/passwordmanager;1"] + .getService(Components.interfaces.nsIPasswordManager); + try + { + // Firefox 2 seems to lose info from subsequent addUser calls + // addUser on an existing host/user after restarting. + passwordManager.removeUser(this.host, user); + } + catch (ex) {} + passwordManager.addUser(this.host, user, valueSave); + return true; + }, + + hasLoginManager: function() + { + return ("@mozilla.org/login-manager;1" in Components.classes); + }, + + findLoginManagerUserLogin: function(user) + { + // Find user from returned array of nsILoginInfo objects + var logins = this.findAllLoginManagerLogins(); + for (var i = 0; i < logins.length; i++) + if (logins[i].username == user) + return logins[i]; + return null; + }, + + findAllLoginManagerLogins: function() + { + var loginManager = Components.classes["@mozilla.org/login-manager;1"]. + getService(Components.interfaces.nsILoginManager); + return loginManager.findLogins({}, this.host, "passhash", null); + }, + + // TODO: There's probably a better way + getDomain: function(input) + { + var h = input.host.split("."); + if (h.length <= 1) + return null; + // Handle domains like co.uk + if (h.length > 2 && h[h.length-1].length == 2 && h[h.length-2] == "co") + return h[h.length-3] + '.' + h[h.length-2] + '.' + h[h.length-1]; + return h[h.length-2] + '.' + h[h.length-1]; + }, + + // IMPORTANT: This function should be changed carefully. It must be + // completely deterministic and consistent between releases. Otherwise + // users would be forced to update their passwords. In other words, the + // algorithm must always be backward-compatible. It's only acceptable to + // violate backward compatibility when new options are used. + // SECURITY: The optional adjustments are positioned and calculated based + // on the sum of all character codes in the raw hash string. So it becomes + // far more difficult to guess the injected special characters without + // knowing the master key. + // TODO: Is it ok to assume ASCII is ok for adjustments? + generateHashWord: function( + siteTag, + masterKey, + hashWordSize, + requireDigit, + requirePunctuation, + requireMixedCase, + restrictSpecial, + restrictDigits) + { + // Start with the SHA1-encrypted master key/site tag. + var s = b64_hmac_sha1(masterKey, siteTag); + // Use the checksum of all characters as a pseudo-randomizing seed to + // avoid making the injected characters easy to guess. Note that it + // isn't random in the sense of not being deterministic (i.e. + // repeatable). Must share the same seed between all injected + // characters so that they are guaranteed unique positions based on + // their offsets. + var sum = 0; + for (var i = 0; i < s.length; i++) + sum += s.charCodeAt(i); + // Restrict digits just does a mod 10 of all the characters + if (restrictDigits) + s = PassHashCommon.convertToDigits(s, sum, hashWordSize); + else + { + // Inject digit, punctuation, and mixed case as needed. + if (requireDigit) + s = PassHashCommon.injectSpecialCharacter(s, 0, 4, sum, hashWordSize, 48, 10); + if (requirePunctuation && !restrictSpecial) + s = PassHashCommon.injectSpecialCharacter(s, 1, 4, sum, hashWordSize, 33, 15); + if (requireMixedCase) + { + s = PassHashCommon.injectSpecialCharacter(s, 2, 4, sum, hashWordSize, 65, 26); + s = PassHashCommon.injectSpecialCharacter(s, 3, 4, sum, hashWordSize, 97, 26); + } + // Strip out special characters as needed. + if (restrictSpecial) + s = PassHashCommon.removeSpecialCharacters(s, sum, hashWordSize); + } + // Trim it to size. + return s.substr(0, hashWordSize); + }, + + // This is a very specialized method to inject a character chosen from a + // range of character codes into a block at the front of a string if one of + // those characters is not already present. + // Parameters: + // sInput = input string + // offset = offset for position of injected character + // reserved = # of offsets reserved for special characters + // seed = seed for pseudo-randomizing the position and injected character + // lenOut = length of head of string that will eventually survive truncation. + // cStart = character code for first valid injected character. + // cNum = number of valid character codes starting from cStart. + injectSpecialCharacter: function(sInput, offset, reserved, seed, lenOut, cStart, cNum) + { + var pos0 = seed % lenOut; + var pos = (pos0 + offset) % lenOut; + // Check if a qualified character is already present + // Write the loop so that the reserved block is ignored. + for (var i = 0; i < lenOut - reserved; i++) + { + var i2 = (pos0 + reserved + i) % lenOut + var c = sInput.charCodeAt(i2); + if (c >= cStart && c < cStart + cNum) + return sInput; // Already present - nothing to do + } + var sHead = (pos > 0 ? sInput.substring(0, pos) : ""); + var sInject = String.fromCharCode(((seed + sInput.charCodeAt(pos)) % cNum) + cStart); + var sTail = (pos + 1 < sInput.length ? sInput.substring(pos+1, sInput.length) : ""); + return (sHead + sInject + sTail); + }, + + // Another specialized method to replace a class of character, e.g. + // punctuation, with plain letters and numbers. + // Parameters: + // sInput = input string + // seed = seed for pseudo-randomizing the position and injected character + // lenOut = length of head of string that will eventually survive truncation. + removeSpecialCharacters: function(sInput, seed, lenOut) + { + var s = ''; + var i = 0; + while (i < lenOut) + { + var j = sInput.substring(i).search(/[^a-z0-9]/i); + if (j < 0) + break; + if (j > 0) + s += sInput.substring(i, i + j); + s += String.fromCharCode((seed + i) % 26 + 65); + i += (j + 1); + } + if (i < sInput.length) + s += sInput.substring(i); + return s; + }, + + // Convert input string to digits-only. + // Parameters: + // sInput = input string + // seed = seed for pseudo-randomizing the position and injected character + // lenOut = length of head of string that will eventually survive truncation. + convertToDigits: function(sInput, seed, lenOut) + { + var s = ''; + var i = 0; + while (i < lenOut) + { + var j = sInput.substring(i).search(/[^0-9]/i); + if (j < 0) + break; + if (j > 0) + s += sInput.substring(i, i + j); + s += String.fromCharCode((seed + sInput.charCodeAt(i)) % 10 + 48); + i += (j + 1); + } + if (i < sInput.length) + s += sInput.substring(i); + return s; + }, + + bumpSiteTag: function(siteTag) + { + var tag = siteTag.replace(/^[ \t]*(.*)[ \t]*$/, "$1"); // redundant + if (tag) + { + var splitTag = tag.match(/^(.*):([0-9]+)?$/); + if (splitTag == null || splitTag.length < 3) + tag += ":1"; + else + tag = splitTag[1] + ":" + (parseInt(splitTag[2]) + 1); + } + return tag; + }, + + // Returns true if an HTML node is some kind of text field. + isTextNode: function(node) + { + try + { + var name = node.localName.toUpperCase(); + if (name == "TEXTAREA" || name == "TEXTBOX" || + (name == "INPUT" && + (node.type == "text" || node.type == "password"))) + return true; + } + catch(e) {} + return false; + }, + + // From Mozilla utilityOverlay.js + // TODO: Can I access it directly? + openUILinkIn: function(url, where) + { + if (!where) + return; + + if ((url == null) || (url == "")) + return; + + // xlate the URL if necessary + if (url.indexOf("urn:") == 0) + url = xlateURL(url); // does RDF urn expansion + + // avoid loading "", since this loads a directory listing + if (url == "") + url = "about:blank"; + + if (where == "save") + { + saveURL(url, null, null, true); + return; + } + + var w = (where == "window") ? null : this.getTopWin(); + if (!w) + { + openDialog(getBrowserURL(), "_blank", "chrome,all,dialog=no", url); + return; + } + var browser = w.document.getElementById("content"); + + switch (where) + { + case "current": + browser.loadURI(url); + w.content.focus(); + break; + case "tabshifted": + case "tab": + var tab = browser.addTab(url); + if ((where == "tab") ^ this.getBoolPref("browser.tabs.loadBookmarksInBackground", + false)) + { + browser.selectedTab = tab; + w.content.focus(); + } + break; + } + }, + + // From Mozilla utilityOverlay.js + getTopWin: function() + { + var windowManager = Components.classes['@mozilla.org/appshell/window-mediator;1']. + getService(); + var windowManagerInterface = windowManager.QueryInterface( + Components.interfaces.nsIWindowMediator); + var topWindowOfType = windowManagerInterface.getMostRecentWindow("navigator:browser"); + + if (topWindowOfType) + return topWindowOfType; + + return null; + }, + + // From Mozilla utilityOverlay.js + getBoolPref: function(prefname, def) + { + try + { + var pref = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); + return pref.getBoolPref(prefname); + } + catch(ex) + { + return def; + } + }, + + // Build an array sorted by domain name with properties populated, as + // available, for site tag, master key and options. + getSavedEntries: function() + { + // Because of Javascript limitations on associative arrays, e.g. not + // handling non-alphanumeric, we'll go to the trouble of building + // separate sortable arrays of site tags, master keys and options using + // domain/value objects. After sorting the three arrays we can walk + // through them and build the returned array of fully-fleshed-out + // objects. + var siteTags = new Array(); + var masterKeys = new Array(); + var options = new Array(); + if (this.hasLoginManager()) + this.getAllLoginManagerEntries(siteTags, masterKeys, options); + else + this.getAllPasswordManagerEntries(siteTags, masterKeys, options); + + var entries = Array(); + siteTags.sort( function(a, b) {return a.name.localeCompare(b.name);}); + masterKeys.sort(function(a, b) {return a.name.localeCompare(b.name);}); + options.sort( function(a, b) {return a.name.localeCompare(b.name);}); + var iSiteTag = 0, iMasterKey = 0, iOption = 0; + while (iSiteTag < siteTags.length || + iMasterKey < masterKeys.length || + iOption < options.length) + { + // Find the lowest domain name from the three waiting values + var next = null; + if (iSiteTag < siteTags.length && (next == null || + siteTags[iSiteTag].name < next)) + next = siteTags[iSiteTag].name; + if (iMasterKey < masterKeys.length && (next == null || + masterKeys[iMasterKey].name < next)) + next = masterKeys[iMasterKey].name; + if (iOption < options.length && (next == null || + options[iOption].name < next)) + next = options[iOption].name; + // Grab all data with a matching domain name and advance the corresponding index + entries[entries.length] = {name: next}; + if (iSiteTag < siteTags.length && next == siteTags[iSiteTag].name) + { + entries[entries.length-1].siteTag = siteTags[iSiteTag].value; + iSiteTag++; + } + else + entries[entries.length-1].siteTag = ""; + if (iMasterKey < masterKeys.length && next == masterKeys[iMasterKey].name) + { + entries[entries.length-1].masterKey = masterKeys[iMasterKey].value; + iMasterKey++; + } + else + entries[entries.length-1].masterKey = ""; + if (iOption < options.length && next == options[iOption].name) + { + entries[entries.length-1].options = options[iOption].value; + iOption++; + } + else + entries[entries.length-1].options = ""; + } + return entries; + }, + + // Gather all extension-related FF3 login manager entries. Return as 3 + // arrays for site tags, master keys and options. + getAllLoginManagerEntries: function(siteTags, masterKeys, options) + { + var logins = this.findAllLoginManagerLogins(); + for (var i = 0; i < logins.length; i++) + { + var login = logins[i]; + try + { + if (login.hostname == this.host) + { + if (login.username.indexOf("site-tag-") == 0) + { + var o = new Object(); + o.name = login.username.substring(9); + o.value = login.password; + siteTags[siteTags.length] = o; + } + else + { + if (login.username.indexOf("master-key-") == 0) + { + var o = new Object(); + o.name = login.username.substring(11); + o.value = login.password; + masterKeys[masterKeys.length] = o; + } + else + { + if (login.username.indexOf("options-") == 0) + { + var o = new Object(); + o.name = login.username.substring(8); + o.value = login.password; + options[options.length] = o; + } + } + } + } + } + catch(e) {} + } + }, + + // Gather all extension-related FF2 login manager entries. Return as 3 + // arrays for site tags, master keys and options. + getAllPasswordManagerEntries: function(siteTags, masterKeys, options) + { + var passwordManager = Components.classes["@mozilla.org/passwordmanager;1"]. + createInstance(); + passwordManager.QueryInterface(Components.interfaces.nsIPasswordManager); + passwordManager.QueryInterface(Components.interfaces.nsIPasswordManagerInternal); + var passwordEnumerator = passwordManager.enumerator; + while(passwordEnumerator.hasMoreElements()) + { + try + { + var pw = passwordEnumerator.getNext() + .QueryInterface(Components.interfaces.nsIPasswordInternal); + if (pw.host == this.host) + { + if (pw.user.indexOf("site-tag-") == 0) + { + var o = new Object(); + o.name = pw.user.substring(9); + o.value = pw.password; + siteTags[siteTags.length] = o; + } + else + { + if (pw.user.indexOf("master-key-") == 0) + { + var o = new Object(); + o.name = pw.user.substring(11); + o.value = pw.password; + masterKeys[masterKeys.length] = o; + } + else + { + if (pw.user.indexOf("options-") == 0) + { + var o = new Object(); + o.name = pw.user.substring(8); + o.value = pw.password; + options[options.length] = o; + } + } + } + } + } + catch(e) {} + } + }, + + getResourceFile: function(uri) + { + var handler = Components.classes["@mozilla.org/network/protocol;1?name=file"] + .createInstance(Components.interfaces.nsIFileProtocolHandler); + var urlSrc = Components.classes["@mozilla.org/network/standard-url;1"] + .createInstance( Components.interfaces.nsIURL ); + urlSrc.spec = uri; + var chromeReg = Components.classes["@mozilla.org/chrome/chrome-registry;1"] + .getService( Components.interfaces.nsIChromeRegistry ); + var urlIn = chromeReg.convertChromeURL(urlSrc); + return handler.getFileFromURLSpec(urlIn.spec); + }, + + openInputFile: function(fileIn) + { + var streamIn = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(Components.interfaces.nsIFileInputStream); + streamIn.init(fileIn, 0x01, 0444, 0); + streamIn.QueryInterface(Components.interfaces.nsILineInputStream); + return streamIn; + }, + + openOutputFile: function(fileOut) + { + var streamOut = Components.classes["@mozilla.org/network/file-output-stream;1"] + .createInstance(Components.interfaces.nsIFileOutputStream); + streamOut.init(fileOut, 0x02 | 0x08 | 0x20, 0664, 0); // write, create, truncate + return streamOut; + }, + + streamWriteLine: function(stream, line) + { + stream.write(line, line.length); + stream.write("\n", 1); + }, + + // Expand variables and return resulting string + expandLine: function(lineIn) + { + var strings = document.getElementById("pshOpt_strings"); + var lineOut = ""; + var splicePos = 0; + var re = /[$][{][ \t]*([^ }]+)[^}]*[}]/g; + var match; + while ((match = re.exec(lineIn)) != null) + { + lineOut += lineIn.substr(splicePos, match.index); + try + { + lineOut += strings.getString(match[1]); + } + catch (ex) + { + alert("Couldn't find string \"" + match[1] + "\""); + lineOut += "???" + match[1] + "???"; + } + splicePos = re.lastIndex; + } + lineOut += lineIn.substr(splicePos); + return lineOut; + }, + + // Expand variables and write line to output stream + streamWriteExpandedLine: function(stream, line) + { + PassHashCommon.streamWriteLine(stream, PassHashCommon.expandLine(line)); + }, + + browseFile: function(file, where) + { + var handler = Components.classes["@mozilla.org/network/protocol;1?name=file"] + .createInstance(Components.interfaces.nsIFileProtocolHandler); + PassHashCommon.openUILinkIn(handler.getURLSpecFromFile(file), where); + }, + + pickHTMLFile: function(titleTag, defaultName) + { + var title = document.getElementById("pshOpt_strings").getString(titleTag); + var nsIFilePicker = Components.interfaces.nsIFilePicker; + var picker = Components.classes["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker); + if (defaultName) + picker.defaultString = defaultName; + picker.appendFilters(nsIFilePicker.filterHTML); + picker.init(window, title, nsIFilePicker.modeSave); + var file; + do + { + var action = picker.show(); + if (action == 1) + return null; + file = picker.file; + if (! /\.html{0,1}$/.test(picker.file.path)) + file.initWithPath(picker.file.path + ".html"); + picker.defaultString = file.leafName; + } + while (file.exists() && (action == 0)); + return file; + }, +} diff --git a/passmanager/passmanager_wm.lua b/passmanager/passmanager_wm.lua new file mode 100644 index 0000000..f49e939 --- /dev/null +++ b/passmanager/passmanager_wm.lua @@ -0,0 +1,527 @@ +--- passmanager web module. +-- +-- # Capabilities +-- +-- # Usage +-- +-- For usage information check init.lua +-- +-- # Troubleshooting +-- +-- # Files and Directories +-- +-- @module passmanager.passmanager_wm +-- @author Serg Kozhemyakin +-- @copyright 2017-2018 Serg Kozhemyakin + +local ui = ipc_channel('plugins/passmanager/passmanager_wm') +local luakit = require('luakit') +local lousy = require("lousy") +local clone = lousy.util.table.clone +local hasitem = lousy.util.table.hasitem +local filter = lousy.util.table.filter_array +local join = lousy.util.table.join +local keys = lousy.util.table.keys +local lfs = require("lfs") + +-- list of known credentials and forms per page +local _page_credentials = setmetatable({}, { __mode = "k" }) +local _page_forms = setmetatable({}, { __mode = "k" }) + +local _plugin_location = nil + +local function uri_to_domain(uri) + local _uri = lousy.uri.parse(uri) + local domain = _uri.host + if _uri.port and _uri.port ~= 80 and _uri.port ~= 443 then + domain = domain .. ":" .. _uri.port + end + return domain +end + +local function find_login(tbl, login) + for _, p in ipairs(tbl) do + if p.login == login then + return p + end + end + return nil +end + +local function find_login_index(tbl, login) + for i, p in ipairs(tbl) do + if p.login == login then + return i + end + end + return nil +end + +-- random string generation borrowed from lua-wiki +local Chars = {} +for Loop = 0, 255 do + Chars[Loop+1] = string.char(Loop) +end +local String = table.concat(Chars) + +local Built = {['.'] = Chars} + +local AddLookup = function(CharSet) + local Substitute = string.gsub(String, '[^'..CharSet..']', '') + local Lookup = {} + for Loop = 1, string.len(Substitute) do + Lookup[Loop] = string.sub(Substitute, Loop, Loop) + end + Built[CharSet] = Lookup + + return Lookup +end + +local function random_string(Length, CharSet) + -- Length (number) + -- CharSet (string, optional); e.g. %l%d for lower case letters and digits + + CharSet = CharSet or '%l%d' + + if CharSet == '' then + return '' + else + local Result = {} + local Lookup = Built[CharSet] or AddLookup(CharSet) + local Range = #Lookup + + for Loop = 1,Length do + Result[Loop] = Lookup[math.random(1, Range)] + end + + return table.concat(Result) + end +end +-- end of random string generation + +local function _eval_js_on_element(page, element, js) + local reset = false + if not element.attr.id or element.attr.id == '' then + element.attr.id = random_string(32) + reset = true + end + local ret = page:eval_js(string.format("(function(element) { return element.%s })(document.getElementById('%s'))", js, element.attr.id)) + if reset then + element.attr.id = '' + end + return ret +end + +local function _input_select_range(page, input, from, to) + local direction = 'forward' + if not from or not to then return end + if from > to then + from, to = to, from + direction = 'backward' + end + _eval_js_on_element(page, input, string.format("setSelectionRange(%i,%i,'%s')", from, to, direction)) +end + +local function _input_field_input_cb(page, tbl) + local input = tbl.target + local txt = input.value + if txt ~= "" then + for _, p in ipairs(_page_credentials[page][1]) do + local login = p.login + if string.find(login, txt) == 1 then + input.value = login + _input_select_range(page, input, #txt, #login, 'backward') + break + end + end + end +end + +local function _input_field_keydown_cb(page, tbl) + -- handle Up and Down keys in login fields. + -- Scrolls over all known logins for this site + if tbl.key == 'Up' or tbl.key == 'Down' then + local input = tbl.target + local txt = input.value + local i = 0 + local candidates = clone(_page_credentials[page][1]) + local _start = _eval_js_on_element(page, input, 'selectionStart') + local _end = _eval_js_on_element(page, input, 'selectionEnd') + if txt ~= "" then + -- if we have input without selection then let's find index + -- in array with password for this login + i = find_login_index(candidates, txt) + -- if we have input field with some selection then let's + -- find candidates that starts from manually entered text + if _start ~= _end then + local manual = string.sub(txt, 1, _start) + candidates = filter(candidates, function(_, p) return string.find(p.login, manual) == 1 end) + end + end + -- it may be nil when we have something in input fields + -- but this value not in our list of candidates + if i ~= nil then + i = i + (tbl.key == 'Down' and 1 or -1); + if i <= 0 then i = #candidates end + if i > #candidates then i = 1 end + input.value = candidates[i].login + if _start ~= _end and _end == #txt then + _end = #input.value + end + _input_select_range(page, input, _start, _end, 'backward') + end + end +end + +local function _find_password_fields(form) + local password_fields = form:query("input") + password_fields = filter(password_fields, function(_, input) + return string.match(input.type, "password") or input.attr.revealed_password + end) + return password_fields +end + +local function _fill_password_fields(form, passwd) + if not passwd then return end + local password_fields = _find_password_fields(form) + -- fill every password field with specified password + for _, p in ipairs(password_fields) do + p.value = passwd + end +end + +local function _load_passhash(page, form, input) + local div = page.document.body:query("div#passhash-generator") + if #div == 0 then + -- load div template + local f, _ = io.open(_plugin_location.."/passhash/passhash.html") + local passhash_ui = f:read('*a') + f:close() + + -- load style + f, _ = io.open(_plugin_location.."/passhash/modal.css") + local modal_css = f:read('*a') + f:close() + + -- load js code + f, _ = io.open(_plugin_location.."/passhash/passhash.js") + local passhash_js = f:read('*a') + f:close() + + -- now let's inject our div to page + local _div = page.document:create_element('div', {id = 'passhash-generator', class = 'passhash-popup'}) + _div.inner_html= passhash_ui + + local _style = page.document:create_element('style', {}) + _style.inner_html= modal_css + + local _js = page.document:create_element('script', {language = 'JavaScript', type="text/javascript" }) + _js.inner_html= passhash_js + + page.document.body:append(_style) + page.document.body:append(_js) + page.document.body:append(_div) + + -- attach callbacs + div = page.document.body:query("div#passhash-generator")[1] + + local close_cb = function() + _eval_js_on_element(page, div, "style.display='none'") + _eval_js_on_element(page, input, 'focus()') + end + + local close = div:query("#passhash-close")[1] + close:add_event_listener('click', false, close_cb) + + local submit_cb = function() + local passwd = div:query("#hash-word")[1].value + if passwd ~= '' then + _fill_password_fields(form, passwd) + end + close_cb() + end + + local submit = div:query("#passhash-ok")[1] + submit:add_event_listener('click', false, submit_cb) + + local master_key = div:query("#master-key")[1] + master_key:add_event_listener('keydown', false, function(_, tbl) + if tbl.key == 'Enter' then + submit_cb() + tbl.cancel = true + tbl.prevent_default = true + end + end) + end +end + +local function _passwd_field_keydown_cb(page, form, tbl) + if tbl.alt_key then + local input = tbl.target + -- generate new password (alt-g) + if tbl.key == 'U+0047' then + _load_passhash(page, form, input) + + local div = page.document.body:query("div#passhash-generator")[1] + _eval_js_on_element(page, div, "style.display='block'") + + local site_tag = div:query("#site-tag")[1] + if site_tag then + site_tag.value = _page_credentials[page][2] + end + + local master_key = div:query("#master-key")[1] + _eval_js_on_element(page, master_key, 'focus()') + + end + -- show/hide password (alt-s) + if tbl.key == 'U+0053' then + input.attr.type = (input.attr.type == 'text' and 'password') or 'text'; + input.attr.revealed_password = 'true'; + end + end +end + +local function _input_field_focusin_cb(page, tbl) + local input = tbl.target + local login = input.value + + -- on focusIn populate login if no yet any text in field + if _page_credentials[page][1][1] and login == '' then + local first_login = _page_credentials[page][1][1].login + input.value = first_login + _input_select_range(page, input, #first_login, 0) + else + -- otherwise preserve selection in login field + local _start = _eval_js_on_element(page, input, 'selectionStart') + local _end = _eval_js_on_element(page, input, 'selectionEnd') + _input_select_range(page, input, _end, _start) + end +end + +local function _input_field_focusout_cb(page, form, tbl) + local input = tbl.target + local login = input.value + + -- on focusOut find matching password and fill passwd fields with it + if login then + local p = find_login(_page_credentials[page][1], login) + if p then + local passwd = p.password + _fill_password_fields(form, passwd) + end + end +end + +local function call_pass_and_wait(signal, page_id, ...) + -- creating lock file and waiting while it will be removed + local lock_file = os.tmpname() + io.open(lock_file):close() + + ui:emit_signal(signal, page_id, lock_file, ...) + + -- waiting for removal of lock file. ugly but works + repeat + local attr = lfs.attributes(lock_file) + if attr then + -- sleep one second + os.execute("sleep " .. 1) + else + break + end + until false; +end + +-- during submitting check login/password pair and if: +-- 1) login different -- request creation of new pass record +-- 2) pass changed for existing login -- request updating of pass record +-- 3) login and/or password are empty -- skip updation/creation of pass records +local function _form_submit_cb(page, form, login_field) + local login = login_field.value + local password + + -- let's check that all password fields contains same password + local password_fields = _find_password_fields(form) + local entered_passwords = {} + for _, p in ipairs(password_fields) do + entered_passwords[p.value] = true + end + entered_passwords = keys(entered_passwords) + if #entered_passwords == 1 then + password = entered_passwords[1] + if password == '' then + msg.error("Empty password entered in form. Ignoring.") + return + end + else + msg.error("Several different passwords entered in form. Ignoring.") + return + end + + if login then + local domain = uri_to_domain(page.uri) + local p = find_login(_page_credentials[page][1], login) + if p and p.password then + if p.password ~= password then + -- we have login and different password. let's update pass record + call_pass_and_wait("update-pass-for-login", page.id, domain, login, password, p.file) + end + else + -- no such login yet, let's create new one + call_pass_and_wait("create-pass-for-login", page.id, domain, login, password) + end + end +end + +local function _process_one_form(page, form) + if not _page_credentials[page] or _page_forms[page][form] then return end + local inputs = form:query("input") + -- filter out non text fields + -- input type email used by some sites so we will catch it as well + inputs = filter(inputs, function(_, input) + return input.attr.type == 'text' or input.attr.type == 'email' + end) + -- filter input fields with id or name containing 'login', 'user' or 'identifier' + inputs = filter(inputs, function(_, input) + local id = string.lower(input.attr.id or '') + local name = string.lower(input.attr.name or '') + return string.match(name, "login") or + string.match(name, "identifier") or + string.match(name, "user") or + string.match(id, "login") or + string.match(id, "identifier") or + string.match(id, "user") + end) + -- attach signals only if we have form with login field + if #inputs >= 1 then + if #inputs > 1 then + msg.info("Several login fields in one form "..tostring(form).." detected. Will use only first one.") + end + local login = inputs[1] + + login:add_event_listener('input', false, function(_, tbl) _input_field_input_cb(page, tbl) end) + login:add_event_listener('focusin', false, function(_, tbl) _input_field_focusin_cb(page, tbl) end) + login:add_event_listener('focusout', false, function(_, tbl) _input_field_focusout_cb(page, form, tbl) end) + login:add_event_listener('keydown', false, function(_, tbl) _input_field_keydown_cb(page, tbl) end) + + -- attach to every password field handler that will be responsible for generation passwords + local password_fields = form:query("input") + password_fields = filter(password_fields, function(_, input) + return string.match(input.type, "password") + end) + + for _, p in ipairs(password_fields) do + p:add_event_listener('keydown', false, function(_, tbl) _passwd_field_keydown_cb(page, form, tbl) end) + end + + -- attach to form submit signal + form:add_event_listener('submit', false, function(_) _form_submit_cb(page, form, login) end) + + -- prefill login/password fields with first known login/password + -- if we have something already in login field then try to find corresponding password for it + -- such prefilled forms used by google for example + local cred = _page_credentials[page][1][1] + if login.value and login.value ~= "" then + cred = find_login(_page_credentials[page][1], login.value) + end + if cred and cred.password then + login.value = cred.login + _fill_password_fields(form, cred.password) + end + end + if not _page_forms[page] then + _page_forms[page] = setmetatable({}, { __mode = "k" }) + end + _page_forms[page][form] = true +end + +local function _process_forms(page, forms) + if #forms then + for _, f in ipairs(forms) do + _process_one_form(page, f) + end + end +end + +local function _look_for_forms (page, frame) + local forms= {} + if page.document.body then + if frame and frame.document.body then + forms = frame.document.body:query('form') or {} + else + forms = page.document.body:query('form') or {} + end + -- passwd forms filtering + forms = filter(forms, function(_, form) + local passwords = filter(form:query("input"), function(_, input) + return hasitem({"password"}, input.type) + end) + return #passwords > 0 + end) + -- in addtion let's check iframes content if they exists + local frames = {} + if frame then + frames = frame.document.body and frame.document.body:query('frame, iframe') or {} + else + frames = page.document.body:query('frame, iframe') or {} + end + for _, f in ipairs(frames) do + local iframe_forms = _look_for_forms(page, f) + if #iframe_forms > 0 then + forms = join(forms, iframe_forms) + end + end + end + return forms +end + +local function _capture_forms(page) + local captured = _look_for_forms(page) + if #captured > 0 then + -- attach callbacks for new forms + local new = {} + for _, f in ipairs(captured) do + if not _page_forms[page][f] then + new[#new+1] = f + end + end + if #new > 0 then + -- if no yet info about credentials for page -- skip form processing + -- forms will be processed later after receiving signal with credentials + if _page_credentials[page] then + _process_forms(page, new) + end + end + end +end + +ui:add_signal('get-plugin-configuration-reply', function(_, _, location) + _plugin_location = location +end) + +ui:add_signal('get-credentials-for-uri-reply', function(_, page, reply) + _page_credentials[page] = reply + _capture_forms(page) +end) + +luakit.add_signal("page-created", function(page) + if not _plugin_location then ui:emit_signal("get-plugin-configuration", page.id) end + page:add_signal("document-loaded", function(_page) + if not _page then return end + _page_credentials[_page] = nil + _page_forms[_page] = setmetatable({}, { __mode = "k" }) + + local domain = uri_to_domain(_page.uri) + if domain then + ui:emit_signal("get-credentials-for-uri", _page.id, domain) + + if _page.document.body then + -- and catch dom tree modification for refreshing for info + -- if some form will be added at runtime or iframe will be loaded + _page.document.body:add_event_listener("DOMSubtreeModified", false, function (_) return _capture_forms(_page) end) + end + end + end) +end) + +-- vim: et:sw=4:ts=8:sts=4:tw=80