Skip to content

Commit

Permalink
Metadata API: enforce role name uniqueness
Browse files Browse the repository at this point in the history
The spec does not say anything about role name uniqueness in a
delegations object, but I believe we cannot safely allow multiple roles
with the same role name in the roles array of a delegations object.
If we did then the roles could have different keyids, and then we would
end up in a situation where metadata may be both a valid delegation
and an invalid delegation at the same time, depending on how the role
gets chosen and that does not seem like the intention of the design.
There is an issue open in the specification with number 167 about
that issue.

Regardless of the Metadata API, I think we should enforce role name
uniqueness.
I chose to change the data structure containing roles to
OrderedDict, where keys are role names and values are DelegatedRole
instances.
This made sense to me as role names are the unique identifier of a role
and their order is important to the way they are traversed afterward.

Note: we can't use OrderedDict as type annotation until we drop support
for Python 3.6:
https://docs.python.org/3/library/typing.html#typing.OrderedDict

Signed-off-by: Martin Vrachev <[email protected]>
  • Loading branch information
MVrachev committed Aug 24, 2021
1 parent f4ffb9d commit c95582c
Show file tree
Hide file tree
Showing 3 changed files with 36 additions and 9 deletions.
16 changes: 16 additions & 0 deletions tests/test_metadata_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,22 @@ def test_invalid_delegated_role_serialization(self, test_case_data: str):
DelegatedRole.from_dict(copy.copy(case_dict))


invalid_delegations: DataSet = {
"duplicate role names": '{"keys": {"keyid" : {"keytype": "rsa", "scheme": "rsassa-pss-sha256", "keyval": {"public": "foo"}}}, \
"roles": [ \
{"keyids": ["keyid"], "name": "a", "paths": ["fn1", "fn2"], "terminating": false, "threshold": 3}, \
{"keyids": ["keyid2"], "name": "a", "paths": ["fn3"], "terminating": false, "threshold": 2} \
] \
}',
}

@run_sub_tests_with_dataset(invalid_delegations)
def test_invalid_delegation_serialization(self, test_case_data: str):
case_dict = json.loads(test_case_data)
with self.assertRaises(ValueError):
Delegations.from_dict(copy.deepcopy(case_dict))


valid_delegations: DataSet = {
"all": '{"keys": {"keyid" : {"keytype": "rsa", "scheme": "rsassa-pss-sha256", "keyval": {"public": "foo"}}}, \
"roles": [ {"keyids": ["keyid"], "name": "a", "paths": ["fn1", "fn2"], "terminating": true, "threshold": 3} ]}',
Expand Down
27 changes: 19 additions & 8 deletions tuf/api/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,9 @@ def verify_delegate(
roles = self.signed.delegations.roles
# Assume role names are unique in delegations.roles: #1426
# Find first role in roles with matching name (or None if no match)
role = next((r for r in roles if r.name == delegated_role), None)
role = next(
(r for r in roles.values() if r.name == delegated_role), None
)
else:
raise TypeError("Call is valid only on delegator metadata")

Expand Down Expand Up @@ -1090,16 +1092,19 @@ class Delegations:
Attributes:
keys: Dictionary of keyids to Keys. Defines the keys used in 'roles'.
roles: List of DelegatedRoles that define which keys are required to
sign the metadata for a specific role. The roles order also
defines the order that role delegations are considered in.
roles: Ordered dictionary of role names to DelegatedRoles instances. It
defines which keys are required to sign the metadata for a specific
role. The roles order also defines the order that role delegations
are considered in.
unrecognized_fields: Dictionary of all unrecognized fields.
"""

# TODO: Change "roles" annotation to OrderedDict when we drop python 3.6.
# See https://docs.python.org/3/library/typing.html#typing.OrderedDict
def __init__(
self,
keys: Dict[str, Key],
roles: List[DelegatedRole],
roles: Dict[str, DelegatedRole],
unrecognized_fields: Optional[Mapping[str, Any]] = None,
) -> None:
self.keys = keys
Expand All @@ -1114,17 +1119,23 @@ def from_dict(cls, delegations_dict: Dict[str, Any]) -> "Delegations":
for keyid, key_dict in keys.items():
keys_res[keyid] = Key.from_dict(keyid, key_dict)
roles = delegations_dict.pop("roles")
roles_res = []
roles_res: Dict[str, DelegatedRole] = OrderedDict()
for role_dict in roles:
new_role = DelegatedRole.from_dict(role_dict)
roles_res.append(new_role)
role_name = new_role.name
if roles_res.get(role_name) is not None:
raise ValueError(
f"Delegated role names should be unique! "
f"Got a role name '{role_name}' which is duplicate!"
)
roles_res[role_name] = new_role
# All fields left in the delegations_dict are unrecognized.
return cls(keys_res, roles_res, delegations_dict)

def to_dict(self) -> Dict[str, Any]:
"""Returns the dict representation of self."""
keys = {keyid: key.to_dict() for keyid, key in self.keys.items()}
roles = [role_obj.to_dict() for role_obj in self.roles]
roles = [role_obj.to_dict() for role_obj in self.roles.values()]
return {
"keys": keys,
"roles": roles,
Expand Down
2 changes: 1 addition & 1 deletion tuf/ngclient/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ def _preorder_depth_first_walk(
child_roles_to_visit = []
# NOTE: This may be a slow operation if there are many
# delegated roles.
for child_role in role_metadata.delegations.roles:
for child_role in role_metadata.delegations.roles.values():
if child_role.is_delegated_path(target_filepath):
logger.debug("Adding child role %s", child_role.name)

Expand Down

0 comments on commit c95582c

Please sign in to comment.