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 <mvrachev@vmware.com>
This commit is contained in:
Martin Vrachev 2022-05-17 19:27:58 +03:00
parent b8360a2f69
commit 9259ced68a
3 changed files with 137 additions and 0 deletions

View file

@ -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": []},

View file

@ -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": [ \

View file

@ -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.