Skip to content

Commit b9595f3

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. Signed-off-by: Jussi Kukkonen <[email protected]>
1 parent bb85115 commit b9595f3

File tree

2 files changed

+105
-0
lines changed

2 files changed

+105
-0
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: 55 additions & 0 deletions
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: Optional[Role] = None
275+
if isinstance(self.signed, Root):
276+
keys: Mapping[str, Key] = 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.

0 commit comments

Comments
 (0)