From 9259ced68af74add640a01fce61ccc6bbce6a04e Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Tue, 17 May 2022 19:27:58 +0300 Subject: [PATCH] Add SuccinctRole class Add SuccinctRoles class containing the information from the succint_roles dict described in TAP 15. This allows for easy mypy checks on the types, easy enforcement on TAP 15 restrictions (as for example that "bit_length" must be between 1 and 32) and support for unrecognized fields inside succinct_roles without much of a hassle. Signed-off-by: Martin Vrachev --- tests/test_metadata_eq_.py | 3 + tests/test_metadata_serialization.py | 31 ++++++++ tuf/api/metadata.py | 103 +++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/tests/test_metadata_eq_.py b/tests/test_metadata_eq_.py index e79815f0..88a87c75 100644 --- a/tests/test_metadata_eq_.py +++ b/tests/test_metadata_eq_.py @@ -23,6 +23,7 @@ Metadata, MetaFile, Role, + SuccinctRoles, TargetFile, ) @@ -55,6 +56,7 @@ def setUpClass(cls) -> None: cls.objects["Role"] = Role(["keyid1", "keyid2"], 3) cls.objects["MetaFile"] = MetaFile(1, 12, {"sha256": "abc"}) cls.objects["DelegatedRole"] = DelegatedRole("a", [], 1, False, ["d"]) + cls.objects["SuccinctRoles"] = SuccinctRoles(["keyid"], 1, 8, "foo") cls.objects["Delegations"] = Delegations( {"keyid": cls.objects["Key"]}, {"a": cls.objects["DelegatedRole"]} ) @@ -79,6 +81,7 @@ def setUpClass(cls) -> None: "paths": [""], "path_hash_prefixes": [""], }, + "SuccinctRoles": {"bit_length": 0, "name_prefix": ""}, "Delegations": {"keys": {}, "roles": {}}, "TargetFile": {"length": 0, "hashes": {}, "path": ""}, "Targets": {"targets": {}, "delegations": []}, diff --git a/tests/test_metadata_serialization.py b/tests/test_metadata_serialization.py index a856ab54..9f75027c 100644 --- a/tests/test_metadata_serialization.py +++ b/tests/test_metadata_serialization.py @@ -24,6 +24,7 @@ Role, Root, Snapshot, + SuccinctRoles, TargetFile, Targets, Timestamp, @@ -408,6 +409,36 @@ def test_invalid_delegated_role_serialization( with self.assertRaises(ValueError): DelegatedRole.from_dict(case_dict) + valid_succinct_roles: utils.DataSet = { + # SuccinctRoles inherits Role and some use cases can be found in the valid_roles. + "standard succinct_roles information": '{"keyids": ["keyid"], "threshold": 1, \ + "bit_length": 8, "name_prefix": "foo"}', + "succinct_roles with unrecognized fields": '{"keyids": ["keyid"], "threshold": 1, \ + "bit_length": 8, "name_prefix": "foo", "foo": "bar"}', + } + + @utils.run_sub_tests_with_dataset(valid_succinct_roles) + def test_succinct_roles_serialization(self, test_case_data: str) -> None: + case_dict = json.loads(test_case_data) + succinct_roles = SuccinctRoles.from_dict(copy.copy(case_dict)) + self.assertDictEqual(case_dict, succinct_roles.to_dict()) + + invalid_succinct_roles: utils.DataSet = { + # SuccinctRoles inherits Role and some use cases can be found in the invalid_roles. + "missing bit_length from succinct_roles": '{"keyids": ["keyid"], "threshold": 1, "name_prefix": "foo"}', + "missing name_prefix from succinct_roles": '{"keyids": ["keyid"], "threshold": 1, "bit_length": 8}', + "succinct_roles with invalid bit_length type": '{"keyids": ["keyid"], "threshold": 1, "bit_length": "a", "name_prefix": "foo"}', + "succinct_roles with invalid name_prefix type": '{"keyids": ["keyid"], "threshold": 1, "bit_length": 8, "name_prefix": 1}', + "succinct_roles with high bit_length value": '{"keyids": ["keyid"], "threshold": 1, "bit_length": 50, "name_prefix": "foo"}', + "succinct_roles with low bit_length value": '{"keyids": ["keyid"], "threshold": 1, "bit_length": 0, "name_prefix": "foo"}', + } + + @utils.run_sub_tests_with_dataset(invalid_succinct_roles) + def test_invalid_succinct_roles_serialization(self, test_data: str) -> None: + case_dict = json.loads(test_data) + with self.assertRaises((ValueError, KeyError, TypeError)): + SuccinctRoles.from_dict(case_dict) + invalid_delegations: utils.DataSet = { "empty delegations": "{}", "missing keys": '{ "roles": [ \ diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py index 7bdccb95..32f2d3fe 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -1435,6 +1435,109 @@ def is_delegated_path(self, target_filepath: str) -> bool: return False +class SuccinctRoles(Role): + """Succinctly defines a hash bin delegation graph. + + A ``SuccinctRoles`` object describes a delegation graph that covers all + targets, distributing them uniformly over the delegated roles (i.e. bins) + in the graph. + + The total number of bins is 2 to the power of the passed ``bit_length``. + Targets are assigned to bins by casting the left-most ``bit_length`` of + bits of the file path hash digest to int, using it as bin index between 0 + and ``2**bit_length - 1``. + + Bin names are the concatenation of the passed ``name_prefix`` and a hex + representation of the bin index between separated by a hyphen. + + The passed ``keyids`` and ``threshold`` is used for each bin, and each bin + is 'terminating'. + + For details: https://github.com/theupdateframework/taps/blob/master/tap15.md + + Args: + keyids: Signing key identifiers for any bin metadata. + threshold: Number of keys required to sign any bin metadata. + bit_length: Number of bits between 1 and 32. + name_prefix: Prefix of all bin names. + unrecognized_fields: Dictionary of all attributes that are not managed + by TUF Metadata API. + + Raises: + ValueError, TypeError, AttributeError: Invalid arguments. + """ + + def __init__( + self, + keyids: List[str], + threshold: int, + bit_length: int, + name_prefix: str, + unrecognized_fields: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__(keyids, threshold, unrecognized_fields) + + if bit_length <= 0 or bit_length > 32: + raise ValueError("bit_length must be between 1 and 32") + if not isinstance(name_prefix, str): + raise ValueError("name_prefix must be a string") + + self.bit_length = bit_length + self.name_prefix = name_prefix + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, SuccinctRoles): + return False + + return ( + super().__eq__(other) + and self.bit_length == other.bit_length + and self.name_prefix == other.name_prefix + ) + + @classmethod + def from_dict(cls, role_dict: Dict[str, Any]) -> "SuccinctRoles": + """Creates ``SuccinctRoles`` object from its json/dict representation. + + Raises: + ValueError, KeyError, AttributeError, TypeError: Invalid arguments. + """ + keyids = role_dict.pop("keyids") + threshold = role_dict.pop("threshold") + bit_length = role_dict.pop("bit_length") + name_prefix = role_dict.pop("name_prefix") + # All fields left in the role_dict are unrecognized. + return cls(keyids, threshold, bit_length, name_prefix, role_dict) + + def to_dict(self) -> Dict[str, Any]: + """Returns the dict representation of self.""" + base_role_dict = super().to_dict() + return { + "bit_length": self.bit_length, + "name_prefix": self.name_prefix, + **base_role_dict, + } + + def get_role_for_target(self, target_filepath: str) -> str: + """Calculates the name of the delegated role responsible for + ``target_filepath``. + + Args: + target_filepath: URL path to a target file, relative to a base + targets URL. + """ + hasher = sslib_hash.digest(algorithm="sha256") + hasher.update(target_filepath.encode("utf-8")) + + # We can't ever need more than 4 bytes (32 bits). + hash_bytes = hasher.digest()[:4] + # Right shift hash bytes, so that we only have the leftmost + # bit_length bits that we care about. + shift_value = 32 - self.bit_length + bin_number = int.from_bytes(hash_bytes, byteorder="big") >> shift_value + return f"{self.name_prefix}-{bin_number}" + + class Delegations: """A container object storing information about all delegations.