From 916e100efa6179cb57fad26e3d6284de28b9d87d Mon Sep 17 00:00:00 2001 From: Andre Ramnitz Date: Tue, 5 Dec 2023 00:08:20 +0100 Subject: [PATCH] qutebrowser add config --- qutebrowser/autoconfig.yml | 297 ++++++++++++++++ qutebrowser/config.py | 368 ++++++++++++++++++++ qutebrowser/userscripts/qr | 8 + qutebrowser/userscripts/qute-keepassxc | 448 +++++++++++++++++++++++++ 4 files changed, 1121 insertions(+) create mode 100644 qutebrowser/autoconfig.yml create mode 100644 qutebrowser/config.py create mode 100755 qutebrowser/userscripts/qr create mode 100755 qutebrowser/userscripts/qute-keepassxc diff --git a/qutebrowser/autoconfig.yml b/qutebrowser/autoconfig.yml new file mode 100644 index 00000000..7825b1d9 --- /dev/null +++ b/qutebrowser/autoconfig.yml @@ -0,0 +1,297 @@ +# If a config.py file exists, this file is ignored unless it's explicitly loaded +# via config.load_autoconfig(). For more information, see: +# https://github.com/qutebrowser/qutebrowser/blob/main/doc/help/configuring.asciidoc#loading-autoconfigyml +# DO NOT edit this file by hand, qutebrowser will overwrite it. +# Instead, create a config.py - see :help for details. + +config_version: 2 +settings: + backend: + global: webengine + bindings.commands: + global: + insert: + : spawn --userscript qute-keepassxc --key 285D16B66B11BD45BFD57B6CB22C3113D0E83104 + normal: + '''gh''': '''open duckduckgo.com''' + +: null + ',M': hint links spawn vlc {hint-url} + ',m': spawn vlc {url} + '-': null + : home + : zoom-in + : zoom-out + : back + : forward + Ctrl-+: zoom-in + Ctrl--: zoom-out + E: edit-url + d: null + dd: tab-close + dlA: spawn -u yt-dlp-video {url} + dlV: spawn -u yt-dlp-video {url} + dla: hint links userscript yt-dlp-audio + dlv: hint links userscript yt-dlp-video + eu: edit-url + ga: null + ge: scroll-to-perc 100 + gg: scroll-to-perc 0 + gh: open duckduckgo.com + pw: spawn --userscript qute-keepassxc --key 285D16B66B11BD45BFD57B6CB22C3113D0E83104 + tG: home + tda: tab-only + tg1: tab-give 1 + tg2: tab-give 2 + tg3: tab-give 3 + tgg: tab-give 0 + tp: tab-pin + ttb: set tabs.position bottom + ttl: set tabs.position left + ttr: set tabs.position right + ttt: set tabs.position top + tw1: set tabs.width 48 + tw2: set tabs.width 96 + tw3: set tabs.width 160 + tw4: set tabs.width 240 + tw5: set tabs.width 320 + tw6: set tabs.width 480 + changelog_after_upgrade: + global: patch + colors.statusbar.private.bg: + global: '#2a2e32' + colors.statusbar.private.fg: + global: '#9b59b6' + colors.tabs.indicator.system: + global: rgb + colors.tabs.pinned.selected.odd.fg: + global: '#232629' + colors.webpage.bg: + global: white + colors.webpage.darkmode.enabled: + global: false + completion.open_categories: + global: + - searchengines + - quickmarks + - bookmarks + - history + - filesystem + completion.scrollbar.width: + global: 16 + confirm_quit: + global: + - never + content.autoplay: + global: false + content.blocking.adblock.lists: + global: + - https://easylist.to/easylist/easylist.txt + - https://easylist.to/easylist/easyprivacy.txt + content.blocking.enabled: + global: true + content.blocking.hosts.block_subdomains: + global: true + content.blocking.whitelist: + global: + - piwik.org + - linuxnews.de + content.cache.size: + global: 0 + content.canvas_reading: + global: true + content.default_encoding: + global: _utf-8_,iso-8859-1 + content.dns_prefetch: + global: false + content.geolocation: + https://www.clubderdampfer.de: false + https://www.r-m.de: false + content.headers.accept_language: + global: en-US,en;q=0.9 + content.headers.do_not_track: + global: false + content.javascript.clipboard: + wago.io: access + wowhead.com: access + content.javascript.enabled: + global: true + content.javascript.log_message.excludes: + global: + userscript:_qute_stylesheet: + - '*Refused to apply inline style because it violates the following Content + Security Policy directive: *' + content.notifications.enabled: + global: ask + content.notifications.presenter: + global: auto + content.pdfjs: + global: false + content.plugins: + global: true + content.register_protocol_handler: + https://mail.google.com?extsrc=mailto&url=%25s: true + content.webgl: + global: true + content.xss_auditing: + global: false + downloads.location.directory: + global: null + downloads.location.prompt: + global: false + downloads.location.remember: + global: true + downloads.location.suggestion: + global: both + downloads.prevent_mixed_content: + global: true + editor.command: + global: + - konsole + - --separate + - -e + - kak + - '{file}' + fileselect.folder.command: + global: + - kitty + - nnn + - -p + - '-' + fileselect.handler: + global: external + fileselect.multiple_files.command: + global: + - kitty + - nnn + - -p + - '-' + fileselect.single_file.command: + global: + - kitty + - nnn + - -p + - '-' + fonts.completion.category: + global: bold 12pt LiterationMono Nerd Font + fonts.completion.entry: + global: 12pt LiterationMono Nerd Font + fonts.contextmenu: + global: 12pt LiterationMono Nerd Font + fonts.debug_console: + global: default_size default_family + fonts.default_family: + global: 12pt LiterationMono Nerd Font + fonts.default_size: + global: 12pt + fonts.downloads: + global: 12pt LiterationMono Nerd Font + fonts.hints: + global: bold 12pt LiterationMono Nerd Font + fonts.keyhint: + global: 12pt LiterationMono Nerd Font + fonts.messages.error: + global: 12pt LiterationMono Nerd Font + fonts.messages.info: + global: 12pt LiterationMono Nerd Font + fonts.messages.warning: + global: 12pt LiterationMono Nerd Font + fonts.prompts: + global: 12pt LiterationMono Nerd Font + fonts.statusbar: + global: 12pt LiterationMono Nerd Font + fonts.tabs.selected: + global: 12pt LiterationMono Nerd Font + fonts.tabs.unselected: + global: 12pt LiterationMono Nerd Font + fonts.tooltip: + global: 12pt LiterationMono Nerd Font + fonts.web.family.cursive: + global: Noto Sans Old Italic + fonts.web.family.fantasy: + global: FantasqueSansM Nerd Font + fonts.web.family.fixed: + global: LiterationMono Nerd Font + fonts.web.family.sans_serif: + global: Noto Sans + fonts.web.family.serif: + global: Noto Serif + fonts.web.family.standard: + global: Noto Sans + fonts.web.size.default: + global: 15 + fonts.web.size.default_fixed: + global: 15 + fonts.web.size.minimum: + global: 6 + fonts.web.size.minimum_logical: + global: 7 + hints.min_chars: + global: 2 + hints.scatter: + global: true + input.insert_mode.auto_enter: + global: true + input.insert_mode.auto_load: + global: false + input.insert_mode.leave_on_load: + global: true + input.mode_override: + global: normal + qt.chromium.process_model: + global: process-per-site + qt.force_platform: + global: null + qt.force_platformtheme: + global: qt6ct + qt.force_software_rendering: + global: chromium + qt.highdpi: + global: false + scrolling.bar: + global: overlay + scrolling.smooth: + global: true + session.lazy_restore: + global: true + statusbar.show: + global: always + tabs.favicons.scale: + global: 1.0 + tabs.indicator.padding: + global: + bottom: 1 + left: 0 + right: 6 + top: 1 + tabs.indicator.width: + global: 10 + tabs.min_width: + global: -1 + tabs.padding: + global: + bottom: 4 + left: 5 + right: 5 + top: 4 + tabs.pinned.shrink: + global: true + tabs.position: + global: left + tabs.title.alignment: + global: left + tabs.title.elide: + global: right + tabs.title.format: + global: '{audio}{index}: {current_title}' + tabs.title.format_pinned: + global: '{audio}{index}: ** {current_title}' + tabs.undo_stack_size: + global: 50 + tabs.width: + global: 240 + window.hide_decoration: + global: false + zoom.default: + global: 110% + zoom.mouse_divider: + global: 1024 diff --git a/qutebrowser/config.py b/qutebrowser/config.py new file mode 100644 index 00000000..60477411 --- /dev/null +++ b/qutebrowser/config.py @@ -0,0 +1,368 @@ +import subprocess +config.load_autoconfig() + +## testing +# +config.bind('', 'spawn --userscript qute-keepassxc --key 285D16B66B11BD45BFD57B6CB22C3113D0E83104', mode='insert') +config.bind('pw', 'spawn --userscript qute-keepassxc --key 285D16B66B11BD45BFD57B6CB22C3113D0E83104', mode='normal') + +## Generic +# +c.auto_save.session = True +c.session.lazy_restore = True +c.content.fullscreen.window = True +c.content.notifications.enabled = True +c.content.cookies.accept = 'no-3rdparty' +c.content.blocking.whitelist = ["piwik.org"] +c.downloads.position = "bottom" +c.editor.command = ["konsole", "--separate", "-e", "kak", "{file}"] +c.statusbar.widgets = ["url", "progress", "scroll"] +c.zoom.default = "123%" +c.completion.web_history.max_items = 1000 +c.input.insert_mode.auto_load = True +c.new_instance_open_target = "tab-bg" + +## Key bindings +# +config.unbind('d') +config.unbind('ga') +config.unbind('+') +config.unbind('-') +config.bind ('gg', 'scroll-to-perc 0') +config.bind ('ge', 'scroll-to-perc 100') +config.bind ('gh', 'open duckduckgo.com') +config.bind('dd', 'tab-close') +config.bind('tda', 'tab-only') +config.bind('tgg', 'tab-give 0') +config.bind('tg1', 'tab-give 1') +config.bind('tg2', 'tab-give 2') +config.bind('tg3', 'tab-give 3') +config.bind('tp', 'tab-pin') +config.bind('E', 'edit-url') +config.bind('eu', 'edit-url') +config.bind('ttl', 'set tabs.position left') +config.bind('ttr', 'set tabs.position right') +config.bind('ttt', 'set tabs.position top') +config.bind('ttb', 'set tabs.position bottom') +config.bind('tw1', 'set tabs.width 48') +config.bind('tw2', 'set tabs.width 96') +config.bind('tw3', 'set tabs.width 160') +config.bind('tw4', 'set tabs.width 240') +config.bind('tw5', 'set tabs.width 320') +config.bind('tw6', 'set tabs.width 480') +config.unbind('') +config.bind('', 'back') +config.bind('', 'forward') +config.bind('', 'home') +config.bind('tG', 'home') +config.bind('dlV', 'spawn -u yt-dlp-video {url}') +config.bind('dlA', 'spawn -u yt-dlp-video {url}') +config.bind('dlv', 'hint links userscript yt-dlp-video') +config.bind('dla', 'hint links userscript yt-dlp-audio') +config.bind(',m', 'spawn vlc {url}') +config.bind(',M', 'hint links spawn vlc {hint-url}') +config.bind('', 'zoom-in') +config.bind('', 'zoom-out') + +## Font config +# +c.fonts.completion.category = 'bold 12pt LiterationMono\ Nerd\ Font' +c.fonts.completion.entry = '12pt LiterationMono\ Nerd\ Font' +c.fonts.downloads = '12pt LiterationMono\ Nerd\ Font' +c.fonts.hints = 'bold 12pt LiterationMono\ Nerd\ Font' +c.fonts.keyhint = '12pt LiterationMono\ Nerd\ Font' +c.fonts.messages.error = '12pt LiterationMono\ Nerd\ Font' +c.fonts.messages.info = '12pt LiterationMono\ Nerd\ Font' +c.fonts.messages.warning = '12pt LiterationMono\ Nerd\ Font' +c.fonts.prompts = '12pt LiterationMono\ Nerd\ Font' +c.fonts.statusbar = '12pt LiterationMono\ Nerd\ Font' +c.fonts.tabs.selected= '12pt LiterationMono\ Nerd\ Font' +c.fonts.tabs.unselected= '12pt LiterationMono\ Nerd\ Font' + +## Tabs config +# +c.tabs.position = "left" +c.tabs.indicator.width = 12 +c.tabs.favicons.show = "always" +c.tabs.select_on_remove = 'last-used' +c.tabs.background = True + +## Color config +# +base00 = '#2a2e32' #black +base01 = '#da4453' #red +base02 = '#27ae60' #green +base03 = '#fdbc4b' #yellow +base04 = '#1d99f3' #blue +base05 = '#9b59b6' #magenta +base06 = '#1cdc9a' #cyan +base07 = '#eff0f1' #white +base08 = '#4d4d4d' #brightblack +base09 = '#da4453' #brightred +base0A = '#27ae60' #brightgreen +base0B = '#fdbc4b' #brightyellow +base0C = '#1d99f3' #brightblue +base0D = '#9b59b6' #brightmagenta +base0E = '#1cdc9a' #brightcyan +base0F = '#fcfcfc' #brightwhite +base10 = '#3b4045' #backgroundalternate1 + +# Text color of the completion widget. May be a single color to use for +# all columns or a list of three colors, one for each column. +c.colors.completion.fg = base07 + +# Background color of the completion widget for odd rows. +c.colors.completion.odd.bg = base10 + +# Background color of the completion widget for even rows. +c.colors.completion.even.bg = base00 + +# Foreground color of completion widget category headers. +c.colors.completion.category.fg = base04 + +# Background color of the completion widget category headers. +c.colors.completion.category.bg = base00 + +# Top border color of the completion widget category headers. +c.colors.completion.category.border.top = base00 + +# Bottom border color of the completion widget category headers. +c.colors.completion.category.border.bottom = base00 + +# Foreground color of the selected completion item. +c.colors.completion.item.selected.fg = base0F + +# Background color of the selected completion item. +c.colors.completion.item.selected.bg = base04 + +# Top border color of the completion widget category headers. +c.colors.completion.item.selected.border.top = base10 + +# Bottom border color of the selected completion item. +c.colors.completion.item.selected.border.bottom = base10 + +# Foreground color of the matched text in the selected completion item. +c.colors.completion.item.selected.match.fg = base10 + +# Foreground color of the matched text in the completion. +c.colors.completion.match.fg = base0B + +# Color of the scrollbar handle in the completion view. +c.colors.completion.scrollbar.fg = base05 + +# Color of the scrollbar in the completion view. +c.colors.completion.scrollbar.bg = base00 + +# Background color for the download bar. +c.colors.downloads.bar.bg = base00 + +# Color gradient start for download text. +c.colors.downloads.start.fg = base00 + +# Color gradient start for download backgrounds. +c.colors.downloads.start.bg = base0D + +# Color gradient end for download text. +c.colors.downloads.stop.fg = base00 + +# Color gradient stop for download backgrounds. +c.colors.downloads.stop.bg = base0C + +# Background color for downloads with errors. +c.colors.downloads.error.bg = base00 + +# Foreground color for downloads with errors. +c.colors.downloads.error.fg = base02 + +# Font color for hints. +c.colors.hints.fg = base03 + +# Background color for hints. Note that you can use a `rgba(...)` value +# for transparency. +c.colors.hints.bg = base08 + +# Font color for the matched part of hints. +c.colors.hints.match.fg = base05 + +# Text color for the keyhint widget. +c.colors.keyhint.fg = base05 + +# Highlight color for keys to complete the current keychain. +c.colors.keyhint.suffix.fg = base05 + +# Background color of the keyhint widget. +c.colors.keyhint.bg = base00 + +# Foreground color of an error message. +c.colors.messages.error.fg = base00 + +# Background color of an error message. +c.colors.messages.error.bg = base01 + +# Border color of an error message. +c.colors.messages.error.border = base01 + +# Foreground color of a warning message. +c.colors.messages.warning.fg = base09 + +# Background color of a warning message. +c.colors.messages.warning.bg = base03 + +# Border color of a warning message. +c.colors.messages.warning.border = base03 + +# Foreground color of an info message. +c.colors.messages.info.fg = base0F + +# Background color of an info message. +c.colors.messages.info.bg = base02 + +# Border color of an info message. +c.colors.messages.info.border = base02 + +# Foreground color for prompts. +c.colors.prompts.fg = base05 + +# Border used around UI elements in prompts. +c.colors.prompts.border = base00 + +# Background color for prompts. +c.colors.prompts.bg = base00 + +# Background color for the selected item in filename prompts. +c.colors.prompts.selected.bg = base10 + +# Foreground color of the statusbar. +c.colors.statusbar.normal.fg = base0B + +# Background color of the statusbar. +c.colors.statusbar.normal.bg = base00 + +# Foreground color of the statusbar in insert mode. +c.colors.statusbar.insert.fg = base00 + +# Background color of the statusbar in insert mode. +c.colors.statusbar.insert.bg = base0D + +# Foreground color of the statusbar in passthrough mode. +c.colors.statusbar.passthrough.fg = base00 + +# Background color of the statusbar in passthrough mode. +c.colors.statusbar.passthrough.bg = base0C + +# Foreground color of the statusbar in private browsing mode. +c.colors.statusbar.private.fg = base05 + +# Background color of the statusbar in private browsing mode. +c.colors.statusbar.private.bg = base00 + +# Foreground color of the statusbar in command mode. +c.colors.statusbar.command.fg = base0F + +# Background color of the statusbar in command mode. +c.colors.statusbar.command.bg = base04 + +# Foreground color of the statusbar in private browsing + command mode. +c.colors.statusbar.command.private.fg = base05 + +# Background color of the statusbar in private browsing + command mode. +c.colors.statusbar.command.private.bg = base00 + +# Foreground color of the statusbar in caret mode. +c.colors.statusbar.caret.fg = base00 + +# Background color of the statusbar in caret mode. +c.colors.statusbar.caret.bg = base0E + +# Foreground color of the statusbar in caret mode with a selection. +c.colors.statusbar.caret.selection.fg = base00 + +# Background color of the statusbar in caret mode with a selection. +c.colors.statusbar.caret.selection.bg = base0D + +# Background color of the progress bar. +c.colors.statusbar.progress.bg = base0B + +# Default foreground color of the URL in the statusbar. +c.colors.statusbar.url.fg = base05 + +# Foreground color of the URL in the statusbar on error. +c.colors.statusbar.url.error.fg = base10 + +# Foreground color of the URL in the statusbar for hovered links. +c.colors.statusbar.url.hover.fg = base05 + +# Foreground color of the URL in the statusbar on successful load +# (http). +c.colors.statusbar.url.success.http.fg = base0C + +# Foreground color of the URL in the statusbar on successful load +# (https). +c.colors.statusbar.url.success.https.fg = base0F + +# Foreground color of the URL in the statusbar when there's a warning. +c.colors.statusbar.url.warn.fg = base01 + +# Background color of the tab bar. +c.colors.tabs.bar.bg = base00 + +# Color gradient start for the tab indicator. +c.colors.tabs.indicator.start = base03 + +# Color gradient end for the tab indicator. +c.colors.tabs.indicator.stop = base02 + +# Color for the tab indicator on errors. +c.colors.tabs.indicator.error = base01 + +# Foreground color of unselected odd tabs. +c.colors.tabs.odd.fg = base07 + +# Background color of unselected odd tabs. +c.colors.tabs.odd.bg = base10 + +# Foreground color of unselected even tabs. +c.colors.tabs.even.fg = base07 + +# Background color of unselected even tabs. +c.colors.tabs.even.bg = base00 + +# Background color of pinned unselected even tabs. +c.colors.tabs.pinned.even.bg = base03 + +# Foreground color of pinned unselected even tabs. +c.colors.tabs.pinned.even.fg = base00 + +# Background color of pinned unselected odd tabs. +c.colors.tabs.pinned.odd.bg = base03 + +# Foreground color of pinned unselected odd tabs. +c.colors.tabs.pinned.odd.fg = base00 + +# Background color of pinned selected even tabs. +c.colors.tabs.pinned.selected.even.bg = base03 + +# Foreground color of pinned selected even tabs. +c.colors.tabs.pinned.selected.even.fg = base04 + +# Background color of pinned selected odd tabs. +c.colors.tabs.pinned.selected.odd.bg = base03 + +# Foreground color of pinned selected odd tabs. +c.colors.tabs.pinned.selected.odd.fg = base04 + +# Foreground color of selected odd tabs. +c.colors.tabs.selected.odd.fg = base07 + +# Background color of selected odd tabs. +c.colors.tabs.selected.odd.bg = base04 + +# Foreground color of selected even tabs. +c.colors.tabs.selected.even.fg = base07 + +# Background color of selected even tabs. +c.colors.tabs.selected.even.bg = base04 + +# Background color for webpages if unset (or empty to use the theme's +# color). +# c.colors.webpage.bg = base00 diff --git a/qutebrowser/userscripts/qr b/qutebrowser/userscripts/qr new file mode 100755 index 00000000..84215249 --- /dev/null +++ b/qutebrowser/userscripts/qr @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +pngfile=$(mktemp --suffix=.png) +trap 'rm -f "$pngfile"' EXIT + +qrencode -t PNG -o "$pngfile" -s 10 "$QUTE_URL" +echo ":open -t file:///$pngfile" >> "$QUTE_FIFO" +sleep 1 # give qutebrowser time to open the file before it gets removed diff --git a/qutebrowser/userscripts/qute-keepassxc b/qutebrowser/userscripts/qute-keepassxc new file mode 100755 index 00000000..61a6c7bc --- /dev/null +++ b/qutebrowser/userscripts/qute-keepassxc @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2018-2021 Markus Blöchl +# +# 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 . + +""" +# Introduction + +This is a [qutebrowser][2] [userscript][5] to fill website credentials from a [KeepassXC][1] password database. + + +# Installation + +First, you need to enable [KeepassXC-Browser][6] extensions in your KeepassXC config. + + +Second, you must make sure to have a working private-public-key-pair in your [GPG keyring][3]. + + +Third, install the python module `pynacl`. + + +Finally, adapt your qutebrowser config. +You can e.g. add the following lines to your `~/.config/qutebrowser/config.py` +Remember to replace `ABC1234` with your actual GPG key. + +```python +config.bind('', 'spawn --userscript qute-keepassxc --key ABC1234', mode='insert') +config.bind('pw', 'spawn --userscript qute-keepassxc --key ABC1234', mode='normal') +``` + +To manage multiple accounts you also need [rofi](https://github.com/davatorium/rofi) installed. + + +# Usage + +If you are on a webpage with a login form, simply activate one of the configured key-bindings. + +The first time you run this script, KeepassXC will ask you for authentication like with any other browser extension. +Just provide a name of your choice and accept the request if nothing looks fishy. + + +# How it works + +This script will talk to KeepassXC using the native [KeepassXC-Browser protocol][4]. + + +This script needs to store the key used to associate with your KeepassXC instance somewhere. +Unlike most browser extensions which only use plain local storage, this one attempts to do so in a safe way +by storing the key in encrypted form using GPG. +Therefore you need to have a public-key-pair readily set up. + +GPG might then ask for your private-key password whenever you query the database for login credentials. + + +# TOTP + +This script recently received experimental TOTP support. +To use it, you need to have working TOTP authentication within KeepassXC. +Then call `qute-keepassxc` with the `--totp` flags. + +For example, I have the following line in my `config.py`: + +```python +config.bind('pt', 'spawn --userscript qute-keepassxc --key ABC1234 --totp', mode='normal') +``` + +For now this script will simply insert the TOTP-token into the currently selected +input field, since I have not yet found a reliable way to identify the correct field +within all existing login forms. +Thus you need to manually select the TOTP input field, press escape to leave input +mode and then enter `pt` to fill in the token (or configure another key-binding for +insert mode if you prefer that). + + +[1]: https://keepassxc.org/ +[2]: https://qutebrowser.org/ +[3]: https://gnupg.org/ +[4]: https://github.com/keepassxreboot/keepassxc-browser/blob/develop/keepassxc-protocol.md +[5]: https://github.com/qutebrowser/qutebrowser/blob/master/doc/userscripts.asciidoc +[6]: https://keepassxc.org/docs/KeePassXC_GettingStarted.html#_setup_browser_integration +""" + +import sys +import os +import socket +import json +import base64 +import subprocess +import argparse + +import nacl.utils +import nacl.public + + +def parse_args(): + parser = argparse.ArgumentParser(description="Full passwords from KeepassXC") + parser.add_argument('url', nargs='?', default=os.environ.get('QUTE_URL')) + parser.add_argument('--totp', action='store_true', + help="Fill in current TOTP field instead of username/password") + parser.add_argument('--socket', '-s', default='/run/user/{}/org.keepassxc.KeePassXC.BrowserServer'.format(os.getuid()), + help='Path to KeepassXC browser socket') + parser.add_argument('--key', '-k', default='alice@example.com', + help='GPG key to encrypt KeepassXC auth key with') + parser.add_argument('--insecure', action='store_true', + help="Do not encrypt auth key") + return parser.parse_args() + + +class KeepassError(Exception): + def __init__(self, code, desc): + self.code = code + self.description = desc + + def __str__(self): + return f"KeepassXC Error [{self.code}]: {self.description}" + + +class KeepassXC: + """ Wrapper around the KeepassXC socket API """ + def __init__(self, id=None, *, key, socket_path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.id = id + self.socket_path = socket_path + self.client_key = nacl.public.PrivateKey.generate() + self.id_key = nacl.public.PrivateKey.from_seed(key) + self.cryptobox = None + + def connect(self): + if not os.path.exists(self.socket_path): + raise KeepassError(-1, "KeepassXC Browser socket does not exists") + self.client_id = base64.b64encode(nacl.utils.random(nacl.public.Box.NONCE_SIZE)).decode('utf-8') + self.sock.connect(self.socket_path) + + self.send_raw_msg(dict( + action = 'change-public-keys', + publicKey = base64.b64encode(self.client_key.public_key.encode()).decode('utf-8'), + nonce = base64.b64encode(nacl.utils.random(nacl.public.Box.NONCE_SIZE)).decode('utf-8'), + clientID = self.client_id + )) + + resp = self.recv_raw_msg() + assert resp['action'] == 'change-public-keys' + assert resp['success'] == 'true' + assert resp['nonce'] + self.cryptobox = nacl.public.Box( + self.client_key, + nacl.public.PublicKey(base64.b64decode(resp['publicKey'])) + ) + + def get_databasehash(self): + self.send_msg(dict(action='get-databasehash')) + return self.recv_msg()['hash'] + + def lock_database(self): + self.send_msg(dict(action='lock-database')) + try: + self.recv_msg() + except KeepassError as e: + if e.code == 1: + return True + raise + return False + + + def test_associate(self): + if not self.id: + return False + self.send_msg(dict( + action = 'test-associate', + id = self.id, + key = base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') + ), triggerUnlock = 'true') + return self.recv_msg()['success'] == 'true' + + def associate(self): + self.send_msg(dict( + action = 'associate', + key = base64.b64encode(self.client_key.public_key.encode()).decode('utf-8'), + idKey = base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') + )) + resp = self.recv_msg() + self.id = resp['id'] + + def get_logins(self, url): + self.send_msg(dict( + action = 'get-logins', + url = url, + keys = [{ 'id': self.id, 'key': base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') }] + )) + return self.recv_msg()['entries'] + + def get_totp(self, uuid): + self.send_msg(dict( + action = 'get-totp', + uuid = uuid + )) + response = self.recv_msg() + if response['success'] != 'true' or not response['totp']: + return None + return response['totp'] + + def send_raw_msg(self, msg): + self.sock.send( json.dumps(msg).encode('utf-8') ) + + def recv_raw_msg(self): + return json.loads( self.sock.recv(4096).decode('utf-8') ) + + def send_msg(self, msg, **extra): + nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE) + self.send_raw_msg(dict( + action = msg['action'], + message = base64.b64encode(self.cryptobox.encrypt(json.dumps(msg).encode('utf-8'), nonce).ciphertext).decode('utf-8'), + nonce = base64.b64encode(nonce).decode('utf-8'), + clientID = self.client_id, + **extra + )) + + def recv_msg(self): + resp = self.recv_raw_msg() + if 'error' in resp: + raise KeepassError(resp['errorCode'], resp['error']) + assert resp['action'] + return json.loads(self.cryptobox.decrypt(base64.b64decode(resp['message']), base64.b64decode(resp['nonce'])).decode('utf-8')) + + + +class SecretKeyStore: + def __init__(self, gpgkey, insecure): + self.gpgkey = gpgkey + self.insecure = insecure + if self.insecure: + self.path = os.path.join(os.environ['QUTE_DATA_DIR'], 'keepassxc.key') + else: + self.path = os.path.join(os.environ['QUTE_DATA_DIR'], 'keepassxc.key.gpg') + + def load(self): + "Load existing association key from file" + if self.insecure: + jsondata = open(self.path, 'r').read() + else: + jsondata = subprocess.check_output(['gpg', '--decrypt', self.path]).decode('utf-8') + data = json.loads(jsondata) + self.id = data['id'] + self.key = base64.b64decode(data['key']) + + def create(self): + "Create new association key" + self.key = nacl.utils.random(32) + self.id = None + + def store(self, id): + "Store newly created association key in file" + self.id = id + jsondata = json.dumps({'id':self.id, 'key':base64.b64encode(self.key).decode('utf-8')}) + if self.insecure: + open(self.path, "w").write(jsondata) + else: + subprocess.run(['gpg', '--encrypt', '-o', self.path, '-r', self.gpgkey], input=jsondata.encode('utf-8'), check=True) + + +def qute(cmd): + with open(os.environ['QUTE_FIFO'], 'w') as fifo: + fifo.write(cmd) + fifo.write('\n') + fifo.flush() + +def error(msg): + print(msg, file=sys.stderr) + qute('message-error "{}"'.format(msg)) + + +def connect_to_keepassxc(args): + if not args.insecure and not args.key: + error("Missing GPG key to use for auth key encryption") + return + keystore = SecretKeyStore(args.key, args.insecure) + if os.path.isfile(keystore.path): + keystore.load() + kp = KeepassXC(keystore.id, key=keystore.key, socket_path=args.socket) + kp.connect() + if not kp.test_associate(): + error('No KeepassXC association') + return None + else: + keystore.create() + kp = KeepassXC(key=keystore.key, socket_path=args.socket) + kp.connect() + kp.associate() + if not kp.test_associate(): + error('No KeepassXC association') + return None + keystore.store(kp.id) + return kp + + +def select_account(creds): + try: + if len(creds) == 1: + return creds[0] + idx = subprocess.check_output( + ['rofi', '-dmenu', '-format', 'i', '-matching', 'fuzzy', + '-p', 'Search', + '-mesg', 'qute-keepassxc: select an account, please!'], + input=b"\n".join(c['login'].encode('utf-8') for c in creds) + ) + idx = int(idx) + if idx < 0: + return None + return creds[idx] + except subprocess.CalledProcessError: + return None + except FileNotFoundError: + error("rofi not found. Please install rofi to select from multiple credentials") + return creds[0] + except Exception as e: + error(f"Error while picking account: {e}") + return None + + +def make_js_code(username, password): + return ' '.join(""" + function isVisible(elem) { + var style = elem.ownerDocument.defaultView.getComputedStyle(elem, null); + + if (style.getPropertyValue("visibility") !== "visible" || + style.getPropertyValue("display") === "none" || + style.getPropertyValue("opacity") === "0") { + return false; + } + + return elem.offsetWidth > 0 && elem.offsetHeight > 0; + }; + + function hasPasswordField(form) { + var inputs = form.getElementsByTagName("input"); + for (var j = 0; j < inputs.length; j++) { + var input = inputs[j]; + if (input.type === "password") { + return true; + } + } + return false; + }; + + function loadData2Form (form) { + var inputs = form.getElementsByTagName("input"); + for (var j = 0; j < inputs.length; j++) { + var input = inputs[j]; + if (isVisible(input) && (input.type === "text" || input.type === "email")) { + input.focus(); + input.value = %s; + input.dispatchEvent(new Event('input', { 'bubbles': true })); + input.dispatchEvent(new Event('change', { 'bubbles': true })); + input.blur(); + } + if (input.type === "password") { + input.focus(); + input.value = %s; + input.dispatchEvent(new Event('input', { 'bubbles': true })); + input.dispatchEvent(new Event('change', { 'bubbles': true })); + input.blur(); + } + } + }; + + function fillFirstForm() { + var forms = document.getElementsByTagName("form"); + for (i = 0; i < forms.length; i++) { + if (hasPasswordField(forms[i])) { + loadData2Form(forms[i]); + return; + } + } + alert("No Credentials Form found"); + }; + + fillFirstForm() + """.splitlines()) % (json.dumps(username), json.dumps(password)) + + +def make_js_totp_code(totp): + return ' '.join(""" + (function () { + var input = document.activeElement; + if (!input || input.tagName !== "INPUT") { + alert("No TOTP input field selected"); + return; + } + input.value = %s; + input.dispatchEvent(new Event('input', { 'bubbles': true })); + input.dispatchEvent(new Event('change', { 'bubbles': true })); + })(); + """.splitlines()) % (json.dumps(totp),) + + +def main(): + if 'QUTE_FIFO' not in os.environ: + print(f"No QUTE_FIFO found - {sys.argv[0]} must be run as a qutebrowser userscript") + sys.exit(-1) + + try: + args = parse_args() + assert args.url, "Missing URL" + kp = connect_to_keepassxc(args) + if not kp: + error('Could not connect to KeepassXC') + return + creds = kp.get_logins(args.url) + if not creds: + error('No credentials found') + return + cred = select_account(creds) + if not cred: + error('No credentials selected') + return + if args.totp: + uuid = cred['uuid'] + totp = kp.get_totp(uuid) + if not totp: + error('No TOTP key found') + return + qute('jseval -q ' + make_js_totp_code(totp)) + else: + name, pw = cred['login'], cred['password'] + if name and pw: + qute('jseval -q ' + make_js_code(name, pw)) + except Exception as e: + error(str(e)) + + +if __name__ == '__main__': + main() + -- 2.51.2