Skip to content

Commit 0da6be5

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 0da6be5

2 files changed

Lines changed: 259 additions & 12 deletions

File tree

qubesadmin/tests/tools/qvm_template.py

Lines changed: 208 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5559,9 +5559,14 @@ def execute(pubkey, packagename):
55595559
gen_rpm(False, execute)
55605560
self.assertAllCalled()
55615561

5562-
@mock.patch('qubesadmin.tools.qvm_template.repolist')
5563-
def test_300_repo_files_glob(self, mock_repolist):
5564-
with tempfile.TemporaryDirectory() as temp_dir:
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
55655570
repo_str1 = \
55665571
'''[qubes-templates-itl]
55675572
name = Qubes Templates repository
@@ -5572,8 +5577,8 @@ def test_300_repo_files_glob(self, mock_repolist):
55725577
fastestmirror = 1
55735578
metadata_expire = 7d
55745579
gpgcheck = 1
5575-
gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
5576-
'''
5580+
gpgkey = file://{}
5581+
'''.format(gpg_key_primary.name)
55775582
repo_str2 = \
55785583
'''[qubes-templates-itl-testing]
55795584
name = Qubes Templates repository
@@ -5583,14 +5588,206 @@ def test_300_repo_files_glob(self, mock_repolist):
55835588
enabled = 0
55845589
fastestmirror = 1
55855590
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+
#QUJD
5611+
#{}
5612+
#QkNE
5613+
#{}
5614+
#Q0RF
5615+
###!Q!END-QUBES-WRAPPER!Q!###'''.format(gpg_key_primary.name,
5616+
ssl_cert.name,
5617+
ssl_key.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
55865696
gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
55875697
'''
5588-
with open(temp_dir + '/first.repo', 'w', encoding="utf-8") \
5589-
as f_repo:
5590-
f_repo.write(repo_str1)
5591-
with open(temp_dir + '/second.repo', 'w', encoding="utf-8") \
5592-
as f_repo:
5593-
f_repo.write(repo_str2)
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+
5761+
@mock.patch('qubesadmin.tools.qvm_template.repolist')
5762+
def test_300_repo_files_glob(self, mock_repolist):
5763+
with tempfile.TemporaryDirectory() as temp_dir:
5764+
repo_str1 = \
5765+
'''[qubes-templates-itl]
5766+
name = Qubes Templates repository
5767+
#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl
5768+
#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl
5769+
metalink = https://yum.qubes-os.org/r$releasever/templates-itl/repodata/repomd.xml.metalink
5770+
enabled = 1
5771+
fastestmirror = 1
5772+
metadata_expire = 7d
5773+
gpgcheck = 1
5774+
gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
5775+
'''
5776+
repo_str2 = \
5777+
'''[qubes-templates-itl-testing]
5778+
name = Qubes Templates repository
5779+
#baseurl = https://yum.qubes-os.org/r$releasever/templates-itl-testing
5780+
#baseurl = http://yum.qubesosfasa4zl44o4tws22di6kepyzfeqv3tg4e3ztknltfxqrymdad.onion/r$releasever/templates-itl-testing
5781+
metalink = https://yum.qubes-os.org/r$releasever/templates-itl-testing/repodata/repomd.xml.metalink
5782+
enabled = 0
5783+
fastestmirror = 1
5784+
gpgcheck = 1
5785+
gpgkey = file:///etc/qubes/repo-templates/keys/RPM-GPG-KEY-qubes-$releasever-primary
5786+
'''
5787+
with open(temp_dir + '/first.repo', 'w') as f:
5788+
f.write(repo_str1)
5789+
with open(temp_dir + '/second.repo', 'w') as f:
5790+
f.write(repo_str2)
55945791

55955792
qubesadmin.tools.qvm_template.main(
55965793
['--updatevm=',

qubesadmin/tools/qvm_template.py

Lines changed: 51 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,48 @@ 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+
if path.startswith("file://"):
478+
path = path[7:]
479+
480+
if not _is_file_in_repo_templates_keys_dir(path):
481+
return ""
482+
483+
encoded_key = "#" + path + "\n"
484+
with open(path, "rb") as key:
485+
encoded_key += f"#{base64.b64encode(key.read()).decode('ascii')}\n"
486+
return encoded_key
487+
488+
def _replace_dnf_vars(path, releasever):
489+
for var in ["$releasever", "${releasever}"]:
490+
path = path.replace(var, releasever)
491+
return path
492+
493+
def _append_keys(payload, releasever):
494+
config = configparser.ConfigParser()
495+
try:
496+
config.read_string(payload)
497+
except RuntimeError:
498+
return ""
499+
500+
file_list = set()
501+
for section in config.sections():
502+
for option in ["gpgkey", "sslclientcert", "sslclientkey"]:
503+
if config.has_option(section, option):
504+
file_list.add(
505+
_replace_dnf_vars(config.get(section, option),
506+
releasever))
507+
508+
encoded_keys = "".join(_encode_key(file_path) for file_path in file_list)
509+
if not encoded_keys:
510+
return ""
511+
512+
return f"\n{WRAPPER_PAYLOAD_BEGIN}\n{encoded_keys}{WRAPPER_PAYLOAD_END}"
468513

469514
def qrexec_payload(args: argparse.Namespace, app: qubesadmin.app.QubesBase,
470515
spec: str, refresh: bool) -> str:
@@ -502,9 +547,14 @@ def check_newline(string, name):
502547
check_newline(spec, 'template name')
503548
payload += spec + '\n'
504549
payload += '---\n'
550+
551+
repo_config = ""
505552
for path in args.repo_files:
506553
with open(path, 'r', encoding='utf-8') as fd:
507-
payload += fd.read() + '\n'
554+
repo_config += fd.read() + '\n'
555+
payload += repo_config
556+
557+
payload += _append_keys(repo_config, args.releasever)
508558
return payload
509559

510560

0 commit comments

Comments
 (0)