#!/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') CMD_DELAY = 50 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('insert-text {}{}'.format(username, insert_mode)) 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 qute_command('insert-text {} ;;' 'later {} fake-key ;;' 'later {} insert-text {}{}' .format(username, CMD_DELAY, CMD_DELAY * 2, password, insert_mode)) return ExitCodes.SUCCESS if __name__ == '__main__': arguments = argument_parser.parse_args() sys.exit(run(arguments))