diff --git a/examples/repo_example/hashed_bin_delegation.py b/examples/repo_example/hashed_bin_delegation.py index f287afe106..5a4e2a008c 100644 --- a/examples/repo_example/hashed_bin_delegation.py +++ b/examples/repo_example/hashed_bin_delegation.py @@ -26,7 +26,6 @@ from securesystemslib.signer import SSlibSigner from tuf.api.metadata import ( - SPECIFICATION_VERSION, DelegatedRole, Delegations, Key, @@ -42,8 +41,7 @@ def _in(days: float) -> datetime: return datetime.utcnow().replace(microsecond=0) + timedelta(days=days) -SPEC_VERSION = ".".join(SPECIFICATION_VERSION) -roles: Dict[str, Metadata] = {} +roles: Dict[str, Metadata[Targets]] = {} keys: Dict[str, Dict[str, Any]] = {} # Hash bin delegation @@ -200,11 +198,11 @@ def find_hash_bin(path: str) -> str: # Sign and persist # ---------------- -# Sign all metadata and persist to temporary directory at CWD for review -# (most notably see 'bins.json' and '80-87.json'). +# Sign all metadata and write to temporary directory at CWD for review using +# versioned file names. Most notably see '1.bins.json' and '1.80-87.json'. # NOTE: See "Persist metadata" paragraph in 'basic_repo.py' example for more -# details about serialization formats and metadata file name convention. +# details about serialization formats and metadata file name conventions. PRETTY = JSONSerializer(compact=False) TMP_DIR = tempfile.mkdtemp(dir=os.getcwd()) @@ -213,6 +211,6 @@ def find_hash_bin(path: str) -> str: signer = SSlibSigner(key) role.sign(signer) - filename = f"{role_name}.json" + filename = f"1.{role_name}.json" filepath = os.path.join(TMP_DIR, filename) role.to_file(filepath, serializer=PRETTY) diff --git a/examples/repo_example/succinct_hash_bin_delegations.py b/examples/repo_example/succinct_hash_bin_delegations.py new file mode 100644 index 0000000000..6e86c0d6c9 --- /dev/null +++ b/examples/repo_example/succinct_hash_bin_delegations.py @@ -0,0 +1,176 @@ +# Copyright New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 +""" +A TUF succinct hash bin delegation example using the low-level TUF Metadata API. + +The example code in this file demonstrates how to perform succinct hash bin +delegation using the low-level Metadata API. +Succinct hash bin delegation achieves a similar result as using a standard hash +bin delegation, but the delegating metadata is smaller, resulting in fewer bytes +to transfer and parse. + +See 'basic_repo.py' for a more comprehensive TUF metadata API example. + +For a comprehensive explanation of succinct hash bin delegation and the +difference between succinct and standard hash bin delegation read: +https://github.com/theupdateframework/taps/blob/master/tap15.md + +NOTE: Metadata files will be written to a 'tmp*'-directory in CWD. +""" +import math +import os +import tempfile +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, Tuple + +from securesystemslib.keys import generate_ed25519_key +from securesystemslib.signer import SSlibSigner + +from tuf.api.metadata import ( + Delegations, + Key, + Metadata, + SuccinctRoles, + TargetFile, + Targets, +) +from tuf.api.serialization.json import JSONSerializer + +# Succinct hash bin delegation +# ============================ +# Succinct hash bin delegation aims to distribute a large number of target files +# over multiple delegated targets metadata roles (bins). The consequence is +# smaller metadata files and thus a lower network overhead for repository-client +# communication. +# +# The assignment of target files to a target's metadata is done automatically, +# based on the byte digest of the target file name. +# +# The number of bins, name prefix for all bins and key threshold are all +# attributes that need to be configured. + +# Number of bins, bit length and bin number computation +# ----------------------------------------------------- +# Determining the correct number of bins is dependent on the expected number of +# target files in a repository. For the purpose of this example we choose: +NUMBER_OF_BINS = 32 +# +# The number of bins will determine the number of bits in a target path +# considered in assigning the target to a bin. +BIT_LENGTH = int(math.log2(NUMBER_OF_BINS)) + +# Delegated role (bin) name format +# -------------------------------- +# Each bin has a name in the format of f"{NAME_PREFIX}-{bin_number}". +# +# Name prefix is the common prefix of all delegated target roles (bins). +# For our example it will be: +NAME_PREFIX = "delegated_bin" +# +# The suffix "bin_number" is a zero-padded hexadecimal number of that +# particular bin. + +# Keys and threshold +# ------------------ +# Succinct hash bin delegation uses the same key(s) to sign all bins. This is +# acceptable because the primary concern of this type of delegation is to reduce +# network overhead. For the purpose of this example only one key is required. +THRESHOLD = 1 + + +def create_key() -> Tuple[Key, SSlibSigner]: + """Generates a new Key and Signer.""" + sslib_key = generate_ed25519_key() + return Key.from_securesystemslib_key(sslib_key), SSlibSigner(sslib_key) + + +# Create one signing key for all bins, and one for the delegating targets role. +bins_key, bins_signer = create_key() +_, targets_signer = create_key() + +# Delegating targets role +# ----------------------- +# Akin to regular targets delegation, the delegating role ships the public keys +# of the delegated roles. However, instead of providing individual delegation +# information about each role, one single `SuccinctRoles` object is used to +# provide the information for all delegated roles (bins). + +# NOTE: See "Targets" and "Targets delegation" paragraphs in 'basic_repo.py' +# example for more details about the Targets object. + +expiration_date = datetime.utcnow().replace(microsecond=0) + timedelta(days=7) +targets = Metadata(Targets(expires=expiration_date)) + +succinct_roles = SuccinctRoles( + keyids=[bins_key.keyid], + threshold=THRESHOLD, + bit_length=BIT_LENGTH, + name_prefix=NAME_PREFIX, +) +delegations_keys_info: Dict[str, Key] = {} +delegations_keys_info[bins_key.keyid] = bins_key + +targets.signed.delegations = Delegations( + delegations_keys_info, roles=None, succinct_roles=succinct_roles +) + +# Delegated targets roles (bins) +# ------------------------------ +# We can use the SuccinctRoles object from the delegating role above to iterate +# over all bin names in the delegation and create the corresponding metadata. + +assert targets.signed.delegations.succinct_roles is not None # make mypy happy + +delegated_bins: Dict[str, Metadata[Targets]] = {} +for delegated_bin_name in targets.signed.delegations.succinct_roles.get_roles(): + delegated_bins[delegated_bin_name] = Metadata( + Targets(expires=expiration_date) + ) + +# Add target file inside a delegated role (bin) +# --------------------------------------------- +# For the purpose of this example we will protect the integrity of this +# example script by adding its file info to the corresponding bin metadata. + +# NOTE: See "Targets" paragraph in 'basic_repo.py' example for more details +# about adding target file infos to targets metadata. +local_path = Path(__file__).resolve() +target_path = f"{local_path.parts[-2]}/{local_path.parts[-1]}" +target_file_info = TargetFile.from_file(target_path, str(local_path)) + +# We don't know yet in which delegated role (bin) our target belongs. +# With SuccinctRoles.get_role_for_target() we can get the name of the delegated +# role (bin) responsible for that target_path. +target_bin = targets.signed.delegations.succinct_roles.get_role_for_target( + target_path +) + +# In our example with NUMBER_OF_BINS = 32 and the current file as target_path +# the target_bin is "delegated_bin-0d" + +# Now we can add the current target to the bin responsible for it. +delegated_bins[target_bin].signed.targets[target_path] = target_file_info + +# Sign and persist +# ---------------- +# Sign all metadata and write to a temporary directory at CWD for review using +# versioned file names. Most notably see '1.targets.json' and +# '1.delegated_bin-0d.json'. + +# NOTE: See "Persist metadata" paragraph in 'basic_repo.py' example for more +# details about serialization formats and metadata file name convention. +PRETTY = JSONSerializer(compact=False) +TMP_DIR = tempfile.mkdtemp(dir=os.getcwd()) + + +targets.sign(targets_signer) +targets.to_file(os.path.join(TMP_DIR, "1.targets.json"), serializer=PRETTY) + +for bin_name, bin_target_role in delegated_bins.items(): + file_name = f"1.{bin_name}.json" + file_path = os.path.join(TMP_DIR, file_name) + + bin_target_role.sign(bins_signer, append=True) + + bin_target_role.to_file(file_path, serializer=PRETTY) diff --git a/tests/test_examples.py b/tests/test_examples.py index 6014334fca..daa8839507 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -92,39 +92,79 @@ def test_hashed_bin_delegation(self) -> None: self._run_script_and_assert_files( "hashed_bin_delegation.py", [ - "bins.json", - "00-07.json", - "08-0f.json", - "10-17.json", - "18-1f.json", - "20-27.json", - "28-2f.json", - "30-37.json", - "38-3f.json", - "40-47.json", - "48-4f.json", - "50-57.json", - "58-5f.json", - "60-67.json", - "68-6f.json", - "70-77.json", - "78-7f.json", - "80-87.json", - "88-8f.json", - "90-97.json", - "98-9f.json", - "a0-a7.json", - "a8-af.json", - "b0-b7.json", - "b8-bf.json", - "c0-c7.json", - "c8-cf.json", - "d0-d7.json", - "d8-df.json", - "e0-e7.json", - "e8-ef.json", - "f0-f7.json", - "f8-ff.json", + "1.bins.json", + "1.00-07.json", + "1.08-0f.json", + "1.10-17.json", + "1.18-1f.json", + "1.20-27.json", + "1.28-2f.json", + "1.30-37.json", + "1.38-3f.json", + "1.40-47.json", + "1.48-4f.json", + "1.50-57.json", + "1.58-5f.json", + "1.60-67.json", + "1.68-6f.json", + "1.70-77.json", + "1.78-7f.json", + "1.80-87.json", + "1.88-8f.json", + "1.90-97.json", + "1.98-9f.json", + "1.a0-a7.json", + "1.a8-af.json", + "1.b0-b7.json", + "1.b8-bf.json", + "1.c0-c7.json", + "1.c8-cf.json", + "1.d0-d7.json", + "1.d8-df.json", + "1.e0-e7.json", + "1.e8-ef.json", + "1.f0-f7.json", + "1.f8-ff.json", + ], + ) + + def test_succinct_hash_bin_delegation(self) -> None: + self._run_script_and_assert_files( + "succinct_hash_bin_delegations.py", + [ + "1.targets.json", + "1.delegated_bin-00.json", + "1.delegated_bin-01.json", + "1.delegated_bin-02.json", + "1.delegated_bin-03.json", + "1.delegated_bin-04.json", + "1.delegated_bin-05.json", + "1.delegated_bin-06.json", + "1.delegated_bin-07.json", + "1.delegated_bin-08.json", + "1.delegated_bin-09.json", + "1.delegated_bin-0a.json", + "1.delegated_bin-0b.json", + "1.delegated_bin-0c.json", + "1.delegated_bin-0d.json", + "1.delegated_bin-0e.json", + "1.delegated_bin-0f.json", + "1.delegated_bin-10.json", + "1.delegated_bin-11.json", + "1.delegated_bin-12.json", + "1.delegated_bin-13.json", + "1.delegated_bin-14.json", + "1.delegated_bin-15.json", + "1.delegated_bin-16.json", + "1.delegated_bin-17.json", + "1.delegated_bin-18.json", + "1.delegated_bin-19.json", + "1.delegated_bin-1a.json", + "1.delegated_bin-1b.json", + "1.delegated_bin-1c.json", + "1.delegated_bin-1d.json", + "1.delegated_bin-1e.json", + "1.delegated_bin-1f.json", ], ) diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py index 49dada674a..d05f12fcf8 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -1495,8 +1495,9 @@ def __init__( self.name_prefix = name_prefix # Calculate the suffix_len value based on the total number of bins in - # hex. If bit_length = 8 then number_of_bins = 256 or 100 in hex - # and suffix_len = 3 meaning the third bin will have a suffix of "003" + # hex. If bit_length = 10 then number_of_bins = 1024 or bin names will + # have a suffix between "000" and "3ff" in hex and suffix_len will be 3 + # meaning the third bin will have a suffix of "003". self.number_of_bins = 2**bit_length # suffix_len is calculated based on "number_of_bins - 1" as the name # of the last bin contains the number "number_of_bins -1" as a suffix.