Skip to content

Commit 3185a30

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 2a5bfb9 commit 3185a30

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
@@ -358,6 +358,56 @@ def test_metadata_timestamp(self):
358358
timestamp_test = Timestamp.from_dict(test_dict)
359359
self.assertEqual(timestamp_dict['signed'], timestamp_test.to_dict())
360360

361+
def test_metadata_verify_delegate(self):
362+
root_path = os.path.join(self.repo_dir, 'metadata', 'root.json')
363+
root = Metadata.from_file(root_path)
364+
snapshot_path = os.path.join(
365+
self.repo_dir, 'metadata', 'snapshot.json')
366+
snapshot = Metadata.from_file(snapshot_path)
367+
targets_path = os.path.join(
368+
self.repo_dir, 'metadata', 'targets.json')
369+
targets = Metadata.from_file(targets_path)
370+
role1_path = os.path.join(
371+
self.repo_dir, 'metadata', 'role1.json')
372+
role1 = Metadata.from_file(role1_path)
373+
role2_path = os.path.join(
374+
self.repo_dir, 'metadata', 'role2.json')
375+
role2 = Metadata.from_file(role2_path)
376+
377+
# test the expected delegation tree
378+
root.verify_delegate('root', root)
379+
root.verify_delegate('snapshot', snapshot)
380+
root.verify_delegate('targets', targets)
381+
targets.verify_delegate('role1', role1)
382+
role1.verify_delegate('role2', role2)
383+
384+
# only root and targets can verify delegates
385+
with self.assertRaises(ValueError):
386+
snapshot.verify_delegate('snapshot', snapshot)
387+
# verify fails for roles that are not delegated by delegator
388+
with self.assertRaises(ValueError):
389+
root.verify_delegate('role1', role1)
390+
with self.assertRaises(ValueError):
391+
targets.verify_delegate('targets', targets)
392+
393+
# verify fails when delegate content is modified
394+
expires = snapshot.signed.expires
395+
snapshot.signed.bump_expiration()
396+
with self.assertRaises(tuf.exceptions.UnsignedMetadataError):
397+
root.verify_delegate('snapshot', snapshot)
398+
snapshot.signed.expires = expires
399+
400+
# verify fails if roles keys do not sign the metadata
401+
with self.assertRaises(tuf.exceptions.UnsignedMetadataError):
402+
root.verify_delegate('timestamp', snapshot)
403+
404+
# verify fails if threshold of signatures is not reached
405+
root.signed.roles['snapshot'].threshold += 1
406+
with self.assertRaises(tuf.exceptions.UnsignedMetadataError):
407+
root.verify_delegate('snapshot', snapshot)
408+
409+
# TODO test successful verify with higher thresholds
410+
361411
def test_key_class(self):
362412
keys = {
363413
"59a4df8af818e9ed7abe0764c0b47b4240952aa0d179b5b78346c470ac30278d":{

tuf/api/metadata.py

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

257257
return signature
258258

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

260315
class Signed(metaclass=abc.ABCMeta):
261316
"""A base class for the signed part of TUF metadata.
@@ -875,7 +930,7 @@ class Delegations:
875930

876931
def __init__(
877932
self,
878-
keys: Mapping[str, Key],
933+
keys: Dict[str, Key],
879934
roles: List[DelegatedRole],
880935
unrecognized_fields: Optional[Mapping[str, Any]] = None,
881936
) -> None:

0 commit comments

Comments
 (0)