├── LICENSE ├── README.md └── qute-keepassxc /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2024 ususdei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Introduction 4 | 5 | This is a [qutebrowser][2] [userscript][5] to fill website credentials from a [KeepassXC][1] password database. 6 | 7 | 8 | # Installation 9 | 10 | First, you need to enable [KeepassXC-Browser][6] extensions in your KeepassXC config. 11 | 12 | 13 | Second, you must make sure to have a working private-public-key-pair in your [GPG keyring][3]. 14 | 15 | 16 | Then, simply check out this repository and, if you do not want to configure an explicit path, symlink the 17 | `qute-keepassxc` to your `~/.local/share/qutebrowser/userscripts/qute-keepassxc`. 18 | Install the python module `pynacl`. 19 | Make sure `qute-keepassxc` is executable. 20 | 21 | 22 | Finally, adapt your qutebrowser config. 23 | You can e.g. add the following lines to your `~/.config/qutebrowser/config.py` 24 | Remember to replace `ABC1234` with your actual GPG key. 25 | 26 | ```python 27 | config.bind('', 'spawn --userscript qute-keepassxc --key ABC1234', mode='insert') 28 | config.bind('pw', 'spawn --userscript qute-keepassxc --key ABC1234', mode='normal') 29 | ``` 30 | 31 | If you did not symlink `qute-keepassxc` you need to provide the full path here. 32 | 33 | N.B. To manage multiple accounts you need the [rofi](https://github.com/davatorium/rofi) program. 34 | 35 | 36 | # Usage 37 | 38 | If you are on a webpage with a login form, simply activate one of the configured key-bindings. 39 | 40 | The first time you run this script, KeepassXC will ask you for authentication like with any other browser extension. 41 | Just provide a name of your choice and accept the request if nothing looks fishy. 42 | 43 | 44 | # How it works 45 | 46 | This script will talk to KeepassXC using the native [KeepassXC-Browser protocol][4]. 47 | 48 | 49 | This script needs to store the key used to associate with your KeepassXC instance somewhere. 50 | Unlike most browser extensions which only use plain local storage, this one attempts to do so in a safe way 51 | by storing the key in encrypted form using GPG. 52 | Therefore you need to have a public-key-pair readily set up. 53 | 54 | GPG might then ask for your private-key password whenever you query the database for login credentials. 55 | 56 | 57 | # TOTP 58 | 59 | This script recently received experimental TOTP support. 60 | To use it, you need to have working TOTP authentication within KeepassXC. 61 | Then call `qute-keepassxc` with the `--totp` flags. 62 | 63 | For example, I have the following line in my `config.py`: 64 | 65 | ```python 66 | config.bind('pt', 'spawn --userscript qute-keepassxc --key ABC1234 --totp', mode='normal') 67 | ``` 68 | 69 | For now this script will simply insert the TOTP-token into the currently selected 70 | input field, since I have not yet found a reliable way to identify the correct field 71 | within all existing login forms. 72 | Thus you need to manually select the TOTP input field, press escape to leave input 73 | mode and then enter `pt` to fill in the token (or configure another key-binding for 74 | insert mode if you prefer that). 75 | 76 | 77 | # Compatiblity 78 | 79 | Tested with: 80 | 81 | - KeepassXC 2.7.9 82 | - qutebrowser v3.3.1 83 | - python 3.12.7 84 | 85 | 86 | [1]: https://keepassxc.org/ 87 | [2]: https://qutebrowser.org/ 88 | [3]: https://gnupg.org/ 89 | [4]: https://github.com/keepassxreboot/keepassxc-browser/blob/develop/keepassxc-protocol.md 90 | [5]: https://github.com/qutebrowser/qutebrowser/blob/master/doc/userscripts.asciidoc 91 | [6]: https://keepassxc.org/docs/KeePassXC_GettingStarted.html#_configure_keepassxc_browser 92 | 93 | 94 | # License 95 | 96 | Copyright (c) 2018-2024, Markus Blöchl. Released under the MIT License. 97 | 98 | -------------------------------------------------------------------------------- /qute-keepassxc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Qutebrowser userscript to fetch credentials from KeepassXC password database 4 | # Copyright (c) 2018-2024 Markus Blöchl 5 | # Released under the MIT License 6 | 7 | import sys 8 | import os 9 | import socket 10 | import json 11 | import base64 12 | import subprocess 13 | import argparse 14 | 15 | import nacl.utils 16 | import nacl.public 17 | 18 | BUFFER_SIZE = 1024 * 1024 19 | 20 | 21 | def parse_args(): 22 | parser = argparse.ArgumentParser(description="Full passwords from KeepassXC") 23 | parser.add_argument('url', nargs='?', default=os.environ.get('QUTE_URL')) 24 | parser.add_argument('--totp', action='store_true', 25 | help="Fill in current TOTP field instead of username/password") 26 | parser.add_argument('--socket', '-s', default='/run/user/{}/org.keepassxc.KeePassXC.BrowserServer'.format(os.getuid()), 27 | help='Path to KeepassXC browser socket') 28 | parser.add_argument('--key', '-k', default='alice@example.com', 29 | help='GPG key to encrypt KeepassXC auth key with') 30 | parser.add_argument('--insecure', action='store_true', 31 | help="Do not encrypt auth key") 32 | return parser.parse_args() 33 | 34 | 35 | class KeepassError(Exception): 36 | def __init__(self, code, desc): 37 | self.code = code 38 | self.description = desc 39 | 40 | def __str__(self): 41 | return f"KeepassXC Error [{self.code}]: {self.description}" 42 | 43 | 44 | class KeepassXC: 45 | """ Wrapper around the KeepassXC socket API """ 46 | def __init__(self, id=None, *, key, socket_path): 47 | self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 48 | self.id = id 49 | self.socket_path = socket_path 50 | self.client_key = nacl.public.PrivateKey.generate() 51 | self.id_key = nacl.public.PrivateKey.from_seed(key) 52 | self.cryptobox = None 53 | 54 | def connect(self): 55 | if not os.path.exists(self.socket_path): 56 | raise KeepassError(-1, "KeepassXC Browser socket does not exists") 57 | self.client_id = base64.b64encode(nacl.utils.random(nacl.public.Box.NONCE_SIZE)).decode('utf-8') 58 | self.sock.connect(self.socket_path) 59 | 60 | self.send_raw_msg(dict( 61 | action = 'change-public-keys', 62 | publicKey = base64.b64encode(self.client_key.public_key.encode()).decode('utf-8'), 63 | nonce = base64.b64encode(nacl.utils.random(nacl.public.Box.NONCE_SIZE)).decode('utf-8'), 64 | clientID = self.client_id 65 | )) 66 | 67 | resp = self.recv_raw_msg() 68 | assert resp['action'] == 'change-public-keys' 69 | assert resp['success'] == 'true' 70 | assert resp['nonce'] 71 | self.cryptobox = nacl.public.Box( 72 | self.client_key, 73 | nacl.public.PublicKey(base64.b64decode(resp['publicKey'])) 74 | ) 75 | 76 | def get_databasehash(self): 77 | self.send_msg(dict(action='get-databasehash')) 78 | return self.recv_msg()['hash'] 79 | 80 | def lock_database(self): 81 | self.send_msg(dict(action='lock-database')) 82 | try: 83 | self.recv_msg() 84 | except KeepassError as e: 85 | if e.code == 1: 86 | return True 87 | raise 88 | return False 89 | 90 | 91 | def test_associate(self): 92 | if not self.id: 93 | return False 94 | self.send_msg(dict( 95 | action = 'test-associate', 96 | id = self.id, 97 | key = base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') 98 | ), triggerUnlock = 'true') 99 | return self.recv_msg()['success'] == 'true' 100 | 101 | def associate(self): 102 | self.send_msg(dict( 103 | action = 'associate', 104 | key = base64.b64encode(self.client_key.public_key.encode()).decode('utf-8'), 105 | idKey = base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') 106 | )) 107 | resp = self.recv_msg() 108 | self.id = resp['id'] 109 | 110 | def get_logins(self, url): 111 | self.send_msg(dict( 112 | action = 'get-logins', 113 | url = url, 114 | keys = [{ 'id': self.id, 'key': base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') }] 115 | )) 116 | return self.recv_msg()['entries'] 117 | 118 | def send_raw_msg(self, msg): 119 | self.sock.send( json.dumps(msg).encode('utf-8') ) 120 | 121 | def recv_raw_msg(self): 122 | return json.loads( self.sock.recv(BUFFER_SIZE).decode('utf-8') ) 123 | 124 | def send_msg(self, msg, **extra): 125 | nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE) 126 | self.send_raw_msg(dict( 127 | action = msg['action'], 128 | message = base64.b64encode(self.cryptobox.encrypt(json.dumps(msg).encode('utf-8'), nonce).ciphertext).decode('utf-8'), 129 | nonce = base64.b64encode(nonce).decode('utf-8'), 130 | clientID = self.client_id, 131 | **extra 132 | )) 133 | 134 | def recv_msg(self): 135 | resp = self.recv_raw_msg() 136 | if 'error' in resp: 137 | raise KeepassError(resp['errorCode'], resp['error']) 138 | assert resp['action'] 139 | return json.loads(self.cryptobox.decrypt(base64.b64decode(resp['message']), base64.b64decode(resp['nonce'])).decode('utf-8')) 140 | 141 | 142 | 143 | class SecretKeyStore: 144 | def __init__(self, gpgkey): 145 | self.gpgkey = gpgkey 146 | if gpgkey is None: 147 | self.path = os.path.join(os.environ['QUTE_DATA_DIR'], 'keepassxc.key') 148 | else: 149 | self.path = os.path.join(os.environ['QUTE_DATA_DIR'], 'keepassxc.key.gpg') 150 | 151 | def load(self): 152 | "Load existing association key from file" 153 | if self.gpgkey is None: 154 | jsondata = open(self.path, 'r').read() 155 | else: 156 | jsondata = subprocess.check_output(['gpg', '--decrypt', self.path]).decode('utf-8') 157 | data = json.loads(jsondata) 158 | self.id = data['id'] 159 | self.key = base64.b64decode(data['key']) 160 | 161 | def create(self): 162 | "Create new association key" 163 | self.key = nacl.utils.random(32) 164 | self.id = None 165 | 166 | def store(self, id): 167 | "Store newly created association key in file" 168 | self.id = id 169 | jsondata = json.dumps({'id':self.id, 'key':base64.b64encode(self.key).decode('utf-8')}) 170 | if self.gpgkey is None: 171 | open(self.path, "w").write(jsondata) 172 | else: 173 | subprocess.run(['gpg', '--encrypt', '-o', self.path, '-r', self.gpgkey], input=jsondata.encode('utf-8'), check=True) 174 | 175 | 176 | def qute(cmd): 177 | with open(os.environ['QUTE_FIFO'], 'w') as fifo: 178 | fifo.write(cmd) 179 | fifo.write('\n') 180 | fifo.flush() 181 | 182 | def error(msg): 183 | print(msg, file=sys.stderr) 184 | qute('message-error "{}"'.format(msg)) 185 | 186 | 187 | def connect_to_keepassxc(args): 188 | assert args.key or args.insecure, "Missing GPG key to use for auth key encryption" 189 | keystore = SecretKeyStore(args.key) 190 | if os.path.isfile(keystore.path): 191 | keystore.load() 192 | kp = KeepassXC(keystore.id, key=keystore.key, socket_path=args.socket) 193 | kp.connect() 194 | if not kp.test_associate(): 195 | error('No KeepassXC association') 196 | return None 197 | else: 198 | keystore.create() 199 | kp = KeepassXC(key=keystore.key, socket_path=args.socket) 200 | kp.connect() 201 | kp.associate() 202 | if not kp.test_associate(): 203 | error('No KeepassXC association') 204 | return None 205 | keystore.store(kp.id) 206 | return kp 207 | 208 | 209 | def select_account(creds): 210 | try: 211 | if len(creds) == 1: 212 | return creds[0] 213 | idx = subprocess.check_output( 214 | ['rofi', '-dmenu', '-format', 'i', '-matching', 'fuzzy', 215 | '-p', 'Search', 216 | '-mesg', 'qute-keepassxc: select an account, please!'], 217 | input=b"\n".join(c['login'].encode('utf-8') for c in creds) 218 | ) 219 | idx = int(idx) 220 | if idx < 0: 221 | return None 222 | return creds[idx] 223 | except subprocess.CalledProcessError: 224 | return None 225 | except FileNotFoundError: 226 | error("rofi not found. Please install rofi to select from multiple credentials") 227 | return creds[0] 228 | except Exception as e: 229 | error(f"Error while picking account: {e}") 230 | return None 231 | 232 | 233 | def make_js_code(username, password): 234 | return ' '.join(""" 235 | function isVisible(elem) { 236 | var style = elem.ownerDocument.defaultView.getComputedStyle(elem, null); 237 | 238 | if (style.getPropertyValue("visibility") !== "visible" || 239 | style.getPropertyValue("display") === "none" || 240 | style.getPropertyValue("opacity") === "0") { 241 | return false; 242 | } 243 | 244 | return elem.offsetWidth > 0 && elem.offsetHeight > 0; 245 | }; 246 | 247 | function hasPasswordOrUsernameField(form) { 248 | var inputs = form.getElementsByTagName("input"); 249 | for (var j = 0; j < inputs.length; j++) { 250 | var input = inputs[j]; 251 | if (input.type === "password") { 252 | return true; 253 | } 254 | if (input.type === "text" || input.type === "email") { 255 | if (input.autocomplete && input.autocomplete.includes("username")) { 256 | return true; 257 | } 258 | } 259 | } 260 | return false; 261 | }; 262 | 263 | function loadData2Form (form) { 264 | var inputs = form.getElementsByTagName("input"); 265 | for (var j = 0; j < inputs.length; j++) { 266 | var input = inputs[j]; 267 | if (isVisible(input) && (input.type === "text" || input.type === "email")) { 268 | input.focus(); 269 | input.value = %s; 270 | input.dispatchEvent(new Event('input', { 'bubbles': true })); 271 | input.dispatchEvent(new Event('change', { 'bubbles': true })); 272 | input.blur(); 273 | } 274 | if (input.type === "password") { 275 | input.focus(); 276 | input.value = %s; 277 | input.dispatchEvent(new Event('input', { 'bubbles': true })); 278 | input.dispatchEvent(new Event('change', { 'bubbles': true })); 279 | input.blur(); 280 | } 281 | } 282 | }; 283 | 284 | function fillFirstForm() { 285 | var forms = document.getElementsByTagName("form"); 286 | for (i = 0; i < forms.length; i++) { 287 | if (hasPasswordOrUsernameField(forms[i])) { 288 | loadData2Form(forms[i]); 289 | return; 290 | } 291 | } 292 | alert("No Credentials Form found"); 293 | }; 294 | 295 | fillFirstForm() 296 | """.splitlines()) % (json.dumps(username), json.dumps(password)) 297 | 298 | 299 | def make_js_totp_code(totp): 300 | return ' '.join(""" 301 | (function () { 302 | var input = document.activeElement; 303 | if (!input || input.tagName !== "INPUT") { 304 | alert("No TOTP input field selected"); 305 | return; 306 | } 307 | input.value = %s; 308 | input.dispatchEvent(new Event('input', { 'bubbles': true })); 309 | input.dispatchEvent(new Event('change', { 'bubbles': true })); 310 | })(); 311 | """.splitlines()) % (json.dumps(totp),) 312 | 313 | 314 | def main(): 315 | if 'QUTE_FIFO' not in os.environ: 316 | print(f"No QUTE_FIFO found - {sys.argv[0]} must be run as a qutebrowser userscript") 317 | sys.exit(-1) 318 | 319 | try: 320 | args = parse_args() 321 | assert args.url, "Missing URL" 322 | kp = connect_to_keepassxc(args) 323 | if not kp: 324 | error('Could not connect to KeepassXC') 325 | return 326 | creds = kp.get_logins(args.url) 327 | if not creds: 328 | error('No credentials found') 329 | return 330 | cred = select_account(creds) 331 | if not cred: 332 | error('No credentials selected') 333 | return 334 | if args.totp: 335 | totp = cred.get('totp') 336 | if not totp: 337 | error('No TOTP key found') 338 | return 339 | qute('jseval -q ' + make_js_totp_code(totp)) 340 | else: 341 | name, pw = cred['login'], cred['password'] 342 | if name and pw: 343 | qute('jseval -q ' + make_js_code(name, pw)) 344 | except Exception as e: 345 | error(str(e)) 346 | 347 | 348 | if __name__ == '__main__': 349 | main() 350 | 351 | --------------------------------------------------------------------------------