From d018279e21e520ddbba5e2d56cc1128528c20f11 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Thu, 2 Sep 2021 18:24:50 +0300 Subject: [PATCH 1/7] ngclient: Fix rollback checks The rollback checks themselves work, but they create a situation where Updater does not realize that it needs to download e.g. a new snapshot because the local snapshot is valid as _intermediate_ snapshot (that can be used for rollback protection but nothing else), but is not valid as final snapshot. Raise in the end of update_snapshot and update_timestamp if the files are not valid final metadata: this way the intermediate metadata does get loaded but Updater also knows it is not the final metadata. This modifies the existing tests but does not yet test the situation described in the first paragraph. Fixes #1563 Signed-off-by: Jussi Kukkonen --- tests/test_trusted_metadata_set.py | 27 +++++---- .../_internal/trusted_metadata_set.py | 56 ++++++++++++------- tuf/ngclient/updater.py | 4 +- 3 files changed, 54 insertions(+), 33 deletions(-) diff --git a/tests/test_trusted_metadata_set.py b/tests/test_trusted_metadata_set.py index 4f848d5f8c..b097e350a1 100644 --- a/tests/test_trusted_metadata_set.py +++ b/tests/test_trusted_metadata_set.py @@ -260,11 +260,12 @@ def test_update_timestamp_expired(self): def timestamp_expired_modifier(timestamp: Timestamp) -> None: timestamp.expires = datetime(1970, 1, 1) - # intermediate timestamp is allowed to be expired + # expired intermediate timestamp is loaded but raises timestamp = self.modify_metadata("timestamp", timestamp_expired_modifier) - self.trusted_set.update_timestamp(timestamp) + with self.assertRaises(exceptions.ExpiredMetadataError): + self.trusted_set.update_timestamp(timestamp) - # update snapshot to trigger final timestamp expiry check + # snapshot update does start but fails because timestamp is expired with self.assertRaises(exceptions.ExpiredMetadataError): self.trusted_set.update_snapshot(self.metadata["snapshot"]) @@ -293,10 +294,11 @@ def timestamp_version_modifier(timestamp: Timestamp) -> None: timestamp = self.modify_metadata("timestamp", timestamp_version_modifier) self.trusted_set.update_timestamp(timestamp) - #intermediate snapshot is allowed to not match meta version - self.trusted_set.update_snapshot(self.metadata["snapshot"]) + # if intermediate snapshot version is incorrect, load it but also raise + with self.assertRaises(exceptions.BadVersionNumberError): + self.trusted_set.update_snapshot(self.metadata["snapshot"]) - # final snapshot must match meta version + # targets update starts but fails if snapshot version does not match with self.assertRaises(exceptions.BadVersionNumberError): self.trusted_set.update_targets(self.metadata["targets"]) @@ -328,11 +330,12 @@ def test_update_snapshot_expired_new_snapshot(self): def snapshot_expired_modifier(snapshot: Snapshot) -> None: snapshot.expires = datetime(1970, 1, 1) - # intermediate snapshot is allowed to be expired + # expired intermediate snapshot is loaded but will raise snapshot = self.modify_metadata("snapshot", snapshot_expired_modifier) - self.trusted_set.update_snapshot(snapshot) + with self.assertRaises(exceptions.ExpiredMetadataError): + self.trusted_set.update_snapshot(snapshot) - # update targets to trigger final snapshot expiry check + # targets update does start but fails because snapshot is expired with self.assertRaises(exceptions.ExpiredMetadataError): self.trusted_set.update_targets(self.metadata["targets"]) @@ -348,8 +351,10 @@ def version_bump(snapshot: Snapshot) -> None: new_timestamp = self.modify_metadata("timestamp", meta_version_bump) self.trusted_set.update_timestamp(new_timestamp) - # load a "local" snapshot, then update to newer one: - self.trusted_set.update_snapshot(self.metadata["snapshot"]) + # load a "local" snapshot with mismatching version (loading happens but + # BadVersionNumberError is raised), then update to newer one: + with self.assertRaises(exceptions.BadVersionNumberError): + self.trusted_set.update_snapshot(self.metadata["snapshot"]) new_snapshot = self.modify_metadata("snapshot", version_bump) self.trusted_set.update_snapshot(new_snapshot) diff --git a/tuf/ngclient/_internal/trusted_metadata_set.py b/tuf/ngclient/_internal/trusted_metadata_set.py index be1b0b44ed..336c45c075 100644 --- a/tuf/ngclient/_internal/trusted_metadata_set.py +++ b/tuf/ngclient/_internal/trusted_metadata_set.py @@ -178,16 +178,20 @@ def update_root(self, data: bytes) -> None: def update_timestamp(self, data: bytes) -> None: """Verifies and loads 'data' as new timestamp metadata. - Note that an expired intermediate timestamp is considered valid so it - can be used for rollback checks on newer, final timestamp. Expiry is - only checked for the final timestamp in update_snapshot(). + Note that an intermediate timestamp is allowed to be expired: + TrustedMetadataSet will throw an ExpiredMetadataError in this case + but the intermediate timestamp will be loaded. This way a newer + timestamp can still be loaded (and the intermediate timestamp will + be used for rollback protection). Expired timestamp will prevent + loading snapshot metadata. Args: data: unverified new timestamp metadata as bytes Raises: - RepositoryError: Metadata failed to load or verify. The actual - error type and content will contain more details. + RepositoryError: Metadata failed to load or verify as final + timestamp. The actual error type and content will contain + more details. """ if self.snapshot is not None: raise RuntimeError("Cannot update timestamp after snapshot") @@ -237,21 +241,33 @@ def update_timestamp(self, data: bytes) -> None: self._trusted_set["timestamp"] = new_timestamp logger.debug("Updated timestamp") + # timestamp is loaded: raise if it is not valid _final_ timestamp + self._check_final_timestamp() + + def _check_final_timestamp(self) -> None: + """Raise if timestamp is expired""" + + assert self.timestamp is not None # nosec + if self.timestamp.signed.is_expired(self.reference_time): + raise exceptions.ExpiredMetadataError("timestamp.json is expired") + def update_snapshot(self, data: bytes) -> None: """Verifies and loads 'data' as new snapshot metadata. - Note that intermediate snapshot is considered valid even if it is - expired or the version does not match the timestamp meta version. This - means the intermediate snapshot can be used for rollback checks on - newer, final snapshot. Expiry and meta version are only checked for - the final snapshot in update_delegated_targets(). + Note that an intermediate snapshot is allowed to be expired and version + is allowed to not match timestamp meta version: TrustedMetadataSet will + throw an ExpiredMetadataError/BadVersionNumberError in these cases + but the intermediate snapshot will be loaded. This way a newer + snapshot can still be loaded (and the intermediate snapshot will + be used for rollback protection). Expired snapshot or snapshot that + does not match timestamp meta version will prevent loading targets. Args: data: unverified new snapshot metadata as bytes Raises: - RepositoryError: Metadata failed to load or verify. The actual - error type and content will contain more details. + RepositoryError: data failed to load or verify as final snapshot. + The actual error type and content will contain more details. """ if self.timestamp is None: @@ -260,10 +276,8 @@ def update_snapshot(self, data: bytes) -> None: raise RuntimeError("Cannot update snapshot after targets") logger.debug("Updating snapshot") - # Local timestamp was allowed to be expired to allow for rollback - # checks on new timestamp but now timestamp must not be expired - if self.timestamp.signed.is_expired(self.reference_time): - raise exceptions.ExpiredMetadataError("timestamp.json is expired") + # Snapshot cannot be loaded if final timestamp is expired + self._check_final_timestamp() meta = self.timestamp.signed.meta["snapshot.json"] @@ -314,8 +328,11 @@ def update_snapshot(self, data: bytes) -> None: self._trusted_set["snapshot"] = new_snapshot logger.debug("Updated snapshot") + # snapshot is loaded, but we raise if it's not valid _final_ snapshot + self._check_final_snapshot() + def _check_final_snapshot(self) -> None: - """Check snapshot expiry and version before targets is updated""" + """Raise if snapshot is expired or meta version does not match""" assert self.snapshot is not None # nosec assert self.timestamp is not None # nosec @@ -361,9 +378,8 @@ def update_delegated_targets( if self.snapshot is None: raise RuntimeError("Cannot load targets before snapshot") - # Local snapshot was allowed to be expired and to not match meta - # version to allow for rollback checks on new snapshot but now - # snapshot must not be expired and must match meta version + # Targets cannot be loaded if final snapshot is expired or its version + # does not match meta version in timestamp self._check_final_snapshot() delegator: Optional[Metadata] = self.get(delegator_name) diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index a3c7189d75..318cb85f4b 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -322,7 +322,7 @@ def _load_timestamp(self) -> None: self._trusted_set.update_timestamp(data) except (OSError, exceptions.RepositoryError) as e: # Local timestamp does not exist or is invalid - logger.debug("Failed to load local timestamp %s", e) + logger.debug("Local timestamp not valid as final: %s", e) # Load from remote (whether local load succeeded or not) data = self._download_metadata( @@ -339,7 +339,7 @@ def _load_snapshot(self) -> None: logger.debug("Local snapshot is valid: not downloading new one") except (OSError, exceptions.RepositoryError) as e: # Local snapshot does not exist or is invalid: update from remote - logger.debug("Failed to load local snapshot %s", e) + logger.debug("Local snapshot not valid as final: %s", e) assert self._trusted_set.timestamp is not None # nosec metainfo = self._trusted_set.timestamp.signed.meta["snapshot.json"] From 5d10735fe275a926c870b7f843722d272d4b3203 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Thu, 9 Sep 2021 13:07:09 +0300 Subject: [PATCH 2/7] TrustedMetadataSet: Improve module docstring Explain what "intermediate" metadata is and how it affects the loading process. Signed-off-by: Jussi Kukkonen --- tuf/ngclient/_internal/trusted_metadata_set.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tuf/ngclient/_internal/trusted_metadata_set.py b/tuf/ngclient/_internal/trusted_metadata_set.py index 336c45c075..dac579bca3 100644 --- a/tuf/ngclient/_internal/trusted_metadata_set.py +++ b/tuf/ngclient/_internal/trusted_metadata_set.py @@ -13,11 +13,18 @@ (trusted_set["root"]) or, in the case of top-level metadata, using the helper properties (trusted_set.root). -The rules for top-level metadata are - * Metadata is updatable only if metadata it depends on is loaded - * Metadata is not updatable if any metadata depending on it has been loaded - * Metadata must be updated in order: - root -> timestamp -> snapshot -> targets -> (delegated targets) +The rules that TrustedMetadataSet follows for top-level metadata are + * Metadata must be loaded in order: + root -> timestamp -> snapshot -> targets -> (delegated targets). + * Metadata can be loaded even if it is expired (or in the snapshot case if the + meta info does not match): this is called "intermediate metadata". + * Intermediate metadata can _only_ be used to load newer versions of the + same metadata: As an example an expired root can be used to load a new root. + * Metadata is loadable only if metadata before it in loading order is loaded + (and is not intermediate): As an example timestamp can be loaded if a + final (non-expired) root has been loaded. + * Metadata is not loadable if any metadata after it in loading order has been + loaded: As an example new roots cannot be loaded if timestamp is loaded. Exceptions are raised if metadata fails to load in any way. From 4e5980e89dd179c9eb98747312ffba57da2db947 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Thu, 2 Sep 2021 18:44:28 +0300 Subject: [PATCH 3/7] tests: Start testing ngclient with repo simulator Signed-off-by: Jussi Kukkonen --- tests/repository_simulator.py | 163 +++++++++++++++++++++++++++ tests/test_updater_with_simulator.py | 68 +++++++++++ 2 files changed, 231 insertions(+) create mode 100644 tests/repository_simulator.py create mode 100644 tests/test_updater_with_simulator.py diff --git a/tests/repository_simulator.py b/tests/repository_simulator.py new file mode 100644 index 0000000000..24f8ea3aa7 --- /dev/null +++ b/tests/repository_simulator.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python + +# Copyright 2021, New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +""""Test utility to simulate a repository + +RepositorySimulator provides methods to modify repository metadata so that it's +easy to "publish" new repository versions with modified metadata, while serving +the versions to client test code. + +RepositorySimulator implements FetcherInterface so Updaters in tests can use it +as a way to "download" new metadata from remote: in practice no downloading, +network connections or even file access happens as RepositorySimulator serves +everything from memory. +""" + +import logging +from collections import OrderedDict +from datetime import datetime, timedelta +from securesystemslib.keys import generate_ed25519_key +from securesystemslib.signer import SSlibSigner +from tuf.exceptions import FetcherHTTPError +from typing import Dict, Iterator, List, Optional, Tuple +from urllib import parse + +from tuf.api.metadata import( + Key, + Metadata, + MetaFile, + Role, + Root, + SPECIFICATION_VERSION, + Snapshot, + Targets, + Timestamp +) +from tuf.ngclient.fetcher import FetcherInterface + +logger = logging.getLogger(__name__) + +SPEC_VER = ".".join(SPECIFICATION_VERSION) + +class RepositorySimulator(FetcherInterface): + def __init__(self): + # all root versions are stored + self.md_roots: Dict[int, Metadata[Root]] = {} + self.md_timestamp: Metadata[Timestamp] = None + self.md_snapshot: Metadata[Snapshot] = None + self.md_targets: Metadata[Targets] = None + # all targets in one dict + self.md_delegates: Dict[str, Metadata[Targets]] = {} + + self.signers: Dict[str, List[SSlibSigner]] = {} + + self._initialize() + + @property + def root(self) -> Root: + raise NotImplementedError + + @property + def timestamp(self) -> Timestamp: + return self.md_timestamp.signed + + @property + def snapshot(self) -> Snapshot: + return self.md_snapshot.signed + + @property + def targets(self) -> Targets: + return self.md_targets.signed + + def delegates(self) -> Iterator[Tuple[str, Targets]]: + for role, md in self.md_delegates.items(): + yield role, md.signed + + def _create_key(self, role:str) -> Key: + sslib_key = generate_ed25519_key() + if role not in self.signers: + self.signers[role] = [] + self.signers[role].append(SSlibSigner(sslib_key)) + + key = Key.from_securesystemslib_key(sslib_key) + return key + + def _initialize(self): + """Setup a minimal valid repository""" + expiry = datetime.utcnow().replace(microsecond=0) + timedelta(days=30) + + targets = Targets(1, SPEC_VER, expiry, {}, None) + self.md_targets = Metadata(targets, OrderedDict()) + + meta = {"targets.json": MetaFile(targets.version)} + snapshot = Snapshot(1, SPEC_VER, expiry, meta) + self.md_snapshot = Metadata(snapshot, OrderedDict()) + + meta = {"snapshot.json": MetaFile(snapshot.version)} + timestamp = Timestamp(1, SPEC_VER, expiry, meta) + self.md_timestamp = Metadata(timestamp, OrderedDict()) + + keys = {} + roles = {} + for role in ["root", "timestamp", "snapshot", "targets"]: + key = self._create_key(role) + keys[key.keyid] = key + roles[role] = Role([key.keyid], 1) + root = Root(1, SPEC_VER, expiry, keys, roles, True) + self.md_roots[1] = Metadata(root, OrderedDict()) + + def fetch(self, url: str) -> Iterator[bytes]: + spliturl = parse.urlparse(url) + if spliturl.path.startswith("/metadata/"): + parts = spliturl.path[len("/metadata/"):].split(".") + if len(parts) == 3: + version = int(parts[0]) + role = parts[1] + else: + version = None + role = parts[0] + yield self._fetch_metadata (role, version) + else: + raise FetcherHTTPError(f"Unknown path '{spliturl.path}'", 404) + + def _fetch_metadata(self, role: str, version: Optional[int] = None) -> bytes: + if role == "root": + md = self.md_roots.get(version) + elif role == "timestamp": + md = self.md_timestamp + elif role == "snapshot": + md = self.md_snapshot + elif role == "targets": + md = self.md_targets + else: + md = self.md_delegates.get(role) + + if md is None: + raise FetcherHTTPError(f"Unknown role {role}", 404) + + md.signatures.clear() + for signer in self.signers[role]: + md.sign(signer) + + logger.debug("fetched metadata %s version %d", role, md.signed.version) + return md.to_bytes() + + def update_timestamp(self): + self.timestamp.meta["snapshot.json"].version = self.snapshot.version + + self.timestamp.version += 1 + + def update_snapshot(self): + self.snapshot.meta["targets.json"].version = self.targets.version + for role, delegate in self.delegates(): + self.snapshot.meta[f"{role}.json"].version = delegate.version + + self.snapshot.version += 1 + self.update_timestamp() + + def write(self, directory:str): + """Write current repository metadata to a directory""" + raise NotImplementedError + diff --git a/tests/test_updater_with_simulator.py b/tests/test_updater_with_simulator.py new file mode 100644 index 0000000000..6e36024c5b --- /dev/null +++ b/tests/test_updater_with_simulator.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +# Copyright 2021, New York University and the TUF contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +"""Test ngclient Updater using the repository simulator +""" + +import logging +import os +import sys +import tempfile +import unittest + +from tuf.ngclient import Updater + +from tests import utils +from tests.repository_simulator import RepositorySimulator + +class TestUpdater(unittest.TestCase): + def setUp(self): + self.client_dir = tempfile.TemporaryDirectory() + + # Setup the repository, bootstrap client root.json + self.sim = RepositorySimulator() + with open(os.path.join(self.client_dir.name, "root.json"), "bw") as f: + root = self.sim.download_bytes("https://example.com/metadata/1.root.json", 100000) + f.write(root) + + def _new_updater(self): + return Updater( + self.client_dir.name, + "https://example.com/metadata/", + "https://example.com/targets/", + self.sim + ) + + def test_refresh(self): + # Update top level metadata + updater = self._new_updater() + updater.refresh() + + # TODO compare file contents? + + # New timestamp version + self.sim.update_timestamp() + + updater = self._new_updater() + updater.refresh() + + # TODO compare file contents? + + # New targets version + self.sim.targets.version += 1 + self.sim.update_snapshot() + + updater = self._new_updater() + updater.refresh() + + # TODO compare file contents? + + + def tearDown(self): + self.client_dir.cleanup() + +if __name__ == "__main__": + utils.configure_test_logging(sys.argv) + unittest.main() From c7f106cf89be44093a848b937f39ec8e1c2392ed Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 3 Sep 2021 16:25:21 +0300 Subject: [PATCH 4/7] ngclient: Improve logging Use info level to log which versions we currently have as trusted metadata versions. Signed-off-by: Jussi Kukkonen --- tuf/ngclient/_internal/trusted_metadata_set.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tuf/ngclient/_internal/trusted_metadata_set.py b/tuf/ngclient/_internal/trusted_metadata_set.py index dac579bca3..cef3d6fa1e 100644 --- a/tuf/ngclient/_internal/trusted_metadata_set.py +++ b/tuf/ngclient/_internal/trusted_metadata_set.py @@ -180,7 +180,7 @@ def update_root(self, data: bytes) -> None: new_root.verify_delegate("root", new_root) self._trusted_set["root"] = new_root - logger.debug("Updated root") + logger.info("Updated root v%d", new_root.signed.version) def update_timestamp(self, data: bytes) -> None: """Verifies and loads 'data' as new timestamp metadata. @@ -246,7 +246,7 @@ def update_timestamp(self, data: bytes) -> None: # protection of new timestamp: expiry is checked in update_snapshot() self._trusted_set["timestamp"] = new_timestamp - logger.debug("Updated timestamp") + logger.info("Updated timestamp v%d", new_timestamp.signed.version) # timestamp is loaded: raise if it is not valid _final_ timestamp self._check_final_timestamp() @@ -333,7 +333,7 @@ def update_snapshot(self, data: bytes) -> None: # protection of new snapshot: it is checked when targets is updated self._trusted_set["snapshot"] = new_snapshot - logger.debug("Updated snapshot") + logger.info("Updated snapshot v%d", new_snapshot.signed.version) # snapshot is loaded, but we raise if it's not valid _final_ snapshot self._check_final_snapshot() @@ -421,17 +421,17 @@ def update_delegated_targets( delegator.verify_delegate(role_name, new_delegate) - if new_delegate.signed.version != meta.version: + version = new_delegate.signed.version + if version != meta.version: raise exceptions.BadVersionNumberError( - f"Expected {role_name} version " - f"{meta.version}, got {new_delegate.signed.version}." + f"Expected {role_name} v{meta.version}, got v{version}." ) if new_delegate.signed.is_expired(self.reference_time): raise exceptions.ExpiredMetadataError(f"New {role_name} is expired") self._trusted_set[role_name] = new_delegate - logger.debug("Updated %s delegated by %s", role_name, delegator_name) + logger.info("Updated %s v%d", role_name, version) def _load_trusted_root(self, data: bytes) -> None: """Verifies and loads 'data' as trusted root metadata. @@ -452,4 +452,4 @@ def _load_trusted_root(self, data: bytes) -> None: new_root.verify_delegate("root", new_root) self._trusted_set["root"] = new_root - logger.debug("Loaded trusted root") + logger.info("Loaded trusted root v%d", new_root.signed.version) From d64730b1e6ec80fc59f5392c96c8a218184498d4 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 3 Sep 2021 14:03:38 +0300 Subject: [PATCH 5/7] tests: RepositorySimulator: Add special handling for roots We need to store past versions of root: that means an explicit publish step (publish_root()) is required. It stores a serialization of current root as a new version: fetch() then serves only these serialized root versions. Add a few tests demonstrating how to create root versions and change signatures. Signed-off-by: Jussi Kukkonen --- tests/repository_simulator.py | 94 +++++++++++++++++----------- tests/test_updater_with_simulator.py | 52 +++++++++++++-- 2 files changed, 102 insertions(+), 44 deletions(-) diff --git a/tests/repository_simulator.py b/tests/repository_simulator.py index 24f8ea3aa7..92ba2c053b 100644 --- a/tests/repository_simulator.py +++ b/tests/repository_simulator.py @@ -18,6 +18,7 @@ import logging from collections import OrderedDict from datetime import datetime, timedelta +from tuf.api.serialization.json import JSONSerializer from securesystemslib.keys import generate_ed25519_key from securesystemslib.signer import SSlibSigner from tuf.exceptions import FetcherHTTPError @@ -43,21 +44,24 @@ class RepositorySimulator(FetcherInterface): def __init__(self): - # all root versions are stored - self.md_roots: Dict[int, Metadata[Root]] = {} + self.md_root: Metadata[Root] = None self.md_timestamp: Metadata[Timestamp] = None self.md_snapshot: Metadata[Snapshot] = None self.md_targets: Metadata[Targets] = None - # all targets in one dict self.md_delegates: Dict[str, Metadata[Targets]] = {} + # other metadata is signed on-demand (when fetched) but roots must be + # explicitly published with publish_root() which maintains this list + self.signed_roots: List[bytes] = [] + + # signers are used on-demand at fetch time to sign metadata self.signers: Dict[str, List[SSlibSigner]] = {} self._initialize() @property def root(self) -> Root: - raise NotImplementedError + return self.md_root.signed @property def timestamp(self) -> Timestamp: @@ -75,14 +79,9 @@ def delegates(self) -> Iterator[Tuple[str, Targets]]: for role, md in self.md_delegates.items(): yield role, md.signed - def _create_key(self, role:str) -> Key: + def create_key(self) -> Tuple[Key, SSlibSigner]: sslib_key = generate_ed25519_key() - if role not in self.signers: - self.signers[role] = [] - self.signers[role].append(SSlibSigner(sslib_key)) - - key = Key.from_securesystemslib_key(sslib_key) - return key + return Key.from_securesystemslib_key(sslib_key), SSlibSigner(sslib_key) def _initialize(self): """Setup a minimal valid repository""" @@ -99,14 +98,26 @@ def _initialize(self): timestamp = Timestamp(1, SPEC_VER, expiry, meta) self.md_timestamp = Metadata(timestamp, OrderedDict()) - keys = {} - roles = {} + root = Root(1, SPEC_VER, expiry, {}, {}, True) for role in ["root", "timestamp", "snapshot", "targets"]: - key = self._create_key(role) - keys[key.keyid] = key - roles[role] = Role([key.keyid], 1) - root = Root(1, SPEC_VER, expiry, keys, roles, True) - self.md_roots[1] = Metadata(root, OrderedDict()) + key, signer = self.create_key() + root.roles[role] = Role([], 1) + root.add_key(role, key) + # store the private key + if role not in self.signers: + self.signers[role] = [] + self.signers[role].append(signer) + self.md_root = Metadata(root, OrderedDict()) + self.publish_root() + + def publish_root(self): + """Sign and store a new serialized version of root""" + self.md_root.signatures.clear() + for signer in self.signers["root"]: + self.md_root.sign(signer) + + self.signed_roots.append(self.md_root.to_bytes(JSONSerializer())) + logger.debug("Published root v%d", self.root.version) def fetch(self, url: str) -> Iterator[bytes]: spliturl = parse.urlparse(url) @@ -124,25 +135,37 @@ def fetch(self, url: str) -> Iterator[bytes]: def _fetch_metadata(self, role: str, version: Optional[int] = None) -> bytes: if role == "root": - md = self.md_roots.get(version) - elif role == "timestamp": - md = self.md_timestamp - elif role == "snapshot": - md = self.md_snapshot - elif role == "targets": - md = self.md_targets + # return a version previously serialized in publish_root() + if version > len(self.signed_roots): + raise FetcherHTTPError(f"Unknown root version {version}", 404) + logger.debug("fetched root version %d", role, version) + return self.signed_roots[version - 1] else: - md = self.md_delegates.get(role) + # sign and serialize the requested metadata + if role == "timestamp": + md = self.md_timestamp + elif role == "snapshot": + md = self.md_snapshot + elif role == "targets": + md = self.md_targets + else: + md = self.md_delegates.get(role) - if md is None: - raise FetcherHTTPError(f"Unknown role {role}", 404) + if md is None: + raise FetcherHTTPError(f"Unknown role {role}", 404) + if version is not None and version != md.signed.version: + raise FetcherHTTPError(f"Unknown {role} version {version}", 404) - md.signatures.clear() - for signer in self.signers[role]: - md.sign(signer) + md.signatures.clear() + for signer in self.signers[role]: + md.sign(signer,append=True) - logger.debug("fetched metadata %s version %d", role, md.signed.version) - return md.to_bytes() + logger.debug( + "fetched %s v%d with %d sigs", + role, + md.signed.version, + len(self.signers[role])) + return md.to_bytes(JSONSerializer()) def update_timestamp(self): self.timestamp.meta["snapshot.json"].version = self.snapshot.version @@ -156,8 +179,3 @@ def update_snapshot(self): self.snapshot.version += 1 self.update_timestamp() - - def write(self, directory:str): - """Write current repository metadata to a directory""" - raise NotImplementedError - diff --git a/tests/test_updater_with_simulator.py b/tests/test_updater_with_simulator.py index 6e36024c5b..3453a4f683 100644 --- a/tests/test_updater_with_simulator.py +++ b/tests/test_updater_with_simulator.py @@ -10,6 +10,7 @@ import os import sys import tempfile +from tuf.exceptions import UnsignedMetadataError import unittest from tuf.ngclient import Updater @@ -37,16 +38,18 @@ def _new_updater(self): def test_refresh(self): # Update top level metadata - updater = self._new_updater() - updater.refresh() + self._new_updater().refresh() + + # New root (root needs to be explicitly published) + self.sim.root.version += 1 + self.sim.publish_root() # TODO compare file contents? # New timestamp version self.sim.update_timestamp() - updater = self._new_updater() - updater.refresh() + self._new_updater().refresh() # TODO compare file contents? @@ -54,11 +57,48 @@ def test_refresh(self): self.sim.targets.version += 1 self.sim.update_snapshot() - updater = self._new_updater() - updater.refresh() + self._new_updater().refresh() # TODO compare file contents? + # this is just an example of testing different key/signature situations + def test_targets_signatures(self): + # Update top level metadata + self._new_updater().refresh() + + # New targets: signed by a new key that is not in roles keys + old_signer = self.sim.signers["targets"].pop() + key, signer = self.sim.create_key() + self.sim.signers["targets"] = [signer] + self.sim.targets.version += 1 + self.sim.update_snapshot() + + with self.assertRaises(UnsignedMetadataError): + self._new_updater().refresh() + + # New root: Add the new key as targets role key + # (root changes require explicit publishing) + self.sim.root.add_key("targets", key) + self.sim.root.version += 1 + self.sim.publish_root() + + self._new_updater().refresh() + + # New root: Raise targets threshold to 2 + self.sim.root.roles["targets"].threshold = 2 + self.sim.root.version += 1 + self.sim.publish_root() + + with self.assertRaises(UnsignedMetadataError): + self._new_updater().refresh() + + # New targets: sign with both new and old key + self.sim.signers["targets"] = [signer, old_signer] + self.sim.targets.version += 1 + self.sim.update_snapshot() + + self._new_updater().refresh() + def tearDown(self): self.client_dir.cleanup() From 87c200d014ac5d22bb1a2b91e900c1c5675e4cb9 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 3 Sep 2021 16:53:33 +0300 Subject: [PATCH 6/7] tests: Add state dumping into RepositorySimulator if state dumping is enabled with e.g. python3 test_updater_with_simulator.py --dump The repository state can be dumped at will. Modify the test so it dumps the state on every updater refresh if --dump is set. Add a root modifying case to test_refresh() Signed-off-by: Jussi Kukkonen --- tests/repository_simulator.py | 37 +++++++++++++++-- tests/test_updater_with_simulator.py | 61 +++++++++++++++++----------- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/tests/repository_simulator.py b/tests/repository_simulator.py index 92ba2c053b..df22bd172c 100644 --- a/tests/repository_simulator.py +++ b/tests/repository_simulator.py @@ -15,16 +15,18 @@ everything from memory. """ -import logging from collections import OrderedDict from datetime import datetime, timedelta -from tuf.api.serialization.json import JSONSerializer +import logging +import os +import tempfile from securesystemslib.keys import generate_ed25519_key from securesystemslib.signer import SSlibSigner -from tuf.exceptions import FetcherHTTPError from typing import Dict, Iterator, List, Optional, Tuple from urllib import parse +from tuf.api.serialization.json import JSONSerializer +from tuf.exceptions import FetcherHTTPError from tuf.api.metadata import( Key, Metadata, @@ -57,6 +59,9 @@ def __init__(self): # signers are used on-demand at fetch time to sign metadata self.signers: Dict[str, List[SSlibSigner]] = {} + self.dump_dir = None + self.dump_version = 0 + self._initialize() @property @@ -179,3 +184,29 @@ def update_snapshot(self): self.snapshot.version += 1 self.update_timestamp() + + def write(self): + """Dump current repository metadata to self.dump_dir + + This is a debugging tool: dumping repository state before running + Updater refresh may be useful while debugging a test. + """ + if self.dump_dir is None: + self.dump_dir = tempfile.mkdtemp() + print(f"Repository Simulator dumps in {self.dump_dir}") + + self.dump_version += 1 + dir = os.path.join(self.dump_dir, str(self.dump_version)) + os.makedirs(dir) + + for ver in range(1, len(self.signed_roots) + 1): + with open(os.path.join(dir, f"{ver}.root.json"), "wb") as f: + f.write(self._fetch_metadata("root", ver)) + + for role in ["timestamp", "snapshot", "targets"]: + with open(os.path.join(dir, f"{role}.json"), "wb") as f: + f.write(self._fetch_metadata(role)) + + for role in self.md_delegates.keys(): + with open(os.path.join(dir, f"{role}.json"), "wb") as f: + f.write(self._fetch_metadata(role)) diff --git a/tests/test_updater_with_simulator.py b/tests/test_updater_with_simulator.py index 3453a4f683..9a157f3809 100644 --- a/tests/test_updater_with_simulator.py +++ b/tests/test_updater_with_simulator.py @@ -10,6 +10,7 @@ import os import sys import tempfile +from typing import Optional from tuf.exceptions import UnsignedMetadataError import unittest @@ -19,6 +20,9 @@ from tests.repository_simulator import RepositorySimulator class TestUpdater(unittest.TestCase): + # set dump_dir to trigger repository state dumps + dump_dir:Optional[str] = None + def setUp(self): self.client_dir = tempfile.TemporaryDirectory() @@ -28,45 +32,52 @@ def setUp(self): root = self.sim.download_bytes("https://example.com/metadata/1.root.json", 100000) f.write(root) - def _new_updater(self): - return Updater( + if self.dump_dir is not None: + # create test specific dump directory + name = self.id().split('.')[-1] + self.sim.dump_dir = os.path.join(self.dump_dir, name) + os.mkdir(self.sim.dump_dir) + + def _run_refresh(self): + if self.sim.dump_dir is not None: + self.sim.write() + + updater = Updater( self.client_dir.name, "https://example.com/metadata/", "https://example.com/targets/", self.sim ) + updater.refresh() def test_refresh(self): # Update top level metadata - self._new_updater().refresh() + self._run_refresh() - # New root (root needs to be explicitly published) + # New root (root needs to be explicitly signed) self.sim.root.version += 1 self.sim.publish_root() - # TODO compare file contents? + self._run_refresh() - # New timestamp version + # New timestamp self.sim.update_timestamp() - self._new_updater().refresh() + self._run_refresh() - # TODO compare file contents? - - # New targets version + # New targets, snapshot, timestamp version self.sim.targets.version += 1 self.sim.update_snapshot() - self._new_updater().refresh() + self._run_refresh() - # TODO compare file contents? + def test_keys_and_signatures(self): + """Example of the two trickiest test areas: keys and root updates""" - # this is just an example of testing different key/signature situations - def test_targets_signatures(self): # Update top level metadata - self._new_updater().refresh() + self._run_refresh() - # New targets: signed by a new key that is not in roles keys + # New targets: signed with a new key that is not in roles keys old_signer = self.sim.signers["targets"].pop() key, signer = self.sim.create_key() self.sim.signers["targets"] = [signer] @@ -74,7 +85,7 @@ def test_targets_signatures(self): self.sim.update_snapshot() with self.assertRaises(UnsignedMetadataError): - self._new_updater().refresh() + self._run_refresh() # New root: Add the new key as targets role key # (root changes require explicit publishing) @@ -82,7 +93,7 @@ def test_targets_signatures(self): self.sim.root.version += 1 self.sim.publish_root() - self._new_updater().refresh() + self._run_refresh() # New root: Raise targets threshold to 2 self.sim.root.roles["targets"].threshold = 2 @@ -90,19 +101,23 @@ def test_targets_signatures(self): self.sim.publish_root() with self.assertRaises(UnsignedMetadataError): - self._new_updater().refresh() + self._run_refresh() # New targets: sign with both new and old key self.sim.signers["targets"] = [signer, old_signer] self.sim.targets.version += 1 self.sim.update_snapshot() - self._new_updater().refresh() - + self._run_refresh() def tearDown(self): self.client_dir.cleanup() if __name__ == "__main__": - utils.configure_test_logging(sys.argv) - unittest.main() + if "--dump" in sys.argv: + TestUpdater.dump_dir = tempfile.mkdtemp() + print(f"Repository Simulator dumps in {TestUpdater.dump_dir}") + sys.argv.remove("--dump") + + utils.configure_test_logging(sys.argv) + unittest.main() From ad813a5d0d4489544110b3ec398b2d86bd98afb7 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 10 Sep 2021 09:30:58 +0300 Subject: [PATCH 7/7] tests: Add type checks suggested by mypy also black fixes. Signed-off-by: Jussi Kukkonen --- tests/repository_simulator.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/repository_simulator.py b/tests/repository_simulator.py index df22bd172c..67329bde72 100644 --- a/tests/repository_simulator.py +++ b/tests/repository_simulator.py @@ -27,7 +27,7 @@ from tuf.api.serialization.json import JSONSerializer from tuf.exceptions import FetcherHTTPError -from tuf.api.metadata import( +from tuf.api.metadata import ( Key, Metadata, MetaFile, @@ -36,7 +36,7 @@ SPECIFICATION_VERSION, Snapshot, Targets, - Timestamp + Timestamp, ) from tuf.ngclient.fetcher import FetcherInterface @@ -44,6 +44,7 @@ SPEC_VER = ".".join(SPECIFICATION_VERSION) + class RepositorySimulator(FetcherInterface): def __init__(self): self.md_root: Metadata[Root] = None @@ -127,34 +128,34 @@ def publish_root(self): def fetch(self, url: str) -> Iterator[bytes]: spliturl = parse.urlparse(url) if spliturl.path.startswith("/metadata/"): - parts = spliturl.path[len("/metadata/"):].split(".") + parts = spliturl.path[len("/metadata/") :].split(".") if len(parts) == 3: - version = int(parts[0]) + version: Optional[int] = int(parts[0]) role = parts[1] else: version = None role = parts[0] - yield self._fetch_metadata (role, version) + yield self._fetch_metadata(role, version) else: raise FetcherHTTPError(f"Unknown path '{spliturl.path}'", 404) def _fetch_metadata(self, role: str, version: Optional[int] = None) -> bytes: if role == "root": # return a version previously serialized in publish_root() - if version > len(self.signed_roots): + if version is None or version > len(self.signed_roots): raise FetcherHTTPError(f"Unknown root version {version}", 404) logger.debug("fetched root version %d", role, version) return self.signed_roots[version - 1] else: # sign and serialize the requested metadata if role == "timestamp": - md = self.md_timestamp + md: Metadata = self.md_timestamp elif role == "snapshot": md = self.md_snapshot elif role == "targets": md = self.md_targets else: - md = self.md_delegates.get(role) + md = self.md_delegates[role] if md is None: raise FetcherHTTPError(f"Unknown role {role}", 404) @@ -163,13 +164,14 @@ def _fetch_metadata(self, role: str, version: Optional[int] = None) -> bytes: md.signatures.clear() for signer in self.signers[role]: - md.sign(signer,append=True) + md.sign(signer, append=True) logger.debug( "fetched %s v%d with %d sigs", role, md.signed.version, - len(self.signers[role])) + len(self.signers[role]), + ) return md.to_bytes(JSONSerializer()) def update_timestamp(self):