1# Copyright 2021-2026, the TUF contributors
2# SPDX-License-Identifier: MIT OR Apache-2.0
3
4"""Trusted collection of client-side TUF Metadata.
5
6``TrustedMetadataSet`` keeps track of the current valid set of metadata for the
7client, and handles almost every step of the "Detailed client workflow" (
8https://theupdateframework.github.io/specification/latest#detailed-client-workflow)
9in the TUF specification: the remaining steps are related to filesystem and
10network IO, which are not handled here.
11
12Loaded metadata can be accessed via index access with rolename as key
13(``trusted_set[Root.type]``) or, in the case of top-level metadata, using the
14helper properties (``trusted_set.root``).
15
16Signatures are verified and discarded upon inclusion into the trusted set.
17
18The rules that ``TrustedMetadataSet`` follows for top-level metadata are
19 * Metadata must be loaded in order:
20 root -> timestamp -> snapshot -> targets -> (delegated targets).
21 * Metadata can be loaded even if it is expired (or in the snapshot case if the
22 meta info does not match): this is called "intermediate metadata".
23 * Intermediate metadata can _only_ be used to load newer versions of the
24 same metadata: As an example an expired root can be used to load a new root.
25 * Metadata is loadable only if metadata before it in loading order is loaded
26 (and is not intermediate): As an example timestamp can be loaded if a
27 final (non-expired) root has been loaded.
28 * Metadata is not loadable if any metadata after it in loading order has been
29 loaded: As an example new roots cannot be loaded if timestamp is loaded.
30
31Exceptions are raised if metadata fails to load in any way.
32
33Example of loading root, timestamp and snapshot:
34
35>>> # Load local root (RepositoryErrors here stop the update)
36>>> with open(root_path, "rb") as f:
37>>> trusted_set = TrustedMetadataSet(f.read(), EnvelopeType.METADATA)
38>>>
39>>> # update root from remote until no more are available
40>>> with download(Root.type, trusted_set.root.version + 1) as f:
41>>> trusted_set.update_root(f.read())
42>>>
43>>> # load local timestamp, then update from remote
44>>> try:
45>>> with open(timestamp_path, "rb") as f:
46>>> trusted_set.update_timestamp(f.read())
47>>> except (RepositoryError, OSError):
48>>> pass # failure to load a local file is ok
49>>>
50>>> with download(Timestamp.type) as f:
51>>> trusted_set.update_timestamp(f.read())
52>>>
53>>> # load local snapshot, then update from remote if needed
54>>> try:
55>>> with open(snapshot_path, "rb") as f:
56>>> trusted_set.update_snapshot(f.read())
57>>> except (RepositoryError, OSError):
58>>> # local snapshot is not valid, load from remote
59>>> # (RepositoryErrors here stop the update)
60>>> with download(Snapshot.type, version) as f:
61>>> trusted_set.update_snapshot(f.read())
62"""
63
64from __future__ import annotations
65
66import datetime
67import logging
68from collections import abc
69from typing import TYPE_CHECKING, cast
70
71from tuf.api import exceptions
72from tuf.api.dsse import SimpleEnvelope
73from tuf.api.metadata import (
74 Metadata,
75 Root,
76 Signed,
77 Snapshot,
78 T,
79 Targets,
80 Timestamp,
81)
82from tuf.ngclient.config import EnvelopeType
83
84if TYPE_CHECKING:
85 from collections.abc import Iterator
86
87 from securesystemslib.signer import Signature
88
89logger = logging.getLogger(__name__)
90
91Delegator = Root | Targets
92
93
94class TrustedMetadataSet(abc.Mapping):
95 """Internal class to keep track of trusted metadata in ``Updater``.
96
97 ``TrustedMetadataSet`` ensures that the collection of metadata in it is
98 valid and trusted through the whole client update workflow. It provides
99 easy ways to update the metadata with the caller making decisions on
100 what is updated.
101 """
102
103 def __init__(self, root_data: bytes, envelope_type: EnvelopeType):
104 """Initialize ``TrustedMetadataSet`` by loading trusted root metadata.
105
106 Args:
107 root_data: Trusted root metadata as bytes. Note that this metadata
108 will only be verified by itself: it is the source of trust for
109 all metadata in the ``TrustedMetadataSet``
110 envelope_type: Configures deserialization and verification mode of
111 TUF metadata.
112
113 Raises:
114 RepositoryError: Metadata failed to load or verify. The actual
115 error type and content will contain more details.
116 """
117 self._trusted_set: dict[str, Signed] = {}
118 self._trusted_delegations: set[tuple[str, str]] = set()
119 self.reference_time = datetime.datetime.now(datetime.timezone.utc)
120
121 if envelope_type is EnvelopeType.SIMPLE:
122 self._load_data = _load_from_simple_envelope
123 else:
124 self._load_data = _load_from_metadata
125
126 # Load and validate the local root metadata. Valid initial trusted root
127 # metadata is required
128 logger.debug("Updating initial trusted root")
129 self._load_trusted_root(root_data)
130
131 def contains(self, role: str, delegator: str) -> bool:
132 """Check if ``role`` is in ``TrustedMetadataSet`` and was verified
133 against ``delegator``.
134 """
135 return (delegator, role) in self._trusted_delegations
136
137 def _add(self, role: str, signed: Signed, delegator: str) -> None:
138 """Add role to trusted set, keep track of delegator(s)"""
139 self._trusted_set[role] = signed
140 self._trusted_delegations.add((delegator, role))
141
142 def __getitem__(self, role: str) -> Signed:
143 """Return current ``Signed`` for ``role``."""
144 return self._trusted_set[role]
145
146 def __len__(self) -> int:
147 """Return number of ``Signed`` objects in ``TrustedMetadataSet``."""
148 return len(self._trusted_set)
149
150 def __iter__(self) -> Iterator[Signed]:
151 """Return iterator over ``Signed`` objects in
152 ``TrustedMetadataSet``.
153 """
154 return iter(self._trusted_set.values())
155
156 # Helper properties for top level metadata
157 @property
158 def root(self) -> Root:
159 """Get current root."""
160 return cast("Root", self._trusted_set[Root.type])
161
162 @property
163 def timestamp(self) -> Timestamp:
164 """Get current timestamp."""
165 return cast("Timestamp", self._trusted_set[Timestamp.type])
166
167 @property
168 def snapshot(self) -> Snapshot:
169 """Get current snapshot."""
170 return cast("Snapshot", self._trusted_set[Snapshot.type])
171
172 @property
173 def targets(self) -> Targets:
174 """Get current top-level targets."""
175 return cast("Targets", self._trusted_set[Targets.type])
176
177 # Methods for updating metadata
178 def update_root(self, data: bytes) -> Root:
179 """Verify and load ``data`` as new root metadata.
180
181 Note that an expired intermediate root is considered valid: expiry is
182 only checked for the final root in ``update_timestamp()``.
183
184 Args:
185 data: Unverified new root metadata as bytes
186
187 Raises:
188 RuntimeError: This function is called after updating timestamp.
189 RepositoryError: Metadata failed to load or verify. The actual
190 error type and content will contain more details.
191
192 Returns:
193 Deserialized and verified ``Root`` object
194 """
195 if Timestamp.type in self._trusted_set:
196 raise RuntimeError("Cannot update root after timestamp")
197 logger.debug("Updating root")
198
199 new_root, new_root_bytes, new_root_signatures = self._load_data(
200 Root, data, self.root
201 )
202 if new_root.version != self.root.version + 1:
203 raise exceptions.BadVersionNumberError(
204 f"Expected root version {self.root.version + 1}"
205 f" instead got version {new_root.version}"
206 )
207
208 # Verify that new root is signed by itself
209 new_root.verify_delegate(Root.type, new_root_bytes, new_root_signatures)
210
211 self._add(Root.type, new_root, Root.type)
212 logger.debug("Updated root v%d", new_root.version)
213
214 return new_root
215
216 def update_timestamp(self, data: bytes) -> Timestamp:
217 """Verify and load ``data`` as new timestamp metadata.
218
219 Note that an intermediate timestamp is allowed to be expired:
220 ``TrustedMetadataSet`` will throw an ``ExpiredMetadataError`` in
221 this case but the intermediate timestamp will be loaded. This way
222 a newer timestamp can still be loaded (and the intermediate
223 timestamp will be used for rollback protection). Expired timestamp
224 will prevent loading snapshot metadata.
225
226 Args:
227 data: Unverified new timestamp metadata as bytes
228
229 Raises:
230 RuntimeError: This function is called after updating snapshot.
231 RepositoryError: Metadata failed to load or verify as final
232 timestamp. The actual error type and content will contain
233 more details.
234
235 Returns:
236 Deserialized and verified ``Timestamp`` object
237 """
238 if Snapshot.type in self._trusted_set:
239 raise RuntimeError("Cannot update timestamp after snapshot")
240
241 # client workflow 5.3.10: Make sure final root is not expired.
242 if self.root.is_expired(self.reference_time):
243 raise exceptions.ExpiredMetadataError("Final root.json is expired")
244 # No need to check for 5.3.11 (fast forward attack recovery):
245 # timestamp/snapshot can not yet be loaded at this point
246
247 new_timestamp, _, _ = self._load_data(Timestamp, data, self.root)
248
249 # If an existing trusted timestamp is updated,
250 # check for a rollback attack
251 if Timestamp.type in self._trusted_set:
252 # Prevent rolling back timestamp version
253 if new_timestamp.version < self.timestamp.version:
254 raise exceptions.BadVersionNumberError(
255 f"New timestamp version {new_timestamp.version} must"
256 f" be >= {self.timestamp.version}"
257 )
258 # Keep using old timestamp if versions are equal.
259 if new_timestamp.version == self.timestamp.version:
260 raise exceptions.EqualVersionNumberError
261
262 # Prevent rolling back snapshot version
263 snapshot_meta = self.timestamp.snapshot_meta
264 new_snapshot_meta = new_timestamp.snapshot_meta
265 if new_snapshot_meta.version < snapshot_meta.version:
266 raise exceptions.BadVersionNumberError(
267 f"New snapshot version must be >= {snapshot_meta.version}"
268 f", got version {new_snapshot_meta.version}"
269 )
270
271 # expiry not checked to allow old timestamp to be used for rollback
272 # protection of new timestamp: expiry is checked in update_snapshot()
273
274 self._add(Timestamp.type, new_timestamp, Root.type)
275 logger.debug("Updated timestamp v%d", new_timestamp.version)
276
277 # timestamp is loaded: raise if it is not valid _final_ timestamp
278 self._check_final_timestamp()
279
280 return new_timestamp
281
282 def _check_final_timestamp(self) -> None:
283 """Raise if timestamp is expired."""
284
285 if self.timestamp.is_expired(self.reference_time):
286 raise exceptions.ExpiredMetadataError("timestamp.json is expired")
287
288 def update_snapshot(
289 self, data: bytes, trusted: bool | None = False
290 ) -> Snapshot:
291 """Verify and load ``data`` as new snapshot metadata.
292
293 Note that an intermediate snapshot is allowed to be expired and version
294 is allowed to not match timestamp meta version: ``TrustedMetadataSet``
295 will throw an ``ExpiredMetadataError``/``BadVersionNumberError`` in
296 these cases but the intermediate snapshot will be loaded. This way a
297 newer snapshot can still be loaded (and the intermediate snapshot will
298 be used for rollback protection). Expired snapshot or snapshot that
299 does not match timestamp meta version will prevent loading targets.
300
301 Args:
302 data: Unverified new snapshot metadata as bytes
303 trusted: ``True`` if data has at some point been verified by
304 ``TrustedMetadataSet`` as a valid snapshot. Purpose of trusted
305 is to allow loading of locally stored snapshot as intermediate
306 snapshot even if hashes in current timestamp meta no longer
307 match data. Default is False.
308
309 Raises:
310 RuntimeError: This function is called before updating timestamp
311 or after updating targets.
312 RepositoryError: Data failed to load or verify as final snapshot.
313 The actual error type and content will contain more details.
314
315 Returns:
316 Deserialized and verified ``Snapshot`` object
317 """
318
319 if Timestamp.type not in self._trusted_set:
320 raise RuntimeError("Cannot update snapshot before timestamp")
321 if Targets.type in self._trusted_set:
322 raise RuntimeError("Cannot update snapshot after targets")
323 logger.debug("Updating snapshot")
324
325 # Snapshot cannot be loaded if final timestamp is expired
326 self._check_final_timestamp()
327
328 snapshot_meta = self.timestamp.snapshot_meta
329
330 # Verify non-trusted data against the hashes in timestamp, if any.
331 # Trusted snapshot data has already been verified once.
332 if not trusted:
333 snapshot_meta.verify_length_and_hashes(data)
334
335 new_snapshot, _, _ = self._load_data(Snapshot, data, self.root)
336
337 # version not checked against meta version to allow old snapshot to be
338 # used in rollback protection: it is checked when targets is updated
339
340 # If an existing trusted snapshot is updated, check for rollback attack
341 if Snapshot.type in self._trusted_set:
342 for filename, fileinfo in self.snapshot.meta.items():
343 new_fileinfo = new_snapshot.meta.get(filename)
344
345 # Prevent removal of any metadata in meta
346 if new_fileinfo is None:
347 raise exceptions.RepositoryError(
348 f"New snapshot is missing info for '{filename}'"
349 )
350
351 # Prevent rollback of any metadata versions
352 if new_fileinfo.version < fileinfo.version:
353 raise exceptions.BadVersionNumberError(
354 f"Expected {filename} version "
355 f"{new_fileinfo.version}, got {fileinfo.version}."
356 )
357
358 # expiry not checked to allow old snapshot to be used for rollback
359 # protection of new snapshot: it is checked when targets is updated
360
361 self._add(Snapshot.type, new_snapshot, Root.type)
362 logger.debug("Updated snapshot v%d", new_snapshot.version)
363
364 # snapshot is loaded, but we raise if it's not valid _final_ snapshot
365 self._check_final_snapshot()
366
367 return new_snapshot
368
369 def _check_final_snapshot(self) -> None:
370 """Raise if snapshot is expired or meta version does not match."""
371
372 if self.snapshot.is_expired(self.reference_time):
373 raise exceptions.ExpiredMetadataError("snapshot.json is expired")
374 snapshot_meta = self.timestamp.snapshot_meta
375 if self.snapshot.version != snapshot_meta.version:
376 raise exceptions.BadVersionNumberError(
377 f"Expected snapshot version {snapshot_meta.version}, "
378 f"got {self.snapshot.version}"
379 )
380
381 def update_targets(self, data: bytes) -> Targets:
382 """Verify and load ``data`` as new top-level targets metadata.
383
384 Args:
385 data: Unverified new targets metadata as bytes
386
387 Raises:
388 RepositoryError: Metadata failed to load or verify. The actual
389 error type and content will contain more details.
390
391 Returns:
392 Deserialized and verified `Targets`` object
393 """
394 return self.update_delegated_targets(data, Targets.type, Root.type)
395
396 def update_delegated_targets(
397 self, data: bytes, role_name: str, delegator_name: str
398 ) -> Targets:
399 """Verify and load ``data`` as new metadata for target ``role_name``.
400
401 Args:
402 data: Unverified new metadata as bytes
403 role_name: Role name of the new metadata
404 delegator_name: Name of the role delegating to the new metadata
405
406 Raises:
407 RuntimeError: This function is called before updating snapshot.
408 RepositoryError: Metadata failed to load or verify. The actual
409 error type and content will contain more details.
410
411 Returns:
412 Deserialized and verified ``Targets`` object
413 """
414 if Snapshot.type not in self._trusted_set:
415 raise RuntimeError("Cannot load targets before snapshot")
416
417 # Targets cannot be loaded if final snapshot is expired or its version
418 # does not match meta version in timestamp
419 self._check_final_snapshot()
420
421 delegator: Delegator | None = self.get(delegator_name)
422 if delegator is None:
423 raise RuntimeError("Cannot load targets before delegator")
424
425 logger.debug("Updating %s delegated by %s", role_name, delegator_name)
426
427 # Verify against the hashes in snapshot, if any
428 meta = self.snapshot.meta.get(f"{role_name}.json")
429 if meta is None:
430 raise exceptions.RepositoryError(
431 f"Snapshot does not contain information for '{role_name}'"
432 )
433
434 meta.verify_length_and_hashes(data)
435
436 new_delegate, _, _ = self._load_data(
437 Targets, data, delegator, role_name
438 )
439
440 version = new_delegate.version
441 if version != meta.version:
442 raise exceptions.BadVersionNumberError(
443 f"Expected {role_name} v{meta.version}, got v{version}."
444 )
445
446 if new_delegate.is_expired(self.reference_time):
447 raise exceptions.ExpiredMetadataError(f"New {role_name} is expired")
448
449 self._add(role_name, new_delegate, delegator_name)
450 logger.debug("Updated %s v%d", role_name, version)
451
452 return new_delegate
453
454 def _load_trusted_root(self, data: bytes) -> None:
455 """Verify and load ``data`` as trusted root metadata.
456
457 Note that an expired initial root is considered valid: expiry is
458 only checked for the final root in ``update_timestamp()``.
459 """
460 new_root, new_root_bytes, new_root_signatures = self._load_data(
461 Root, data
462 )
463 new_root.verify_delegate(Root.type, new_root_bytes, new_root_signatures)
464
465 self._add(Root.type, new_root, Root.type)
466 logger.debug("Loaded trusted root v%d", new_root.version)
467
468
469def _load_from_metadata(
470 role: type[T],
471 data: bytes,
472 delegator: Delegator | None = None,
473 role_name: str | None = None,
474) -> tuple[T, bytes, dict[str, Signature]]:
475 """Load traditional metadata bytes, and extract and verify payload.
476
477 If no delegator is passed, verification is skipped. Returns a tuple of
478 deserialized payload, signed payload bytes, and signatures.
479 """
480 md = Metadata[T].from_bytes(data)
481
482 if md.signed.type != role.type:
483 raise exceptions.RepositoryError(
484 f"Expected '{role.type}', got '{md.signed.type}'"
485 )
486
487 if delegator:
488 if role_name is None:
489 role_name = role.type
490
491 delegator.verify_delegate(role_name, md.signed_bytes, md.signatures)
492
493 return md.signed, md.signed_bytes, md.signatures
494
495
496def _load_from_simple_envelope(
497 role: type[T],
498 data: bytes,
499 delegator: Delegator | None = None,
500 role_name: str | None = None,
501) -> tuple[T, bytes, dict[str, Signature]]:
502 """Load simple envelope bytes, and extract and verify payload.
503
504 If no delegator is passed, verification is skipped. Returns a tuple of
505 deserialized payload, signed payload bytes, and signatures.
506 """
507
508 envelope = SimpleEnvelope[T].from_bytes(data)
509
510 if envelope.payload_type != SimpleEnvelope.DEFAULT_PAYLOAD_TYPE:
511 raise exceptions.RepositoryError(
512 f"Expected '{SimpleEnvelope.DEFAULT_PAYLOAD_TYPE}', "
513 f"got '{envelope.payload_type}'"
514 )
515
516 if delegator:
517 if role_name is None:
518 role_name = role.type
519 delegator.verify_delegate(
520 role_name, envelope.pae(), envelope.signatures
521 )
522
523 signed = envelope.get_signed()
524 if signed.type != role.type:
525 raise exceptions.RepositoryError(
526 f"Expected '{role.type}', got '{signed.type}'"
527 )
528
529 return signed, envelope.pae(), envelope.signatures