Skip to content

Commit e3ff011

Browse files
authored
Merge pull request #1101 from joshuagl/joshuagl/updater-verify-root
Fix updater workflow
2 parents be9944b + 2fc25ad commit e3ff011

File tree

3 files changed

+231
-60
lines changed

3 files changed

+231
-60
lines changed

tests/test_updater.py

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,38 +1728,19 @@ def test_10__visit_child_role(self):
17281728

17291729

17301730

1731-
def test_11__verify_uncompressed_metadata_file(self):
1731+
def test_11__verify_metadata_file(self):
17321732
# Test for invalid metadata content.
17331733
metadata_file_object = tempfile.TemporaryFile()
17341734
metadata_file_object.write(b'X')
17351735
metadata_file_object.seek(0)
17361736

17371737
self.assertRaises(tuf.exceptions.InvalidMetadataJSONError,
1738-
self.repository_updater._verify_uncompressed_metadata_file,
1738+
self.repository_updater._verify_metadata_file,
17391739
metadata_file_object, 'root')
17401740

17411741

17421742

1743-
def test_12__verify_root_chain_link(self):
1744-
# Test for an invalid signature in the chain link.
1745-
# current = (i.e., 1.root.json)
1746-
# next = signable for the next metadata in the chain (i.e., 2.root.json)
1747-
rolename = 'root'
1748-
current_root = self.repository_updater.metadata['current']['root']
1749-
1750-
targets_path = os.path.join(self.repository_directory, 'metadata', 'targets.json')
1751-
1752-
# 'next_invalid_root' is a Targets signable, as written to disk.
1753-
# We use the Targets metadata here to ensure the signatures are invalid.
1754-
next_invalid_root = securesystemslib.util.load_json_file(targets_path)
1755-
1756-
self.assertRaises(securesystemslib.exceptions.BadSignatureError,
1757-
self.repository_updater._verify_root_chain_link, rolename, current_root,
1758-
next_invalid_root)
1759-
1760-
1761-
1762-
def test_13__get_file(self):
1743+
def test_12__get_file(self):
17631744
# Test for an "unsafe" download, where the file is downloaded up to
17641745
# a required length (and no more). The "safe" download approach
17651746
# downloads an exact required length.
@@ -1779,7 +1760,7 @@ def verify_target_file(targets_path):
17791760
self.repository_updater._get_file('targets.json', verify_target_file,
17801761
file_type, file_size, download_safely=False)
17811762

1782-
def test_14__targets_of_role(self):
1763+
def test_13__targets_of_role(self):
17831764
# Test case where a list of targets is given. By default, the 'targets'
17841765
# parameter is None.
17851766
targets = [{'filepath': 'file1.txt', 'fileinfo': {'length': 1, 'hashes': {'sha256': 'abc'}}}]

tests/test_updater_root_rotation_integration.py

Lines changed: 170 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,68 @@ def test_root_rotation(self):
217217

218218

219219

220+
def test_verify_root_with_current_keyids_and_threshold(self):
221+
"""
222+
Each root file is signed by the current root threshold of keys as well
223+
as the previous root threshold of keys. Test that a root file which is
224+
not 'self-signed' with the current root threshold of keys causes the
225+
update to fail
226+
"""
227+
# Load repository with root.json == 1.root.json (available on client)
228+
# Signing key: "root", Threshold: 1
229+
repository = repo_tool.load_repository(self.repository_directory)
230+
231+
# Rotate keys and update root: 1.root.json --> 2.root.json
232+
# Signing key: "root" (previous) and "root2" (current)
233+
# Threshold (for both): 1
234+
repository.root.load_signing_key(self.role_keys['root']['private'])
235+
repository.root.add_verification_key(self.role_keys['root2']['public'])
236+
repository.root.load_signing_key(self.role_keys['root2']['private'])
237+
# Remove the previous "root" key from the list of current
238+
# verification keys
239+
repository.root.remove_verification_key(self.role_keys['root']['public'])
240+
repository.writeall()
241+
242+
# Move staged metadata to "live" metadata
243+
shutil.rmtree(os.path.join(self.repository_directory, 'metadata'))
244+
shutil.copytree(os.path.join(self.repository_directory, 'metadata.staged'),
245+
os.path.join(self.repository_directory, 'metadata'))
246+
247+
# Intercept 2.root.json and tamper with "root2" (current) key signature
248+
root2_path_live = os.path.join(
249+
self.repository_directory, 'metadata', '2.root.json')
250+
root2 = securesystemslib.util.load_json_file(root2_path_live)
251+
252+
for idx, sig in enumerate(root2['signatures']):
253+
if sig['keyid'] == self.role_keys['root2']['public']['keyid']:
254+
sig_len = len(root2['signatures'][idx]['sig'])
255+
root2['signatures'][idx]['sig'] = "deadbeef".ljust(sig_len, '0')
256+
257+
roo2_fobj = tempfile.TemporaryFile()
258+
roo2_fobj.write(tuf.repository_lib._get_written_metadata(root2))
259+
securesystemslib.util.persist_temp_file(roo2_fobj, root2_path_live)
260+
261+
# Update 1.root.json -> 2.root.json
262+
# Signature verification with current keys should fail because we replaced
263+
with self.assertRaises(tuf.exceptions.NoWorkingMirrorError) as cm:
264+
self.repository_updater.refresh()
265+
266+
for mirror_url, mirror_error in six.iteritems(cm.exception.mirror_errors):
267+
self.assertTrue(mirror_url.endswith('/2.root.json'))
268+
self.assertTrue(isinstance(mirror_error,
269+
securesystemslib.exceptions.BadSignatureError))
270+
271+
# Assert that the current 'root.json' on the client side is the verified one
272+
self.assertTrue(filecmp.cmp(
273+
os.path.join(self.repository_directory, 'metadata', '1.root.json'),
274+
os.path.join(self.client_metadata_current, 'root.json')))
275+
276+
277+
278+
279+
280+
281+
220282
def test_root_rotation_full(self):
221283
"""Test that a client whose root is outdated by multiple versions and who
222284
has none of the latest nor next-to-latest root keys can still update and
@@ -340,22 +402,26 @@ def test_root_rotation_missing_keys(self):
340402
shutil.copytree(os.path.join(self.repository_directory, 'metadata.staged'),
341403
os.path.join(self.repository_directory, 'metadata'))
342404

343-
try:
405+
with self.assertRaises(tuf.exceptions.NoWorkingMirrorError) as cm:
344406
self.repository_updater.refresh()
345407

346-
except tuf.exceptions.NoWorkingMirrorError as exception:
347-
for mirror_url, mirror_error in six.iteritems(exception.mirror_errors):
348-
url_prefix = self.repository_mirrors['mirror1']['url_prefix']
349-
url_file = os.path.join(url_prefix, 'metadata', '2.root.json')
350-
351-
# Verify that '2.root.json' is the culprit.
352-
self.assertEqual(url_file.replace('\\', '/'), mirror_url)
353-
self.assertTrue(isinstance(mirror_error,
408+
for mirror_url, mirror_error in six.iteritems(cm.exception.mirror_errors):
409+
self.assertTrue(mirror_url.endswith('/2.root.json'))
410+
self.assertTrue(isinstance(mirror_error,
354411
securesystemslib.exceptions.BadSignatureError))
355412

413+
# Assert that the current 'root.json' on the client side is the verified one
414+
self.assertTrue(filecmp.cmp(
415+
os.path.join(self.repository_directory, 'metadata', '1.root.json'),
416+
os.path.join(self.client_metadata_current, 'root.json')))
417+
418+
356419

357420

358-
def test_root_rotation_unmet_threshold(self):
421+
def test_root_rotation_unmet_last_version_threshold(self):
422+
"""Test that client detects a root.json version that is not signed
423+
by a previous threshold of signatures """
424+
359425
repository = repo_tool.load_repository(self.repository_directory)
360426

361427
# Add verification keys
@@ -411,8 +477,100 @@ def test_root_rotation_unmet_threshold(self):
411477

412478
# The following refresh should fail because root must be signed by the
413479
# previous self.role_keys['root'] key, which wasn't loaded.
414-
self.assertRaises(tuf.exceptions.NoWorkingMirrorError,
415-
self.repository_updater.refresh)
480+
with self.assertRaises(tuf.exceptions.NoWorkingMirrorError) as cm:
481+
self.repository_updater.refresh()
482+
483+
for mirror_url, mirror_error in six.iteritems(cm.exception.mirror_errors):
484+
self.assertTrue(mirror_url.endswith('/3.root.json'))
485+
self.assertTrue(isinstance(mirror_error,
486+
securesystemslib.exceptions.BadSignatureError))
487+
488+
# Assert that the current 'root.json' on the client side is the verified one
489+
self.assertTrue(filecmp.cmp(
490+
os.path.join(self.repository_directory, 'metadata', '2.root.json'),
491+
os.path.join(self.client_metadata_current, 'root.json')))
492+
493+
494+
495+
def test_root_rotation_unmet_new_threshold(self):
496+
"""Test that client detects a root.json version that is not signed
497+
by a current threshold of signatures """
498+
repository = repo_tool.load_repository(self.repository_directory)
499+
500+
# Create a new, valid root.json.
501+
repository.root.threshold = 2
502+
repository.root.load_signing_key(self.role_keys['root']['private'])
503+
repository.root.add_verification_key(self.role_keys['root2']['public'])
504+
repository.root.load_signing_key(self.role_keys['root2']['private'])
505+
506+
repository.writeall()
507+
508+
# Increase the threshold and add a new verification key without
509+
# actually loading the signing key
510+
repository.root.threshold = 3
511+
repository.root.add_verification_key(self.role_keys['root3']['public'])
512+
513+
# writeall fails as expected since the third signature is missing
514+
self.assertRaises(tuf.exceptions.UnsignedMetadataError, repository.writeall)
515+
# write an invalid '3.root.json' as partially signed
516+
repository.write('root')
517+
518+
# Move the staged metadata to the "live" metadata.
519+
shutil.rmtree(os.path.join(self.repository_directory, 'metadata'))
520+
shutil.copytree(os.path.join(self.repository_directory, 'metadata.staged'),
521+
os.path.join(self.repository_directory, 'metadata'))
522+
523+
524+
# The following refresh should fail because root must be signed by the
525+
# current self.role_keys['root3'] key, which wasn't loaded.
526+
with self.assertRaises(tuf.exceptions.NoWorkingMirrorError) as cm:
527+
self.repository_updater.refresh()
528+
529+
for mirror_url, mirror_error in six.iteritems(cm.exception.mirror_errors):
530+
self.assertTrue(mirror_url.endswith('/3.root.json'))
531+
self.assertTrue(isinstance(mirror_error,
532+
securesystemslib.exceptions.BadSignatureError))
533+
534+
# Assert that the current 'root.json' on the client side is the verified one
535+
self.assertTrue(filecmp.cmp(
536+
os.path.join(self.repository_directory, 'metadata', '2.root.json'),
537+
os.path.join(self.client_metadata_current, 'root.json')))
538+
539+
540+
541+
def test_root_rotation_discard_untrusted_version(self):
542+
"""Test that client discards root.json version that failed the
543+
signature verification """
544+
repository = repo_tool.load_repository(self.repository_directory)
545+
546+
# Rotate the root key without signing with the previous version key 'root'
547+
repository.root.remove_verification_key(self.role_keys['root']['public'])
548+
repository.root.add_verification_key(self.role_keys['root2']['public'])
549+
repository.root.load_signing_key(self.role_keys['root2']['private'])
550+
551+
# 2.root.json
552+
repository.writeall()
553+
554+
# Move the staged metadata to the "live" metadata.
555+
shutil.rmtree(os.path.join(self.repository_directory, 'metadata'))
556+
shutil.copytree(os.path.join(self.repository_directory, 'metadata.staged'),
557+
os.path.join(self.repository_directory, 'metadata'))
558+
559+
# Refresh on the client side should fail because 2.root.json is not signed
560+
# with a threshold of prevous keys
561+
with self.assertRaises(tuf.exceptions.NoWorkingMirrorError) as cm:
562+
self.repository_updater.refresh()
563+
564+
for mirror_url, mirror_error in six.iteritems(cm.exception.mirror_errors):
565+
self.assertTrue(mirror_url.endswith('/2.root.json'))
566+
self.assertTrue(isinstance(mirror_error,
567+
securesystemslib.exceptions.BadSignatureError))
568+
569+
# Assert that the current 'root.json' on the client side is the trusted one
570+
# and 2.root.json is discarded
571+
self.assertTrue(filecmp.cmp(
572+
os.path.join(self.repository_directory, 'metadata', '1.root.json'),
573+
os.path.join(self.client_metadata_current, 'root.json')))
416574

417575

418576

tuf/client/updater.py

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,12 +1370,51 @@ def verify_target_file(target_file_object):
13701370

13711371

13721372

1373-
def _verify_uncompressed_metadata_file(self, metadata_file_object,
1373+
def _verify_root_self_signed(self, signable):
1374+
"""
1375+
Verify the root metadata in signable is signed by a threshold of keys,
1376+
where the threshold and valid keys are defined by itself
1377+
"""
1378+
threshold = signable['signed']['roles']['root']['threshold']
1379+
keyids = signable['signed']['roles']['root']['keyids']
1380+
keys = signable['signed']['keys']
1381+
signatures = signable['signatures']
1382+
signed = securesystemslib.formats.encode_canonical(
1383+
signable['signed']).encode('utf-8')
1384+
validated = 0
1385+
1386+
for signature in signatures:
1387+
keyid = signature['keyid']
1388+
1389+
# At this point we are verifying that the root metadata is signed by a
1390+
# threshold of keys listed in the current root role, therefore skip
1391+
# keys with a keyid that is not listed in the current root role.
1392+
if keyid not in keyids:
1393+
continue
1394+
1395+
key = keys[keyid]
1396+
# The ANYKEY_SCHEMA check in verify_signature expects the keydict to
1397+
# include a keyid
1398+
key['keyid'] = keyid
1399+
valid_sig = securesystemslib.keys.verify_signature(key, signature, signed)
1400+
1401+
if valid_sig:
1402+
validated = validated + 1
1403+
1404+
if validated >= threshold:
1405+
return True
1406+
return False
1407+
1408+
1409+
1410+
1411+
1412+
def _verify_metadata_file(self, metadata_file_object,
13741413
metadata_role):
13751414
"""
13761415
<Purpose>
1377-
Non-public method that verifies an uncompressed metadata file. An
1378-
exception is raised if 'metadata_file_object is invalid. There is no
1416+
Non-public method that verifies a metadata file. An exception is
1417+
raised if 'metadata_file_object is invalid. There is no
13791418
return value.
13801419
13811420
<Arguments>
@@ -1439,6 +1478,20 @@ def _verify_uncompressed_metadata_file(self, metadata_file_object,
14391478
if not valid:
14401479
raise securesystemslib.exceptions.BadSignatureError(metadata_role)
14411480

1481+
# For root metadata, verify the downloaded root metadata object with the
1482+
# new threshold of new signatures contained within the downloaded root
1483+
# metadata object
1484+
# NOTE: we perform the checks on root metadata here because this enables
1485+
# us to perform the check before the tempfile is persisted. Furthermore,
1486+
# by checking here we can easily perform the check for each download
1487+
# mirror. Whereas if we check after _verify_metadata_file we may be
1488+
# persisting invalid files and we cannot try copies of the file from other
1489+
# mirrors.
1490+
if valid and metadata_role == 'root':
1491+
valid = self._verify_root_self_signed(metadata_signable)
1492+
if not valid:
1493+
raise securesystemslib.exceptions.BadSignatureError(metadata_role)
1494+
14421495

14431496

14441497

@@ -1569,7 +1622,7 @@ def _get_metadata_file(self, metadata_role, remote_filename,
15691622
except KeyError:
15701623
logger.info(metadata_role + ' not available locally.')
15711624

1572-
self._verify_uncompressed_metadata_file(file_object, metadata_role)
1625+
self._verify_metadata_file(file_object, metadata_role)
15731626

15741627
except Exception as exception:
15751628
# Remember the error from this mirror, and "reset" the target file.
@@ -1590,24 +1643,6 @@ def _get_metadata_file(self, metadata_role, remote_filename,
15901643

15911644

15921645

1593-
def _verify_root_chain_link(self, rolename, current_root_metadata,
1594-
next_root_metadata):
1595-
1596-
if rolename != 'root':
1597-
return True
1598-
1599-
current_root_role = current_root_metadata['roles'][rolename]
1600-
1601-
# Verify next metadata with current keys/threshold
1602-
valid = tuf.sig.verify(next_root_metadata, rolename, self.repository_name,
1603-
current_root_role['threshold'], current_root_role['keyids'])
1604-
1605-
if not valid:
1606-
raise securesystemslib.exceptions.BadSignatureError('Root is not signed'
1607-
' by previous threshold of keys.')
1608-
1609-
1610-
16111646

16121647

16131648
def _get_file(self, filepath, verify_file_function, file_type, file_length,
@@ -1802,9 +1837,6 @@ def _update_metadata(self, metadata_role, upperbound_filelength, version=None):
18021837
updated_metadata_object = metadata_signable['signed']
18031838
current_metadata_object = self.metadata['current'].get(metadata_role)
18041839

1805-
self._verify_root_chain_link(metadata_role, current_metadata_object,
1806-
metadata_signable)
1807-
18081840
# Finally, update the metadata and fileinfo stores, and rebuild the
18091841
# key and role info for the top-level roles if 'metadata_role' is root.
18101842
# Rebuilding the key and role info is required if the newly-installed

0 commit comments

Comments
 (0)