Skip to content

Commit cea1745

Browse files
committed
Implement root bootstrapping
Application may have a "more secure" data store than the metadata cache is: Allow application to bootstrap the Updater with this more secure root. This means the Updater must also cache the subsequent root versions (and not just the last one). * Store versioned root metadata in local cache * maintain a non versioned symlink to last known good root * When loading root metadata, look in local cache too * Add a 'bootstrap' argument to Updater: this allows initializing the Updater with known good root metadata instead of trusting the root.json in cache Additional changes to current functionality: * when using bootstrap argument, the initial root is written to cache. This write happens every time Updater is initialized with bootstrap * The "root.json" symlink is recreated at the end of every refresh() Signed-off-by: Jussi Kukkonen <[email protected]>
1 parent f35b237 commit cea1745

File tree

3 files changed

+78
-25
lines changed

3 files changed

+78
-25
lines changed

examples/client/client

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import sys
1111
import traceback
1212
from hashlib import sha256
1313
from pathlib import Path
14-
from urllib import request
14+
15+
import urllib3
1516

1617
from tuf.api.exceptions import DownloadError, RepositoryError
1718
from tuf.ngclient import Updater
@@ -30,18 +31,25 @@ def build_metadata_dir(base_url: str) -> str:
3031
def init_tofu(base_url: str) -> bool:
3132
"""Initialize local trusted metadata (Trust-On-First-Use) and create a
3233
directory for downloads"""
34+
3335
metadata_dir = build_metadata_dir(base_url)
3436

3537
if not os.path.isdir(metadata_dir):
3638
os.makedirs(metadata_dir)
3739

38-
root_url = f"{base_url}/metadata/1.root.json"
39-
try:
40-
request.urlretrieve(root_url, f"{metadata_dir}/root.json")
41-
except OSError:
42-
print(f"Failed to download initial root from {root_url}")
40+
response = urllib3.request("GET", f"{base_url}/metadata/1.root.json")
41+
if response.status != 200:
42+
print(f"Failed to download initial root {base_url}/metadata/1.root.json")
4343
return False
4444

45+
Updater(
46+
metadata_dir=metadata_dir,
47+
metadata_base_url=f"{base_url}/metadata/",
48+
target_base_url=f"{base_url}/targets/",
49+
target_dir=DOWNLOAD_DIR,
50+
bootstrap=response.data,
51+
)
52+
4553
print(f"Trust-on-First-Use: Initialized new root in {metadata_dir}")
4654
return True
4755

tests/test_updater_ng.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55

66
from __future__ import annotations
77

8-
from collections.abc import Iterable
98
import logging
109
import os
1110
import shutil
1211
import sys
1312
import tempfile
1413
import unittest
14+
from collections.abc import Iterable
1515
from typing import TYPE_CHECKING, Callable, ClassVar
1616
from unittest.mock import MagicMock, patch
1717

tuf/ngclient/updater.py

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@
4949

5050
from tuf.api import exceptions
5151
from tuf.api.metadata import Root, Snapshot, TargetFile, Targets, Timestamp
52-
from tuf.ngclient import urllib3_fetcher
53-
from tuf.ngclient._internal import trusted_metadata_set
52+
from tuf.ngclient._internal.trusted_metadata_set import TrustedMetadataSet
5453
from tuf.ngclient.config import EnvelopeType, UpdaterConfig
54+
from tuf.ngclient.urllib3_fetcher import Urllib3Fetcher
5555

5656
if TYPE_CHECKING:
5757
from tuf.ngclient.fetcher import FetcherInterface
@@ -75,6 +75,9 @@ class Updater:
7575
download both metadata and targets. Default is ``Urllib3Fetcher``
7676
config: ``Optional``; ``UpdaterConfig`` could be used to setup common
7777
configuration options.
78+
bootstrap: ``Optional``; initial root metadata. If a boostrap root is
79+
not provided then the root.json in the metadata cache is used as the
80+
initial root.
7881
7982
Raises:
8083
OSError: Local root.json cannot be read
@@ -89,6 +92,7 @@ def __init__(
8992
target_base_url: str | None = None,
9093
fetcher: FetcherInterface | None = None,
9194
config: UpdaterConfig | None = None,
95+
bootstrap: bytes | None = None,
9296
):
9397
self._dir = metadata_dir
9498
self._metadata_base_url = _ensure_trailing_slash(metadata_base_url)
@@ -99,27 +103,28 @@ def __init__(
99103
self._target_base_url = _ensure_trailing_slash(target_base_url)
100104

101105
self.config = config or UpdaterConfig()
102-
103106
if fetcher is not None:
104107
self._fetcher = fetcher
105108
else:
106-
self._fetcher = urllib3_fetcher.Urllib3Fetcher(
109+
self._fetcher = Urllib3Fetcher(
107110
app_user_agent=self.config.app_user_agent
108111
)
109-
110112
supported_envelopes = [EnvelopeType.METADATA, EnvelopeType.SIMPLE]
111113
if self.config.envelope_type not in supported_envelopes:
112114
raise ValueError(
113115
f"config: envelope_type must be one of {supported_envelopes}, "
114116
f"got '{self.config.envelope_type}'"
115117
)
116118

117-
# Read trusted local root metadata
118-
data = self._load_local_metadata(Root.type)
119+
if not bootstrap:
120+
# if no root was provided, use the cached non-versioned root.json
121+
bootstrap = self._load_local_metadata(Root.type)
119122

120-
self._trusted_set = trusted_metadata_set.TrustedMetadataSet(
121-
data, self.config.envelope_type
123+
# Load the initial root, make sure it's cached in root_history/
124+
self._trusted_set = TrustedMetadataSet(
125+
bootstrap, self.config.envelope_type
122126
)
127+
self._persist_root(self._trusted_set.root.version, bootstrap)
123128

124129
def refresh(self) -> None:
125130
"""Refresh top-level metadata.
@@ -296,12 +301,31 @@ def _load_local_metadata(self, rolename: str) -> bytes:
296301
return f.read()
297302

298303
def _persist_metadata(self, rolename: str, data: bytes) -> None:
299-
"""Write metadata to disk atomically to avoid data loss."""
300-
temp_file_name: str | None = None
304+
"""Write metadata to disk atomically to avoid data loss.
305+
306+
Use a filename _not_ prefixed with version (e.g. "timestamp.json")
307+
. Encode the rolename to avoid issues with e.g. path separators
308+
"""
309+
310+
encoded_name = parse.quote(rolename, "")
311+
filename = os.path.join(self._dir, f"{encoded_name}.json")
312+
self._persist_file(filename, data)
313+
314+
def _persist_root(self, version: int, data: bytes) -> None:
315+
"""Write root metadata to disk atomically to avoid data loss.
316+
317+
Use a filename prefixed with version (e.g. "1.root.json").
318+
"""
319+
rootdir = os.path.join(self._dir, "root_history")
320+
with contextlib.suppress(FileExistsError):
321+
os.mkdir(rootdir)
322+
self._persist_file(os.path.join(rootdir, f"{version}.root.json"), data)
323+
324+
def _persist_file(self, filename: str, data: bytes) -> None:
325+
"""Write a file to disk atomically to avoid data loss."""
326+
temp_file_name = None
327+
301328
try:
302-
# encode the rolename to avoid issues with e.g. path separators
303-
encoded_name = parse.quote(rolename, "")
304-
filename = os.path.join(self._dir, f"{encoded_name}.json")
305329
with tempfile.NamedTemporaryFile(
306330
dir=self._dir, delete=False
307331
) as temp_file:
@@ -317,32 +341,53 @@ def _persist_metadata(self, rolename: str, data: bytes) -> None:
317341
raise e
318342

319343
def _load_root(self) -> None:
320-
"""Load remote root metadata.
344+
"""Load root metadata.
321345
322-
Sequentially load and persist on local disk every newer root metadata
323-
version available on the remote.
346+
Sequentially load and persist every newer root metadata
347+
version available, either locally or on the remote.
324348
"""
325349

326350
# Update the root role
327351
lower_bound = self._trusted_set.root.version + 1
328352
upper_bound = lower_bound + self.config.max_root_rotations
329353

330354
for next_version in range(lower_bound, upper_bound):
355+
# look for next_version in local cache
356+
try:
357+
root_path = os.path.join(
358+
self._dir, "root_history", f"{next_version}.root.json"
359+
)
360+
with open(root_path, "rb") as f:
361+
self._trusted_set.update_root(f.read())
362+
continue
363+
except (OSError, exceptions.RepositoryError) as e:
364+
# this root did not exist locally or is invalid
365+
logger.debug("Local root is not valid: %s", e)
366+
367+
# next_version was not found locally, try remote
331368
try:
332369
data = self._download_metadata(
333370
Root.type,
334371
self.config.root_max_length,
335372
next_version,
336373
)
337374
self._trusted_set.update_root(data)
338-
self._persist_metadata(Root.type, data)
375+
self._persist_root(next_version, data)
339376

340377
except exceptions.DownloadHTTPError as exception:
341378
if exception.status_code not in {403, 404}:
342379
raise
343380
# 404/403 means current root is newest available
344381
break
345382

383+
# Make sure there's a non-versioned root.json
384+
linkname = os.path.join(self._dir, "root.json")
385+
version = self._trusted_set.root.version
386+
current = os.path.join("root_history", f"{version}.root.json")
387+
with contextlib.suppress(FileNotFoundError):
388+
os.remove(linkname)
389+
os.symlink(current, linkname)
390+
346391
def _load_timestamp(self) -> None:
347392
"""Load local and remote timestamp metadata."""
348393
try:

0 commit comments

Comments
 (0)