diff --git a/.config/qutebrowser/config.py b/.config/qutebrowser/config.py index 931874d..c47fab4 100644 --- a/.config/qutebrowser/config.py +++ b/.config/qutebrowser/config.py @@ -392,7 +392,7 @@ c.fonts.web.size.minimum = 15 # Bindings for normal mode config.bind(';#', 'hint code userscript code_select.py') config.bind(';D', 'spawn --userscript dark_mode.user ;; greasemonkey-reload') -config.bind(';G', 'hint links userscript qute-gemini-tab') +config.bind(';G', 'hint links userscript qute-gemini') config.bind(';b', 'hint links userscript getbib') config.bind(';g', 'hint links userscript qute-gemini') config.bind(';m', 'hint links spawn nohup mpv --gpu-context=wayland --hwdec=auto {hint-url}') diff --git a/.local/share/qutebrowser/userscripts/code_select.py b/.local/share/qutebrowser/userscripts/code_select.py deleted file mode 100755 index d5ebae1..0000000 --- a/.local/share/qutebrowser/userscripts/code_select.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 - -import os -import html -import re -import sys -import xml.etree.ElementTree as ET -try: - import pyperclip -except ImportError: - PYPERCLIP = False -else: - PYPERCLIP = True - - -def parse_text_content(element): - root = ET.fromstring(element) - text = ET.tostring(root, encoding="unicode", method="text") - text = html.unescape(text) - return text - - -def send_command_to_qute(command): - with open(os.environ.get("QUTE_FIFO"), "w") as f: - f.write(command) - - -def main(): - delimiter = sys.argv[1] if len(sys.argv) > 1 else ";" - # For info on qute environment vairables, see - # https://github.com/qutebrowser/qutebrowser/blob/master/doc/userscripts.asciidoc - element = os.environ.get("QUTE_SELECTED_HTML") - code_text = parse_text_content(element) - if PYPERCLIP: - pyperclip.copy(code_text) - send_command_to_qute( - "message-info 'copied to clipboard: {info}{suffix}'".format( - info=code_text.splitlines()[0], - suffix="..." if len(code_text.splitlines()) > 1 else "" - ) - ) - else: - # Qute's yank command won't copy accross multiple lines so we - # compromise by placing lines on a single line seperated by the - # specified delimiter - code_text = re.sub("(\n)+", delimiter, code_text) - code_text = code_text.replace("'", "\"") - send_command_to_qute("yank inline '{code}'\n".format(code=code_text)) - - -if __name__ == "__main__": - main() diff --git a/.local/share/qutebrowser/userscripts/getbib b/.local/share/qutebrowser/userscripts/getbib deleted file mode 100755 index e50bc71..0000000 --- a/.local/share/qutebrowser/userscripts/getbib +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""Qutebrowser userscript scraping the current web page for DOIs and downloading -corresponding bibtex information. -Set the environment variable 'QUTE_BIB_FILEPATH' to indicate the path to -download to. Otherwise, bibtex information is downloaded to '/tmp' and hence -deleted at reboot. -Installation: see qute://help/userscripts.html -Inspired by -https://ocefpaf.github.io/python4oceanographers/blog/2014/05/19/doi2bibtex/ -""" - -import os -import sys -import re -from collections import Counter -from urllib import parse as url_parse -from urllib import request as url_request - - -FIFO_PATH = os.getenv("QUTE_FIFO") - -def message_fifo(message, level="warning"): - """Send message to qutebrowser FIFO. The level must be one of 'info', - 'warning' (default) or 'error'.""" - with open(FIFO_PATH, "w") as fifo: - fifo.write("message-{} '{}'".format(level, message)) - - -source = os.getenv("QUTE_TEXT") -with open(source) as f: - text = f.read() - -# find DOIs on page using regex -dval = re.compile(r'(10\.(\d)+/([^(\s\>\"\<)])+)') -# https://stackoverflow.com/a/10324802/3865876, too strict -# dval = re.compile(r'\b(10[.][0-9]{4,}(?:[.][0-9]+)*/(?:(?!["&\'<>])\S)+)\b') -dois = dval.findall(text) -dois = Counter(e[0] for e in dois) -try: - doi = dois.most_common(1)[0][0] -except IndexError: - message_fifo("No DOIs found on page") - sys.exit() -message_fifo("Found {} DOIs on page, selecting {}".format(len(dois), doi), - level="info") - -# get bibtex data corresponding to DOI -url = "https://dx.doi.org/" + url_parse.quote(doi) -headers = dict(Accept='text/bibliography; style=bibtex') -request = url_request.Request(url, headers=headers) -response = url_request.urlopen(request) -status_code = response.getcode() -if status_code >= 400: - message_fifo("Request returned {}".format(status_code)) - sys.exit() - -# obtain content and format it -bibtex = response.read().decode("utf-8").strip() -bibtex = bibtex.replace(" ", "\n ", 1).\ - replace("}, ", "},\n ").replace("}}", "}\n}") - -# append to file -bib_filepath = os.getenv("QUTE_BIB_FILEPATH", "/tmp/qute.bib") -with open(bib_filepath, "a") as f: - f.write(bibtex + "\n\n") diff --git a/.local/share/qutebrowser/userscripts/qute-gemini b/.local/share/qutebrowser/userscripts/qute-gemini deleted file mode 100755 index bfcd1b1..0000000 --- a/.local/share/qutebrowser/userscripts/qute-gemini +++ /dev/null @@ -1,340 +0,0 @@ -#!/usr/bin/env python3 -# qute-gemini - Open Gemini links in qutebrowser and render them as HTML -# -# SPDX-FileCopyrightText: 2019-2020 solderpunk -# SPDX-FileCopyrightText: 2020 Aaron Janse -# SPDX-FileCopyrightText: 2020 petedussin -# SPDX-FileCopyrightText: 2020-2021 Sotiris Papatheodorou -# SPDX-License-Identifier: GPL-3.0-or-later - -import cgi -import html -import os -import socket -import ssl -import sys -import tempfile -import urllib.parse - -from typing import Tuple - - -_version = "1.0.0" - -_max_redirects = 5 - -_error_page_template = ''' - - -Error opening page: URL - - - -

qute-gemini error

-

Error while opening:
URL_TEXT

-

DESCRIPTION

- - -''' - -_status_code_desc = { - "1": "Gemini status code 1 Input. This is not implemented in qute-gemini.", - "10": "Gemini status code 10 Input. This is not implemented in qute-gemini.", - "11": "Gemini status code 11 Sensitive Input. This is not implemented in qute-gemini.", - "3": "Gemini status code 3 Redirect. Stopped after " + str(_max_redirects) + " redirects.", - "30": "Gemini status code 30 Temporary Redirect. Stopped after " + str(_max_redirects) + " redirects.", - "31": "Gemini status code 31 Permanent Redirect. Stopped after " + str(_max_redirects) + " redirects.", - "4": "Gemini status code 4 Temporary Failure. Server message: META", - "40": "Gemini status code 40 Temporary Failure. Server message: META", - "41": "Gemini status code 41 Server Unavailable. The server is unavailable due to overload or maintenance. Server message: META", - "42": "Gemini status code 42 CGI Error. A CGI process, or similar system for generating dynamic content, died unexpectedly or timed out. Server message: META", - "43": "Gemini status code 43 Proxy Error. A proxy request failed because the server was unable to successfully complete a transaction with the remote host. Server message: META", - "44": "Gemini status code 44 Slow Down. Rate limiting is in effect. Please wait META seconds before making another request to this server.", - "5": "Gemini status code 5 Permanent Failure. Server message: META", - "50": "Gemini status code 50 Permanent Failure. Server message: META", - "51": "Gemini status code 51 Not Found. he requested resource could not be found but may be available in the future. Server message: META", - "52": "Gemini status code 52 Gone. The resource requested is no longer available and will not be available again. Server message: META", - "53": "Gemini status code 53 Proxy Request Refused. The request was for a resource at a domain not served by the server and the server does not accept proxy requests. Server message: META", - "59": "Gemini status code 59 Bad Request. The server was unable to parse the client's request, presumably due to a malformed request. Server message: META", - "6": "Gemini status code 6 Client Certificate Required. This is not implemented in qute-gemini.", -} - - -def qute_url() -> str: - """Get the URL passed to the script by qutebrowser.""" - return os.environ["QUTE_URL"] - - -def qute_fifo() -> str: - """Get the FIFO or file to write qutebrowser commands to.""" - return os.environ["QUTE_FIFO"] - - -def html_href(url: str, description: str) -> str: - return "".join(['', description, ""]) - - -def qute_gemini_css_path() -> str: - """Return the path where the custom CSS file is expected to be.""" - try: - base_dir = os.environ["XDG_DATA_HOME"] - except KeyError: - base_dir = os.path.join(os.environ["HOME"], ".local/share") - return os.path.join(base_dir, "qutebrowser/userscripts/qute-gemini.css") - - -def gemini_absolutise_url(base_url: str, relative_url: str) -> str: - """Absolutise relative gemini URLs. - - Adapted from gcat: https://github.com/aaronjanse/gcat - """ - if "://" not in relative_url: - # Python's URL tools somehow only work with known schemes? - base_url = base_url.replace("gemini://", "http://") - relative_url = urllib.parse.urljoin(base_url, relative_url) - relative_url = relative_url.replace("http://", "gemini://") - return relative_url - - -def gemini_fetch_url(url: str) -> Tuple[str, str, str, str, str]: - """Fetch a Gemini URL and return the content as a string. - - url: URL with gemini:// or no scheme. - Returns 4 strings: the content, the URL the content was fetched from, the - Gemini status code, the value of the meta field and an error message. - - Adapted from gcat: https://github.com/aaronjanse/gcat - """ - # Parse the URL to get the hostname and port - parsed_url = urllib.parse.urlparse(url) - if not parsed_url.scheme: - url = "gemini://" + url - parsed_url = urllib.parse.urlparse(url) - if parsed_url.scheme != "gemini": - return "", "Received non-gemini:// URL: " + url - if parsed_url.port is not None: - useport = parsed_url.port - else: - useport = 1965 - # Do the Gemini transaction, looping for redirects - redirects = 0 - while True: - # Send the request - s = socket.create_connection((parsed_url.hostname, useport)) - context = ssl.SSLContext(ssl.PROTOCOL_TLS) - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - s = context.wrap_socket(s, server_hostname = parsed_url.netloc) - s.sendall((url + "\r\n").encode("UTF-8")) - # Get the status code and meta - fp = s.makefile("rb") - header = fp.readline().decode("UTF-8").strip() - status, meta = header.split()[:2] - # Follow up to 5 redirects - if status.startswith("3"): - url = gemini_absolutise_url(url, meta) - parsed_url = urllib.parse.urlparse(url) - redirects += 1 - if redirects > _max_redirects: - # Too many redirects - break - # Otherwise we're done - else: - break - # Process the response - content = "" - error_msg = "" - # 2x Success - if status.startswith("2"): - media_type, media_type_opts = cgi.parse_header(meta) - # Decode according to declared charset defaulting to UTF-8 - if meta.startswith("text/gemini"): - charset = media_type_opts.get("charset", "UTF-8") - content = fp.read().decode(charset) - else: - error_msg = "Expected media type text/gemini but received " \ - + media_type - # Handle errors - else: - # Try matching a 2-digit and then a 1-digit status code - try: - error_msg = _status_code_desc[status[0:2]] - except KeyError: - try: - error_msg = _status_code_desc[status[0]] - except KeyError: - error_msg = "The server sent back something weird." - # Substitute the contents of meta into the error message if needed - error_msg = error_msg.replace("META", meta) - return content, url, status, meta, error_msg - - -def gemtext_to_html(gemtext: str, url: str, original_url: str, - status: str, meta: str) -> str: - """Convert gemtext to HTML. - - title: Used as the document title. - url: The URL the gemtext was received from. Used to resolve - relative URLs in the gemtext content. - original_url: The URL the original request was made at. - status: The Gemini status code returned by the server. - meta: The meta returned by the server. - Returns the HTML representation as a string. - """ - # Accumulate converted gemtext lines - lines = ['', - '', - "\t", - "\t\t" + html.escape(url) + "", - "\t\t", - "\t", - "\t", - "\t
"] - in_pre = False - in_list = False - # Add an extra newline to ensure list tags are closed properly - for line in (gemtext + "\n").splitlines(): - # Add the list closing tag - if not line.startswith("*") and in_list: - lines.append("\t\t") - in_list = False - # Blank line, ignore - if not line: - pass - # Link - elif line.startswith("=>"): - l = line[2:].split(None, 1) - # Use the URL itself as the description if there is none - if len(l) == 1: - l.append(l[0]) - # Encode the link description - l[1] = html.escape(l[1]) - # Resolve relative URLs - l[0] = gemini_absolutise_url(url, l[0]) - lines.append("\t\t

" + html_href(l[0], l[1]) + "

") - # Preformated toggle - elif line.startswith("```"): - if in_pre: - lines.append("\t\t") - else: - lines.append("\t\t
")
-            in_pre = not in_pre
-        # Preformated
-        elif in_pre:
-            lines.append(line)
-        # Header
-        elif line.startswith("###"):
-            lines.append("\t\t

" + html.escape(line[3:].strip()) + "

") - elif line.startswith("##"): - lines.append("\t\t

" + html.escape(line[2:].strip()) + "

") - elif line.startswith("#"): - lines.append("\t\t

" + html.escape(line[1:].strip()) + "

") - # List - elif line.startswith("*"): - if not in_list: - lines.append("\t\t
", - "\t
", - "\t\t", - "\t\t\tContent from " + url_html, - "\t\t", - "\t\t
", - "\t\t\t
Original URL
", - "\t\t\t
" + original_url_html + "
", - "\t\t\t
Status
", - "\t\t\t
" + status + "
", - "\t\t\t
Meta
", - "\t\t\t
" + meta + "
", - "\t\t\t
Fetched by
", - '\t\t\t
qute-gemini ' + str(_version) + "
", - "\t\t
", - "\t
", - "\t", - ""]) - return "\n".join(lines) - - -def get_css() -> str: - # Search for qute-gemini.css in the directory this script is located in - css_file = qute_gemini_css_path() - if os.path.isfile(css_file): - # Return the file contents - with open(css_file, "r") as f: - return f.read().strip() - else: - # Use no CSS - return "" - - -def qute_error_page(url: str, description: str) -> str: - """Return a data URI error page like qutebrowser does. - - url: The URL of the page that failed to load. - description: A description of the error. - Returns a data URI containing the error page. - """ - # Generate the HTML error page - html_page = _error_page_template.replace("URL", url) - html_page = html_page.replace("URL_TEXT", html.escape(url)) - html_page = html_page.replace("DESCRIPTION", html.escape(description)) - html_page = html_page.replace("CSS", get_css()) - # URL encode and return as a data URI - return "data:text/html;charset=UTF-8," + urllib.parse.quote(html_page) - - -def open_gemini(url: str, open_args: str) -> None: - """Open Gemini URL in qutebrowser.""" - # Get the Gemini content - content, content_url, status, meta, error_msg = gemini_fetch_url(url) - if error_msg: - # Generate an error page in a data URI - open_url = qute_error_page(url, error_msg) - else: - # Success, convert to HTML in a temporary file - tmpf = tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) - tmp_filename = tmpf.name - tmpf.close() - with open(tmp_filename, "w") as f: - f.write(gemtext_to_html(content, content_url, url, status, meta)) - open_url = " file://" + tmp_filename - # Open the HTML file in qutebrowser - with open(qute_fifo(), "w") as qfifo: - qfifo.write("open " + open_args + open_url) - - -def open_other(url: str, open_args: str) -> None: - """Open non-Gemini URL in qutebrowser.""" - with open(qute_fifo(), "w") as qfifo: - qfifo.write("open " + open_args + " " + url) - - -if __name__ == "__main__": - # Open in the current or a new tab depending on the script name - if sys.argv[0].endswith("-tab"): - open_args = "-t" - else: - open_args = "" - # Select how to open the URL depending on its scheme - url = qute_url() - parsed_url = urllib.parse.urlparse(url) - if parsed_url.scheme == "gemini": - open_gemini(url, open_args) - else: - open_other(url, open_args) diff --git a/.local/share/qutebrowser/userscripts/qute-gemini-tab b/.local/share/qutebrowser/userscripts/qute-gemini-tab deleted file mode 120000 index bfcb515..0000000 --- a/.local/share/qutebrowser/userscripts/qute-gemini-tab +++ /dev/null @@ -1 +0,0 @@ -qute-gemini \ No newline at end of file diff --git a/.local/share/qutebrowser/userscripts/qute-keepassxc b/.local/share/qutebrowser/userscripts/qute-keepassxc deleted file mode 100755 index 11d0a33..0000000 --- a/.local/share/qutebrowser/userscripts/qute-keepassxc +++ /dev/null @@ -1,361 +0,0 @@ -#!/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') -``` - - -# 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 passwort whenever you query the database for login credentials. - - -[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('--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') - )) - 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 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): - self.gpgkey = gpgkey - if gpgkey is None: - 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.gpgkey is None: - 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.gpgkey is None: - 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): - assert args.key or args.insecure, "Missing GPG key to use for auth key encryption" - keystore = SecretKeyStore(args.key) - 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 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 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 - # TODO: handle multiple matches - name, pw = creds[0]['login'], creds[0]['password'] - if name and pw: - qute('jseval -q ' + make_js_code(name, pw)) - except Exception as e: - error(str(e)) - - -if __name__ == '__main__': - main() - diff --git a/home-surtur.nix b/home-surtur.nix index fe42ece..19d10ed 100644 --- a/home-surtur.nix +++ b/home-surtur.nix @@ -738,6 +738,8 @@ in { ''; executable = true; }; + + # qutebrowser userscripts start. ".config/qutebrowser/userscripts/localhost" = { executable = true; text = '' @@ -790,6 +792,846 @@ in { fi ''; }; + ".config/qutebrowser/userscripts/code_select.py" = { + executable = true; + # source = .local/share/qutebrowser/userscripts/code_select.py; + text = '' + #!/usr/bin/env python3 + import os + import html + import re + import sys + import xml.etree.ElementTree as ET + try: + import pyperclip + except ImportError: + PYPERCLIP = False + else: + PYPERCLIP = True + + + def parse_text_content(element): + root = ET.fromstring(element) + text = ET.tostring(root, encoding="unicode", method="text") + text = html.unescape(text) + return text + + + def send_command_to_qute(command): + with open(os.environ.get("QUTE_FIFO"), "w") as f: + f.write(command) + + + def main(): + delimiter = sys.argv[1] if len(sys.argv) > 1 else ";" + # For info on qute environment vairables, see + # https://github.com/qutebrowser/qutebrowser/blob/master/doc/userscripts.asciidoc + element = os.environ.get("QUTE_SELECTED_HTML") + code_text = parse_text_content(element) + if PYPERCLIP: + pyperclip.copy(code_text) + send_command_to_qute( + "message-info 'copied to clipboard: {info}{suffix}'".format( + info=code_text.splitlines()[0], + suffix="..." if len(code_text.splitlines()) > 1 else "" + ) + ) + else: + # Qute's yank command won't copy accross multiple lines so we + # compromise by placing lines on a single line seperated by the + # specified delimiter + code_text = re.sub("(\n)+", delimiter, code_text) + code_text = code_text.replace("'", "\"") + send_command_to_qute("yank inline '{code}'\n".format(code=code_text)) + + + if __name__ == "__main__": + main() + ''; + }; + ".config/qutebrowser/userscripts/getbib" = { + executable = true; + text = '' + #!/usr/bin/env python3 + """Qutebrowser userscript scraping the current web page for DOIs and downloading + corresponding bibtex information. + Set the environment variable 'QUTE_BIB_FILEPATH' to indicate the path to + download to. Otherwise, bibtex information is downloaded to '/tmp' and hence + deleted at reboot. + Installation: see qute://help/userscripts.html + Inspired by + https://ocefpaf.github.io/python4oceanographers/blog/2014/05/19/doi2bibtex/ + """ + + import os + import sys + import re + from collections import Counter + from urllib import parse as url_parse + from urllib import request as url_request + + + FIFO_PATH = os.getenv("QUTE_FIFO") + + def message_fifo(message, level="warning"): + """Send message to qutebrowser FIFO. The level must be one of 'info', + 'warning' (default) or 'error'.""" + with open(FIFO_PATH, "w") as fifo: + fifo.write("message-{} '{}'".format(level, message)) + + + source = os.getenv("QUTE_TEXT") + with open(source) as f: + text = f.read() + + # find DOIs on page using regex + dval = re.compile(r'(10\.(\d)+/([^(\s\>\"\<)])+)') + # https://stackoverflow.com/a/10324802/3865876, too strict + # dval = re.compile(r'\b(10[.][0-9]{4,}(?:[.][0-9]+)*/(?:(?!["&\'<>])\S)+)\b') + dois = dval.findall(text) + dois = Counter(e[0] for e in dois) + try: + doi = dois.most_common(1)[0][0] + except IndexError: + message_fifo("No DOIs found on page") + sys.exit() + message_fifo("Found {} DOIs on page, selecting {}".format(len(dois), doi), + level="info") + + # get bibtex data corresponding to DOI + url = "https://dx.doi.org/" + url_parse.quote(doi) + headers = dict(Accept='text/bibliography; style=bibtex') + request = url_request.Request(url, headers=headers) + response = url_request.urlopen(request) + status_code = response.getcode() + if status_code >= 400: + message_fifo("Request returned {}".format(status_code)) + sys.exit() + + # obtain content and format it + bibtex = response.read().decode("utf-8").strip() + bibtex = bibtex.replace(" ", "\n ", 1).\ + replace("}, ", "},\n ").replace("}}", "}\n}") + + # append to file + bib_filepath = os.getenv("QUTE_BIB_FILEPATH", "/tmp/qute.bib") + with open(bib_filepath, "a") as f: + f.write(bibtex + "\n\n") + ''; + }; + ".config/qutebrowser/userscripts/qute-gemini" = { + executable = true; + text = '' + #!/usr/bin/env python3 + # qute-gemini - Open Gemini links in qutebrowser and render them as HTML + # + # SPDX-FileCopyrightText: 2019-2020 solderpunk + # SPDX-FileCopyrightText: 2020 Aaron Janse + # SPDX-FileCopyrightText: 2020 petedussin + # SPDX-FileCopyrightText: 2020-2021 Sotiris Papatheodorou + # SPDX-License-Identifier: GPL-3.0-or-later + + import cgi + import html + import os + import socket + import ssl + import sys + import tempfile + import urllib.parse + + from typing import Tuple + + + _version = "1.0.0" + + _max_redirects = 5 + + _error_page_template = '''' + + + Error opening page: URL + + + +

qute-gemini error

+

Error while opening:
URL_TEXT

+

DESCRIPTION

+ + + '''' + + _status_code_desc = { + "1": "Gemini status code 1 Input. This is not implemented in qute-gemini.", + "10": "Gemini status code 10 Input. This is not implemented in qute-gemini.", + "11": "Gemini status code 11 Sensitive Input. This is not implemented in qute-gemini.", + "3": "Gemini status code 3 Redirect. Stopped after " + str(_max_redirects) + " redirects.", + "30": "Gemini status code 30 Temporary Redirect. Stopped after " + str(_max_redirects) + " redirects.", + "31": "Gemini status code 31 Permanent Redirect. Stopped after " + str(_max_redirects) + " redirects.", + "4": "Gemini status code 4 Temporary Failure. Server message: META", + "40": "Gemini status code 40 Temporary Failure. Server message: META", + "41": "Gemini status code 41 Server Unavailable. The server is unavailable due to overload or maintenance. Server message: META", + "42": "Gemini status code 42 CGI Error. A CGI process, or similar system for generating dynamic content, died unexpectedly or timed out. Server message: META", + "43": "Gemini status code 43 Proxy Error. A proxy request failed because the server was unable to successfully complete a transaction with the remote host. Server message: META", + "44": "Gemini status code 44 Slow Down. Rate limiting is in effect. Please wait META seconds before making another request to this server.", + "5": "Gemini status code 5 Permanent Failure. Server message: META", + "50": "Gemini status code 50 Permanent Failure. Server message: META", + "51": "Gemini status code 51 Not Found. he requested resource could not be found but may be available in the future. Server message: META", + "52": "Gemini status code 52 Gone. The resource requested is no longer available and will not be available again. Server message: META", + "53": "Gemini status code 53 Proxy Request Refused. The request was for a resource at a domain not served by the server and the server does not accept proxy requests. Server message: META", + "59": "Gemini status code 59 Bad Request. The server was unable to parse the client's request, presumably due to a malformed request. Server message: META", + "6": "Gemini status code 6 Client Certificate Required. This is not implemented in qute-gemini.", + } + + + def qute_url() -> str: + """Get the URL passed to the script by qutebrowser.""" + return os.environ["QUTE_URL"] + + + def qute_fifo() -> str: + """Get the FIFO or file to write qutebrowser commands to.""" + return os.environ["QUTE_FIFO"] + + + def html_href(url: str, description: str) -> str: + return "".join(['', description, ""]) + + + def qute_gemini_css_path() -> str: + """Return the path where the custom CSS file is expected to be.""" + try: + base_dir = os.environ["XDG_DATA_HOME"] + except KeyError: + base_dir = os.path.join(os.environ["HOME"], ".local/share") + return os.path.join(base_dir, "qutebrowser/userscripts/qute-gemini.css") + + + def gemini_absolutise_url(base_url: str, relative_url: str) -> str: + """Absolutise relative gemini URLs. + + Adapted from gcat: https://github.com/aaronjanse/gcat + """ + if "://" not in relative_url: + # Python's URL tools somehow only work with known schemes? + base_url = base_url.replace("gemini://", "http://") + relative_url = urllib.parse.urljoin(base_url, relative_url) + relative_url = relative_url.replace("http://", "gemini://") + return relative_url + + + def gemini_fetch_url(url: str) -> Tuple[str, str, str, str, str]: + """Fetch a Gemini URL and return the content as a string. + + url: URL with gemini:// or no scheme. + Returns 4 strings: the content, the URL the content was fetched from, the + Gemini status code, the value of the meta field and an error message. + + Adapted from gcat: https://github.com/aaronjanse/gcat + """ + # Parse the URL to get the hostname and port + parsed_url = urllib.parse.urlparse(url) + if not parsed_url.scheme: + url = "gemini://" + url + parsed_url = urllib.parse.urlparse(url) + if parsed_url.scheme != "gemini": + return "", "Received non-gemini:// URL: " + url + if parsed_url.port is not None: + useport = parsed_url.port + else: + useport = 1965 + # Do the Gemini transaction, looping for redirects + redirects = 0 + while True: + # Send the request + s = socket.create_connection((parsed_url.hostname, useport)) + context = ssl.SSLContext(ssl.PROTOCOL_TLS) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + s = context.wrap_socket(s, server_hostname = parsed_url.netloc) + s.sendall((url + "\r\n").encode("UTF-8")) + # Get the status code and meta + fp = s.makefile("rb") + header = fp.readline().decode("UTF-8").strip() + status, meta = header.split()[:2] + # Follow up to 5 redirects + if status.startswith("3"): + url = gemini_absolutise_url(url, meta) + parsed_url = urllib.parse.urlparse(url) + redirects += 1 + if redirects > _max_redirects: + # Too many redirects + break + # Otherwise we're done + else: + break + # Process the response + content = "" + error_msg = "" + # 2x Success + if status.startswith("2"): + media_type, media_type_opts = cgi.parse_header(meta) + # Decode according to declared charset defaulting to UTF-8 + if meta.startswith("text/gemini"): + charset = media_type_opts.get("charset", "UTF-8") + content = fp.read().decode(charset) + else: + error_msg = "Expected media type text/gemini but received " \ + + media_type + # Handle errors + else: + # Try matching a 2-digit and then a 1-digit status code + try: + error_msg = _status_code_desc[status[0:2]] + except KeyError: + try: + error_msg = _status_code_desc[status[0]] + except KeyError: + error_msg = "The server sent back something weird." + # Substitute the contents of meta into the error message if needed + error_msg = error_msg.replace("META", meta) + return content, url, status, meta, error_msg + + + def gemtext_to_html(gemtext: str, url: str, original_url: str, + status: str, meta: str) -> str: + """Convert gemtext to HTML. + + title: Used as the document title. + url: The URL the gemtext was received from. Used to resolve + relative URLs in the gemtext content. + original_url: The URL the original request was made at. + status: The Gemini status code returned by the server. + meta: The meta returned by the server. + Returns the HTML representation as a string. + """ + # Accumulate converted gemtext lines + lines = ['', + '', + "\t", + "\t\t" + html.escape(url) + "", + "\t\t", + "\t", + "\t", + "\t
"] + in_pre = False + in_list = False + # Add an extra newline to ensure list tags are closed properly + for line in (gemtext + "\n").splitlines(): + # Add the list closing tag + if not line.startswith("*") and in_list: + lines.append("\t\t") + in_list = False + # Blank line, ignore + if not line: + pass + # Link + elif line.startswith("=>"): + l = line[2:].split(None, 1) + # Use the URL itself as the description if there is none + if len(l) == 1: + l.append(l[0]) + # Encode the link description + l[1] = html.escape(l[1]) + # Resolve relative URLs + l[0] = gemini_absolutise_url(url, l[0]) + lines.append("\t\t

" + html_href(l[0], l[1]) + "

") + # Preformated toggle + elif line.startswith("```"): + if in_pre: + lines.append("\t\t") + else: + lines.append("\t\t
")
+                    in_pre = not in_pre
+                # Preformated
+                elif in_pre:
+                    lines.append(line)
+                # Header
+                elif line.startswith("###"):
+                    lines.append("\t\t

" + html.escape(line[3:].strip()) + "

") + elif line.startswith("##"): + lines.append("\t\t

" + html.escape(line[2:].strip()) + "

") + elif line.startswith("#"): + lines.append("\t\t

" + html.escape(line[1:].strip()) + "

") + # List + elif line.startswith("*"): + if not in_list: + lines.append("\t\t
    ") + in_list = True + lines.append("\t\t\t
  • " + html.escape(line[1:].strip()) + "
  • ") + # Quote + elif line.startswith(">"): + lines.extend(["\t\t
    ", + "\t\t\t

    " + line[1:].strip() + "

    ", + "\t\t
    "]) + # Normal text + else: + lines.append("\t\t

    " + html.escape(line.strip()) + "

    ") + url_html = html_href(url, html.escape(url)) + original_url_html = html_href(original_url, html.escape(original_url)) + lines.extend(["", + "\t
", + "\t
", + "\t\t", + "\t\t\tContent from " + url_html, + "\t\t", + "\t\t
", + "\t\t\t
Original URL
", + "\t\t\t
" + original_url_html + "
", + "\t\t\t
Status
", + "\t\t\t
" + status + "
", + "\t\t\t
Meta
", + "\t\t\t
" + meta + "
", + "\t\t\t
Fetched by
", + '\t\t\t
qute-gemini ' + str(_version) + "
", + "\t\t
", + "\t
", + "\t", + ""]) + return "\n".join(lines) + + + def get_css() -> str: + # Search for qute-gemini.css in the directory this script is located in + css_file = qute_gemini_css_path() + if os.path.isfile(css_file): + # Return the file contents + with open(css_file, "r") as f: + return f.read().strip() + else: + # Use no CSS + return "" + + + def qute_error_page(url: str, description: str) -> str: + """Return a data URI error page like qutebrowser does. + + url: The URL of the page that failed to load. + description: A description of the error. + Returns a data URI containing the error page. + """ + # Generate the HTML error page + html_page = _error_page_template.replace("URL", url) + html_page = html_page.replace("URL_TEXT", html.escape(url)) + html_page = html_page.replace("DESCRIPTION", html.escape(description)) + html_page = html_page.replace("CSS", get_css()) + # URL encode and return as a data URI + return "data:text/html;charset=UTF-8," + urllib.parse.quote(html_page) + + + def open_gemini(url: str, open_args: str) -> None: + """Open Gemini URL in qutebrowser.""" + # Get the Gemini content + content, content_url, status, meta, error_msg = gemini_fetch_url(url) + if error_msg: + # Generate an error page in a data URI + open_url = qute_error_page(url, error_msg) + else: + # Success, convert to HTML in a temporary file + tmpf = tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) + tmp_filename = tmpf.name + tmpf.close() + with open(tmp_filename, "w") as f: + f.write(gemtext_to_html(content, content_url, url, status, meta)) + open_url = " file://" + tmp_filename + # Open the HTML file in qutebrowser + with open(qute_fifo(), "w") as qfifo: + qfifo.write("open " + open_args + open_url) + + + def open_other(url: str, open_args: str) -> None: + """Open non-Gemini URL in qutebrowser.""" + with open(qute_fifo(), "w") as qfifo: + qfifo.write("open " + open_args + " " + url) + + + if __name__ == "__main__": + # Open in the current or a new tab depending on the script name + if sys.argv[0].endswith("-tab"): + open_args = "-t" + else: + open_args = "" + # Select how to open the URL depending on its scheme + url = qute_url() + parsed_url = urllib.parse.urlparse(url) + if parsed_url.scheme == "gemini": + open_gemini(url, open_args) + else: + open_other(url, open_args) + ''; + }; + ".config/qutebrowser/userscripts/qute-keepassxc" = { + executable = true; + text = '' + #!/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') + ``` + + + # 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 passwort whenever you query the database for login credentials. + + + [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('--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') + )) + 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 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): + self.gpgkey = gpgkey + if gpgkey is None: + 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.gpgkey is None: + 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.gpgkey is None: + 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): + assert args.key or args.insecure, "Missing GPG key to use for auth key encryption" + keystore = SecretKeyStore(args.key) + 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 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 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 + # TODO: handle multiple matches + name, pw = creds[0]['login'], creds[0]['password'] + if name and pw: + qute('jseval -q ' + make_js_code(name, pw)) + except Exception as e: + error(str(e)) + + + if __name__ == '__main__': + main() + + ''; + }; + # qutebrowser userscripts end. + ".config/qutebrowser/greasemonkey/DR.js" = { text = '' // ==UserScript== @@ -827,22 +1669,7 @@ in { DarkReader.disable(); ''; }; - ".config/qutebrowser/userscripts/code_select.py" = { - executable = true; - source = .local/share/qutebrowser/userscripts/code_select.py; - }; - ".config/qutebrowser/userscripts/getbib" = { - executable = true; - source = .local/share/qutebrowser/userscripts/getbib; - }; - ".config/qutebrowser/userscripts/qute-gemini" = { - executable = true; - source = .local/share/qutebrowser/userscripts/qute-gemini; - }; - ".config/qutebrowser/userscripts/qute-gemini-tab" = { - executable = true; - source = .local/share/qutebrowser/userscripts/qute-gemini; - }; + ".config/qutebrowser/config.py" = { source = .config/qutebrowser/config.py; };