From 29eadf71414d583fc2162857cf08eab6bd23f1f4 Mon Sep 17 00:00:00 2001 From: Michal Siedlaczek Date: Sun, 11 Mar 2018 17:47:18 -0400 Subject: [PATCH 01/73] Filter installed dictionaries using a regex to ensure correct name --- qutebrowser/browser/webengine/spell.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/qutebrowser/browser/webengine/spell.py b/qutebrowser/browser/webengine/spell.py index beebe4da7..b887a2295 100644 --- a/qutebrowser/browser/webengine/spell.py +++ b/qutebrowser/browser/webengine/spell.py @@ -24,16 +24,14 @@ import os import re from PyQt5.QtCore import QLibraryInfo -from qutebrowser.utils import log +from qutebrowser.utils import log, message + +dict_version_re = re.compile(r".+-(?P[0-9]+-[0-9]+?)\.bdic") def version(filename): """Extract the version number from the dictionary file name.""" - version_re = re.compile(r".+-(?P[0-9]+-[0-9]+?)\.bdic") - match = version_re.fullmatch(filename) - if match is None: - raise ValueError('the given dictionary file name is malformed: {}' - .format(filename)) + match = dict_version_re.match(filename) return tuple(int(n) for n in match.group('version').split('-')) @@ -46,7 +44,7 @@ def dictionary_dir(): def local_files(code): """Return all installed dictionaries for the given code.""" pathname = os.path.join(dictionary_dir(), '{}*.bdic'.format(code)) - matching_dicts = glob.glob(pathname) + matching_dicts = filter(dict_version_re.match, glob.glob(pathname)) files = [] for matching_dict in sorted(matching_dicts, key=version, reverse=True): filename = os.path.basename(matching_dict) From f9e702bae5da5f91f3f7a5d8564f42f5b4e7cbdb Mon Sep 17 00:00:00 2001 From: Michal Siedlaczek Date: Fri, 16 Mar 2018 11:28:45 -0400 Subject: [PATCH 02/73] Warn about malformed dictionaries --- qutebrowser/browser/webengine/spell.py | 28 +++++++++++++++------- tests/unit/browser/webengine/test_spell.py | 13 ++++++---- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/qutebrowser/browser/webengine/spell.py b/qutebrowser/browser/webengine/spell.py index b887a2295..5c2ed551b 100644 --- a/qutebrowser/browser/webengine/spell.py +++ b/qutebrowser/browser/webengine/spell.py @@ -32,6 +32,10 @@ dict_version_re = re.compile(r".+-(?P[0-9]+-[0-9]+?)\.bdic") def version(filename): """Extract the version number from the dictionary file name.""" match = dict_version_re.match(filename) + if match is None: + message.warning( + "Found a dictionary with a malformed name: {}".format(filename)) + return None return tuple(int(n) for n in match.group('version').split('-')) @@ -42,15 +46,23 @@ def dictionary_dir(): def local_files(code): - """Return all installed dictionaries for the given code.""" + """Return all installed dictionaries for the given code. + + The returned dictionaries are sorted by version, therefore the latest will + be the first element. The list will be empty if no dictionaries are found. + """ pathname = os.path.join(dictionary_dir(), '{}*.bdic'.format(code)) - matching_dicts = filter(dict_version_re.match, glob.glob(pathname)) - files = [] - for matching_dict in sorted(matching_dicts, key=version, reverse=True): - filename = os.path.basename(matching_dict) - log.config.debug('Found file for dict {}: {}'.format(code, filename)) - files.append(filename) - return files + matching_dicts = glob.glob(pathname) + versioned_dicts = [] + for matching_dict in matching_dicts: + parsed_version = version(matching_dict) + if parsed_version is not None: + filename = os.path.basename(matching_dict) + log.config.debug('Found file for dict {}: {}' + .format(code, filename)) + versioned_dicts.append((parsed_version, filename)) + return [filename for version, filename + in sorted(versioned_dicts, reverse=True)] def local_filename(code): diff --git a/tests/unit/browser/webengine/test_spell.py b/tests/unit/browser/webengine/test_spell.py index efe62fb20..ec2831cc6 100644 --- a/tests/unit/browser/webengine/test_spell.py +++ b/tests/unit/browser/webengine/test_spell.py @@ -17,15 +17,20 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -import pytest +import logging + from qutebrowser.browser.webengine import spell +from qutebrowser.utils import usertypes -def test_version(): +def test_version(message_mock, caplog): assert spell.version('en-US-8-0.bdic') == (8, 0) assert spell.version('pl-PL-3-0.bdic') == (3, 0) - with pytest.raises(ValueError): - spell.version('malformed_filename') + with caplog.at_level(logging.WARNING): + assert spell.version('malformed_filename') is None + msg = message_mock.getmsg(usertypes.MessageLevel.warning) + expected = ("Found a dictionary with a malformed name: malformed_filename") + assert msg.text == expected def test_local_filename_dictionary_does_not_exist(tmpdir, monkeypatch): From b169a1c802e2c90182df76820c00190e4669271e Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Thu, 22 Mar 2018 02:38:38 -0400 Subject: [PATCH 03/73] Add raw first draft of qute-keepass This needs a lot more work... --- misc/userscripts/qute-keepass | 209 ++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100755 misc/userscripts/qute-keepass diff --git a/misc/userscripts/qute-keepass b/misc/userscripts/qute-keepass new file mode 100755 index 000000000..4d25e7037 --- /dev/null +++ b/misc/userscripts/qute-keepass @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 + +# Copyright 2018 Jay Kamat +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +USAGE = """This userscript allows for insertion of usernames and passwords from keepass +databases using pykeepass. Since it is a userscript, it must be run from qutebrowser. + +A sample invocation of this script is: + +:spawn --userscript qute-keepass -p ~/KeePassFiles/MainDatabase.kdbx + +And a sample binding + +:bind --mode=insert spawn --userscript qute-keepass -p ~/KeePassFiles/MainDatabase.kdbx + +login information is inserted by emulating key events using qutebrowser's +fake-key command in this manner: [USERNAME][PASSWORD], which is compatible +with almost all login forms. + +Dependencies: pykeepass (in python3), PyQt5 + +WARNING: The login details are viewable as plaintext in qutebrowser's debug log +(qute://log) and might be shared if you decide to submit a crash report!""" + +import argparse +import enum +import functools +import os +import shlex +import subprocess +import sys + + +from PyQt5.QtCore import QUrl +from PyQt5.QtWidgets import QApplication, QInputDialog, QLineEdit + +try: + import pykeepass +except ImportError: + print("pykeepass not found!", file=sys.stderr) + sys.exit(100) + +argument_parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=USAGE) +argument_parser.add_argument('url', nargs='?', default=os.getenv('QUTE_URL')) +argument_parser.add_argument('--path', '-p', required=True, + help='Path to the keepass db') +argument_parser.add_argument( + '--dmenu-invocation', '-d', default='dmenu', + help='Invocation used to execute a dmenu-provider') +argument_parser.add_argument( + '--dmenu-format', '-f', default='{title}: {username}', + help='Format string for keys to display in dmenu. Must be unique.') +argument_parser.add_argument( + '--no-insert-mode', '-n', dest='insert_mode', action='store_false', + help="Don't automatically enter insert mode") +argument_parser.add_argument( + '--io-encoding', '-i', default='UTF-8', + help='Encoding used to communicate with subprocesses') +group = argument_parser.add_mutually_exclusive_group() +group.add_argument('--username-only', '-e', + action='store_true', help='Only insert username') +group.add_argument('--password-only', '-w', + action='store_true', help='Only insert password') + + +class ExitCodes(enum.IntEnum): + """Stores various exit codes groups to use.""" + SUCCESS = 0 + FAILURE = 1 + # 1 is automatically used if Python throws an exception + NO_CANDIDATES = 2 + USER_QUIT = 3 + DB_OPEN_FAIL = 3 + + INTERNAL_ERROR = 10 + + +def qute_command(command): + with open(os.environ['QUTE_FIFO'], 'w') as fifo: + fifo.write(command + '\n') + fifo.flush() + + +def stderr(to_print): + print(to_print, file=sys.stderr) + qute_command('message-error "{}"'.format(to_print)) + + +def dmenu(items, invocation, encoding): + """Runs dmenu with given arguments.""" + command = shlex.split(invocation) + process = subprocess.run(command, input='\n'.join(items).encode(encoding), + stdout=subprocess.PIPE) + return process.stdout.decode(encoding).strip() + + +def get_password(): + """Get a keepass db password from user.""" + _app = QApplication(sys.argv) + text, ok = QInputDialog.getText( + None, "KeePass DB Password", + "Please enter your KeePass Master Password", + QLineEdit.Password) + if not ok: + stderr('Password Prompt Rejected.') + return ExitCodes.USER_QUIT + return text + + +def find_candidates(args, host): + """Finds candidates that match host""" + file_path = os.path.expanduser(args.path) + + # TODO find a way to keep the db open, so we don't open it every time. + try: + kp = pykeepass.PyKeePass(file_path, password=get_password()) + except Exception as e: + stderr("There was an error opening the DB: {}".format(str(e))) + + return kp.find_entries(url="{}{}{}".format(".*", host, ".*"), regex=True) + + +def candidate_to_str(args, candidate): + """Turns candidate into a human readable string for dmenu""" + return args.dmenu_format.format(title=candidate.title, + url=candidate.url, + username=candidate.username, + path=candidate.path, + uuid=candidate.uuid) + + +def candidate_to_secret(candidate): + """Turns candidate into a generic (user, password) tuple""" + return (candidate.username, candidate.password) + + +def run(args): + """Runs qute-keepass""" + if not args.url: + argument_parser.print_help() + return ExitCodes.FAILURE + + url_host = QUrl(args.url).host() + + if not url_host: + stderr('{} was not parsed as a valid URL!'.format(args.url)) + return ExitCodes.INTERNAL_ERROR + + # Find candidates matching the host of the given URL + candidates = find_candidates(args, url_host) + if not candidates: + stderr('No candidates for URL {!r} found!'.format(args.url)) + return ExitCodes.NO_CANDIDATES + + # Create a map so we can get turn the resulting string from dmenu back into + # a candidate + candidates_map = dict(zip(map( + functools.partial(candidate_to_str, args), candidates), candidates)) + + if len(candidates) == 1: + selection = candidates.pop() + else: + selection = dmenu(candidates_map.keys(), + args.dmenu_invocation, + args.io_encoding) + + if selection not in candidates_map: + stderr("'{}' was not a valid entry!").format(selection) + return ExitCodes.USER_QUIT + + selection = candidates_map[selection] + username, password = candidate_to_secret(selection) + + insert_mode = ';; enter-mode insert' if args.insert_mode else '' + if args.username_only: + qute_command('fake-key {}{}'.format(username, insert_mode)) + elif args.password_only: + qute_command('fake-key {}{}'.format(password, insert_mode)) + else: + # Enter username and password using fake-key and (which seems to + # work almost universally), then switch back into insert-mode, so the + # form can be directly submitted by hitting enter afterwards + qute_command('fake-key {} ;; fake-key ;; fake-key {}{}' + .format(username, password, insert_mode)) + + return ExitCodes.SUCCESS + + +if __name__ == '__main__': + arguments = argument_parser.parse_args() + sys.exit(run(arguments)) From a9a7f5da45a1f731f651e2948c4852e0dc27b6f1 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Thu, 22 Mar 2018 03:01:50 -0400 Subject: [PATCH 04/73] Fix choking on passwords with syntax in them --- misc/userscripts/qute-keepass | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/misc/userscripts/qute-keepass b/misc/userscripts/qute-keepass index 4d25e7037..70b9a53d7 100755 --- a/misc/userscripts/qute-keepass +++ b/misc/userscripts/qute-keepass @@ -80,6 +80,7 @@ group.add_argument('--username-only', '-e', group.add_argument('--password-only', '-w', action='store_true', help='Only insert password') +CMD_DELAY = 50 class ExitCodes(enum.IntEnum): """Stores various exit codes groups to use.""" @@ -191,15 +192,18 @@ def run(args): insert_mode = ';; enter-mode insert' if args.insert_mode else '' if args.username_only: - qute_command('fake-key {}{}'.format(username, insert_mode)) + qute_command('insert-text {}{}'.format(username, insert_mode)) elif args.password_only: - qute_command('fake-key {}{}'.format(password, insert_mode)) + qute_command('insert-text {}{}'.format(password, insert_mode)) else: # Enter username and password using fake-key and (which seems to # work almost universally), then switch back into insert-mode, so the # form can be directly submitted by hitting enter afterwards - qute_command('fake-key {} ;; fake-key ;; fake-key {}{}' - .format(username, password, insert_mode)) + qute_command('insert-text {} ;;' + 'later {} fake-key ;;' + 'later {} insert-text {}{}' + .format(username, CMD_DELAY, + CMD_DELAY * 2, password, insert_mode)) return ExitCodes.SUCCESS From 948866f4f29f48c707d527ea71b23624aa9deb25 Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Thu, 22 Mar 2018 21:05:35 -0400 Subject: [PATCH 05/73] Add support for keepass keyfiles --- misc/userscripts/qute-keepass | 79 ++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/misc/userscripts/qute-keepass b/misc/userscripts/qute-keepass index 70b9a53d7..3258a3e0f 100755 --- a/misc/userscripts/qute-keepass +++ b/misc/userscripts/qute-keepass @@ -17,8 +17,11 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -USAGE = """This userscript allows for insertion of usernames and passwords from keepass -databases using pykeepass. Since it is a userscript, it must be run from qutebrowser. +# pylint: disable=bad-builtin + +USAGE = """This userscript allows for insertion of usernames and passwords from +keepass databases using pykeepass. Since it is a userscript, it must be run from +qutebrowser. A sample invocation of this script is: @@ -28,14 +31,29 @@ And a sample binding :bind --mode=insert spawn --userscript qute-keepass -p ~/KeePassFiles/MainDatabase.kdbx -login information is inserted by emulating key events using qutebrowser's -fake-key command in this manner: [USERNAME][PASSWORD], which is compatible -with almost all login forms. +-p or --path is a required argument. -Dependencies: pykeepass (in python3), PyQt5 +--keyfile-path allows you to specify a keepass keyfile. If you only use a + keyfile, also add --no-password as well. Specifying --no-password without + --keyfile-path will lead to an error. + +login information is inserted using :insert-text and :fake-key , which +means you must have a cursor in position before initiating this userscript. If +you do not do this, you will get 'element not editable' errors. + +If keepass takes a while to open the DB, you might want to consider reducing +the number of transform rounds in your database settings. + +Dependencies: pykeepass (in python3), PyQt5. Without pykeepass, you will get an +exit code of 100. + +********************!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!****************** WARNING: The login details are viewable as plaintext in qutebrowser's debug log -(qute://log) and might be shared if you decide to submit a crash report!""" +(qute://log) and could be compromised if you decide to submit a crash report! + +********************!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!****************** +""" import argparse import enum @@ -45,7 +63,6 @@ import shlex import subprocess import sys - from PyQt5.QtCore import QUrl from PyQt5.QtWidgets import QApplication, QInputDialog, QLineEdit @@ -61,13 +78,19 @@ argument_parser = argparse.ArgumentParser( epilog=USAGE) argument_parser.add_argument('url', nargs='?', default=os.getenv('QUTE_URL')) argument_parser.add_argument('--path', '-p', required=True, - help='Path to the keepass db') + help='Path to the keepass db.') +argument_parser.add_argument('--keyfile-path', '-k', default=None, + help='Path to a keepass keyfile') +argument_parser.add_argument('--no-password', action='store_true', + help='True if no password is required.' + 'Only allowed with --keyfile-path') argument_parser.add_argument( '--dmenu-invocation', '-d', default='dmenu', help='Invocation used to execute a dmenu-provider') argument_parser.add_argument( '--dmenu-format', '-f', default='{title}: {username}', - help='Format string for keys to display in dmenu. Must be unique.') + help='Format string for keys to display in dmenu.' + ' Must generate a unique string.') argument_parser.add_argument( '--no-insert-mode', '-n', dest='insert_mode', action='store_false', help="Don't automatically enter insert mode") @@ -82,6 +105,7 @@ group.add_argument('--password-only', '-w', CMD_DELAY = 50 + class ExitCodes(enum.IntEnum): """Stores various exit codes groups to use.""" SUCCESS = 0 @@ -89,7 +113,7 @@ class ExitCodes(enum.IntEnum): # 1 is automatically used if Python throws an exception NO_CANDIDATES = 2 USER_QUIT = 3 - DB_OPEN_FAIL = 3 + DB_OPEN_FAIL = 4 INTERNAL_ERROR = 10 @@ -101,6 +125,7 @@ def qute_command(command): def stderr(to_print): + """Extra functionality to echo out errors to qb ui.""" print(to_print, file=sys.stderr) qute_command('message-error "{}"'.format(to_print)) @@ -122,7 +147,7 @@ def get_password(): QLineEdit.Password) if not ok: stderr('Password Prompt Rejected.') - return ExitCodes.USER_QUIT + sys.exit(ExitCodes.USER_QUIT) return text @@ -130,9 +155,19 @@ def find_candidates(args, host): """Finds candidates that match host""" file_path = os.path.expanduser(args.path) - # TODO find a way to keep the db open, so we don't open it every time. + # TODO find a way to keep the db open, so we don't open (and query + # password) it every time + + pw = None + if not args.no_password: + pw = get_password() + + kf = args.keyfile_path + if kf: + kf = os.path.expanduser(kf) + try: - kp = pykeepass.PyKeePass(file_path, password=get_password()) + kp = pykeepass.PyKeePass(file_path, password=pw, keyfile=kf) except Exception as e: stderr("There was an error opening the DB: {}".format(str(e))) @@ -173,13 +208,14 @@ def run(args): # Create a map so we can get turn the resulting string from dmenu back into # a candidate - candidates_map = dict(zip(map( - functools.partial(candidate_to_str, args), candidates), candidates)) + candidates_strs = list(map(functools.partial(candidate_to_str, args), + candidates)) + candidates_map = dict(zip(candidates_strs, candidates)) if len(candidates) == 1: selection = candidates.pop() else: - selection = dmenu(candidates_map.keys(), + selection = dmenu(candidates_strs, args.dmenu_invocation, args.io_encoding) @@ -196,9 +232,12 @@ def run(args): elif args.password_only: qute_command('insert-text {}{}'.format(password, insert_mode)) else: - # Enter username and password using fake-key and (which seems to - # work almost universally), then switch back into insert-mode, so the - # form can be directly submitted by hitting enter afterwards + # Enter username and password using insert-key and fake-key + # (which supports more passwords than fake-key only), then switch back + # into insert-mode, so the form can be directly submitted by hitting + # enter afterwards. It dosen't matter when we go into insert mode, but + # the other commands need to be be executed sequentially, so we add + # delays with later. qute_command('insert-text {} ;;' 'later {} fake-key ;;' 'later {} insert-text {}{}' From a1776087e0233009721cf82a9a12c6deb9b0cebe Mon Sep 17 00:00:00 2001 From: Jay Kamat Date: Thu, 22 Mar 2018 21:35:00 -0400 Subject: [PATCH 06/73] Fix login when only one entry is available --- misc/userscripts/qute-keepass | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/misc/userscripts/qute-keepass b/misc/userscripts/qute-keepass index 3258a3e0f..55d39e6e4 100755 --- a/misc/userscripts/qute-keepass +++ b/misc/userscripts/qute-keepass @@ -219,11 +219,12 @@ def run(args): args.dmenu_invocation, args.io_encoding) - if selection not in candidates_map: - stderr("'{}' was not a valid entry!").format(selection) - return ExitCodes.USER_QUIT + if selection not in candidates_map: + stderr("'{}' was not a valid entry!").format(selection) + return ExitCodes.USER_QUIT + + selection = candidates_map[selection] - selection = candidates_map[selection] username, password = candidate_to_secret(selection) insert_mode = ';; enter-mode insert' if args.insert_mode else '' From f237a87ad0e568d305426ab6b3baf6b83702b4fc Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Sun, 25 Mar 2018 21:59:30 -0400 Subject: [PATCH 07/73] Completion for varargs. When a command has positional varargs, keep offering the configured completion for each successive argument. Right now this only influences `config-cycle`. Previously, `config-cycle