├── README.md ├── wm.lua └── init.lua /README.md: -------------------------------------------------------------------------------- 1 | # luakit-pass 2 | 3 | [pass](https://www.passwordstore.org/) password manager plugin for the 4 | [luakit](https://luakit.github.io/) browser, provided as an alternative to the 5 | [formfiller module](https://luakit.github.io/docs/modules/formfiller.html). Uses 6 | [multi-line password data](https://www.passwordstore.org/#organization) for 7 | embedding extra data such as username or email, and is compatible with similar 8 | tools such as [browserpass](https://github.com/dannyvankooten/browserpass) or 9 | [passff](https://github.com/passff/passff). 10 | 11 | ## Commands and Bindings 12 | 13 | - `:pass` will try to auto fill form fields with info from a relevant password 14 | store. Searches for a file matching the current page's domain, or optionally 15 | takes a search term as an argument. Use `:pass!` to auto-submit. 16 | - `:pshow` will display the contents of the password file in the browser. 17 | - `^O` in `insert` mode will insert an OTP code into the currently selected 18 | input field. Requires the [pass-otp](https://github.com/tadfisher/pass-otp) 19 | extension to be installed and configured. 20 | - `^Enter` in a `:pass` menu will instead execute `:pshow` 21 | -------------------------------------------------------------------------------- /wm.lua: -------------------------------------------------------------------------------- 1 | local select = require("select_wm") 2 | local lousy = require("lousy") 3 | local ui = ipc_channel("pass.wm") 4 | local filter = lousy.util.table.filter_array 5 | 6 | -- browserpass form search logic: https://github.com/dannyvankooten/browserpass/blob/master/chrome/inject.js 7 | 8 | local FORM_MARKERS = { 9 | "login", 10 | "log-in", 11 | "log_in", 12 | "signin", 13 | "sign-in", 14 | "sign_in" 15 | } 16 | 17 | local USERNAME_FIELDS = { 18 | selectors = { 19 | "input[name*=user i]", 20 | "input[name*=login i]", 21 | "input[name*=email i]", 22 | "input[id*=user i]", 23 | "input[id*=login i]", 24 | "input[id*=email i]", 25 | "input[class*=user i]", 26 | "input[class*=login i]", 27 | "input[class*=email i]", 28 | "input[type=email i]", 29 | "input[type=text i]", 30 | "input[type=tel i]" 31 | }, 32 | types = {"email", "text", "tel"} 33 | } 34 | 35 | local PASSWORD_FIELDS = { 36 | selectors = {"input[type=password i]"} 37 | } 38 | 39 | local INPUT_FIELDS = { 40 | selectors = lousy.util.table.join(PASSWORD_FIELDS.selectors, USERNAME_FIELDS.selectors) 41 | } 42 | 43 | local SUBMIT_FIELDS = { 44 | selectors = { 45 | "[type=submit i]", 46 | "button[name*=login i]", 47 | "button[name*=log-in i]", 48 | "button[name*=log_in i]", 49 | "button[name*=signin i]", 50 | "button[name*=sign-in i]", 51 | "button[name*=sign_in i]", 52 | "button[id*=login i]", 53 | "button[id*=log-in i]", 54 | "button[id*=log_in i]", 55 | "button[id*=signin i]", 56 | "button[id*=sign-in i]", 57 | "button[id*=sign_in i]", 58 | "button[class*=login i]", 59 | "button[class*=log-in i]", 60 | "button[class*=log_in i]", 61 | "button[class*=signin i]", 62 | "button[class*=sign-in i]", 63 | "button[class*=sign_in i]", 64 | "input[type=button i][name*=login i]", 65 | "input[type=button i][name*=log-in i]", 66 | "input[type=button i][name*=log_in i]", 67 | "input[type=button i][name*=signin i]", 68 | "input[type=button i][name*=sign-in i]", 69 | "input[type=button i][name*=sign_in i]", 70 | "input[type=button i][id*=login i]", 71 | "input[type=button i][id*=log-in i]", 72 | "input[type=button i][id*=log_in i]", 73 | "input[type=button i][id*=signin i]", 74 | "input[type=button i][id*=sign-in i]", 75 | "input[type=button i][id*=sign_in i]", 76 | "input[type=button i][class*=login i]", 77 | "input[type=button i][class*=log-in i]", 78 | "input[type=button i][class*=log_in i]", 79 | "input[type=button i][class*=signin i]", 80 | "input[type=button i][class*=sign-in i]", 81 | "input[type=button i][class*=sign_in i]" 82 | } 83 | } 84 | 85 | local function elem_meta(page) 86 | return page:wrap_js([=[ 87 | return { 88 | "form_matches": !form || elem.form == form, 89 | "offset": { 90 | "width": elem.offsetWidth, 91 | "height": elem.offsetHeight, 92 | }, 93 | "bounding_client_rect": elem.getBoundingClientRect(), 94 | "window_inner": { 95 | "width": window.innerWidth, 96 | "height": window.innerHeight, 97 | }, 98 | }; 99 | ]=], {"elem", "form"}) 100 | end 101 | 102 | local function elem_focus(page) 103 | return page:wrap_js([=[ 104 | var eventNames = ["click", "focus"]; 105 | eventNames.forEach(function(eventName) { 106 | elem.dispatchEvent(new Event(eventName, { bubbles: true })); 107 | }); 108 | ]=], {"elem"}) 109 | end 110 | 111 | local function elem_unfocus(page) 112 | return page:wrap_js([=[ 113 | elem.setAttribute("value", value); 114 | elem.value = value; 115 | var eventNames = [ 116 | "keypress", 117 | "keydown", 118 | "keyup", 119 | "input", 120 | "blur", 121 | "change" 122 | ]; 123 | eventNames.forEach(function(eventName) { 124 | elem.dispatchEvent(new Event(eventName, { bubbles: true })); 125 | }); 126 | ]=], {"elem"}) 127 | end 128 | 129 | local function find(page, root, selectors) 130 | if type(selectors) == "string" then 131 | selectors = { selectors = selectors } 132 | end 133 | 134 | local meta = elem_meta(page); 135 | local matches = {} 136 | 137 | for _, sel in ipairs(selectors.selectors) do 138 | for _, element in ipairs(root:query(sel)) do 139 | local matches_ty = true 140 | if selectors.types ~= nil then 141 | matches_ty = false 142 | for _, ty in ipairs(selectors.types) do 143 | matches_ty = ty == element.type 144 | if matches_ty then 145 | break 146 | end 147 | end 148 | end 149 | if matches_ty then 150 | local data = meta(element) 151 | 152 | if data.offset.width < 30 or data.offset.height < 10 then 153 | matches_ty = false 154 | end 155 | 156 | local style = element.style 157 | if style.visibility == "hidden" or style.display == "none" then 158 | matches_ty = false 159 | end 160 | 161 | if data.bounding_client_rect.x + data.bounding_client_rect.width < 0 or 162 | data.bounding_client_rect.y + data.bounding_client_rect.height < 0 or 163 | data.bounding_client_rect.x > data.window_inner.width or 164 | data.bounding_client_rect.y > data.window_inner.height then 165 | matches_ty = false 166 | end 167 | end 168 | 169 | if matches_ty then 170 | table.insert(matches, element) 171 | end 172 | end 173 | end 174 | 175 | return matches 176 | end 177 | 178 | ui:add_signal("fill", function (_, page, data) 179 | local root = page.document.body 180 | local focus = elem_focus(page) 181 | local unfocus = elem_unfocus(page) 182 | local username_field = find(page, root, USERNAME_FIELDS) 183 | if #username_field > 0 then 184 | focus(username_field[1]) 185 | username_field = find(page, root, USERNAME_FIELDS) 186 | end 187 | if #username_field > 0 then 188 | if data.username ~= nil then 189 | username_field[1].value = data.username 190 | unfocus(username_field[1]) 191 | end 192 | end 193 | 194 | local password_field = find(page, root, PASSWORD_FIELDS) 195 | if #password_field > 0 then 196 | focus(password_field[1]) 197 | password_field = find(page, root, PASSWORD_FIELDS) 198 | end 199 | if #password_field > 0 then 200 | if data.password ~= nil then 201 | password_field[1].value = data.password 202 | unfocus(password_field[1]) 203 | end 204 | end 205 | 206 | if #password_field > 1 then 207 | msg.warn("still more to fill, otp code maybe?") 208 | focus(password_field[2]) 209 | elseif #password_field == 1 then 210 | focus(password_field[1]) 211 | elseif #username_field > 0 then 212 | focus(username_field[1]) 213 | end 214 | 215 | local submit = find(page, root, SUBMIT_FIELDS) 216 | if #submit > 0 then 217 | if data.submit then 218 | focus(submit[1]) 219 | end 220 | end 221 | end) 222 | 223 | ui:add_signal("fill_otp", function (_, page, data) 224 | local fn = page:wrap_js([=[ 225 | document.activeElement.value = value; 226 | ]=], {"value"}) 227 | fn(data.otp) 228 | end) 229 | 230 | -- vim: et:sw=4:ts=8:sts=4:tw=80 231 | -------------------------------------------------------------------------------- /init.lua: -------------------------------------------------------------------------------- 1 | local _M = {} 2 | 3 | local modes = require "modes" 4 | local binds = require "binds" 5 | local lousy = require "lousy" 6 | local formfiller = require "formfiller" 7 | local pass_wm = require_web_module("pass.wm") 8 | 9 | _M.executable = "/usr/bin/pass" 10 | _M.password_store = os.getenv("PASSWORD_STORE_DIR") or (os.getenv("HOME") .. "/.password-store") 11 | 12 | _M.MODE_SHOW = "show" 13 | _M.MODE_SUBMIT = "submit" 14 | _M.MODE_FILL = "fill" 15 | _M.MODE_OTP = "otp" 16 | 17 | formfiller.extend({ 18 | pass = function (name, field) 19 | local data = _M.pass_show(name) 20 | if data ~= nil then 21 | if field ~= nil then 22 | return _M.parse_password(data) 23 | else 24 | return _M.parse_field(data, field) 25 | end 26 | else 27 | return nil 28 | end 29 | end, 30 | otp = function (name, field) 31 | return _M.pass_otp(name) 32 | end, 33 | }) 34 | 35 | function _M.pass_show(name) 36 | local f = io.popen(string.format("%s show %q", _M.executable, name)) 37 | local ret = f:read("*all") 38 | f:close() 39 | if #ret == 0 then 40 | return nil 41 | else 42 | return ret 43 | end 44 | end 45 | 46 | function _M.pass_otp(name) 47 | local f = io.popen(string.format("%s otp %q", _M.executable, name)) 48 | local ret = f:read("*line") 49 | f:close() 50 | if #ret == 0 then 51 | return nil 52 | else 53 | return ret 54 | end 55 | end 56 | 57 | function _M.parse_lines(pass_data) 58 | return string.gmatch(pass_data, "[^\n]*") 59 | end 60 | 61 | function _M.parse_password(pass_data) 62 | for line in _M.parse_lines(pass_data) do 63 | return line 64 | end 65 | 66 | return nil 67 | end 68 | 69 | local username_fields = { "Username", "username", "User", "user", "Email", "email", "Login", "login" } 70 | local otp_field = "otpauth" 71 | 72 | function _M.parse_username(pass_data) 73 | return _M.parse_field(pass_data, username_fields) 74 | end 75 | 76 | function _M.parse_field(pass_data, fields) 77 | if type(fields) == "string" then 78 | fields = { fields } 79 | end 80 | 81 | for line in _M.parse_lines(pass_data) do 82 | for _, field in ipairs(fields) do 83 | local len = string.len(field) 84 | if string.sub(line, 1, len) == field and string.sub(line, len + 1, len + 1) == ":" then 85 | return string.gsub(string.sub(line, len + 2), "^ *", "") 86 | end 87 | end 88 | end 89 | 90 | return nil 91 | end 92 | 93 | function _M.has_otp(pass_data) 94 | return _M.parse_field(pass_data, otp_field) ~= nil 95 | end 96 | 97 | function _M.fill(w, name, submit) 98 | local data = _M.pass_show(name) 99 | if data ~= nil then 100 | pass_wm:emit_signal(w.view, "fill", { 101 | name = name, 102 | data = data, 103 | username = _M.parse_username(data), 104 | password = _M.parse_password(data), 105 | has_otp = _M.has_otp(data), 106 | submit = submit or false, 107 | }) 108 | w:set_mode("insert") 109 | else 110 | w:error(string.format("Failed to read %s", name)) 111 | end 112 | end 113 | 114 | function _M.fill_otp(w, name) 115 | local data = _M.pass_otp(name) 116 | if data ~= nil then 117 | pass_wm:emit_signal(w.view, "fill_otp", { 118 | name = name, 119 | otp = data, 120 | }) 121 | w:set_mode("insert") 122 | else 123 | w:error(string.format("OTP %s failed or missing", name)) 124 | end 125 | end 126 | 127 | function _M.show(w, name) 128 | local data = _M.pass_show(name) 129 | local otp = nil 130 | if _M.has_otp(data) then 131 | otp = _M.pass_otp(name) 132 | end 133 | 134 | if otp ~= nil then 135 | data = string.format("[%s] [OTP %s]\n%s", name, otp, data) 136 | else 137 | data = string.format("[%s]\n%s", name, data) 138 | end 139 | 140 | w:notify(data) 141 | end 142 | 143 | function _M.query_uri(uri) 144 | local domains = lousy.uri.domains_from_uri(uri) 145 | local patterns = {} 146 | for i, domain in ipairs(domains) do 147 | if string.sub(domain, 1, 1) == "." then 148 | domain = string.sub(domain, 2) 149 | end 150 | 151 | if #domains > 1 and i ~= #domains then -- TLD is useless 152 | table.insert(patterns, domain) 153 | end 154 | end 155 | 156 | return _M.query(patterns) 157 | end 158 | 159 | function _M.query(patterns) 160 | if type(patterns) == "string" then 161 | patterns = { patterns } 162 | elseif type(patterns) == nil then 163 | patterns = {} 164 | end 165 | 166 | local query = "-iname '*.gpg'" 167 | if #patterns > 0 then 168 | query = "-false" 169 | for _, pattern in ipairs(patterns) do 170 | query = query .. string.format(" -or -ipath %q -or -ipath %q", "*/" .. pattern .. ".gpg", "*/" .. pattern .. "/*.gpg") 171 | end 172 | end 173 | 174 | local ret = {} 175 | local cmd = string.format("find %q -mindepth 1 -not \\( -name '.*' -prune \\) -and \\( %s \\)", _M.password_store, query) 176 | local f = io.popen(cmd) 177 | for line in f:lines() do 178 | local pass = string.sub(string.gsub(line, "%.gpg$", ""), #_M.password_store + 2) 179 | table.insert(ret, pass) 180 | end 181 | f:close() 182 | return ret 183 | end 184 | 185 | function _M.build_menu(w, arg, mode) 186 | local menu = {} 187 | if arg == nil then 188 | for _, name in ipairs(_M.query_uri(w.view.uri)) do 189 | table.insert(menu, { name, name = name, mode = mode }) 190 | end 191 | else 192 | for _, name in ipairs(_M.query("*" .. arg .. "*")) do 193 | table.insert(menu, { name, name = name, mode = mode }) 194 | end 195 | end 196 | 197 | if #menu > 1 then 198 | w:set_mode("pass-menu", menu) 199 | elseif #menu == 1 then 200 | if mode == _M.MODE_SHOW then 201 | _M.show(w, menu[1].name) 202 | elseif mode == _M.MODE_FILL then 203 | _M.fill(w, menu[1].name, false) 204 | elseif mode == _M.MODE_SUBMIT then 205 | _M.fill(w, menu[1].name, true) 206 | elseif mode == _M.MODE_OTP then 207 | _M.fill_otp(w, menu[1].name) 208 | end 209 | else 210 | w:error("no related passwords found") 211 | end 212 | end 213 | 214 | modes.new_mode("pass-menu", { 215 | enter = function (w, menu) 216 | local rows = { { "Pass", title = true } } 217 | for _, m in ipairs(menu) do 218 | table.insert(rows, m) 219 | end 220 | w.menu:build(rows) 221 | end, 222 | leave = function (w) 223 | w.menu:hide() 224 | end, 225 | }) 226 | 227 | modes.add_binds("pass-menu", lousy.util.table.join({ 228 | { "", "Select pass profile.", function (w) 229 | local row = w.menu:get() 230 | w:set_mode() 231 | if row.mode == _M.MODE_SHOW then 232 | _M.show(w, row.name) 233 | elseif row.mode == _M.MODE_FILL then 234 | _M.fill(w, row.name, false) 235 | elseif row.mode == _M.MODE_SUBMIT then 236 | _M.fill(w, row.name, true) 237 | elseif row.mode == _M.MODE_OTP then 238 | _M.fill_otp(w, row.name) 239 | end 240 | end }, 241 | { "", "Show pass profile.", function (w) 242 | local row = w.menu:get() 243 | if row.mode ~= nil then 244 | _M.show(w, row.name) 245 | end 246 | end }, 247 | { "", "Select next pass profile.", function (w) w.menu:move_down() end }, 248 | { "", "Select previous pass profile.", function (w) w.menu:move_up() end }, 249 | { ":", "Enter `command` mode", function (w) w:set_mode("command") end }, 250 | }, binds.menu_binds)) 251 | 252 | modes.add_binds("insert", { 253 | { "", "Insert OTP code into currently focused input", function (w) 254 | _M.build_menu(w, nil, _M.MODE_OTP) 255 | end }, 256 | }) 257 | 258 | modes.add_cmds({ 259 | { ":p[ass]", "Fill password form.", function (w, o) 260 | local mode 261 | if o.bang then 262 | mode = _M.MODE_SUBMIT 263 | else 264 | mode = _M.MODE_FILL 265 | end 266 | _M.build_menu(w, o.arg, mode) 267 | end }, 268 | { ":ps[how]", "Show password.", function (w, o) 269 | _M.build_menu(w, o.arg, _M.MODE_SHOW) 270 | end }, 271 | }) 272 | 273 | return _M 274 | 275 | -- vim: et:sw=4:ts=8:sts=4:tw=80 276 | --------------------------------------------------------------------------------