diff --git a/examples/client_example/client_example.py b/examples/client_example/client_example.py index a17b6a7ff9..bed32b9dce 100755 --- a/examples/client_example/client_example.py +++ b/examples/client_example/client_example.py @@ -10,7 +10,7 @@ import shutil from pathlib import Path -from tuf.exceptions import RepositoryError +from tuf.api.exceptions import RepositoryError from tuf.ngclient import Updater # constants diff --git a/tests/repository_simulator.py b/tests/repository_simulator.py index c8ddf8b13c..d97050a20e 100644 --- a/tests/repository_simulator.py +++ b/tests/repository_simulator.py @@ -57,6 +57,7 @@ from securesystemslib.keys import generate_ed25519_key from securesystemslib.signer import SSlibSigner +from tuf.api.exceptions import FetcherHTTPError from tuf.api.metadata import ( SPECIFICATION_VERSION, TOP_LEVEL_ROLE_NAMES, @@ -73,7 +74,6 @@ Timestamp, ) from tuf.api.serialization.json import JSONSerializer -from tuf.exceptions import FetcherHTTPError from tuf.ngclient.fetcher import FetcherInterface logger = logging.getLogger(__name__) diff --git a/tests/test_api.py b/tests/test_api.py index 212786a56c..01a2fa08a8 100755 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,7 +25,7 @@ from securesystemslib.signer import Signature, SSlibSigner from tests import utils -from tuf import exceptions +from tuf.api import exceptions from tuf.api.metadata import ( TOP_LEVEL_ROLE_NAMES, DelegatedRole, @@ -614,7 +614,7 @@ def test_targetfile_from_file(self) -> None: # Test with an unsupported algorithm file_path = os.path.join(self.repo_dir, Targets.type, "file1.txt") - with self.assertRaises(exceptions.UnsupportedAlgorithmError): + with self.assertRaises(ValueError): TargetFile.from_file(file_path, file_path, ["123"]) def test_targetfile_from_data(self) -> None: diff --git a/tests/test_fetcher_ng.py b/tests/test_fetcher_ng.py index 954ac0520f..78a8f1c68a 100644 --- a/tests/test_fetcher_ng.py +++ b/tests/test_fetcher_ng.py @@ -20,7 +20,8 @@ import urllib3.exceptions from tests import utils -from tuf import exceptions, unittest_toolbox +from tuf import unittest_toolbox +from tuf.api import exceptions from tuf.ngclient._internal.requests_fetcher import RequestsFetcher logger = logging.getLogger(__name__) @@ -109,7 +110,7 @@ def test_fetch_in_chunks(self) -> None: # Incorrect URL parsing def test_url_parsing(self) -> None: - with self.assertRaises(exceptions.URLParsingError): + with self.assertRaises(exceptions.DownloadError): self.fetcher.fetch(self.random_string()) # File not found error diff --git a/tests/test_trusted_metadata_set.py b/tests/test_trusted_metadata_set.py index cac2b91299..b891aafdf7 100644 --- a/tests/test_trusted_metadata_set.py +++ b/tests/test_trusted_metadata_set.py @@ -13,7 +13,7 @@ from securesystemslib.signer import SSlibSigner from tests import utils -from tuf import exceptions +from tuf.api import exceptions from tuf.api.metadata import ( Metadata, MetaFile, @@ -245,7 +245,7 @@ def test_update_root_new_root_fail_threshold_verification(self) -> None: self.trusted_set.update_root(root.to_bytes()) def test_update_root_new_root_ver_same_as_trusted_root_ver(self) -> None: - with self.assertRaises(exceptions.ReplayedMetadataError): + with self.assertRaises(exceptions.BadVersionNumberError): self.trusted_set.update_root(self.metadata[Root.type]) def test_root_expired_final_root(self) -> None: @@ -266,7 +266,7 @@ def version_modifier(timestamp: Timestamp) -> None: timestamp = self.modify_metadata(Timestamp.type, version_modifier) self.trusted_set.update_timestamp(timestamp) - with self.assertRaises(exceptions.ReplayedMetadataError): + with self.assertRaises(exceptions.BadVersionNumberError): self.trusted_set.update_timestamp(self.metadata[Timestamp.type]) def test_update_timestamp_snapshot_ver_below_current(self) -> None: @@ -278,7 +278,7 @@ def bump_snapshot_version(timestamp: Timestamp) -> None: self.trusted_set.update_timestamp(timestamp) # newtimestamp.meta.version < trusted_timestamp.meta.version - with self.assertRaises(exceptions.ReplayedMetadataError): + with self.assertRaises(exceptions.BadVersionNumberError): self.trusted_set.update_timestamp(self.metadata[Timestamp.type]) def test_update_timestamp_expired(self) -> None: diff --git a/tests/test_updater_delegation_graphs.py b/tests/test_updater_delegation_graphs.py index 6e2a131798..91bee7c59d 100644 --- a/tests/test_updater_delegation_graphs.py +++ b/tests/test_updater_delegation_graphs.py @@ -15,13 +15,13 @@ from tests import utils from tests.repository_simulator import RepositorySimulator +from tuf.api.exceptions import UnsignedMetadataError from tuf.api.metadata import ( SPECIFICATION_VERSION, TOP_LEVEL_ROLE_NAMES, DelegatedRole, Targets, ) -from tuf.exceptions import UnsignedMetadataError from tuf.ngclient import Updater diff --git a/tests/test_updater_fetch_target.py b/tests/test_updater_fetch_target.py index 431961ffae..a7af0ec157 100644 --- a/tests/test_updater_fetch_target.py +++ b/tests/test_updater_fetch_target.py @@ -15,7 +15,7 @@ from tests import utils from tests.repository_simulator import RepositorySimulator -from tuf.exceptions import RepositoryError +from tuf.api.exceptions import RepositoryError from tuf.ngclient import Updater diff --git a/tests/test_updater_key_rotations.py b/tests/test_updater_key_rotations.py index 66173a6602..c78e5b65aa 100644 --- a/tests/test_updater_key_rotations.py +++ b/tests/test_updater_key_rotations.py @@ -17,8 +17,8 @@ from tests import utils from tests.repository_simulator import RepositorySimulator from tests.utils import run_sub_tests_with_dataset +from tuf.api.exceptions import UnsignedMetadataError from tuf.api.metadata import Key, Root -from tuf.exceptions import UnsignedMetadataError from tuf.ngclient import Updater diff --git a/tests/test_updater_ng.py b/tests/test_updater_ng.py index bedcb2c7d2..2e0d41b21c 100644 --- a/tests/test_updater_ng.py +++ b/tests/test_updater_ng.py @@ -19,7 +19,8 @@ from securesystemslib.signer import SSlibSigner from tests import utils -from tuf import exceptions, ngclient, unittest_toolbox +from tuf import ngclient, unittest_toolbox +from tuf.api import exceptions from tuf.api.metadata import ( Metadata, Root, diff --git a/tests/test_updater_top_level_update.py b/tests/test_updater_top_level_update.py index ecef43561b..3dd188268e 100644 --- a/tests/test_updater_top_level_update.py +++ b/tests/test_updater_top_level_update.py @@ -16,6 +16,12 @@ from tests import utils from tests.repository_simulator import RepositorySimulator +from tuf.api.exceptions import ( + BadVersionNumberError, + ExpiredMetadataError, + LengthOrHashMismatchError, + UnsignedMetadataError, +) from tuf.api.metadata import ( SPECIFICATION_VERSION, TOP_LEVEL_ROLE_NAMES, @@ -26,13 +32,6 @@ Targets, Timestamp, ) -from tuf.exceptions import ( - BadVersionNumberError, - ExpiredMetadataError, - ReplayedMetadataError, - RepositoryError, - UnsignedMetadataError, -) from tuf.ngclient import Updater @@ -267,7 +266,7 @@ def test_new_root_same_version(self) -> None: # Check for a rollback_attack # Repository serves a root file with the same version as previous self.sim.publish_root() - with self.assertRaises(ReplayedMetadataError): + with self.assertRaises(BadVersionNumberError): self._run_refresh() # The update failed, latest root version is v1 @@ -278,7 +277,7 @@ def test_new_root_nonconsecutive_version(self) -> None: # Repository serves non-consecutive root version self.sim.root.version += 2 self.sim.publish_root() - with self.assertRaises(ReplayedMetadataError): + with self.assertRaises(BadVersionNumberError): self._run_refresh() # The update failed, latest root version is v1 @@ -313,7 +312,7 @@ def test_new_timestamp_version_rollback(self) -> None: self._run_refresh() self.sim.timestamp.version = 1 - with self.assertRaises(ReplayedMetadataError): + with self.assertRaises(BadVersionNumberError): self._run_refresh() self._assert_version_equals(Timestamp.type, 2) @@ -328,7 +327,7 @@ def test_new_timestamp_snapshot_rollback(self) -> None: self.sim.timestamp.snapshot_meta.version = 1 self.sim.timestamp.version += 1 # timestamp v3 - with self.assertRaises(ReplayedMetadataError): + with self.assertRaises(BadVersionNumberError): self._run_refresh() self._assert_version_equals(Timestamp.type, 2) @@ -390,7 +389,7 @@ def test_new_snapshot_hash_mismatch(self) -> None: self.sim.timestamp.version += 1 # timestamp v3 # Hash mismatch error - with self.assertRaises(RepositoryError): + with self.assertRaises(LengthOrHashMismatchError): self._run_refresh() self._assert_version_equals(Timestamp.type, 3) @@ -423,7 +422,7 @@ def test_new_snapshot_version_rollback(self) -> None: self.sim.snapshot.version = 1 self.sim.update_timestamp() - with self.assertRaises(ReplayedMetadataError): + with self.assertRaises(BadVersionNumberError): self._run_refresh() self._assert_version_equals(Snapshot.type, 2) @@ -496,7 +495,7 @@ def test_new_targets_hash_mismatch(self) -> None: self.sim.snapshot.version += 1 self.sim.update_timestamp() - with self.assertRaises(RepositoryError): + with self.assertRaises(LengthOrHashMismatchError): self._run_refresh() self._assert_version_equals(Snapshot.type, 3) diff --git a/tox.ini b/tox.ini index 3bc7baabcb..6cddcc82ba 100644 --- a/tox.ini +++ b/tox.ini @@ -49,7 +49,7 @@ commands = # work, unfortunately each subdirectory has to be ignored explicitly. pylint -j 0 tuf --ignore=tuf/api,tuf/api/serialization,tuf/ngclient,tuf/ngclient/_internal - mypy {[testenv:lint]lint_dirs} tuf/exceptions.py + mypy {[testenv:lint]lint_dirs} bandit -r tuf diff --git a/tuf/api/exceptions.py b/tuf/api/exceptions.py new file mode 100644 index 0000000000..9a1303bc69 --- /dev/null +++ b/tuf/api/exceptions.py @@ -0,0 +1,63 @@ +# Copyright New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +""" +Define TUF exceptions used inside the new modern implementation. +The names chosen for TUF Exception classes should end in 'Error' except where +there is a good reason not to, and provide that reason in those cases. +""" + + +#### Repository errors #### + + +class RepositoryError(Exception): + """An error with a repository's state, such as a missing file. + It covers all exceptions that come from the repository side when + looking from the perspective of users of metadata API or ngclient.""" + + +class UnsignedMetadataError(RepositoryError): + """An error about metadata object with insufficient threshold of + signatures.""" + + +class BadVersionNumberError(RepositoryError): + """An error for metadata that contains an invalid version number.""" + + +class ExpiredMetadataError(RepositoryError): + """Indicate that a TUF Metadata file has expired.""" + + +class LengthOrHashMismatchError(RepositoryError): + """An error while checking the length and hash values of an object.""" + + +#### Download Errors #### + + +class DownloadError(Exception): + """An error occurred while attempting to download a file.""" + + +class DownloadLengthMismatchError(DownloadError): + """Indicate that a mismatch of lengths was seen while downloading a file.""" + + +class SlowRetrievalError(DownloadError): + """Indicate that downloading a file took an unreasonably long time.""" + + +class FetcherHTTPError(DownloadError): + """ + Returned by FetcherInterface implementations for HTTP errors. + + Args: + message: The HTTP error messsage + status_code: The HTTP status code + """ + + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py index 2ee2bc06a0..00d5775e33 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -57,7 +57,7 @@ from securesystemslib.storage import FilesystemBackend, StorageBackendInterface from securesystemslib.util import persist_temp_file -from tuf import exceptions +from tuf.api import exceptions from tuf.api.serialization import ( MetadataDeserializer, MetadataSerializer, @@ -381,7 +381,6 @@ def verify_delegate( raise exceptions.UnsignedMetadataError( f"{delegated_role} was signed by {len(signing_keys)}/" f"{role.threshold} keys", - delegated_metadata.signed, ) @@ -623,8 +622,7 @@ def verify_signature( signature = metadata.signatures[self.keyid] except KeyError: raise exceptions.UnsignedMetadataError( - f"no signature for key {self.keyid} found in metadata", - metadata.signed, + f"No signature for key {self.keyid} found in metadata" ) from None if signed_serializer is None: @@ -640,8 +638,7 @@ def verify_signature( signed_serializer.serialize(metadata.signed), ): raise exceptions.UnsignedMetadataError( - f"Failed to verify {self.keyid} signature", - metadata.signed, + f"Failed to verify {self.keyid} signature" ) except ( sslib_exceptions.CryptoError, @@ -649,8 +646,7 @@ def verify_signature( sslib_exceptions.UnsupportedAlgorithmError, ) as e: raise exceptions.UnsignedMetadataError( - f"Failed to verify {self.keyid} signature", - metadata.signed, + f"Failed to verify {self.keyid} signature" ) from e @@ -1323,8 +1319,8 @@ def from_file( specified the securesystemslib default hash algorithm is used. Raises: FileNotFoundError: The file doesn't exist. - UnsupportedAlgorithmError: The hash algorithms list - contains an unsupported algorithm. + ValueError: The hash algorithms list contains an unsupported + algorithm. """ with open(local_path, "rb") as file: return cls.from_data(target_file_path, file, hash_algorithms) @@ -1346,8 +1342,8 @@ def from_data( specified the securesystemslib default hash algorithm is used. Raises: - UnsupportedAlgorithmError: The hash algorithms list - contains an unsupported algorithm. + ValueError: The hash algorithms list contains an unsupported + algorithm. """ if isinstance(data, bytes): length = len(data) @@ -1373,9 +1369,7 @@ def from_data( sslib_exceptions.UnsupportedAlgorithmError, sslib_exceptions.FormatError, ) as e: - raise exceptions.UnsupportedAlgorithmError( - f"Unsupported algorithm '{algorithm}'" - ) from e + raise ValueError(f"Unsupported algorithm '{algorithm}'") from e hashes[algorithm] = digest_object.hexdigest() diff --git a/tuf/api/serialization/__init__.py b/tuf/api/serialization/__init__.py index 36fa5d7686..7aef8b9884 100644 --- a/tuf/api/serialization/__init__.py +++ b/tuf/api/serialization/__init__.py @@ -17,17 +17,18 @@ import abc from typing import TYPE_CHECKING +from tuf.api.exceptions import RepositoryError + if TYPE_CHECKING: # pylint: disable=cyclic-import from tuf.api.metadata import Metadata, Signed -# TODO: Should these be in tuf.exceptions or inherit from tuf.exceptions.Error? -class SerializationError(Exception): +class SerializationError(RepositoryError): """Error during serialization.""" -class DeserializationError(Exception): +class DeserializationError(RepositoryError): """Error during deserialization.""" diff --git a/tuf/ngclient/_internal/requests_fetcher.py b/tuf/ngclient/_internal/requests_fetcher.py index c290416415..03b9ed1c8b 100644 --- a/tuf/ngclient/_internal/requests_fetcher.py +++ b/tuf/ngclient/_internal/requests_fetcher.py @@ -14,7 +14,7 @@ import urllib3.exceptions import tuf -from tuf import exceptions +from tuf.api import exceptions from tuf.ngclient.fetcher import FetcherInterface # Globals @@ -61,6 +61,7 @@ def fetch(self, url: str) -> Iterator[bytes]: exceptions.SlowRetrievalError: A timeout occurs while receiving data. exceptions.FetcherHTTPError: An HTTP error code is received. + exceptions.DownloadError: When there is a problem parsing the url. Returns: A bytes iterator @@ -126,15 +127,16 @@ def _chunks(self, response: "requests.Response") -> Iterator[bytes]: def _get_session(self, url: str) -> requests.Session: """Returns a different customized requests.Session per schema+hostname combination. + + Raises: + exceptions.DownloadError: When there is a problem parsing the url. """ # Use a different requests.Session per schema+hostname combination, to # reuse connections while minimizing subtle security issues. parsed_url = parse.urlparse(url) if not parsed_url.scheme or not parsed_url.hostname: - raise exceptions.URLParsingError( - "Could not get scheme and hostname from URL: " + url - ) + raise exceptions.DownloadError("Failed to parse URL {url}") session_index = parsed_url.scheme + "+" + parsed_url.hostname session = self._sessions.get(session_index) diff --git a/tuf/ngclient/_internal/trusted_metadata_set.py b/tuf/ngclient/_internal/trusted_metadata_set.py index e502609cd0..e23fa45950 100644 --- a/tuf/ngclient/_internal/trusted_metadata_set.py +++ b/tuf/ngclient/_internal/trusted_metadata_set.py @@ -72,9 +72,8 @@ from datetime import datetime from typing import Dict, Iterator, Optional -from tuf import exceptions +from tuf.api import exceptions from tuf.api.metadata import Metadata, Root, Snapshot, Targets, Timestamp -from tuf.api.serialization import DeserializationError logger = logging.getLogger(__name__) @@ -161,10 +160,7 @@ def update_root(self, data: bytes) -> Metadata[Root]: raise RuntimeError("Cannot update root after timestamp") logger.debug("Updating root") - try: - new_root = Metadata[Root].from_bytes(data) - except DeserializationError as e: - raise exceptions.RepositoryError("Failed to load root") from e + new_root = Metadata[Root].from_bytes(data) if new_root.signed.type != Root.type: raise exceptions.RepositoryError( @@ -175,10 +171,9 @@ def update_root(self, data: bytes) -> Metadata[Root]: self.root.verify_delegate(Root.type, new_root) if new_root.signed.version != self.root.signed.version + 1: - raise exceptions.ReplayedMetadataError( - Root.type, - new_root.signed.version, - self.root.signed.version, + raise exceptions.BadVersionNumberError( + f"Expected root version {self.root.signed.version + 1}" + f" instead got version {new_root.signed.version}" ) # Verify that new root is signed by itself @@ -219,10 +214,7 @@ def update_timestamp(self, data: bytes) -> Metadata[Timestamp]: # No need to check for 5.3.11 (fast forward attack recovery): # timestamp/snapshot can not yet be loaded at this point - try: - new_timestamp = Metadata[Timestamp].from_bytes(data) - except DeserializationError as e: - raise exceptions.RepositoryError("Failed to load timestamp") from e + new_timestamp = Metadata[Timestamp].from_bytes(data) if new_timestamp.signed.type != Timestamp.type: raise exceptions.RepositoryError( @@ -236,20 +228,17 @@ def update_timestamp(self, data: bytes) -> Metadata[Timestamp]: if self.timestamp is not None: # Prevent rolling back timestamp version if new_timestamp.signed.version < self.timestamp.signed.version: - raise exceptions.ReplayedMetadataError( - Timestamp.type, - new_timestamp.signed.version, - self.timestamp.signed.version, + raise exceptions.BadVersionNumberError( + f"New timestamp version {new_timestamp.signed.version} must" + f" be >= {self.timestamp.signed.version}" ) # Prevent rolling back snapshot version - if ( - new_timestamp.signed.snapshot_meta.version - < self.timestamp.signed.snapshot_meta.version - ): - raise exceptions.ReplayedMetadataError( - Snapshot.type, - new_timestamp.signed.snapshot_meta.version, - self.timestamp.signed.snapshot_meta.version, + snapshot_meta = self.timestamp.signed.snapshot_meta + new_snapshot_meta = new_timestamp.signed.snapshot_meta + if new_snapshot_meta.version < snapshot_meta.version: + raise exceptions.BadVersionNumberError( + f"New snapshot version must be >= {snapshot_meta.version}" + f", got version {new_snapshot_meta.version}" ) # expiry not checked to allow old timestamp to be used for rollback @@ -313,17 +302,9 @@ def update_snapshot( # Verify non-trusted data against the hashes in timestamp, if any. # Trusted snapshot data has already been verified once. if not trusted: - try: - snapshot_meta.verify_length_and_hashes(data) - except exceptions.LengthOrHashMismatchError as e: - raise exceptions.RepositoryError( - "Snapshot length or hashes do not match" - ) from e - - try: - new_snapshot = Metadata[Snapshot].from_bytes(data) - except DeserializationError as e: - raise exceptions.RepositoryError("Failed to load snapshot") from e + snapshot_meta.verify_length_and_hashes(data) + + new_snapshot = Metadata[Snapshot].from_bytes(data) if new_snapshot.signed.type != Snapshot.type: raise exceptions.RepositoryError( @@ -430,17 +411,9 @@ def update_delegated_targets( f"Snapshot does not contain information for '{role_name}'" ) - try: - meta.verify_length_and_hashes(data) - except exceptions.LengthOrHashMismatchError as e: - raise exceptions.RepositoryError( - f"{role_name} length or hashes do not match" - ) from e + meta.verify_length_and_hashes(data) - try: - new_delegate = Metadata[Targets].from_bytes(data) - except DeserializationError as e: - raise exceptions.RepositoryError("Failed to load snapshot") from e + new_delegate = Metadata[Targets].from_bytes(data) if new_delegate.signed.type != Targets.type: raise exceptions.RepositoryError( @@ -469,10 +442,7 @@ def _load_trusted_root(self, data: bytes) -> None: Note that an expired initial root is considered valid: expiry is only checked for the final root in update_timestamp(). """ - try: - new_root = Metadata[Root].from_bytes(data) - except DeserializationError as e: - raise exceptions.RepositoryError("Failed to load root") from e + new_root = Metadata[Root].from_bytes(data) if new_root.signed.type != Root.type: raise exceptions.RepositoryError( diff --git a/tuf/ngclient/fetcher.py b/tuf/ngclient/fetcher.py index f10c5156e2..1ab2e5363a 100644 --- a/tuf/ngclient/fetcher.py +++ b/tuf/ngclient/fetcher.py @@ -11,7 +11,7 @@ from contextlib import contextmanager from typing import IO, Iterator -from tuf import exceptions +from tuf.api import exceptions logger = logging.getLogger(__name__) @@ -35,9 +35,9 @@ def fetch(self, url: str) -> Iterator[bytes]: url: A URL string that represents a file location. Raises: - tuf.exceptions.SlowRetrievalError: A timeout occurs while receiving + exceptions.SlowRetrievalError: A timeout occurs while receiving data. - tuf.exceptions.FetcherHTTPError: An HTTP error code is received. + exceptions.FetcherHTTPError: An HTTP error code is received. Returns: A bytes iterator @@ -55,7 +55,8 @@ def download_file(self, url: str, max_length: int) -> Iterator[IO]: the file or an upper bound. Raises: - DownloadLengthMismatchError: downloaded bytes exceed 'max_length'. + exceptions.DownloadLengthMismatchError: downloaded bytes exceed + 'max_length'. Yields: A TemporaryFile object that points to the contents of 'url'. @@ -70,7 +71,8 @@ def download_file(self, url: str, max_length: int) -> Iterator[IO]: number_of_bytes_received += len(chunk) if number_of_bytes_received > max_length: raise exceptions.DownloadLengthMismatchError( - max_length, number_of_bytes_received + f"Downloaded {number_of_bytes_received} bytes exceeding" + f" the maximum allowed length of {max_length}" ) temp_file.write(chunk) diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index 21bd8c037f..7fe48a11c7 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -41,7 +41,7 @@ from securesystemslib import util as sslib_util -from tuf import exceptions +from tuf.api import exceptions from tuf.api.metadata import ( Metadata, Root,