Skip to content

Commit af904b3

Browse files
author
Jussi Kukkonen
committed
Metadata API: Implement threshold verification
The delegating Metadata (root or targets) verifies that the delegated metadata is signed by required threshold of keys for the delegated role. Calling the function on non-delegator-metadata or giving a rolename that is not actually delegated by the delegator is considered a programming error and ValueError is raised. If the threshold is not reached, UnsignedMetadataError is raised. Tweak type annotation of Delegations.keys to match the one for Root.keys (so they can be assigned to same local variable). Signed-off-by: Jussi Kukkonen <[email protected]>
1 parent bb85115 commit af904b3

File tree

2 files changed

+106
-1
lines changed

2 files changed

+106
-1
lines changed

tests/test_api.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,56 @@ def test_metadata_timestamp(self):
352352
timestamp_test = Timestamp.from_dict(test_dict)
353353
self.assertEqual(timestamp_dict['signed'], timestamp_test.to_dict())
354354

355+
def test_metadata_verify_delegate(self):
356+
root_path = os.path.join(self.repo_dir, 'metadata', 'root.json')
357+
root = Metadata.from_file(root_path)
358+
snapshot_path = os.path.join(
359+
self.repo_dir, 'metadata', 'snapshot.json')
360+
snapshot = Metadata.from_file(snapshot_path)
361+
targets_path = os.path.join(
362+
self.repo_dir, 'metadata', 'targets.json')
363+
targets = Metadata.from_file(targets_path)
364+
role1_path = os.path.join(
365+
self.repo_dir, 'metadata', 'role1.json')
366+
role1 = Metadata.from_file(role1_path)
367+
role2_path = os.path.join(
368+
self.repo_dir, 'metadata', 'role2.json')
369+
role2 = Metadata.from_file(role2_path)
370+
371+
# test the expected delegation tree
372+
root.verify_delegate('root', root)
373+
root.verify_delegate('snapshot', snapshot)
374+
root.verify_delegate('targets', targets)
375+
targets.verify_delegate('role1', role1)
376+
role1.verify_delegate('role2', role2)
377+
378+
# only root and targets can verify delegates
379+
with self.assertRaises(ValueError):
380+
snapshot.verify_delegate('snapshot', snapshot)
381+
# cannot verify with non-existing role
382+
with self.assertRaises(ValueError):
383+
root.verify_delegate('role1', role1)
384+
with self.assertRaises(ValueError):
385+
targets.verify_delegate('targets', targets)
386+
387+
# modified delegate content should fail verification
388+
expires = snapshot.signed.expires
389+
snapshot.signed.bump_expiration()
390+
with self.assertRaises(tuf.exceptions.UnsignedMetadataError):
391+
root.verify_delegate('snapshot', snapshot)
392+
snapshot.signed.expires = expires
393+
394+
# different delegation keys should fail verification
395+
with self.assertRaises(tuf.exceptions.UnsignedMetadataError):
396+
root.verify_delegate('timestamp', snapshot)
397+
398+
# Higher threshold should fail verification
399+
root.signed.roles['snapshot'].threshold += 1
400+
with self.assertRaises(tuf.exceptions.UnsignedMetadataError):
401+
root.verify_delegate('snapshot', snapshot)
402+
403+
# TODO test higher thresholds
404+
355405
def test_key_class(self):
356406
keys = {
357407
"59a4df8af818e9ed7abe0764c0b47b4240952aa0d179b5b78346c470ac30278d":{

tuf/api/metadata.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,61 @@ def sign(
250250

251251
return signature
252252

253+
def verify_delegate(
254+
self,
255+
role_name: str,
256+
delegate: "Metadata",
257+
signed_serializer: Optional[SignedSerializer] = None,
258+
):
259+
"""Verifies that 'delegate' is signed with the required threshold of
260+
keys for the delegated role 'role_name'.
261+
262+
Args:
263+
role_name: Name of the delegated role to verify
264+
delegate: The Metadata object for the delegated role
265+
signed_serializer: Optional; serializer used for delegate
266+
serialization. Default is CanonicalJSONSerializer.
267+
268+
Raises:
269+
UnsignedMetadataError: 'delegate' was not signed with required
270+
threshold of keys for 'role_name'
271+
"""
272+
273+
# Find the keys and role in our metadata
274+
role = None
275+
if isinstance(self.signed, Root):
276+
keys = self.signed.keys
277+
role = self.signed.roles.get(role_name, None)
278+
elif isinstance(self.signed, Targets):
279+
if self.signed.delegations:
280+
keys = self.signed.delegations.keys
281+
# Assume role names are unique in delegations.roles: #1426
282+
roles = self.signed.delegations.roles
283+
role = next((r for r in roles if r.name == role_name), None)
284+
else:
285+
raise ValueError("Call is valid only on delegator metadata")
286+
287+
if role is None:
288+
raise ValueError(f"No delegation found for {role_name}")
289+
290+
# verify that delegate is signed by required threshold of unique keys
291+
signing_keys = set()
292+
for keyid in role.keyids:
293+
key = keys[keyid]
294+
try:
295+
key.verify_signature(delegate, signed_serializer)
296+
# keyids are unique. Try to make sure the public keys are too
297+
signing_keys.add(key.keyval["public"])
298+
except exceptions.UnsignedMetadataError:
299+
pass
300+
301+
if len(signing_keys) < role.threshold:
302+
raise exceptions.UnsignedMetadataError(
303+
f"{role_name} was signed by {len(signing_keys)}/"
304+
f"{role.threshold} keys",
305+
delegate.signed,
306+
)
307+
253308

254309
class Signed:
255310
"""A base class for the signed part of TUF metadata.
@@ -854,7 +909,7 @@ class Delegations:
854909

855910
def __init__(
856911
self,
857-
keys: Mapping[str, Key],
912+
keys: Dict[str, Key],
858913
roles: List[DelegatedRole],
859914
unrecognized_fields: Optional[Mapping[str, Any]] = None,
860915
) -> None:

0 commit comments

Comments
 (0)