diff --git a/.github/scripts/conformance-client.py b/.github/scripts/conformance-client.py index 0c44c7ff84..1f80ad6ce7 100755 --- a/.github/scripts/conformance-client.py +++ b/.github/scripts/conformance-client.py @@ -27,6 +27,7 @@ def refresh(metadata_url: str, metadata_dir: str) -> None: updater = Updater( metadata_dir, metadata_url, + bootstrap=None, ) updater.refresh() print(f"python-tuf test client: Refreshed metadata in {metadata_dir}") @@ -46,6 +47,7 @@ def download_target( metadata_url, download_dir, target_base_url, + bootstrap=None, ) target_info = updater.get_targetinfo(target_name) if not target_info: diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 4fe5c77946..029eba9dc2 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -11,12 +11,12 @@ jobs: steps: - name: Checkout TUF - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python (oldest supported version) - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.10" cache: 'pip' @@ -50,12 +50,12 @@ jobs: steps: - name: Checkout TUF - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -99,7 +99,7 @@ jobs: run: touch requirements.txt - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.x' cache: 'pip' diff --git a/.github/workflows/_test_sslib_main.yml b/.github/workflows/_test_sslib_main.yml index c84482f2e5..8be70055b3 100644 --- a/.github/workflows/_test_sslib_main.yml +++ b/.github/workflows/_test_sslib_main.yml @@ -11,12 +11,12 @@ jobs: steps: - name: Checkout TUF - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.x' cache: 'pip' diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 88294930e3..b1820770d1 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -18,13 +18,13 @@ jobs: needs: test steps: - name: Checkout release tag - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ github.event.workflow_run.head_branch }} - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.x' @@ -37,7 +37,7 @@ jobs: awk "/## $GITHUB_REF_NAME/{flag=1; next} /## v/{flag=0} flag" docs/CHANGELOG.md > changelog - name: Store build artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: build-artifacts path: | @@ -54,7 +54,7 @@ jobs: release_id: ${{ steps.gh-release.outputs.result }} steps: - name: Fetch build artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: build-artifacts @@ -96,7 +96,7 @@ jobs: id-token: write # to authenticate as Trusted Publisher to pypi.org steps: - name: Fetch build artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: build-artifacts diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b111f37e4e..9d4c54447e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 644ea0078a..3358297097 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout conformance client - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 115232723d..764d8e080c 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: 'Dependency Review' diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index a44a4e81a5..29a5b81ba9 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -22,7 +22,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/specification-version-check.yml b/.github/workflows/specification-version-check.yml index aa8c1e685d..9a78aa0d44 100644 --- a/.github/workflows/specification-version-check.yml +++ b/.github/workflows/specification-version-check.yml @@ -14,10 +14,10 @@ jobs: outputs: version: ${{ steps.get-version.outputs.version }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" - id: get-version @@ -33,6 +33,6 @@ jobs: contents: read issues: write needs: get-supported-tuf-version - uses: theupdateframework/specification/.github/workflows/check-latest-spec-version.yml@master + uses: theupdateframework/specification/.github/workflows/check-latest-spec-version.yml@master # zizmor: ignore[unpinned-uses] with: tuf-version: ${{needs.get-supported-tuf-version.outputs.version}} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6beadca962..92f1bfc591 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Changed + +* ngclient: `Updater()` now requires an explicit `bootstrap` argument + * This is a breaking change: callers must pass `bootstrap=` or `bootstrap=None` + * `bootstrap=None` explicitly opts into using cached `root.json` as trust anchor + ## v6.0.0 This release is not strictly speaking an API break from 5.1 but it does contain some diff --git a/docs/INSTALLATION.rst b/docs/INSTALLATION.rst index 8e23e927f8..012f0878da 100644 --- a/docs/INSTALLATION.rst +++ b/docs/INSTALLATION.rst @@ -53,6 +53,39 @@ from GitHub, change into the project root directory, and install with pip python3 -m pip install -r requirements/dev.txt +Application deployment +---------------------- + +The initial trusted root metadata (``root.json``) is the trust anchor for all +subsequent metadata verification. Applications should deploy a trusted root +with the application and provide it to :class:`tuf.ngclient.Updater`. + +Recommended storage locations for bootstrap root metadata include: + +* a system-wide read-only path (e.g. ``/usr/share/your-app/root.json``) +* an application bundle with appropriate permissions +* a read-only mounted volume in containerized deployments + +Not recommended: + +* ``metadata_dir`` (the metadata cache) since it is writable by design +* user-writable install paths (e.g. a user site-packages directory) +* any location writable by the account running the updater + +Example:: + + from tuf.ngclient import Updater + + with open("/usr/share/your-app/root.json", "rb") as f: + bootstrap = f.read() + + updater = Updater( + metadata_dir="/var/lib/your-app/tuf/metadata", + metadata_base_url="https://example.com/metadata/", + bootstrap=bootstrap, + ) + + Verify release signatures ------------------------- diff --git a/examples/client/client b/examples/client/client index 883fd52cba..3a997a07d4 100755 --- a/examples/client/client +++ b/examples/client/client @@ -79,14 +79,15 @@ def download(base_url: str, target: str) -> bool: print(f"Using trusted root in {metadata_dir}") try: - # NOTE: initial root should be provided with ``bootstrap`` argument: - # This examples uses unsafe Trust-On-First-Use initialization so it is - # not possible here. + # NOTE: production deployments should provide embedded root metadata + # bytes via the ``bootstrap`` argument. This example uses Trust-On-First-Use + # initialization, so it explicitly opts into using cached root.json. updater = Updater( metadata_dir=metadata_dir, metadata_base_url=f"{base_url}/metadata/", target_base_url=f"{base_url}/targets/", target_dir=DOWNLOAD_DIR, + bootstrap=None, ) updater.refresh() diff --git a/examples/uploader/_localrepo.py b/examples/uploader/_localrepo.py index c4d746a34d..7d8181b44e 100644 --- a/examples/uploader/_localrepo.py +++ b/examples/uploader/_localrepo.py @@ -47,6 +47,7 @@ def __init__(self, metadata_dir: str, key_dir: str, base_url: str): self.updater = Updater( metadata_dir=metadata_dir, metadata_base_url=f"{base_url}/metadata/", + bootstrap=None, ) self.updater.refresh() diff --git a/pyproject.toml b/pyproject.toml index cce1fc5487..e51626f22b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling==1.28.0"] +requires = ["hatchling==1.29.0"] build-backend = "hatchling.build" [project] diff --git a/requirements/lint.txt b/requirements/lint.txt index dcbc97b83a..c65fa9c57e 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -6,9 +6,9 @@ # Lint tools # (We are not so interested in the specific versions of the tools: the versions # are pinned to prevent unexpected linting failures when tools update) -ruff==0.14.11 +ruff==0.15.5 mypy==1.19.1 -zizmor==1.20.0 +zizmor==1.23.1 # Required for type stubs freezegun==1.5.5 diff --git a/requirements/pinned.txt b/requirements/pinned.txt index 6ab621aa78..5430cb54d1 100644 --- a/requirements/pinned.txt +++ b/requirements/pinned.txt @@ -6,9 +6,9 @@ # cffi==2.0.0 # via cryptography -cryptography==46.0.3 +cryptography==46.0.5 # via securesystemslib -pycparser==2.23 +pycparser==3.0 # via cffi securesystemslib==1.3.1 # via -r requirements/main.txt diff --git a/requirements/test.txt b/requirements/test.txt index a21d5258c0..cea102cff1 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,5 +4,5 @@ -r pinned.txt # coverage measurement -coverage[toml]==7.13.1 +coverage[toml]==7.13.4 freezegun==1.5.5 diff --git a/tests/repository_simulator.py b/tests/repository_simulator.py index d0c50bc424..bd175e7244 100644 --- a/tests/repository_simulator.py +++ b/tests/repository_simulator.py @@ -36,8 +36,10 @@ updater = Updater( dir, "https://example.com/metadata/", + dir, "https://example.com/targets/", - sim + sim, + bootstrap=sim.signed_roots[0], ) updater.refresh() """ diff --git a/tests/test_updater_consistent_snapshot.py b/tests/test_updater_consistent_snapshot.py index 4ceb1fe7f9..abf6fb4a9b 100644 --- a/tests/test_updater_consistent_snapshot.py +++ b/tests/test_updater_consistent_snapshot.py @@ -74,10 +74,6 @@ def _init_repo( sim.publish_root() sim.prefix_targets_with_hash = prefix_targets - # Init trusted root with the latest consistent_snapshot - with open(os.path.join(self.metadata_dir, "root.json"), "bw") as f: - f.write(sim.signed_roots[-1]) - return sim def _init_updater(self) -> Updater: @@ -88,6 +84,7 @@ def _init_updater(self) -> Updater: self.targets_dir, "https://example.com/targets/", self.sim, + bootstrap=self.sim.signed_roots[-1], ) def _assert_metadata_files_exist(self, roles: Iterable[str]) -> None: diff --git a/tests/test_updater_delegation_graphs.py b/tests/test_updater_delegation_graphs.py index 770a1b3d71..536bb13a2d 100644 --- a/tests/test_updater_delegation_graphs.py +++ b/tests/test_updater_delegation_graphs.py @@ -120,16 +120,13 @@ def _init_repo(self, test_case: DelegationsTestCase) -> None: def _init_updater(self) -> Updater: """Create a new Updater instance""" - # Init trusted root for Updater - with open(os.path.join(self.metadata_dir, "root.json"), "bw") as f: - f.write(self.sim.signed_roots[0]) - return Updater( self.metadata_dir, "https://example.com/metadata/", self.targets_dir, "https://example.com/targets/", self.sim, + bootstrap=self.sim.signed_roots[0], ) def _assert_files_exist(self, roles: Iterable[str]) -> None: diff --git a/tests/test_updater_fetch_target.py b/tests/test_updater_fetch_target.py index 5ab8567032..ecf777c6f1 100644 --- a/tests/test_updater_fetch_target.py +++ b/tests/test_updater_fetch_target.py @@ -40,10 +40,8 @@ def setUp(self) -> None: os.mkdir(self.metadata_dir) os.mkdir(self.targets_dir) - # Setup the repository, bootstrap client root.json + # Setup the repository self.sim = RepositorySimulator() - with open(os.path.join(self.metadata_dir, "root.json"), "bw") as f: - f.write(self.sim.signed_roots[0]) if self.dump_dir is not None: # create test specific dump directory @@ -65,6 +63,7 @@ def _init_updater(self) -> Updater: self.targets_dir, "https://example.com/targets/", self.sim, + bootstrap=self.sim.signed_roots[0], ) targets = { diff --git a/tests/test_updater_key_rotations.py b/tests/test_updater_key_rotations.py index f79c3dd997..90dbd262f9 100644 --- a/tests/test_updater_key_rotations.py +++ b/tests/test_updater_key_rotations.py @@ -72,13 +72,12 @@ def _run_refresh(self) -> None: # bootstrap with initial root self.metadata_dir = tempfile.mkdtemp(dir=self.temp_dir.name) - with open(os.path.join(self.metadata_dir, "root.json"), "bw") as f: - f.write(self.sim.signed_roots[0]) updater = Updater( self.metadata_dir, "https://example.com/metadata/", fetcher=self.sim, + bootstrap=self.sim.signed_roots[0], ) updater.refresh() diff --git a/tests/test_updater_ng.py b/tests/test_updater_ng.py index 5393aa3c21..5fc436ba97 100644 --- a/tests/test_updater_ng.py +++ b/tests/test_updater_ng.py @@ -115,6 +115,7 @@ def setUp(self) -> None: metadata_base_url=self.metadata_url, target_dir=self.dl_dir, target_base_url=self.targets_url, + bootstrap=None, ) def tearDown(self) -> None: @@ -247,14 +248,21 @@ def test_implicit_refresh_with_only_local_root(self) -> None: def test_both_target_urls_not_set(self) -> None: # target_base_url = None and Updater._target_base_url = None - updater = Updater(self.client_directory, self.metadata_url, self.dl_dir) + updater = Updater( + self.client_directory, + self.metadata_url, + self.dl_dir, + bootstrap=None, + ) info = TargetFile(1, {"sha256": ""}, "targetpath") with self.assertRaises(ValueError): updater.download_target(info) def test_no_target_dir_no_filepath(self) -> None: # filepath = None and Updater.target_dir = None - updater = Updater(self.client_directory, self.metadata_url) + updater = Updater( + self.client_directory, self.metadata_url, bootstrap=None + ) info = TargetFile(1, {"sha256": ""}, "targetpath") with self.assertRaises(ValueError): updater.find_cached_target(info) @@ -344,6 +352,7 @@ def test_user_agent(self) -> None: self.dl_dir, self.targets_url, config=UpdaterConfig(app_user_agent="MyApp/1.2.3"), + bootstrap=None, ) updater.refresh() poolmgr = updater._fetcher._proxy_env.get_pool_manager( diff --git a/tests/test_updater_validation.py b/tests/test_updater_validation.py index b9d6bb3cc7..7417b67c5d 100644 --- a/tests/test_updater_validation.py +++ b/tests/test_updater_validation.py @@ -23,10 +23,8 @@ def setUp(self) -> None: os.mkdir(self.metadata_dir) os.mkdir(self.targets_dir) - # Setup the repository, bootstrap client root.json + # Setup the repository self.sim = RepositorySimulator() - with open(os.path.join(self.metadata_dir, "root.json"), "bw") as f: - f.write(self.sim.signed_roots[0]) def tearDown(self) -> None: self.temp_dir.cleanup() @@ -38,8 +36,18 @@ def _new_updater(self) -> Updater: self.targets_dir, "https://example.com/targets/", fetcher=self.sim, + bootstrap=self.sim.signed_roots[0], ) + def test_bootstrap_argument_required(self) -> None: + with self.assertRaises(TypeError) as ctx: + Updater( + self.metadata_dir, + "https://example.com/metadata/", + fetcher=self.sim, + ) # type: ignore[call-arg] + self.assertIn("bootstrap", str(ctx.exception)) + def test_local_target_storage_fail(self) -> None: self.sim.add_target("targets", b"content", "targetpath") self.sim.targets.version += 1 @@ -52,12 +60,14 @@ def test_local_target_storage_fail(self) -> None: updater.download_target(target_info, filepath="") def test_non_existing_metadata_dir(self) -> None: + non_existing_dir = os.path.join(self.temp_dir.name, "non-existing-dir") with self.assertRaises(FileNotFoundError): # Initialize Updater with non-existing metadata_dir Updater( - "non_existing_metadata_dir", + non_existing_dir, "https://example.com/metadata/", fetcher=self.sim, + bootstrap=None, ) diff --git a/tests/utils.py b/tests/utils.py index f4310d0aec..cc35af0447 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -115,7 +115,7 @@ def wait_for_server( pass except OSError as e: # ECONNREFUSED is expected while the server is not started - if e.errno not in [errno.ECONNREFUSED]: + if e.errno != errno.ECONNREFUSED: logger.warning( "Unexpected error while waiting for server: %s", str(e) ) diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index a98e799ce4..a253b18d4c 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -13,7 +13,8 @@ * Initializing an ``Updater`` loads and validates the trusted local root metadata: This root metadata is used as the source of trust for all other metadata. Updater should always be initialized with the ``bootstrap`` - argument: if this is not possible, it can be initialized from cache only. + argument: pass ``bootstrap=None`` only to explicitly opt into using the + cached root.json as the trust anchor. * ``refresh()`` can optionally be called to update and load all top-level metadata as described in the specification, using both locally cached metadata and metadata downloaded from the remote repository. If refresh is @@ -79,7 +80,8 @@ class Updater: Args: metadata_dir: Local metadata directory. Directory must be - writable and it must contain a trusted root.json file + writable. If ``bootstrap`` is ``None``, this directory must contain + a trusted root.json file. metadata_base_url: Base URL for all remote metadata downloads target_dir: Local targets directory. Directory must be writable. It will be used as the default target download directory by @@ -90,9 +92,11 @@ class Updater: download both metadata and targets. Default is ``Urllib3Fetcher`` config: ``Optional``; ``UpdaterConfig`` could be used to setup common configuration options. - bootstrap: ``Optional``; initial root metadata. A bootstrap root should - always be provided. If it is not, the current root.json in the - metadata cache is used as the initial root. + bootstrap: Initial root metadata bytes. This argument is required. + Pass the embedded root metadata bytes for secure initialization. + Pass ``None`` only if you explicitly want to use the cached + root.json as the trust anchor (not recommended for most + deployments). Raises: OSError: Local root.json cannot be read @@ -107,7 +111,8 @@ def __init__( target_base_url: str | None = None, fetcher: FetcherInterface | None = None, config: UpdaterConfig | None = None, - bootstrap: bytes | None = None, + *, + bootstrap: bytes | None, ): self._dir = metadata_dir self._metadata_base_url = _ensure_trailing_slash(metadata_base_url) @@ -131,7 +136,7 @@ def __init__( f"got '{self.config.envelope_type}'" ) - if not bootstrap: + if bootstrap is None: # if no root was provided, use the cached non-versioned root.json bootstrap = self._load_local_metadata(Root.type)