feat(ngclient): require explicit bootstrap argument

make bootstrap required and explicit: callers must pass bootstrap=<root_bytes> or bootstrap=None.

also tighten docs, examples, and tests to reflect the explicit trust anchor choice.

Signed-off-by: 1seal <security@1seal.org>
This commit is contained in:
1seal 2026-01-25 11:50:37 +00:00
parent 7b9d787971
commit c49bdb9322
12 changed files with 82 additions and 14 deletions

View file

@ -2,6 +2,12 @@
## Unreleased
### Changed
* ngclient: `Updater()` now requires an explicit `bootstrap` argument
* This is a breaking change: callers must pass `bootstrap=<root_bytes>` 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

View file

@ -53,6 +53,39 @@ from GitHub, change into the project root directory, and install with pip
python3 -m pip install -r requirements/dev.txt
Bootstrap root metadata
-----------------------
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
-------------------------

View file

@ -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()

View file

@ -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()

View file

@ -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()
"""

View file

@ -88,6 +88,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:

View file

@ -130,6 +130,7 @@ def _init_updater(self) -> Updater:
self.targets_dir,
"https://example.com/targets/",
self.sim,
bootstrap=self.sim.signed_roots[0],
)
def _assert_files_exist(self, roles: Iterable[str]) -> None:

View file

@ -65,6 +65,7 @@ def _init_updater(self) -> Updater:
self.targets_dir,
"https://example.com/targets/",
self.sim,
bootstrap=self.sim.signed_roots[0],
)
targets = {

View file

@ -79,6 +79,7 @@ def _run_refresh(self) -> None:
self.metadata_dir,
"https://example.com/metadata/",
fetcher=self.sim,
bootstrap=self.sim.signed_roots[0],
)
updater.refresh()

View file

@ -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,16 @@ 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 +347,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(

View file

@ -38,8 +38,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,
)
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 +62,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,
)

View file

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