Skip to content

Commit

Permalink
Use stricter config file
Browse files Browse the repository at this point in the history
Change config file to an INI-like format. This allows sections for
separate clients, without writing conditional code in bash.

To preserve useful example from the old format, implement
'isolated_gnupghome_dirs directly in python.

While at it, abandon using config file on the client side at all, and
hardcode qrexec target to '@default'. This moves chosing the server vm
to the qrexec policy.

The new format allows more values for 'autoaccept' - besides just
timeout, allow also 'yes' (always skip confirmation) and 'no' (always
ask).

QubesOS/qubes-issues#474
  • Loading branch information
marmarek committed Jun 30, 2022
1 parent ccb83aa commit 1dcebd3
Show file tree
Hide file tree
Showing 6 changed files with 308 additions and 86 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ install-other:
install -m 755 qubes.Gpg2.service $(DESTDIR)/etc/qubes-rpc/qubes.Gpg2
install -m 644 split-gpg2-client.service $(DESTDIR)/usr/lib/systemd/user/
install -m 644 split-gpg2-client.preset $(DESTDIR)/usr/lib/systemd/user-preset/70-split-gpg2-client.preset
install -m 644 split-gpg2-rc.example $(DESTDIR)/usr/share/doc/split-gpg2/examples/
install -m 644 qubes-split-gpg2.conf.example $(DESTDIR)/usr/share/doc/split-gpg2/examples/
install -m 644 README.md $(DESTDIR)/usr/share/doc/split-gpg2/
install -m 644 tests/* $(DESTDIR)/usr/share/split-gpg2-tests/
57 changes: 57 additions & 0 deletions qubes-split-gpg2.conf.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
[DEFAULT]
# 'autoaccept' option - for how long automatically accept requests from the
# same client qube; accepted values:
# - no - do not automatically accept, prompt each time
# - yes - always automatically accept, never prompt
# - seconds - number of seconds for how long automatically accept further requests
# of the same type
#
# default:
# autoaccept = no

# 'pksign_autoaccept' option - same as 'autoaccept' but only for signing requests
# 'pkdecrypt_autoaccept' option - same as 'autoaccept' but only for decrypt requests
# Note that signing and decrypt requests may be indistinguishable for some key types.

# 'verbose_notification' option - show extra notifications
# accepted values: yes, no
#
# default:
# verbose_notification = no

# 'allow-keygen' option - allow generating new keys
# accepted values: yes, no
#
# default:
# allow_keygen = no

# 'gnupghome' option - set alternative GnuPG home directory; empty value means
# GnuPG's default. This option takes precedence over 'isolated_gnupghome_dirs'.
# accepted values: full path to the GuPG homedir;
#
# default:
# gnupghome =

# 'isolated_gnupghome_dirs' option - use separate GnuPG home directory for each
# client (calling qube). The value points at a directory where each client will
# get its own subdirectory. For example when this option is set to
# '/home/user/gpg-home', then qube 'personal' will use
# /home/user/gpg-home/personal as GnuPG home.
#
# default:
# isolated_gnupghome_dirs =

# 'debug_log' option - enable debug logging and set the debug log path
# This is for debugging purpose only EVERYTHING WILL BE LOGGED including
# potentially confidential data/keys/etc.
#
# default:
# debug_log =


# Each config option can be also set for specific client qube only, by putting
# it in a "client:<name>" section. For example, to automatically accept all
# requests from a qube named "trusted", add section like this:
#
# [client:trusted]
# autoaccept = yes
30 changes: 0 additions & 30 deletions split-gpg2-rc.example

This file was deleted.

125 changes: 95 additions & 30 deletions splitgpg2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
# pylint: disable=too-many-lines

import asyncio
import configparser
import enum

import logging
Expand All @@ -37,6 +38,7 @@
import subprocess
import sys
import time
import xdg.BaseDirectory
from typing import Optional, Dict, Callable, Awaitable, Tuple, Pattern, List

# from assuan.h
Expand Down Expand Up @@ -173,7 +175,8 @@ class GpgServer:
cache_nonce_regex: re.Pattern = re.compile(rb'\A[0-9A-F]{24}\Z')

def __init__(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter, client_domain: str):
writer: asyncio.StreamWriter, client_domain: str,
debug_log: str = None):

# configuration options:
self.verbose_notifications = False
Expand All @@ -183,6 +186,7 @@ def __init__(self, reader: asyncio.StreamReader,
#: signal those Futures when connection is terminated
self.notify_on_disconnect = set()
self.log_io_enable = False
self.gnupghome = None

self.client_reader = reader
self.client_writer = writer
Expand All @@ -200,13 +204,61 @@ def __init__(self, reader: asyncio.StreamReader,
self.update_keygrip_map()
self.seen_data = False

debug_log = os.environ.get('SPLIT_GPG2_DEBUG_LOG', None)
if debug_log:
handler = logging.FileHandler(debug_log)
self.log.addHandler(handler)
self.log.setLevel(logging.DEBUG)
self.log_io_enable = True

def _parse_timer_val(self, value, option_name):
if value == 'no':
return None
if value == 'yes':
return -1
try:
int_value = int(value)
if int_value <= 0:
raise ValueError(value)
except ValueError as e:
self.log.error(
"Invalid value '%s' for '%s' config option",
str(e), option_name
)
raise
return int_value

def _parse_bool_val(self, value, option_name):
if value == 'no':
return False
if value == 'yes':
return True
self.log.error(
"Invalid value '%s' for '%s' config option",
value, option_name
)
raise ValueError(value)

def load_config(self, config):
default_autoaccept = config.get('autoaccept', 'no')
for timer_name in TIMER_NAMES:
timer_value = config.get(timer_name + '_autoaccept',
default_autoaccept)
self.timer_delay[timer_name] = self._parse_timer_val(
timer_value, 'autoaccept')

self.verbose_notifications = self._parse_bool_val(
config.get('verbose_notifications', 'no'), 'verbose_notifications')

self.allow_keygen = self._parse_bool_val(
config.get('allow_keygen', 'no'), 'allow_keygen')

if 'isolated_gnupghome_dirs' in config:
self.gnupghome = os.path.join(
config['isolated_gnupghome_dirs'],
self.client_domain)

self.gnupghome = config.get('gnupghome', self.gnupghome)

async def run(self):
await self.connect_agent()
try:
Expand All @@ -230,13 +282,20 @@ def log_io(self, prefix, untrusted_msg):
chr(c) if c in allowed else '.'
for c in untrusted_msg.strip()))

def homedir_opts(self):
if self.gnupghome:
return ('--homedir', self.gnupghome)
return ()

async def connect_agent(self):
try:
subprocess.check_call(['gpgconf', '--launch', 'gpg-agent'])
subprocess.check_call(
['gpgconf', *self.homedir_opts(), '--launch', 'gpg-agent'])
except subprocess.CalledProcessError as e:
raise StartFailed from e

dirs = subprocess.check_output(['gpgconf', '--list-dirs'])
dirs = subprocess.check_output(
['gpgconf', *self.homedir_opts(), '--list-dirs'])
if self.allow_keygen:
socket_field = b'agent-socket:'
else:
Expand Down Expand Up @@ -623,7 +682,9 @@ def esc(char):
return b''.join(esc(c) for c in to_escape)

def update_keygrip_map(self):
out = subprocess.check_output(['gpg', '--list-secret-keys', '--with-colons'])
out = subprocess.check_output([
'gpg', *self.homedir_opts(), '--list-secret-keys', '--with-colons'
])
keys = []
key = None
subkey = None
Expand Down Expand Up @@ -1092,10 +1153,10 @@ async def inquire_command_END(self, *, untrusted_args):
# endregion


TIMER_NAMES = {
'SPLIT_GPG2_PKSIGN_AUTOACCEPT_TIME': 'PKSIGN',
'SPLIT_GPG2_PKDECRYPT_AUTOACCEPT_TIME': 'PKDECRYPT',
}
TIMER_NAMES = (
'PKSIGN',
'PKDECRYPT',
)

def open_stdinout_connection(*, loop=None):
if loop is None:
Expand All @@ -1114,32 +1175,36 @@ def open_stdinout_connection(*, loop=None):

return reader, writer


def load_config_files(client_domain):
config_dirs = ['/etc', xdg.BaseDirectory.xdg_config_home]

config = configparser.ConfigParser()
config.read([os.path.join(d, 'qubes-split-gpg2.conf') for d in config_dirs])
# 'DEFAULTS' section is special, values there serve as defaults
# for other sections
section = 'client:' + client_domain
if config.has_section(section):
return config[section]
else:
return config['DEFAULT']


def main():
client_domain = os.environ['QREXEC_REMOTE_DOMAIN']
config = load_config_files(client_domain)

loop = asyncio.get_event_loop()
reader, writer = open_stdinout_connection()
server = GpgServer(reader, writer, client_domain)

for timer_var, timer_name in TIMER_NAMES.items():
if timer_var in os.environ:
value = os.environ[timer_var]
server.timer_delay[timer_name] = int(value) \
if re.match(r'\A(0|[1-9][0-9]*)\Z', value) \
else None

for name in ['VERBOSE_NOTIFICATIONS', 'ALLOW_KEYGEN']:
name = 'SPLIT_GPG2_' + name
value = os.environ.get(name, None)
if value not in [None, 'yes', 'no']:
raise ValueError('bad value for %s: '
'must be "yes" or "no", not %r' % (name, value))

if os.environ.get('SPLIT_GPG2_VERBOSE_NOTIFICATIONS', None) == 'yes':
server.verbose_notifications = True
server = GpgServer(reader, writer, client_domain,
debug_log=config.get('debug_log'))

if os.environ.get('SPLIT_GPG2_ALLOW_KEYGEN', None) == 'yes':
server.allow_keygen = True
try:
server.load_config(config)
except ValueError:
print("Error in a config file, aborting", file=sys.stderr)
sys.exit(2)

connection_terminated = loop.create_future()
server.notify_on_disconnect.add(connection_terminated)
loop.run_until_complete(server.run())
loop.run_until_complete(server.run())
Loading

0 comments on commit 1dcebd3

Please sign in to comment.