Skip to content

Commit 9dd5910

Browse files
committed
SSL auth and user GPG key support for user templates repos
This commit adds the support of user defined GPG keys and SSL authentication for user templates repositories. GPG keys and SSL cert/key must be configured in DNF repositories files using the well-known options (gpgkey, sslclientcert, sslclientkey). If the keys are stored in /etc/qubes/repo-templates/keys/, they will be added to the payload sent to proxy in base64 and will be written to the Proxy VM before DNF command execution
1 parent 02a9b4f commit 9dd5910

2 files changed

Lines changed: 254 additions & 1 deletion

File tree

qubesadmin/tests/tools/qvm_template.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5559,6 +5559,205 @@ def execute(pubkey, packagename):
55595559
gen_rpm(False, execute)
55605560
self.assertAllCalled()
55615561

5562+
@mock.patch("qubesadmin.tools.qvm_template._is_file_in_repo_templates_keys_dir")
5563+
def test_260_gpg_key_and_ssl_cert_in_payload(self, mock_file_in_keysdir):
5564+
with tempfile.NamedTemporaryFile() as repo_conf1, \
5565+
tempfile.NamedTemporaryFile() as repo_conf2, \
5566+
tempfile.NamedTemporaryFile(prefix="gpg-") as gpg_key_primary, \
5567+
tempfile.NamedTemporaryFile(prefix="sslcert-") as ssl_cert, \
5568+
tempfile.NamedTemporaryFile(prefix="sslkey-") as ssl_key:
5569+
mock_file_in_keysdir.return_value = True
5570+
repo_str1 = \
5571+
'''[qubes-templates-itl]
5572+
name = Qubes Templates repository
5573+
#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
5574+
#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
5575+
metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
5576+
enabled = 1
5577+
fastestmirror = 1
5578+
metadata_expire = 7d
5579+
gpgcheck = 1
5580+
gpgkey = file://{}
5581+
'''.format(gpg_key_primary.name)
5582+
repo_str2 = \
5583+
'''[qubes-templates-itl-testing]
5584+
name = Qubes Templates repository
5585+
#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl-testing
5586+
#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl-testing
5587+
metalink = https://yum.qubes-os.org/r$releasever/templates-itl-testing/repodata/repomd.xml.metalink
5588+
enabled = 0
5589+
fastestmirror = 1
5590+
gpgcheck = 1
5591+
gpgkey = file://{}
5592+
sslclientcert = {}
5593+
sslclientkey = {}
5594+
'''.format(gpg_key_primary.name,
5595+
ssl_cert.name,
5596+
ssl_key.name)
5597+
repo_conf1.write(repo_str1.encode())
5598+
repo_conf1.flush()
5599+
repo_conf2.write(repo_str2.encode())
5600+
repo_conf2.flush()
5601+
gpg_key_primary.write(b"ABC")
5602+
gpg_key_primary.flush()
5603+
ssl_cert.write(b"BCD")
5604+
ssl_cert.flush()
5605+
ssl_key.write(b"CDE")
5606+
ssl_key.flush()
5607+
wrapper = '''
5608+
###!Q!BEGIN-QUBES-WRAPPER!Q!###
5609+
#{}
5610+
#QkNE
5611+
#{}
5612+
#Q0RF
5613+
#{}
5614+
#QUJD
5615+
###!Q!END-QUBES-WRAPPER!Q!###'''.format(ssl_cert.name,
5616+
ssl_key.name,
5617+
gpg_key_primary.name)
5618+
args = argparse.Namespace(
5619+
repos=[('enablerepo', 'repo1'), ('enablerepo', 'repo2'),
5620+
('disablerepo', 'repo3'), ('disablerepo', 'repo4'),
5621+
('disablerepo', 'repo5')],
5622+
releasever='4.2',
5623+
repo_files=[repo_conf1.name, repo_conf2.name]
5624+
)
5625+
res = qubesadmin.tools.qvm_template.qrexec_payload(args, self.app,
5626+
'qubes-template-fedora-32',
5627+
True)
5628+
self.assertEqual(res,
5629+
'''--enablerepo=repo1
5630+
--enablerepo=repo2
5631+
--disablerepo=repo3
5632+
--disablerepo=repo4
5633+
--disablerepo=repo5
5634+
--refresh
5635+
--releasever=4.2
5636+
qubes-template-fedora-32
5637+
---
5638+
''' + repo_str1 + '\n' + repo_str2 + '\n' + wrapper)
5639+
self.assertAllCalled()
5640+
5641+
@mock.patch("qubesadmin.tools.qvm_template._is_file_in_repo_templates_keys_dir")
5642+
def test_261_gpg_key_not_found_should_not_raise_error(self, mock_file_in_keysdir):
5643+
with tempfile.NamedTemporaryFile() as repo_conf:
5644+
mock_file_in_keysdir.return_value = False
5645+
repo_str = \
5646+
'''[qubes-templates-itl]
5647+
name = Qubes Templates repository
5648+
#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
5649+
#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
5650+
metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
5651+
enabled = 1
5652+
fastestmirror = 1
5653+
metadata_expire = 7d
5654+
gpgcheck = 1
5655+
gpgkey = file:///path/to/non-existing/path
5656+
'''
5657+
repo_conf.write(repo_str.encode())
5658+
repo_conf.flush()
5659+
args = argparse.Namespace(
5660+
repos=[('enablerepo', 'repo1'), ('disablerepo', 'repo2'),
5661+
('disablerepo', 'repo3'), ('disablerepo', 'repo4'),
5662+
('disablerepo', 'repo5')],
5663+
releasever='4.2',
5664+
repo_files=[repo_conf.name]
5665+
)
5666+
res = qubesadmin.tools.qvm_template.qrexec_payload(args, self.app,
5667+
'qubes-template-fedora-32',
5668+
True)
5669+
self.assertEqual(res,
5670+
'''--enablerepo=repo1
5671+
--disablerepo=repo2
5672+
--disablerepo=repo3
5673+
--disablerepo=repo4
5674+
--disablerepo=repo5
5675+
--refresh
5676+
--releasever=4.2
5677+
qubes-template-fedora-32
5678+
---
5679+
''' + repo_str + '\n')
5680+
self.assertAllCalled()
5681+
5682+
@mock.patch("qubesadmin.tools.qvm_template._encode_key")
5683+
def test_262_gpg_key_with_releasever(self, mock_encode_key):
5684+
with tempfile.NamedTemporaryFile() as repo_conf:
5685+
mock_encode_key.return_value = ""
5686+
repo_str = \
5687+
'''[qubes-templates-itl]
5688+
name = Qubes Templates repository
5689+
#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
5690+
#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
5691+
metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
5692+
enabled = 1
5693+
fastestmirror = 1
5694+
metadata_expire = 7d
5695+
gpgcheck = 1
5696+
gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
5697+
'''
5698+
repo_conf.write(repo_str.encode())
5699+
repo_conf.flush()
5700+
args = argparse.Namespace(
5701+
repos=[('enablerepo', 'repo1'), ('disablerepo', 'repo2'),
5702+
('disablerepo', 'repo3'), ('disablerepo', 'repo4'),
5703+
('disablerepo', 'repo5')],
5704+
releasever='4.2',
5705+
repo_files=[repo_conf.name]
5706+
)
5707+
qubesadmin.tools.qvm_template.qrexec_payload(args,
5708+
self.app,
5709+
'qubes-template-fedora-32',
5710+
True)
5711+
mock_encode_key.assert_called_with(
5712+
"file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-4.2-primary")
5713+
self.assertAllCalled()
5714+
5715+
def test_263_invalid_keys_paths_must_be_ignored(self):
5716+
with tempfile.NamedTemporaryFile() as repo_conf, \
5717+
tempfile.NamedTemporaryFile() as gpg_key:
5718+
repo_str = \
5719+
'''[qubes-templates-itl]
5720+
name = Qubes Templates repository
5721+
#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
5722+
#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
5723+
metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
5724+
enabled = 1
5725+
fastestmirror = 1
5726+
metadata_expire = 7d
5727+
gpgcheck = 1
5728+
gpgkey = file://{}
5729+
'''.format(gpg_key.name)
5730+
5731+
repo_conf.write(repo_str.encode())
5732+
repo_conf.flush()
5733+
gpg_key.write(b"ABC")
5734+
gpg_key.flush()
5735+
self.maxDiff = None
5736+
args = argparse.Namespace(
5737+
repos=[('enablerepo', 'repo1'), ('disablerepo', 'repo2'),
5738+
('disablerepo', 'repo3'), ('disablerepo', 'repo4'),
5739+
('disablerepo', 'repo5')],
5740+
releasever='4.2',
5741+
repo_files=[repo_conf.name]
5742+
)
5743+
res = qubesadmin.tools.qvm_template.qrexec_payload(args,
5744+
self.app,
5745+
'qubes-template-fedora-32',
5746+
True)
5747+
self.assertTrue(os.path.exists(gpg_key.name))
5748+
self.assertEqual(res,
5749+
'''--enablerepo=repo1
5750+
--disablerepo=repo2
5751+
--disablerepo=repo3
5752+
--disablerepo=repo4
5753+
--disablerepo=repo5
5754+
--refresh
5755+
--releasever=4.2
5756+
qubes-template-fedora-32
5757+
---
5758+
''' + repo_str + '\n')
5759+
self.assertAllCalled()
5760+
55625761
@mock.patch('qubesadmin.tools.qvm_template.repolist')
55635762
def test_300_repo_files_glob(self, mock_repolist):
55645763
with tempfile.TemporaryDirectory() as temp_dir:

qubesadmin/tools/qvm_template.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"""Tool for managing VM templates."""
2121

2222
import argparse
23+
import base64
2324
import collections
2425
import configparser
2526
import datetime
@@ -59,6 +60,8 @@
5960
LOCK_FILE = '/var/tmp/qvm-template.lck'
6061
DATE_FMT = '%Y-%m-%d %H:%M:%S'
6162
TAR_HEADER_BYTES = 512
63+
WRAPPER_PAYLOAD_BEGIN = "###!Q!BEGIN-QUBES-WRAPPER!Q!###"
64+
WRAPPER_PAYLOAD_END = "###!Q!END-QUBES-WRAPPER!Q!###"
6265

6366
UPDATEVM = str('global UpdateVM')
6467

@@ -465,6 +468,52 @@ def qrexec_popen(
465468
stderr=subprocess.PIPE
466469
)
467470

471+
def _is_file_in_repo_templates_keys_dir(path: str) -> bool:
472+
"""Check if the given path is a file located repo-template keys dir"""
473+
return os.path.isfile(path) and path.startswith(
474+
"/etc/qubes/repo-templates/keys/")
475+
476+
def _encode_key(path):
477+
"""Base64-encoe a file to be placed in qvm-template payload"""
478+
if path.startswith("file://"):
479+
path = path[7:]
480+
481+
if not _is_file_in_repo_templates_keys_dir(path):
482+
return ""
483+
484+
encoded_key = "#" + path + "\n"
485+
with open(path, "rb") as key:
486+
encoded_key += f"#{base64.b64encode(key.read()).decode('ascii')}\n"
487+
return encoded_key
488+
489+
def _replace_dnf_vars(path, releasever):
490+
"""Replace supported dnf variables in repo"""
491+
for var in ["$releasever", "${releasever}"]:
492+
path = path.replace(var, releasever)
493+
return path
494+
495+
def _append_keys(payload, releasever):
496+
"""Add GPG key and SSL cert/keys to qvm-template payload"""
497+
config = configparser.ConfigParser()
498+
try:
499+
config.read_string(payload)
500+
except RuntimeError:
501+
return ""
502+
503+
file_list = set()
504+
for section in config.sections():
505+
for option in ["gpgkey", "sslclientcert", "sslclientkey"]:
506+
if config.has_option(section, option):
507+
file_list.add(
508+
_replace_dnf_vars(config.get(section, option),
509+
releasever))
510+
511+
encoded_keys = "".join(
512+
[_encode_key(file_path) for file_path in sorted(file_list)])
513+
if not encoded_keys:
514+
return ""
515+
516+
return f"\n{WRAPPER_PAYLOAD_BEGIN}\n{encoded_keys}{WRAPPER_PAYLOAD_END}"
468517

469518
def qrexec_payload(args: argparse.Namespace, app: qubesadmin.app.QubesBase,
470519
spec: str, refresh: bool) -> str:
@@ -502,9 +551,14 @@ def check_newline(string, name):
502551
check_newline(spec, 'template name')
503552
payload += spec + '\n'
504553
payload += '---\n'
554+
555+
repo_config = ""
505556
for path in args.repo_files:
506557
with open(path, 'r', encoding='utf-8') as fd:
507-
payload += fd.read() + '\n'
558+
repo_config += fd.read() + '\n'
559+
payload += repo_config
560+
561+
payload += _append_keys(repo_config, args.releasever)
508562
return payload
509563

510564

0 commit comments

Comments
 (0)