python-tuf/tuf/client/updater.py

2938 lines
110 KiB
Python
Executable file

"""
<Program Name>
updater.py
<Author>
Geremy Condra
Vladimir Diaz <vladimir.v.diaz@gmail.com>
<Started>
July 2012. Based on a previous version of this module. (VLAD)
<Copyright>
See LICENSE for licensing information.
<Purpose>
'updater.py' is intended to be the only TUF module that software update
systems need to utilize. It provides a single class representing an
updater that includes methods to download, install, and verify
metadata/target files in a secure manner. Importing 'updater.py' and
instantiating its main class is all that is required by the client prior
to a TUF update request. The importation and instantiation steps allow
TUF to load all of the required metadata files and set the repository mirror
information.
An overview of the update process:
1. The software update system instructs TUF to check for updates.
2. TUF downloads and verifies timestamp.json.
3. If timestamp.json indicates that snapshot.json has changed, TUF downloads
and verifies snapshot.json.
4. TUF determines which metadata files listed in snapshot.json differ from
those described in the last snapshot.json that TUF has seen. If root.json
has changed, the update process starts over using the new root.json.
5. TUF provides the software update system with a list of available files
according to targets.json.
6. The software update system instructs TUF to download a specific target
file.
7. TUF downloads and verifies the file and then makes the file available to
the software update system.
<Example Client>
# The client first imports the 'updater.py' module, the only module the
# client is required to import. The client will utilize a single class
# from this module.
import tuf.client.updater
# The only other module the client interacts with is 'tuf.conf'. The
# client accesses this module solely to set the repository directory.
# This directory will hold the files downloaded from a remote repository.
tuf.conf.repository_directory = 'local-repository'
# Next, the client creates a dictionary object containing the repository
# mirrors. The client may download content from any one of these mirrors.
# In the example below, a single mirror named 'mirror1' is defined. The
# mirror is located at 'http://localhost:8001', and all of the metadata
# and targets files can be found in the 'metadata' and 'targets' directory,
# respectively. If the client wishes to only download target files from
# specific directories on the mirror, the 'confined_target_dirs' field
# should be set. In the example, the client has chosen '', which is
# interpreted as no confinement. In other words, the client can download
# targets from any directory or subdirectories. If the client had chosen
# 'targets1/', they would have been confined to the '/targets/targets1/'
# directory on the 'http://localhost:8001' mirror.
repository_mirrors = {'mirror1': {'url_prefix': 'http://localhost:8001',
'metadata_path': 'metadata',
'targets_path': 'targets',
'confined_target_dirs': ['']}}
# The updater may now be instantiated. The Updater class of 'updater.py'
# is called with two arguments. The first argument assigns a name to this
# particular updater and the second argument the repository mirrors defined
# above.
updater = tuf.client.updater.Updater('updater', repository_mirrors)
# The client next calls the refresh() method to ensure it has the latest
# copies of the metadata files.
updater.refresh()
# The target file information for all the repository targets is determined.
targets = updater.all_targets()
# Among these targets, determine the ones that have changed since the client's
# last refresh(). A target is considered updated if it does not exist in
# 'destination_directory' (current directory) or the target located there has
# changed.
destination_directory = '.'
updated_targets = updater.updated_targets(targets, destination_directory)
# Lastly, attempt to download each target among those that have changed.
# The updated target files are saved locally to 'destination_directory'.
for target in updated_targets:
updater.download_target(target, destination_directory)
"""
# Help with Python 3 compatibility, where the print statement is a function, an
# implicit relative import is invalid, and the '/' operator performs true
# division. Example: print 'hello world' raises a 'SyntaxError' exception.
from __future__ import print_function
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import errno
import logging
import os
import shutil
import time
import random
import tuf
import tuf.conf
import tuf.download
import tuf.formats
import tuf.hash
import tuf.keys
import tuf.keydb
import tuf.log
import tuf.mirrors
import tuf.roledb
import tuf.sig
import tuf.util
import six
import iso8601
# See 'log.py' to learn how logging is handled in TUF.
logger = logging.getLogger('tuf.client.updater')
# Disable 'iso8601' logger messages to prevent 'iso8601' from clogging the
# log file.
iso8601_logger = logging.getLogger('iso8601')
iso8601_logger.disabled = True
class Updater(object):
"""
<Purpose>
Provide a class that can download target files securely. The updater
keeps track of currently and previously trusted metadata, target files
available to the client, target file attributes such as file size and
hashes, key and role information, metadata signatures, and the ability
to determine when the download of a file should be permitted.
<Updater Attributes>
self.metadata:
Dictionary holding the currently and previously trusted metadata.
Example: {'current': {'root': ROOT_SCHEMA,
'targets':TARGETS_SCHEMA, ...},
'previous': {'root': ROOT_SCHEMA,
'targets':TARGETS_SCHEMA, ...}}
self.metadata_directory:
The directory where trusted metadata is stored.
self.versioninfo:
A cache of version numbers for the roles available on the repository.
Example: {'root.json': {'version': 128}, ...}
self.mirrors:
The repository mirrors from which metadata and targets are available.
Conformant to 'tuf.formats.MIRRORDICT_SCHEMA'.
self.name:
The name of the updater instance.
<Updater Methods>
refresh():
This method downloads, verifies, and loads metadata for the top-level
roles in a specific order (i.e., timestamp -> snapshot -> root -> targets)
The expiration time for downloaded metadata is also verified.
The metadata for delegated roles are not refreshed by this method, but by
the target methods (e.g., all_targets(), targets_of_role(), target()).
The refresh() method should be called by the client before any target
requests.
all_targets():
Returns the target information for the 'targets' and delegated roles.
Prior to extracting the target information, this method attempts a file
download of all the target metadata that have changed.
targets_of_role('targets'):
Returns the target information for the targets of a specified role.
Like all_targets(), delegated metadata is updated if it has changed.
target(file_path):
Returns the target information for a specific file identified by its file
path. This target method also downloads the metadata of updated targets.
updated_targets(targets, destination_directory):
After the client has retrieved the target information for those targets
they are interested in updating, they would call this method to determine
which targets have changed from those saved locally on disk. All the
targets that have changed are returns in a list. From this list, they
can request a download by calling 'download_target()'.
download_target(target, destination_directory):
This method performs the actual download of the specified target. The
file is saved to the 'destination_directory' argument.
remove_obsolete_targets(destination_directory):
Any files located in 'destination_directory' that were previously
served by the repository but have since been removed, can be deleted
from disk by the client by calling this method.
Note: The methods listed above are public and intended for the software
updater integrating TUF with this module. All other methods that may begin
with a single leading underscore are non-public and only used internally.
updater.py is not subclassed in TUF, nor is it designed to be subclassed,
so double leading underscores is not used.
http://www.python.org/dev/peps/pep-0008/#method-names-and-instance-variables
"""
def __init__(self, updater_name, repository_mirrors):
"""
<Purpose>
Constructor. Instantiating an updater object causes all the metadata
files for the top-level roles to be read from disk, including the key
and role information for the delegated targets of 'targets'. The actual
metadata for delegated roles is not loaded in __init__. The metadata
for these delegated roles, including nested delegated roles, are
loaded, updated, and saved to the 'self.metadata' store by the target
methods, like all_targets() and targets_of_role().
The initial set of metadata files are provided by the software update
system utilizing TUF.
In order to use an updater, the following directories must already
exist locally:
{tuf.conf.repository_directory}/metadata/current
{tuf.conf.repository_directory}/metadata/previous
and, at a minimum, the root metadata file must exist:
{tuf.conf.repository_directory}/metadata/current/root.json
<Arguments>
updater_name:
The name of the updater.
repository_mirrors:
A dictionary holding repository mirror information, conformant to
'tuf.formats.MIRRORDICT_SCHEMA'. This dictionary holds information
such as the directory containing the metadata and target files, the
server's URL prefix, and the target content directories the client
should be confined to.
repository_mirrors = {'mirror1': {'url_prefix': 'http://localhost:8001',
'metadata_path': 'metadata',
'targets_path': 'targets',
'confined_target_dirs': ['']}}
<Exceptions>
tuf.FormatError:
If the arguments are improperly formatted.
tuf.RepositoryError:
If there is an error with the updater's repository files, such
as a missing 'root.json' file.
<Side Effects>
Th metadata files (e.g., 'root.json', 'targets.json') for the top-
level roles are read from disk and stored in dictionaries.
<Returns>
None.
"""
# Do the arguments have the correct format?
# These checks ensure the arguments have the appropriate
# number of objects and object types and that all dict
# keys are properly named.
# Raise 'tuf.FormatError' if there is a mistmatch.
tuf.formats.NAME_SCHEMA.check_match(updater_name)
tuf.formats.MIRRORDICT_SCHEMA.check_match(repository_mirrors)
# Save the validated arguments.
self.name = updater_name
self.mirrors = repository_mirrors
# Store the trusted metadata read from disk.
self.metadata = {}
# Store the currently trusted/verified metadata.
self.metadata['current'] = {}
# Store the previously trusted/verified metadata.
self.metadata['previous'] = {}
# Store the version numbers of all roles available on the repository. The
# dict keys are paths, and the dict values versioninfo data. This
# information can help determine whether a metadata file has changed and
# needs to be re-downloaded.
self.versioninfo = {}
# Store the location of the client's metadata directory.
self.metadata_directory = {}
# Store the 'consistent_snapshot' of the Root role. This setting
# determines if metadata and target files downloaded from remote
# repositories include the digest.
self.consistent_snapshot = False
# Ensure the repository metadata directory has been set.
if tuf.conf.repository_directory is None:
message = 'The TUF update client module must specify the directory' \
' containing the local repository files.' \
' "tuf.conf.repository_directory" MUST be set.'
raise tuf.RepositoryError(message)
# Set the path for the current set of metadata files.
repository_directory = tuf.conf.repository_directory
current_path = os.path.join(repository_directory, 'metadata', 'current')
# Ensure the current path is valid/exists before saving it.
if not os.path.exists(current_path):
message = 'Missing ' + repr(current_path) + '. This path must exist and, ' \
'at a minimum, contain the root metadata file.'
raise tuf.RepositoryError(message)
self.metadata_directory['current'] = current_path
# Set the path for the previous set of metadata files.
previous_path = os.path.join(repository_directory, 'metadata', 'previous')
# Ensure the previous path is valid/exists.
if not os.path.exists(previous_path):
message = 'Missing ' + repr(previous_path) + '. This path must exist.'
raise tuf.RepositoryError(message)
self.metadata_directory['previous'] = previous_path
# Load current and previous metadata.
for metadata_set in ['current', 'previous']:
for metadata_role in ['root', 'targets', 'snapshot', 'timestamp']:
self._load_metadata_from_file(metadata_set, metadata_role)
# Raise an exception if the repository is missing the required 'root'
# metadata.
if 'root' not in self.metadata['current']:
message = 'No root of trust! Could not find the "root.json" file.'
raise tuf.RepositoryError(message)
def __str__(self):
"""
The string representation of an Updater object.
"""
return self.name
def _load_metadata_from_file(self, metadata_set, metadata_role):
"""
<Purpose>
Non-public method that loads current or previous metadata if there is a
local file. If the expected file belonging to 'metadata_role' (e.g.,
'root.json') cannot be loaded, raise an exception. The extracted metadata
object loaded from file is saved to the metadata store (i.e.,
self.metadata).
<Arguments>
metadata_set:
The string 'current' or 'previous', depending on whether one wants to
load the currently or previously trusted metadata file.
metadata_role:
The name of the metadata. This is a role name and should
not end in '.json'. Examples: 'root', 'targets', 'targets/linux/x86'.
<Exceptions>
tuf.FormatError:
If role information belonging to a delegated role of 'metadata_role'
is improperly formatted.
tuf.Error:
If there was an error importing a delegated role of 'metadata_role'
or the metadata set is not one currently supported.
<Side Effects>
If the metadata is loaded successfully, it is saved to the metadata
store. If 'metadata_role' is 'root', the role and key databases
are reloaded. If 'metadata_role' is a target metadata, all its
delegated roles are refreshed.
<Returns>
None.
"""
# Ensure we have a valid metadata set.
if metadata_set not in ['current', 'previous']:
raise tuf.Error('Invalid metadata set: ' + repr(metadata_set))
# Save and construct the full metadata path.
metadata_directory = self.metadata_directory[metadata_set]
metadata_filename = metadata_role + '.json'
metadata_filepath = os.path.join(metadata_directory, metadata_filename)
# Ensure the metadata path is valid/exists, else ignore the call.
if os.path.exists(metadata_filepath):
# Load the file. The loaded object should conform to
# 'tuf.formats.SIGNABLE_SCHEMA'.
metadata_signable = tuf.util.load_json_file(metadata_filepath)
tuf.formats.check_signable_object_format(metadata_signable)
# Extract the 'signed' role object from 'metadata_signable'.
metadata_object = metadata_signable['signed']
# Save the metadata object to the metadata store.
self.metadata[metadata_set][metadata_role] = metadata_object
# If 'metadata_role' is 'root' or targets metadata, the key and role
# databases must be rebuilt. If 'root', ensure self.consistent_snaptshots
# is updated.
if metadata_set == 'current':
if metadata_role == 'root':
self._rebuild_key_and_role_db()
self.consistent_snapshot = metadata_object['consistent_snapshot']
elif metadata_object['_type'] == 'Targets':
# TODO: Should we also remove the keys of the delegated roles?
tuf.roledb.remove_delegated_roles(metadata_role)
self._import_delegations(metadata_role)
def _rebuild_key_and_role_db(self):
"""
<Purpose>
Non-public method that rebuilds the key and role databases from the
currently trusted 'root' metadata object extracted from 'root.json'. This
private method is called when a new/updated 'root' metadata file is
loaded. This method will only store the role information of the top-level
roles (i.e., 'root', 'targets', 'snapshot', 'timestamp').
<Arguments>
None.
<Exceptions>
tuf.FormatError:
If the 'root' metadata is improperly formatted.
tuf.Error:
If there is an error loading a role contained in the 'root'
metadata.
<Side Effects>
The key and role databases are reloaded for the top-level roles.
<Returns>
None.
"""
# Clobbering this means all delegated metadata files are rendered outdated
# and will need to be reloaded. However, reloading the delegated metadata
# files is avoided here because fetching target information with methods
# like all_targets() and target() always cause a refresh of these files.
# The metadata files for delegated roles are also not loaded when the
# repository is first instantiated. Due to this setup, reloading delegated
# roles is not required here.
tuf.keydb.create_keydb_from_root_metadata(self.metadata['current']['root'])
tuf.roledb.create_roledb_from_root_metadata(self.metadata['current']['root'])
def _import_delegations(self, parent_role):
"""
<Purpose>
Non-public method that imports all the roles delegated by 'parent_role'.
<Arguments>
parent_role:
The role whose delegations will be imported.
<Exceptions>
tuf.FormatError:
If a key attribute of a delegated role's signing key is
improperly formatted.
tuf.Error:
If the signing key of a delegated role cannot not be loaded.
<Side Effects>
The key and role database is modified to include the newly
loaded roles delegated by 'parent_role'.
<Returns>
None.
"""
current_parent_metadata = self.metadata['current'][parent_role]
if 'delegations' not in current_parent_metadata:
return
# This could be quite slow with a huge number of delegations.
keys_info = current_parent_metadata['delegations'].get('keys', {})
roles_info = current_parent_metadata['delegations'].get('roles', [])
logger.debug('Adding roles delegated from ' + repr(parent_role) + '.')
# Iterate through the keys of the delegated roles of 'parent_role'
# and load them.
for keyid, keyinfo in six.iteritems(keys_info):
if keyinfo['keytype'] in ['rsa', 'ed25519']:
key = tuf.keys.format_metadata_to_key(keyinfo)
# We specify the keyid to ensure that it's the correct keyid
# for the key.
try:
tuf.keydb.add_key(key, keyid)
except tuf.KeyAlreadyExistsError:
pass
except (tuf.FormatError, tuf.Error) as e:
logger.exception('Invalid key for keyid: ' + repr(keyid) + '.')
logger.error('Aborting role delegation for parent role ' + parent_role + '.')
raise
else:
logger.warning('Invalid key type for ' + repr(keyid) + '.')
continue
# Add the roles to the role database.
for roleinfo in roles_info:
try:
# NOTE: tuf.roledb.add_role will take care of the case where rolename
# is None.
rolename = roleinfo.get('name')
logger.debug('Adding delegated role: ' + str(rolename) + '.')
tuf.roledb.add_role(rolename, roleinfo)
except tuf.RoleAlreadyExistsError as e:
logger.warning('Role already exists: ' + rolename)
except:
logger.exception('Failed to add delegated role: ' + rolename + '.')
raise
def refresh(self, unsafely_update_root_if_necessary=True):
"""
<Purpose>
Update the latest copies of the metadata for the top-level roles. The
update request process follows a specific order to ensure the metadata
files are securely updated:
timestamp -> snapshot -> root (if necessary) -> targets.
Delegated metadata is not refreshed by this method. After this method is
called, the use of target methods (e.g., all_targets(),
targets_of_role(), or target()) will update delegated metadata, when
required. Calling refresh() ensures that top-level metadata is
up-to-date, so that the target methods can refer to the latest available
content. Thus, refresh() should always be called by the client before any
requests of target file information.
The expiration time for downloaded metadata is also verified, including
local metadata that the repository claims is up to date.
If the refresh fails for any reason, then unless
'unsafely_update_root_if_necessary' is set, refresh will be retried once
after first attempting to update the root metadata file. Only after this
check will the exceptions listed here potentially be raised.
<Arguments>
unsafely_update_root_if_necessary:
Boolean that indicates whether to unsafely update the Root metadata if
any of the top-level metadata cannot be downloaded successfully. The
Root role is unsafely updated if its current version number is unknown.
<Exceptions>
tuf.NoWorkingMirrorError:
If the metadata for any of the top-level roles cannot be updated.
tuf.ExpiredMetadataError:
If any of the top-level metadata is expired (whether a new version was
downloaded expired or no new version was found and the existing
version is now expired).
<Side Effects>
Updates the metadata files of the top-level roles with the latest
information.
<Returns>
None.
"""
# Do the arguments have the correct format?
# This check ensures the arguments have the appropriate
# number of objects and object types, and that all dict
# keys are properly named.
# Raise 'tuf.FormatError' if the check fail.
tuf.formats.BOOLEAN_SCHEMA.check_match(unsafely_update_root_if_necessary)
# The Timestamp role does not have signed metadata about it; otherwise we
# would need an infinite regress of metadata. Therefore, we use some
# default, but sane, upper file length for its metadata.
DEFAULT_TIMESTAMP_UPPERLENGTH = tuf.conf.DEFAULT_TIMESTAMP_REQUIRED_LENGTH
# The Root role may be updated without knowing its version number if
# top-level metadata cannot be safely downloaded (e.g., keys may have been
# revoked, thus requiring a new Root file that includes the updated keys)
# and 'unsafely_update_root_if_necessary' is True.
# We use some default, but sane, upper file length for its metadata.
DEFAULT_ROOT_UPPERLENGTH = tuf.conf.DEFAULT_ROOT_REQUIRED_LENGTH
# Update the top-level metadata. The _update_metadata_if_changed() and
# _update_metadata() calls below do NOT perform an update if there
# is insufficient trusted signatures for the specified metadata.
# Raise 'tuf.NoWorkingMirrorError' if an update fails.
# Is the Root role expired? When the top-level roles are initially loaded
# from disk, their expiration is not checked to allow their updating when
# requested (and give the updater the chance to continue, rather than always
# failing with an expired metadata error.) If
# 'unsafely_update_root_if_necessary' is True, update an expired Root role
# now. Updating the other top-level roles, regardless of their validity,
# should only occur if the root of trust is up-to-date.
root_metadata = self.metadata['current']['root']
try:
self._ensure_not_expired(root_metadata, 'root')
except tuf.ExpiredMetadataError as e:
# Raise 'tuf.NoWorkingMirrorError' if a valid (not expired, properly
# signed, and valid metadata) 'root.json' cannot be installed.
if unsafely_update_root_if_necessary:
message = \
'Expired Root metadata was loaded from disk. Try to update it now.'
logger.info(message)
self._update_metadata('root', DEFAULT_ROOT_UPPERLENGTH)
# The caller explicitly requested not to unsafely fetch an expired Root.
else:
logger.info('An expired Root metadata was loaded and must be updated.')
raise
# If an exception is raised during the metadata update attempts, we will
# attempt to update root metadata once by recursing with a special argument
# (unsafely_update_root_if_necessary) to avoid further recursion.
# Use default but sane information for timestamp metadata, and do not
# require strict checks on its required length.
try:
self._update_metadata('timestamp', DEFAULT_TIMESTAMP_UPPERLENGTH)
self._update_metadata_if_changed('snapshot',
referenced_metadata='timestamp')
self._update_metadata_if_changed('root')
self._update_metadata_if_changed('targets')
# There are two distinct error scenarios that can rise from the
# _update_metadata_if_changed calls in the try block above:
#
# - tuf.NoWorkingMirrorError:
#
# If a change to a metadata file IS detected in an
# _update_metadata_if_changed call, but we are unable to download a
# valid (not expired, properly signed, valid) version of that metadata
# file, a tuf.NoWorkingMirrorError rises to this point.
#
# - tuf.ExpiredMetadataError:
#
# If, on the other hand, a change to a metadata file IS NOT detected
# in a given _update_metadata_if_changed call, but we observe that the
# version of the metadata file we have on hand is now expired, a
# tuf.ExpiredMetadataError exception rises to this point.
#
except tuf.NoWorkingMirrorError:
if unsafely_update_root_if_necessary:
logger.info('Valid top-level metadata cannot be downloaded. Unsafely'
' update the Root metadata.')
self._update_metadata('root', DEFAULT_ROOT_UPPERLENGTH)
self.refresh(unsafely_update_root_if_necessary=False)
else:
raise
except tuf.ExpiredMetadataError:
if unsafely_update_root_if_necessary:
logger.info('No changes were detected from the mirrors for a given role'
', and that metadata that is available on disk has been found to be'
' expired. Trying to update root in case of foul play.')
self._update_metadata('root', DEFAULT_ROOT_UPPERLENGTH)
self.refresh(unsafely_update_root_if_necessary=False)
# The caller explicitly requested not to unsafely fetch an expired Root.
else:
logger.info('No changes were detected from the mirrors for a given role'
', and that metadata that is available on disk has been found to be '
'expired. Your metadata is out of date.')
raise
def _check_hashes(self, file_object, trusted_hashes):
"""
<Purpose>
Non-public method that verifies multiple secure hashes of the downloaded
file 'file_object'. If any of these fail it raises an exception. This is
to conform with the TUF spec, which support clients with different hashing
algorithms. The 'hash.py' module is used to compute the hashes of
'file_object'.
<Arguments>
file_object:
A 'tuf.util.TempFile' file-like object. 'file_object' ensures that a
read() without a size argument properly reads the entire file.
trusted_hashes:
A dictionary with hash-algorithm names as keys and hashes as dict values.
The hashes should be in the hexdigest format. Should be Conformant to
'tuf.formats.HASHDICT_SCHEMA'.
<Exceptions>
tuf.BadHashError, if the hashes don't match.
<Side Effects>
Hash digest object is created using the 'tuf.hash' module.
<Returns>
None.
"""
# Verify each trusted hash of 'trusted_hashes'. If all are valid, simply
# return.
for algorithm, trusted_hash in six.iteritems(trusted_hashes):
digest_object = tuf.hash.digest(algorithm)
digest_object.update(file_object.read())
computed_hash = digest_object.hexdigest()
# Raise an exception if any of the hashes are incorrect.
if trusted_hash != computed_hash:
raise tuf.BadHashError(trusted_hash, computed_hash)
else:
logger.info('The file\'s '+algorithm+' hash is correct: '+trusted_hash)
def _hard_check_file_length(self, file_object, trusted_file_length):
"""
<Purpose>
Non-public method that ensures the length of 'file_object' is strictly
equal to 'trusted_file_length'. This is a deliberately redundant
implementation designed to complement
tuf.download._check_downloaded_length().
<Arguments>
file_object:
A 'tuf.util.TempFile' file-like object. 'file_object' ensures that a
read() without a size argument properly reads the entire file.
trusted_file_length:
A non-negative integer that is the trusted length of the file.
<Exceptions>
tuf.DownloadLengthMismatchError, if the lengths do not match.
<Side Effects>
Reads the contents of 'file_object' and logs a message if 'file_object'
matches the trusted length.
<Returns>
None.
"""
# Read the entire contents of 'file_object', a 'tuf.util.TempFile' file-like
# object that ensures the entire file is read.
observed_length = len(file_object.read())
# Return and log a message if the length 'file_object' is equal to
# 'trusted_file_length', otherwise raise an exception. A hard check
# ensures that a downloaded file strictly matches a known, or trusted,
# file length.
if observed_length != trusted_file_length:
raise tuf.DownloadLengthMismatchError(trusted_file_length,
observed_length)
else:
logger.debug('Observed length ('+str(observed_length)+\
') == trusted length ('+str(trusted_file_length)+')')
def _soft_check_file_length(self, file_object, trusted_file_length):
"""
<Purpose>
Non-public method that checks the trusted file length of a
'tuf.util.TempFile' file-like object. The length of the file must be less
than or equal to the expected length. This is a deliberately redundant
implementation designed to complement
tuf.download._check_downloaded_length().
<Arguments>
file_object:
A 'tuf.util.TempFile' file-like object. 'file_object' ensures that a
read() without a size argument properly reads the entire file.
trusted_file_length:
A non-negative integer that is the trusted length of the file.
<Exceptions>
tuf.DownloadLengthMismatchError, if the lengths do not match.
<Side Effects>
Reads the contents of 'file_object' and logs a message if 'file_object'
is less than or equal to the trusted length.
<Returns>
None.
"""
# Read the entire contents of 'file_object', a 'tuf.util.TempFile' file-like
# object that ensures the entire file is read.
observed_length = len(file_object.read())
# Return and log a message if 'file_object' is less than or equal to
# 'trusted_file_length', otherwise raise an exception. A soft check
# ensures that an upper bound restricts how large a file is downloaded.
if observed_length > trusted_file_length:
raise tuf.DownloadLengthMismatchError(trusted_file_length,
observed_length)
else:
logger.debug('Observed length ('+str(observed_length)+\
') <= trusted length ('+str(trusted_file_length)+')')
def _get_target_file(self, target_filepath, file_length, file_hashes):
"""
<Purpose>
Non-public method that safely (i.e., the file length and hash are strictly
equal to the trusted) downloads a target file up to a certain length, and
checks its hashes thereafter.
<Arguments>
target_filepath:
The target filepath (relative to the repository targets directory)
obtained from TUF targets metadata.
file_length:
The expected compressed length of the target file. If the file is not
compressed, then it will simply be its uncompressed length.
file_hashes:
The expected hashes of the target file.
<Exceptions>
tuf.NoWorkingMirrorError:
The target could not be fetched. This is raised only when all known
mirrors failed to provide a valid copy of the desired target file.
<Side Effects>
The target file is downloaded from all known repository mirrors in the
worst case. If a valid copy of the target file is found, it is stored in
a temporary file and returned.
<Returns>
A 'tuf.util.TempFile' file-like object containing the target.
"""
# Define a callable function that is passed as an argument to _get_file()
# and called. The 'verify_target_file' function ensures the file length
# and hashes of 'target_filepath' are strictly equal to the trusted values.
def verify_target_file(target_file_object):
# Every target file must have its length and hashes inspected.
self._hard_check_file_length(target_file_object, file_length)
self._check_hashes(target_file_object, file_hashes)
# Target files, unlike metadata files, are not decompressed; the
# 'compression' argument to _get_file() is needed only for decompression of
# metadata. Target files may be compressed or uncompressed.
if self.consistent_snapshot:
target_digest = random.choice(list(file_hashes.values()))
dirname, basename = os.path.split(target_filepath)
target_filepath = os.path.join(dirname, target_digest+'.'+basename)
return self._get_file(target_filepath, verify_target_file,
'target', file_length, compression=None,
verify_compressed_file_function=None,
download_safely=True)
def _verify_uncompressed_metadata_file(self, metadata_file_object,
metadata_role):
"""
<Purpose>
Non-public method that verifies an uncompressed metadata file. An
exception is raised if 'metadata_file_object is invalid, and there is no
return value.
<Arguments>
metadata_file_object:
A 'tuf.util.TempFile' instance containing the metadata file.
'metadata_file_object' ensures the entire file is returned with read().
metadata_role:
The role name of the metadata (e.g., 'root', 'targets',
'targets/linux/x86').
<Exceptions>
tuf.ForbiddenTargetError:
In case a targets role has signed for a target it was not delegated to.
tuf.FormatError:
In case the metadata file is valid JSON, but not valid TUF metadata.
tuf.InvalidMetadataJSONError:
In case the metadata file is not valid JSON.
tuf.ReplayedMetadataError:
In case the downloaded metadata file is older than the current one.
tuf.RepositoryError:
In case the repository is somehow inconsistent; e.g. a parent has not
delegated to a child (contrary to expectations).
tuf.SignatureError:
In case the metadata file does not have a valid signature.
<Side Effects>
The content of 'metadata_file_object' is read and loaded.
<Returns>
None.
"""
metadata = metadata_file_object.read().decode('utf-8')
try:
metadata_signable = tuf.util.load_json_string(metadata)
except Exception as exception:
raise tuf.InvalidMetadataJSONError(exception)
else:
# Ensure the loaded 'metadata_signable' is properly formatted. Raise
# 'tuf.FormatError' if not.
tuf.formats.check_signable_object_format(metadata_signable)
# Is 'metadata_signable' expired?
self._ensure_not_expired(metadata_signable['signed'], metadata_role)
# We previously verified version numbers in this function, but have since
# moved version number verification to the functions that retrieve
# metadata.
# Reject the metadata if any specified targets are not allowed.
# 'tuf.ForbiddenTargetError' raised if any of the targets of 'metadata_role'
# are not allowed.
if metadata_signable['signed']['_type'] == 'Targets':
if metadata_role != 'targets':
metadata_targets = list(metadata_signable['signed']['targets'].keys())
parent_rolename = tuf.roledb.get_parent_rolename(metadata_role)
parent_role_metadata = self.metadata['current'][parent_rolename]
parent_delegations = parent_role_metadata['delegations']
tuf.util.ensure_all_targets_allowed(metadata_role, metadata_targets,
parent_delegations)
# Verify the signature on the downloaded metadata object.
valid = tuf.sig.verify(metadata_signable, metadata_role)
if not valid:
raise tuf.BadSignatureError(metadata_role)
def _unsafely_get_metadata_file(self, metadata_role, metadata_filepath,
uncompressed_fileinfo,
compression=None, compressed_fileinfo=None):
"""
<Purpose>
Non-public method that downloads a metadata file up to a certain length.
The actual file length may not be strictly equal to its expected length.
File hashes will not be checked because it is expected to be unknown.
<Arguments>
metadata_role:
The role name of the metadata (e.g., 'root', 'targets',
'targets/linux/x86').
metadata_filepath:
The metadata filepath (i.e., relative to the repository metadata
directory).
uncompressed_fileinfo:
The trusted file length and hashes of the uncompressed version of the
metadata file. Should be 'tuf.formats.FILEINFO_SCHEMA'.
compression:
The name of the compression algorithm (e.g., 'gzip'), if the metadata
file is compressed.
compressed_fileinfo:
The fileinfo of the metadata file, if it is compressed. Should be
'tuf.formats.FILEINFO_SCHEMA'.
<Exceptions>
tuf.NoWorkingMirrorError:
The metadata could not be fetched. This is raised only when all known
mirrors failed to provide a valid copy of the desired metadata file.
<Side Effects>
The metadata file is downloaded from all known repository mirrors in the
worst case. If a valid copy of the metadata file is found, it is stored
in a temporary file and returned.
<Returns>
A 'tuf.util.TempFile' file-like object containing the metadata.
"""
# Store file length and hashes of the uncompressed version metadata.
# The uncompressed version is always verified.
uncompressed_file_length = uncompressed_fileinfo['length']
uncompressed_file_hashes = uncompressed_fileinfo['hashes']
download_file_length = uncompressed_file_length
compressed_file_length = None
compressed_file_hashes = None
# Store the file length and hashes of the compressed version of the
# metadata, if compressions is set.
if compression is not None and compressed_fileinfo is not None:
compressed_file_length = compressed_fileinfo['length']
compressed_file_hashes = compressed_fileinfo['hashes']
download_file_length = compressed_file_length
def unsafely_verify_uncompressed_metadata_file(metadata_file_object):
self._soft_check_file_length(metadata_file_object,
uncompressed_file_length)
self._check_hashes(metadata_file_object, uncompressed_file_hashes)
self._verify_uncompressed_metadata_file(metadata_file_object,
metadata_role)
def unsafely_verify_compressed_metadata_file(metadata_file_object):
self._hard_check_file_length(metadata_file_object, compressed_file_length)
self._check_hashes(metadata_file_object, compressed_file_hashes)
if compression is None:
unsafely_verify_compressed_metadata_file = None
return self._get_file(metadata_filepath,
unsafely_verify_uncompressed_metadata_file, 'meta',
download_file_length, compression,
unsafely_verify_compressed_metadata_file,
download_safely=False)
def _get_metadata_file(self, metadata_role, remote_filename,
upperbound_filelength, expected_version,
compression_algorithm):
"""
<Purpose>
Non-public method that tries downloading, up to a certain length, a
metadata file from a list of known mirrors. As soon as the first valid
copy of the file is found, the downloaded file is returned and the
remaining mirrors are skipped.
<Arguments>
metadata_role:
The role name of the metadata (e.g., 'root', 'targets',
'targets/linux/x86').
remote_filename:
The relative file path (on the remove repository) of 'metadata_role'.
upperbound_filelength:
The expected length, or upper bound, of the metadata file to be
downloaded.
expected_version:
The expected and required version number of the 'metadata_role' file
downloaded. 'expected_version' is an integer.
compression_algorithm:
The name of the compression algorithm (e.g., 'gzip'). The algorithm is
needed if the remote metadata file is compressed.
<Exceptions>
tuf.NoWorkingMirrorError:
The metadata could not be fetched. This is raised only when all known
mirrors failed to provide a valid copy of the desired metadata file.
<Side Effects>
The file is downloaded from all known repository mirrors in the worst
case. If a valid copy of the file is found, it is stored in a temporary
file and returned.
<Returns>
A 'tuf.util.TempFile' file-like object containing the metadata.
"""
file_mirrors = tuf.mirrors.get_list_of_mirrors('meta', remote_filename,
self.mirrors)
# file_mirror (URL): error (Exception)
file_mirror_errors = {}
file_object = None
for file_mirror in file_mirrors:
try:
file_object = tuf.download.unsafe_download(file_mirror,
upperbound_filelength)
if compression_algorithm is not None:
logger.info('Decompressing ' + str(file_mirror))
file_object.decompress_temp_file_object(compression_algorithm)
else:
logger.info('Not decompressing ' + str(file_mirror))
# Verify 'file_object' according to the callable function.
# 'file_object' is also verified if decompressed above (i.e., the
# uncompressed version).
metadata_signable = \
tuf.util.load_json_string(file_object.read().decode('utf-8'))
# If the version number is unspecified, ensure that the version number
# downloaded is greater than the currently trusted version number for
# 'metadata_role'.
version_downloaded = metadata_signable['signed']['version']
if expected_version is not None:
# Verify that the downloaded version matches the version expected by
# the caller.
if version_downloaded != expected_version:
message = \
'Downloaded version number: ' + repr(version_downloaded) + '.' \
' Version number MUST be: ' + repr(expected_version)
raise tuf.BadVersionNumberError(message)
# The caller does not know which version to download. Verify that the
# downloaded version is at least greater than the one locally available.
else:
# Verify that the version number of the locally stored
# 'timestamp.json', if available, is less than what was downloaded.
# Otherwise, accept the new timestamp with version number
# 'version_downloaded'.
logger.info('metadata_role: ' + repr(metadata_role))
try:
current_version = \
self.metadata['current'][metadata_role]['version']
if version_downloaded < current_version:
raise tuf.ReplayedMetadataError(metadata_role, version_downloaded,
current_version)
except KeyError:
logger.info(metadata_role + ' not available locally.')
self._verify_uncompressed_metadata_file(file_object, metadata_role)
except Exception as exception:
# Remember the error from this mirror, and "reset" the target file.
logger.exception('Update failed from ' + file_mirror + '.')
file_mirror_errors[file_mirror] = exception
file_object = None
else:
break
if file_object:
return file_object
else:
logger.error('Failed to update {0} from all mirrors: {1}'.format(
remote_filename, file_mirror_errors))
raise tuf.NoWorkingMirrorError(file_mirror_errors)
def _safely_get_metadata_file(self, metadata_role, metadata_filepath,
uncompressed_fileinfo,
compression=None, compressed_fileinfo=None):
"""
<Purpose>
Non-public method that safely downloads a metadata file up to a certain
length, and checks its hashes thereafter.
<Arguments>
metadata_role:
The role name of the metadata (e.g., 'root', 'targets',
'targets/linux/x86').
metadata_filepath:
The metadata filepath (i.e., relative to the repository metadata
directory).
uncompressed_fileinfo:
The trusted file length and hashes of the uncompressed version of the
metadata file. Should be 'tuf.formats.FILEINFO_SCHEMA'.
compression:
The name of the compression algorithm (e.g., 'gzip'), if the metadata
file is compressed.
compressed_fileinfo:
The fileinfo of the metadata file, if it is compressed. Should be
'tuf.formats.FILEINFO_SCHEMA'.
<Exceptions>
tuf.NoWorkingMirrorError:
The metadata could not be fetched. This is raised only when all known
mirrors failed to provide a valid copy of the desired metadata file.
<Side Effects>
The metadata file is downloaded from all known repository mirrors in the
worst case. If a valid copy of the metadata file is found, it is stored
in a temporary file and returned.
<Returns>
A 'tuf.util.TempFile' file-like object containing the metadata.
"""
# Store file length and hashes of the uncompressed version metadata.
# The uncompressed version is always verified.
uncompressed_file_length = uncompressed_fileinfo['length']
uncompressed_file_hashes = uncompressed_fileinfo['hashes']
download_file_length = uncompressed_file_length
# Store the file length and hashes of the compressed version of the
# metadata, if compressions is set.
if compression and compressed_fileinfo:
compressed_file_length = compressed_fileinfo['length']
compressed_file_hashes = compressed_fileinfo['hashes']
download_file_length = compressed_file_length
def safely_verify_uncompressed_metadata_file(metadata_file_object):
self._hard_check_file_length(metadata_file_object,
uncompressed_file_length)
self._check_hashes(metadata_file_object, uncompressed_file_hashes)
self._verify_uncompressed_metadata_file(metadata_file_object,
metadata_role)
def safely_verify_compressed_metadata_file(metadata_file_object):
self._hard_check_file_length(metadata_file_object, compressed_file_length)
self._check_hashes(metadata_file_object, compressed_file_hashes)
if compression is None:
safely_verify_compressed_metadata_file = None
return self._get_file(metadata_filepath,
safely_verify_uncompressed_metadata_file, 'meta',
download_file_length, compression,
safely_verify_compressed_metadata_file,
download_safely=True)
# TODO: Instead of the more fragile 'download_safely' switch, unroll the
# function into two separate ones: one for "safe" download, and the other one
# for "unsafe" download? This should induce safer and more readable code.
def _get_file(self, filepath, verify_file_function, file_type,
file_length, compression=None,
verify_compressed_file_function=None, download_safely=True):
"""
<Purpose>
Non-public method that tries downloading, up to a certain length, a
metadata or target file from a list of known mirrors. As soon as the first
valid copy of the file is found, the rest of the mirrors will be skipped.
<Arguments>
filepath:
The relative metadata or target filepath.
verify_file_function:
A callable function that expects a 'tuf.util.TempFile' file-like object
and raises an exception if the file is invalid. Target files and
uncompressed versions of metadata may be verified with
'verify_file_function'.
file_type:
Type of data needed for download, must correspond to one of the strings
in the list ['meta', 'target']. 'meta' for metadata file type or
'target' for target file type. It should correspond to the
'tuf.formats.NAME_SCHEMA' format.
file_length:
The expected length, or upper bound, of the target or metadata file to
be downloaded.
compression:
The name of the compression algorithm (e.g., 'gzip'), if the metadata
file is compressed.
verify_compressed_file_function:
If compression is specified, in the case of metadata files, this
callable function may be set to perform verification of the compressed
version of the metadata file. Decompressed metadata is also verified.
download_safely:
A boolean switch to toggle safe or unsafe download of the file.
<Exceptions>
tuf.NoWorkingMirrorError:
The metadata could not be fetched. This is raised only when all known
mirrors failed to provide a valid copy of the desired metadata file.
<Side Effects>
The file is downloaded from all known repository mirrors in the worst
case. If a valid copy of the file is found, it is stored in a temporary
file and returned.
<Returns>
A 'tuf.util.TempFile' file-like object containing the metadata or target.
"""
file_mirrors = tuf.mirrors.get_list_of_mirrors(file_type, filepath,
self.mirrors)
# file_mirror (URL): error (Exception)
file_mirror_errors = {}
file_object = None
for file_mirror in file_mirrors:
try:
if download_safely:
file_object = tuf.download.safe_download(file_mirror,
file_length)
else:
file_object = tuf.download.unsafe_download(file_mirror,
file_length)
if compression is not None:
if verify_compressed_file_function is not None:
verify_compressed_file_function(file_object)
logger.info('Decompressing '+str(file_mirror))
file_object.decompress_temp_file_object(compression)
else:
logger.info('Not decompressing '+str(file_mirror))
# Verify 'file_object' according to the callable function.
# 'file_object' is also verified if decompressed above (i.e., the
# uncompressed version).
verify_file_function(file_object)
except Exception as exception:
# Remember the error from this mirror, and "reset" the target file.
logger.exception('Update failed from '+file_mirror+'.')
file_mirror_errors[file_mirror] = exception
file_object = None
else:
break
if file_object:
return file_object
else:
logger.error('Failed to update {0} from all mirrors: {1}'.format(
filepath, file_mirror_errors))
raise tuf.NoWorkingMirrorError(file_mirror_errors)
def _update_metadata(self, metadata_role, upperbound_filelength, version=None,
compression_algorithm=None):
"""
<Purpose>
Non-public method that downloads, verifies, and 'installs' the metadata
belonging to 'metadata_role'. Calling this method implies the metadata
has been updated by the repository and thus needs to be re-downloaded.
The current and previous metadata stores are updated if the newly
downloaded metadata is successfully downloaded and verified.
<Arguments>
metadata_role:
The name of the metadata. This is a role name and should not end
in '.json'. Examples: 'root', 'targets', 'targets/linux/x86'.
upperbound_filelength:
The expected length, or upper bound, of the metadata file to be
downloaded.
version:
The expected and required version number of the 'metadata_role' file
downloaded. 'expected_version' is an integer.
compression_algorithm:
A string designating the compression type of 'metadata_role'.
The 'snapshot' metadata file may be optionally downloaded and stored in
compressed form. Currently, only metadata files compressed with 'gzip'
are considered. Any other string is ignored.
<Exceptions>
tuf.NoWorkingMirrorError:
The metadata cannot be updated. This is not specific to a single
failure but rather indicates that all possible ways to update the
metadata have been tried and failed.
<Side Effects>
The metadata file belonging to 'metadata_role' is downloaded from a
repository mirror. If the metadata is valid, it is stored in the
metadata store.
<Returns>
None.
"""
# Construct the metadata filename as expected by the download/mirror modules.
metadata_filename = metadata_role + '.json'
uncompressed_metadata_filename = metadata_filename
# The 'snapshot' or Targets metadata may be compressed. Add the appropriate
# extension to 'metadata_filename'.
if compression_algorithm == 'gzip':
metadata_filename = metadata_filename + '.gz'
# Attempt a file download from each mirror until the file is downloaded and
# verified. If the signature of the downloaded file is valid, proceed,
# otherwise log a warning and try the next mirror. 'metadata_file_object'
# is the file-like object returned by 'download.py'. 'metadata_signable'
# is the object extracted from 'metadata_file_object'. Metadata saved to
# files are regarded as 'signable' objects, conformant to
# 'tuf.formats.SIGNABLE_SCHEMA'.
#
# Some metadata (presently timestamp) will be downloaded "unsafely", in the
# sense that we can only estimate its true length and know nothing about
# its version. This is because not all metadata will have other metadata
# for it; otherwise we will have an infinite regress of metadata signing
# for each other. In this case, we will download the metadata up to the
# best length we can get for it, not request a specific version, but
# perform the rest of the checks (e.g., signature verification).
#
# Note also that we presently support decompression of only "safe"
# metadata, but this is easily extend to "unsafe" metadata as well as
# "safe" targets.
remote_filename = metadata_filename
filename_version = ''
if self.consistent_snapshot:
filename_version = version
dirname, basename = os.path.split(remote_filename)
remote_filename = os.path.join(dirname, str(filename_version) + '.' + basename)
logger.info('Verifying ' + repr(metadata_role) + ' requesting version: ' + repr(version))
metadata_file_object = \
self._get_metadata_file(metadata_role, remote_filename,
upperbound_filelength, version,
compression_algorithm)
# The metadata has been verified. Move the metadata file into place.
# First, move the 'current' metadata file to the 'previous' directory
# if it exists.
current_filepath = os.path.join(self.metadata_directory['current'],
metadata_filename)
current_filepath = os.path.abspath(current_filepath)
tuf.util.ensure_parent_dir(current_filepath)
previous_filepath = os.path.join(self.metadata_directory['previous'],
metadata_filename)
previous_filepath = os.path.abspath(previous_filepath)
if os.path.exists(current_filepath):
# Previous metadata might not exist, say when delegations are added.
tuf.util.ensure_parent_dir(previous_filepath)
shutil.move(current_filepath, previous_filepath)
# Next, move the verified updated metadata file to the 'current' directory.
# Note that the 'move' method comes from tuf.util's TempFile class.
# 'metadata_file_object' is an instance of tuf.util.TempFile.
metadata_signable = \
tuf.util.load_json_string(metadata_file_object.read().decode('utf-8'))
if compression_algorithm == 'gzip':
current_uncompressed_filepath = \
os.path.join(self.metadata_directory['current'],
uncompressed_metadata_filename)
current_uncompressed_filepath = \
os.path.abspath(current_uncompressed_filepath)
metadata_file_object.move(current_uncompressed_filepath)
else:
metadata_file_object.move(current_filepath)
# Extract the metadata object so we can store it to the metadata store.
# 'current_metadata_object' set to 'None' if there is not an object
# stored for 'metadata_role'.
updated_metadata_object = metadata_signable['signed']
current_metadata_object = self.metadata['current'].get(metadata_role)
# Finally, update the metadata and fileinfo stores, and rebuild the
# key and role info for the top-level roles if 'metadata_role' is root.
# Rebuilding the the key and role info is required if the newly-installed
# root metadata has revoked keys or updated any top-level role information.
logger.debug('Updated ' + repr(current_filepath) + '.')
self.metadata['previous'][metadata_role] = current_metadata_object
self.metadata['current'][metadata_role] = updated_metadata_object
self._update_versioninfo(uncompressed_metadata_filename)
# Ensure the role and key information of the top-level roles is also updated
# according to the newly-installed Root metadata.
if metadata_role == 'root':
self._rebuild_key_and_role_db()
self.consistent_snapshot = updated_metadata_object['consistent_snapshot']
def _update_metadata_if_changed(self, metadata_role,
referenced_metadata='snapshot'):
"""
<Purpose>
Non-public method that updates the metadata for 'metadata_role' if it has
changed. With the exception of the 'timestamp' role, all the top-level
roles are updated by this method. The 'timestamp' role is always
downloaded from a mirror without first checking if it has been updated; it
is updated in refresh() by calling _update_metadata('timestamp'). This
method is also called for delegated role metadata, which are referenced by
'snapshot'.
If the metadata needs to be updated but an update cannot be obtained,
this method will delete the file (with the exception of the root
metadata, which never gets removed without a replacement).
Due to the way in which metadata files are updated, it is expected that
'referenced_metadata' is not out of date and trusted. The refresh()
method updates the top-level roles in 'timestamp -> snapshot ->
root -> targets' order. For delegated metadata, the parent role is
updated before the delegated role. Taking into account that
'referenced_metadata' is updated and verified before 'metadata_role',
this method determines if 'metadata_role' has changed by checking
the 'meta' field of the newly updated 'referenced_metadata'.
<Arguments>
metadata_role:
The name of the metadata. This is a role name and should not end
in '.json'. Examples: 'root', 'targets', 'targets/linux/x86'.
referenced_metadata:
This is the metadata that provides the role information for
'metadata_role'. For the top-level roles, the 'snapshot' role
is the referenced metadata for the 'root', and 'targets' roles.
The 'timestamp' metadata is always downloaded regardless. In
other words, it is updated by calling _update_metadata('timestamp')
and not by this method. The referenced metadata for 'snapshot'
is 'timestamp'. See refresh().
<Exceptions>
tuf.NoWorkingMirrorError:
If 'metadata_role' could not be downloaded after determining that it had
changed.
tuf.RepositoryError:
If the referenced metadata is missing.
<Side Effects>
If it is determined that 'metadata_role' has been updated, the metadata
store (i.e., self.metadata) is updated with the new metadata and the
affected stores modified (i.e., the previous metadata store is updated).
If the metadata is 'targets' or a delegated targets role, the role
database is updated with the new information, including its delegated
roles.
<Returns>
None.
"""
uncompressed_metadata_filename = metadata_role + '.json'
# Ensure the referenced metadata has been loaded. The 'root' role may be
# updated without having 'snapshot' available.
if referenced_metadata not in self.metadata['current']:
raise tuf.RepositoryError('Cannot update ' + repr(metadata_role) +
' because ' + referenced_metadata + ' is missing.')
# The referenced metadata has been loaded. Extract the new versioninfo for
# 'metadata_role' from it.
else:
logger.debug(repr(metadata_role) + ' referenced in ' +
repr(referenced_metadata)+ '. ' + repr(metadata_role) +
' may be updated.')
# Extract the versioninfo of the uncompressed version of 'metadata_role'.
expected_versioninfo = self.metadata['current'][referenced_metadata] \
['meta'] \
[uncompressed_metadata_filename]
# Simply return if the metadata for 'metadata_role' has not been updated,
# according to the uncompressed metadata provided by the referenced
# metadata. The metadata is considered updated if its version number is
# strictly greater than its currently trusted version number.
if not self._versioninfo_has_been_updated(uncompressed_metadata_filename,
expected_versioninfo):
logger.info(repr(uncompressed_metadata_filename) + ' up-to-date.')
# Since we have not downloaded a new version of this metadata, we
# should check to see if our local version is stale and notify the user
# if so. This raises tuf.ExpiredMetadataError if the metadata we
# have is expired. Resolves issue #322.
self._ensure_not_expired(self.metadata['current'][metadata_role],
metadata_role)
return
logger.debug('Metadata ' + repr(uncompressed_metadata_filename) + ' has changed.')
# There might be a compressed version of 'snapshot.json' or Targets
# metadata available for download. Check the 'meta' field of
# 'referenced_metadata' to see if it is listed when 'metadata_role'
# is 'snapshot'. The full rolename for delegated Targets metadata
# must begin with 'targets/'. The snapshot role lists all the Targets
# metadata available on the repository, including any that may be in
# compressed form.
#
# In addition to validating the fileinfo (i.e., file lengths and hashes)
# of the uncompressed metadata, the compressed version is also verified to
# match its respective fileinfo. Verifying the compressed fileinfo ensures
# untrusted data is not decompressed prior to verifying hashes, or
# decompressing a file that may be invalid or partially intact.
compression = None
# Check for the availability of compressed versions of 'snapshot.json',
# 'targets.json', and delegated Targets (that also start with 'targets').
# For 'targets.json' and delegated metadata, 'referenced_metata'
# should always be 'snapshot'. 'snapshot.json' specifies all roles
# provided by a repository, including their version numbers.
if metadata_role == 'snapshot' or metadata_role.startswith('targets'):
if 'gzip' in self.metadata['current']['root']['compression_algorithms']:
compression = 'gzip'
gzip_metadata_filename = uncompressed_metadata_filename + '.gz'
logger.debug('Compressed version of ' +
repr(uncompressed_metadata_filename) + ' is available at ' +
repr(gzip_metadata_filename) + '.')
else:
logger.debug('Compressed version of ' +
repr(uncompressed_metadata_filename) + ' not available.')
# The file lengths of metadata are unknown, only their version numbers are
# known. Set an upper limit for the length of the downloaded file for each
# expected role. Note: The Timestamp role is not updated via this
# function.
if metadata_role == 'snapshot':
upperbound_filelength = tuf.conf.DEFAULT_SNAPSHOT_REQUIRED_LENGTH
elif metadata_role == 'root':
upperbound_filelength = tuf.conf.DEFAULT_ROOT_REQUIRED_LENGTH
# The metadata is considered Targets (or delegated Targets metadata).
else:
upperbound_filelength = tuf.conf.DEFAULT_TARGETS_REQUIRED_LENGTH
try:
self._update_metadata(metadata_role, upperbound_filelength,
expected_versioninfo['version'], compression)
except:
# The current metadata we have is not current but we couldn't
# get new metadata. We shouldn't use the old metadata anymore.
# This will get rid of in-memory knowledge of the role and
# delegated roles, but will leave delegated metadata files as
# current files on disk.
# TODO: Should we get rid of the delegated metadata files?
# We shouldn't need to, but we need to check the trust
# implications of the current implementation.
self._delete_metadata(metadata_role)
logger.error('Metadata for ' +repr(metadata_role) + ' cannot be updated.')
raise
else:
# We need to remove delegated roles because the delegated roles may not
# be trusted anymore.
if metadata_role == 'targets' or metadata_role.startswith('targets/'):
logger.debug('Removing delegated roles of ' + repr(metadata_role) + '.')
# TODO: Should we also remove the keys of the delegated roles?
tuf.roledb.remove_delegated_roles(metadata_role)
self._import_delegations(metadata_role)
def _versioninfo_has_been_updated(self, metadata_filename, new_versioninfo):
"""
<Purpose>
Non-public method that determines whether the current versioninfo of
'metadata_filename' is less than 'new_versioninfo' (i.e., the version
number has been incremented). The 'new_versioninfo' argument should be
extracted from the latest copy of the metadata that references
'metadata_filename'. Example: 'root.json' would be referenced by
'snapshot.json'.
'new_versioninfo' should only be 'None' if this is for updating
'root.json' without having 'snapshot.json' available.
<Arguments>
metadadata_filename:
The metadata filename for the role. For the 'root' role,
'metadata_filename' would be 'root.json'.
new_versioninfo:
A dict object representing the new file information for
'metadata_filename'. 'new_versioninfo' may be 'None' when
updating 'root' without having 'snapshot' available. This
dict conforms to 'tuf.formats.VERSIONINFO_SCHEMA' and has
the form:
{'version': 288}
<Exceptions>
None.
<Side Effects>
If there is no versioninfo currently loaded for 'metadata_filename', try
to load it.
<Returns>
Boolean. True if the versioninfo has changed, False otherwise.
"""
# If there is no versioninfo currently stored for 'metadata_filename',
# try to load the file, calculate the versioninfo, and store it.
if metadata_filename not in self.versioninfo:
self._update_versioninfo(metadata_filename)
# Return true if there is no versioninfo for 'metadata_filename'.
# 'metadata_filename' is not in the 'self.versioninfo' store
# and it doesn't exist in the 'current' metadata location.
if self.versioninfo[metadata_filename] is None:
return True
current_versioninfo = self.versioninfo[metadata_filename]
if new_versioninfo['version'] > current_versioninfo['version']:
return True
else:
return False
def _update_versioninfo(self, metadata_filename):
"""
<Purpose>
Non-public method that updates the 'self.versioninfo' entry for the
metadata belonging to 'metadata_filename'. If the current metadata for
'metadata_filename' cannot be loaded, set its 'versioninfo' to 'None' to
signal that it is not in 'self.versioninfo' AND it also doesn't exist
locally.
<Arguments>
metadata_filename:
The metadata filename for the role. For the 'root' role,
'metadata_filename' would be 'root.json'.
<Exceptions>
None.
<Side Effects>
The version number of 'metadata_filename' is calculated and stored in its
corresponding entry in 'self.versioninfo'.
<Returns>
None.
"""
# In case we delayed loading the metadata and didn't do it in
# __init__ (such as with delegated metadata), then get the version
# info now.
# Save the path to the current metadata file for 'metadata_filename'.
current_filepath = os.path.join(self.metadata_directory['current'],
metadata_filename)
# If the path is invalid, simply return and leave versioninfo unset.
if not os.path.exists(current_filepath):
self.versioninfo[metadata_filename] = None
return
# Extract the version information from the trusted snapshot role and save
# it to the 'self.versioninfo' store.
if metadata_filename == 'timestamp.json':
trusted_versioninfo = \
self.metadata['current']['timestamp']['version']
# When updating snapshot.json, the client either (1) has a copy of
# snapshot.json, or (2) is in the process of obtaining it by first
# downloading timestamp.json. Note: Clients are allowed to have only
# root.json initially, and perform a refresh of top-level metadata to
# obtain the remaining roles.
elif metadata_filename == 'snapshot.json':
# Verify the version number of the currently trusted snapshot.json in
# snapshot.json itself. Checking the version number specified in
# timestamp.json may be greater than the version specified in the
# client's copy of snapshot.json.
try:
timestamp_version_number = self.metadata['current']['snapshot']['version']
trusted_versioninfo = tuf.formats.make_versioninfo(timestamp_version_number)
except KeyError:
trusted_versioninfo = \
self.metadata['current']['timestamp']['meta']['snapshot.json']
else:
try:
# The metadata file names in 'self.metadata' exclude the role
# extension. Strip the '.json' extension when checking if
# 'metadata_filename' currently exists.
targets_version_number = \
self.metadata['current'][metadata_filename[:-len('.json')]]['version']
trusted_versioninfo = \
tuf.formats.make_versioninfo(targets_version_number)
except KeyError:
trusted_versioninfo = \
self.metadata['current']['snapshot']['meta'][metadata_filenamed]
self.versioninfo[metadata_filename] = trusted_versioninfo
def _move_current_to_previous(self, metadata_role):
"""
<Purpose>
Non-public method that moves the current metadata file for 'metadata_role'
to the previous directory.
<Arguments>
metadata_role:
The name of the metadata. This is a role name and should not end
in '.json'. Examples: 'root', 'targets', 'targets/linux/x86'.
<Exceptions>
None.
<Side Effects>
The metadata file for 'metadata_role' is removed from 'current'
and moved to the 'previous' directory.
<Returns>
None.
"""
# Get the 'current' and 'previous' full file paths for 'metadata_role'
metadata_filepath = metadata_role + '.json'
previous_filepath = os.path.join(self.metadata_directory['previous'],
metadata_filepath)
current_filepath = os.path.join(self.metadata_directory['current'],
metadata_filepath)
# Remove the previous path if it exists.
if os.path.exists(previous_filepath):
os.remove(previous_filepath)
# Move the current path to the previous path.
if os.path.exists(current_filepath):
tuf.util.ensure_parent_dir(previous_filepath)
os.rename(current_filepath, previous_filepath)
def _delete_metadata(self, metadata_role):
"""
<Purpose>
Non-public method that removes all (current) knowledge of 'metadata_role'.
The metadata belonging to 'metadata_role' is removed from the current
'self.metadata' store and from the role database. The 'root.json' role
file is never removed.
<Arguments>
metadata_role:
The name of the metadata. This is a role name and should not end
in '.json'. Examples: 'root', 'targets', 'targets/linux/x86'.
<Exceptions>
None.
<Side Effects>
The role database is modified and the metadata for 'metadata_role'
removed from the 'self.metadata' store.
<Returns>
None.
"""
# The root metadata role is never deleted without a replacement.
if metadata_role == 'root':
return
# Get rid of the current metadata file.
self._move_current_to_previous(metadata_role)
# Remove knowledge of the role.
if metadata_role in self.metadata['current']:
del self.metadata['current'][metadata_role]
tuf.roledb.remove_role(metadata_role)
def _ensure_not_expired(self, metadata_object, metadata_rolename):
"""
<Purpose>
Non-public method that raises an exception if the current specified
metadata has expired.
<Arguments>
metadata_object:
The metadata that should be expired, a 'tuf.formats.ANYROLE_SCHEMA'
object.
metadata_rolename:
The name of the metadata. This is a role name and should not end
in '.json'. Examples: 'root', 'targets', 'targets/linux/x86'.
<Exceptions>
tuf.ExpiredMetadataError:
If 'metadata_rolename' has expired.
<Side Effects>
None.
<Returns>
None.
"""
# Extract the expiration time.
expires = metadata_object['expires']
# If the current time has surpassed the expiration date, raise
# an exception. 'expires' is in 'tuf.formats.ISO8601_DATETIME_SCHEMA'
# format (e.g., '1985-10-21T01:22:00Z'.) Convert it to a unix timestamp and
# compare it against the current time.time() (also in Unix/POSIX time
# format, although with microseconds attached.)
current_time = int(time.time())
# Generate a user-friendly error message if 'expires' is less than the
# current time (i.e., a local time.)
expires_datetime = iso8601.parse_date(expires)
expires_timestamp = tuf.formats.datetime_to_unix_timestamp(expires_datetime)
if expires_timestamp < current_time:
message = 'Metadata '+repr(metadata_rolename)+' expired on ' + \
expires_datetime.ctime() + ' (UTC).'
logger.error(message)
raise tuf.ExpiredMetadataError(message)
def all_targets(self):
"""
<Purpose>
Get a list of the target information for all the trusted targets
on the repository. This list also includes all the targets of
delegated roles. Targets of the list returned are ordered according
the trusted order of the delegated roles, where parent roles come before
children. The list conforms to 'tuf.formats.TARGETFILES_SCHEMA'
and has the form:
[{'filepath': 'a/b/c.txt',
'fileinfo': {'length': 13323,
'hashes': {'sha256': dbfac345..}}
...]
<Arguments>
None.
<Exceptions>
tuf.RepositoryError:
If the metadata for the 'targets' role is missing from
the 'snapshot' metadata.
tuf.UnknownRoleError:
If one of the roles could not be found in the role database.
<Side Effects>
The metadata for target roles is updated and stored.
<Returns>
A list of targets, conformant to 'tuf.formats.TARGETFILES_SCHEMA'.
"""
# Load the most up-to-date targets of the 'targets' role and all
# delegated roles.
self._refresh_targets_metadata(include_delegations=True)
all_targets = []
# Fetch the targets for the 'targets' role.
all_targets = self._targets_of_role('targets', skip_refresh=True)
# Fetch the targets of the delegated roles.
for delegated_role in sorted(tuf.roledb.get_delegated_rolenames('targets')):
all_targets = self._targets_of_role(delegated_role, all_targets,
skip_refresh=True)
return all_targets
def _refresh_targets_metadata(self, rolename='targets', include_delegations=False):
"""
<Purpose>
Non-public method that refreshes the targets metadata of 'rolename'. If
'include_delegations' is True, include all the delegations that follow
'rolename'. The metadata for the 'targets' role is updated in refresh()
by the _update_metadata_if_changed('targets') call, not here. Delegated
roles are not loaded when the repository is first initialized. They are
loaded from disk, updated if they have changed, and stored to the
'self.metadata' store by this method. This method is called by the target
methods, like all_targets() and targets_of_role().
<Arguments>
rolename:
This is a delegated role name and should not end in '.json'. Example:
targets/linux/x86'.
include_delegations:
Boolean indicating if the delegated roles set by 'rolename' should be
refreshed.
<Exceptions>
tuf.RepositoryError:
If the metadata file for the 'targets' role is missing from the
'snapshot' metadata.
<Side Effects>
The metadata for the delegated roles are loaded and updated if they
have changed. Delegated metadata is removed from the role database if
it has expired.
<Returns>
None.
"""
roles_to_update = []
# See if this role provides metadata and, if we're including delegations,
# look for metadata from delegated roles.
role_prefix = rolename + '/'
for metadata_path in six.iterkeys(self.metadata['current']['snapshot']['meta']):
if metadata_path == rolename + '.json':
roles_to_update.append(metadata_path[:-len('.json')])
elif include_delegations and metadata_path.startswith(role_prefix):
# Add delegated roles. Skip roles names containing compression
# extensions.
if metadata_path.endswith('.json'):
roles_to_update.append(metadata_path[:-len('.json')])
else:
continue
# Remove the 'targets' role because it gets updated when the targets.json
# file is updated in _update_metadata_if_changed('targets').
if rolename == 'targets':
try:
roles_to_update.remove('targets')
except ValueError:
message = 'The snapshot metadata file is missing the targets.json entry.'
raise tuf.RepositoryError(message)
# If there is nothing to refresh, we are done.
if not roles_to_update:
return
# Sort the roles so that parent roles always come first.
roles_to_update.sort()
logger.debug('Roles to update: '+repr(roles_to_update)+'.')
# Iterate 'roles_to_update', load its metadata file, and update it if
# changed.
for rolename in roles_to_update:
self._load_metadata_from_file('previous', rolename)
self._load_metadata_from_file('current', rolename)
self._update_metadata_if_changed(rolename)
def refresh_targets_metadata_chain(self, rolename):
"""
<Purpose>
Refresh the minimum targets metadata of 'rolename'. If 'rolename' is
'targets/claimed/3.3/django', refresh the metadata of the following roles:
targets.json
targets/claimed.json
targets/claimed/3.3.json
Note that 'targets/claimed/3.3/django.json' is not refreshed here.
The metadata of the 'targets' role is updated in refresh() by the
_update_metadata_if_changed('targets') call, not here. Delegated roles
are not loaded when the repository is first initialized; they can be
loaded from disk, updated if they have changed, and stored to the
'self.metadata' store by this method. This method may be called
before targets_of_role('rolename') so that the most up-to-date metadata is
available to verify the target files of 'rolename', including the metadata
of 'rolename'.
<Arguments>
rolename:
This is a full delegated rolename and should not end in '.json'.
Example: 'targets/linux/x86'.
<Exceptions>
tuf.FormatError:
If any of the arguments are improperly formatted.
tuf.RepositoryError:
If the metadata of any of the parent roles of 'rolename' is missing
from the 'snapshot.json' metadata file.
<Side Effects>
The metadata of the parent roles of 'rolename' are loaded from disk and
updated if they have changed. Metadata is removed from the role database
if it has expired.
<Returns>
A list of the roles that have been updated, loaded, and are valid.
"""
# Do the arguments have the correct format?
# Ensure the arguments have the appropriate number of objects and object
# types, and that all dict keys are properly named.
# Raise 'tuf.FormatError' if the check fail.
tuf.formats.ROLENAME_SCHEMA.check_match(rolename)
# List of parent roles to update.
parent_roles = []
# Separate each rolename (i.e., each rolename should exclude parent and
# child rolenames). 'rolename' should be the full rolename, as in
# 'targets/linux/x86'.
parts = rolename.split('/')
# Append the first role to the list.
parent_roles.append(parts[0])
# The 'roles_added' string contains the roles (full rolename) already added.
# If 'a' and 'a/b' have been added to 'parent_roles', 'roles_added' would
# contain 'a/b'.
roles_added = parts[0]
# Add each subsequent role to the previous string (with a '/' separator).
# This only goes to -1 because we only want to store the parents (so we
# ignore the last element).
for next_role in parts[1:-1]:
parent_roles.append(roles_added + '/' + next_role)
roles_added = roles_added + '/' + next_role
message = 'Minimum metadata to download and set the chain of trust: '+\
repr(parent_roles)+'.'
logger.info(message)
# Check if 'snapshot.json' provides metadata for each of the roles in
# 'parent_roles'. All the available roles on the repository are specified
# in the 'snapshot.json' metadata.
targets_metadata_allowed = list(self.metadata['current']['snapshot']['meta'].keys())
for parent_role in parent_roles:
parent_role = parent_role + '.json'
if parent_role not in targets_metadata_allowed:
message = '"snapshot.json" does not provide all the parent roles '+\
'of ' + repr(rolename) + '.'
raise tuf.RepositoryError(message)
# Remove the 'targets' role because it gets updated when the targets.json
# file is updated in _update_metadata_if_changed('targets').
if rolename == 'targets':
try:
parent_roles.remove('targets')
except ValueError:
message = 'The snapshot metadata file is missing the "targets.json" entry.'
raise tuf.RepositoryError(message)
# If there is nothing to refresh, we are done.
if not parent_roles:
return
# Sort the roles so that parent roles always come first.
parent_roles.sort()
logger.debug('Roles to update: ' + repr(parent_roles) + '.')
# Iterate 'parent_roles', load each role's metadata file from disk, and
# update it if it has changed.
refreshed_chain = []
for rolename in parent_roles:
self._load_metadata_from_file('previous', rolename)
self._load_metadata_from_file('current', rolename)
self._update_metadata_if_changed(rolename)
return refreshed_chain
def _targets_of_role(self, rolename, targets=None, skip_refresh=False):
"""
<Purpose>
Non-public method that returns the target information of all the targets
of 'rolename'. The returned information is a list conformant to
'tuf.formats.TARGETFILES_SCHEMA', and has the form:
[{'filepath': 'a/b/c.txt',
'fileinfo': {'length': 13323,
'hashes': {'sha256': dbfac345..}}
...]
<Arguments>
rolename:
This is a role name and should not end in '.json'. Examples: 'targets',
'targets/linux/x86'.
targets:
A list of targets containing target information, conformant to
'tuf.formats.TARGETFILES_SCHEMA'.
skip_refresh:
A boolean indicating if the target metadata for 'rolename'
should be refreshed.
<Exceptions>
tuf.UnknownRoleError:
If 'rolename' is not found in the role database.
<Side Effects>
The metadata for 'rolename' is refreshed if 'skip_refresh' is False.
<Returns>
A list of dict objects containing the target information of all the
targets of 'rolename'. Conformant to 'tuf.formats.TARGETFILES_SCHEMA'.
"""
if targets is None:
targets = []
logger.debug('Getting targets of role: '+repr(rolename)+'.')
if not tuf.roledb.role_exists(rolename):
raise tuf.UnknownRoleError(rolename)
# We do not need to worry about the target paths being trusted because
# this is enforced before any new metadata is accepted.
if not skip_refresh:
self._refresh_targets_metadata(rolename)
# Do we have metadata for 'rolename'?
if rolename not in self.metadata['current']:
message = 'No metadata for '+repr(rolename)+'. Unable to determine targets.'
logger.debug(message)
return targets
# Get the targets specified by the role itself.
for filepath, fileinfo in six.iteritems(self.metadata['current'][rolename]['targets']):
new_target = {}
new_target['filepath'] = filepath
new_target['fileinfo'] = fileinfo
targets.append(new_target)
return targets
def targets_of_role(self, rolename='targets'):
"""
<Purpose>
Return a list of trusted targets directly specified by 'rolename'.
The returned information is a list conformant to
'tuf.formats.TARGETFILES_SCHEMA', and has the form:
[{'filepath': 'a/b/c.txt',
'fileinfo': {'length': 13323,
'hashes': {'sha256': dbfac345..}}
...]
The metadata of 'rolename' is updated if out of date, including the
metadata of its parent roles (i.e., the minimum roles needed to set the
chain of trust).
<Arguments>
rolename:
The name of the role whose list of targets are wanted.
The name of the role should start with 'targets'.
<Exceptions>
tuf.FormatError:
If 'rolename' is improperly formatted.
tuf.RepositoryError:
If the metadata of 'rolename' cannot be updated.
tuf.UnknownRoleError:
If 'rolename' is not found in the role database.
<Side Effects>
The metadata of updated delegated roles are downloaded and stored.
<Returns>
A list of targets, conformant to 'tuf.formats.TARGETFILES_SCHEMA'.
"""
# Does 'rolename' have the correct format?
# Raise 'tuf.FormatError' if there is a mismatch.
tuf.formats.RELPATH_SCHEMA.check_match(rolename)
if not tuf.roledb.role_exists(rolename):
raise tuf.UnknownRoleError(rolename)
self.refresh_targets_metadata_chain(rolename)
self._refresh_targets_metadata(rolename)
return self._targets_of_role(rolename, skip_refresh=True)
def target(self, target_filepath):
"""
<Purpose>
Return the target file information of 'target_filepath', and update its
corresponding metadata, if necessary.
<Arguments>
target_filepath:
The path to the target file on the repository. This will be relative to
the 'targets' (or equivalent) directory on a given mirror.
<Exceptions>
tuf.FormatError:
If 'target_filepath' is improperly formatted.
tuf.UnknownTargetError:
If 'target_filepath' was not found.
Any other unforeseen runtime exception.
<Side Effects>
The metadata for updated delegated roles are downloaded and stored.
<Returns>
The target information for 'target_filepath', conformant to
'tuf.formats.TARGETFILE_SCHEMA'.
"""
# Does 'target_filepath' have the correct format?
# Raise 'tuf.FormatError' if there is a mismatch.
tuf.formats.RELPATH_SCHEMA.check_match(target_filepath)
# 'target_filepath' might contain URL encoding escapes.
# http://docs.python.org/2/library/urllib.html#urllib.unquote
target_filepath = six.moves.urllib.parse.unquote(target_filepath)
if not target_filepath.startswith('/'):
target_filepath = '/' + target_filepath
# Get target by looking at roles in order of priority tags.
target = self._preorder_depth_first_walk(target_filepath)
# Raise an exception if the target information could not be retrieved.
if target is None:
message = target_filepath+' not found.'
logger.error(message)
raise tuf.UnknownTargetError(message)
# Otherwise, return the found target.
else:
return target
def _preorder_depth_first_walk(self, target_filepath):
"""
<Purpose>
Non-public method that interrogates the tree of target delegations in
order of appearance (which implicitly order trustworthiness), and return
the matching target found in the most trusted role.
<Arguments>
target_filepath:
The path to the target file on the repository. This will be relative to
the 'targets' (or equivalent) directory on a given mirror.
<Exceptions>
tuf.FormatError:
If 'target_filepath' is improperly formatted.
tuf.RepositoryError:
If 'target_filepath' was not found.
<Side Effects>
The metadata for updated delegated roles are downloaded and stored.
<Returns>
The target information for 'target_filepath', conformant to
'tuf.formats.TARGETFILE_SCHEMA'.
"""
target = None
current_metadata = self.metadata['current']
role_names = ['targets']
visited_role_names = set()
number_of_delegations = tuf.conf.MAX_NUMBER_OF_DELEGATIONS
# Ensure the client has the most up-to-date version of 'targets.json'.
# Raise 'tuf.NoWorkingMirrorError' if the changed metadata cannot be
# successfully downloaded and 'tuf.RepositoryError' if the referenced
# metadata is missing. Target methods such as this one are called after
# the top-level metadata have been refreshed (i.e., updater.refresh()).
self._update_metadata_if_changed('targets')
# Preorder depth-first traversal of the tree of target delegations.
while target is None and number_of_delegations > 0 and len(role_names) > 0:
# Pop the role name from the top of the stack.
role_name = role_names.pop(-1)
# Skip any visited current role.
if role_name in visited_role_names:
logger.debug('Skipping visited current role '+repr(role_name))
continue
# The metadata for 'role_name' must be downloaded/updated before its
# targets, delegations, and child roles can be inspected.
# self.metadata['current'][role_name] is currently missing.
# _refresh_targets_metadata() does not refresh 'targets.json', it expects
# _update_metadata_if_changed() to have already refreshed it, which this
# function has checked above.
self._refresh_targets_metadata(role_name, include_delegations=False)
role_metadata = current_metadata[role_name]
targets = role_metadata['targets']
delegations = role_metadata.get('delegations', {})
child_roles = delegations.get('roles', [])
target = self._get_target_from_targets_role(role_name, targets,
target_filepath)
# After preorder check, add current role to set of visited roles.
visited_role_names.add(role_name)
# And also decrement number of visited roles.
number_of_delegations -= 1
if target is None:
child_roles_to_visit = []
# NOTE: This may be a slow operation if there are many delegated roles.
for child_role in child_roles:
child_role_name = self._visit_child_role(child_role, target_filepath)
if not child_role['backtrack'] and child_role_name is not None:
logger.debug('Adding child role '+repr(child_role_name))
logger.debug('Not backtracking to other roles.')
role_names = []
child_roles_to_visit.append(child_role_name)
break
elif child_role_name is None:
logger.debug('Skipping child role '+repr(child_role_name))
else:
logger.debug('Adding child role '+repr(child_role_name))
child_roles_to_visit.append(child_role_name)
# Push 'child_roles_to_visit' in reverse order of appearance onto
# 'role_names'. Roles are popped from the end of the 'role_names'
# list.
child_roles_to_visit.reverse()
role_names.extend(child_roles_to_visit)
else:
logger.debug('Found target in current role '+repr(role_name))
if target is None and number_of_delegations == 0 and len(role_names) > 0:
logger.debug(repr(len(role_names))+' roles left to visit, '+
'but allowed to visit at most '+
repr(tuf.conf.MAX_NUMBER_OF_DELEGATIONS)+' delegations.')
return target
def _get_target_from_targets_role(self, role_name, targets, target_filepath):
"""
<Purpose>
Non-public method that determines whether the targets role with the given
'role_name' has the target with the name 'target_filepath'.
<Arguments>
role_name:
The name of the targets role that we are inspecting.
targets:
The targets of the Targets role with the name 'role_name'.
target_filepath:
The path to the target file on the repository. This will be relative to
the 'targets' (or equivalent) directory on a given mirror.
<Exceptions>
None.
<Side Effects>
None.
<Returns>
The target information for 'target_filepath', conformant to
'tuf.formats.TARGETFILE_SCHEMA'.
"""
target = None
# Does the current role name have our target?
logger.debug('Asking role ' + repr(role_name) + ' about target '+\
repr(target_filepath))
for filepath, fileinfo in six.iteritems(targets):
if filepath == target_filepath:
logger.debug('Found target ' + target_filepath + ' in role ' + role_name)
target = {'filepath': filepath, 'fileinfo': fileinfo}
break
else:
logger.debug('No target '+target_filepath+' in role '+role_name)
return target
def _visit_child_role(self, child_role, target_filepath):
"""
<Purpose>
Non-public method that determines whether the given 'child_role' has been
delegated the target with the name 'target_filepath'.
Ensure that we explore only delegated roles trusted with the target. We
assume conservation of delegated paths in the complete tree of
delegations. Note that the call to tuf.util.ensure_all_targets_allowed in
_verify_uncompressed_metadata_file should already verify that all
targets metadata is valid; i.e. that the targets signed by a delegatee is
a proper subset of the targets delegated to it by the delegator.
Nevertheless, we check it again here for performance and safety reasons.
TODO: Should the TUF spec restrict the repository to one particular
algorithm? Should we allow the repository to specify in the role
dictionary the algorithm used for these generated hashed paths?
<Arguments>
child_role:
The delegation targets role object of 'child_role', containing its
paths, path_hash_prefixes, keys and so on.
target_filepath:
The path to the target file on the repository. This will be relative to
the 'targets' (or equivalent) directory on a given mirror.
<Exceptions>
None.
<Side Effects>
None.
<Returns>
If 'child_role' has been delegated the target with the name
'target_filepath', then we return the role name of 'child_role'.
Otherwise, we return None.
"""
child_role_name = child_role['name']
child_role_paths = child_role.get('paths')
child_role_path_hash_prefixes = child_role.get('path_hash_prefixes')
# A boolean indicator that tell us whether 'child_role' has been delegated
# the target with the name 'target_filepath'.
child_role_is_relevant = False
if child_role_path_hash_prefixes is not None:
target_filepath_hash = self._get_target_hash(target_filepath)
for child_role_path_hash_prefix in child_role_path_hash_prefixes:
if target_filepath_hash.startswith(child_role_path_hash_prefix):
child_role_is_relevant = True
else:
continue
elif child_role_paths is not None:
for child_role_path in child_role_paths:
# A child role path may be a filepath or directory. The child
# role 'child_role_name' is added if 'target_filepath' is located
# under 'child_role_path'. Explicit filepaths are also added.
prefix = os.path.commonprefix([target_filepath, child_role_path])
if prefix == child_role_path:
child_role_is_relevant = True
else:
# 'role_name' should have been validated when it was downloaded.
# The 'paths' or 'path_hash_prefixes' fields should not be missing,
# so we raise a format error here in case they are both missing.
raise tuf.FormatError(repr(child_role_name) + ' has neither ' \
'"paths" nor "path_hash_prefixes".')
if child_role_is_relevant:
logger.debug('Child role ' + repr(child_role_name) + ' has target ' + \
repr(target_filepath))
return child_role_name
else:
logger.debug('Child role ' + repr(child_role_name) + \
' does not have target ' + repr(target_filepath))
return None
def _get_target_hash(self, target_filepath, hash_function='sha256'):
"""
<Purpose>
Non-public method that computes the hash of 'target_filepath'. This is
useful in conjunction with the "path_hash_prefixes" attribute in a
delegated targets role, which tells us which paths it is implicitly
responsible for.
<Arguments>
target_filepath:
The path to the target file on the repository. This will be relative to
the 'targets' (or equivalent) directory on a given mirror.
hash_function:
The algorithm used by the repository to generate the hashes of the
target filepaths. The repository may optionally organize targets into
hashed bins to ease target delegations and role metadata management.
The use of consistent hashing allows for a uniform distribution of
targets into bins.
<Exceptions>
None.
<Side Effects>
None.
<Returns>
The hash of 'target_filepath'.
"""
# Calculate the hash of the filepath to determine which bin to find the
# target. The client currently assumes the repository (i.e., repository
# tool) uses 'hash_function' to generate hashes and UTF-8.
digest_object = tuf.hash.digest(hash_function)
encoded_target_filepath = target_filepath.encode('utf-8')
digest_object.update(encoded_target_filepath)
target_filepath_hash = digest_object.hexdigest()
return target_filepath_hash
def remove_obsolete_targets(self, destination_directory):
"""
<Purpose>
Remove any files that are in 'previous' but not 'current'. This makes it
so if you remove a file from a repository, it actually goes away. The
targets for the 'targets' role and all delegated roles are checked.
<Arguments>
destination_directory:
The directory containing the target files tracked by TUF.
<Exceptions>
tuf.FormatError:
If 'destination_directory' is improperly formatted.
tuf.RepositoryError:
If an error occurred removing any files.
<Side Effects>
Target files are removed from disk.
<Returns>
None.
"""
# Does 'destination_directory' have the correct format?
# Raise 'tuf.FormatError' if there is a mismatch.
tuf.formats.PATH_SCHEMA.check_match(destination_directory)
# Iterate through the rolenames and verify whether the 'previous'
# directory contains a target no longer found in 'current'.
for role in tuf.roledb.get_rolenames():
if role.startswith('targets'):
if role in self.metadata['previous'] and self.metadata['previous'][role] != None:
for target in self.metadata['previous'][role]['targets']:
if target not in self.metadata['current'][role]['targets']:
# 'target' is only in 'previous', so remove it.
logger.warning('Removing obsolete file: ' + repr(target) + '.')
# Remove the file if it hasn't been removed already.
destination = os.path.join(destination_directory, target.lstrip(os.sep))
try:
os.remove(destination)
except OSError as e:
# If 'filename' already removed, just log it.
if e.errno == errno.ENOENT:
logger.info('File ' + repr(destination) + ' was already removed.')
else:
logger.error(str(e))
except Exception as e:
logger.error(str(e))
def updated_targets(self, targets, destination_directory):
"""
<Purpose>
Return the targets in 'targets' that have changed. Targets are considered
changed if they do not exist at 'destination_directory' or the target
located there has mismatched file properties.
The returned information is a list conformant to
'tuf.formats.TARGETFILES_SCHEMA' and has the form:
[{'filepath': 'a/b/c.txt',
'fileinfo': {'length': 13323,
'hashes': {'sha256': dbfac345..}}
...]
<Arguments>
targets:
A list of target files. Targets that come earlier in the list are
chosen over duplicates that may occur later.
destination_directory:
The directory containing the target files.
<Exceptions>
tuf.FormatError:
If the arguments are improperly formatted.
<Side Effects>
The files in 'targets' are read and their hashes computed.
<Returns>
A list of targets, conformant to 'tuf.formats.TARGETFILES_SCHEMA'.
"""
# Do the arguments have the correct format?
# Raise 'tuf.FormatError' if there is a mismatch.
tuf.formats.TARGETFILES_SCHEMA.check_match(targets)
tuf.formats.PATH_SCHEMA.check_match(destination_directory)
# Keep track of the target objects and filepaths of updated targets.
# Return 'updated_targets' and use 'updated_targetpaths' to avoid
# duplicates.
updated_targets = []
updated_targetpaths = []
for target in targets:
# Prepend 'destination_directory' to the target's relative filepath (as
# stored in metadata.) Verify the hash of 'target_filepath' against
# each hash listed for its fileinfo. Note: join() discards
# 'destination_directory' if 'filepath' contains a leading path separator
# (i.e., is treated as an absolute path).
filepath = target['filepath']
if filepath[0] == '/':
filepath = filepath[1:]
target_filepath = os.path.join(destination_directory, filepath)
if target_filepath in updated_targetpaths:
continue
# Try one of the algorithm/digest combos for a mismatch. We break
# as soon as we find a mismatch.
for algorithm, digest in six.iteritems(target['fileinfo']['hashes']):
digest_object = None
try:
digest_object = tuf.hash.digest_filename(target_filepath,
algorithm=algorithm)
# This exception would occur if the target does not exist locally.
except IOError:
updated_targets.append(target)
updated_targetpaths.append(target_filepath)
break
# The file does exist locally, check if its hash differs.
if digest_object.hexdigest() != digest:
updated_targets.append(target)
updated_targetpaths.append(target_filepath)
break
return updated_targets
def download_target(self, target, destination_directory):
"""
<Purpose>
Download 'target' and verify it is trusted.
This will only store the file at 'destination_directory' if the
downloaded file matches the description of the file in the trusted
metadata.
<Arguments>
target:
The target to be downloaded. Conformant to
'tuf.formats.TARGETFILE_SCHEMA'.
destination_directory:
The directory to save the downloaded target file.
<Exceptions>
tuf.FormatError:
If 'target' is not properly formatted.
tuf.NoWorkingMirrorError:
If a target could not be downloaded from any of the mirrors.
Although expected to be rare, there might be OSError exceptions (except
errno.EEXIST) raised when creating the destination directory (if it
doesn't exist).
<Side Effects>
A target file is saved to the local system.
<Returns>
None.
"""
# Do the arguments have the correct format?
# This check ensures the arguments have the appropriate
# number of objects and object types, and that all dict
# keys are properly named.
# Raise 'tuf.FormatError' if the check fail.
tuf.formats.TARGETFILE_SCHEMA.check_match(target)
tuf.formats.PATH_SCHEMA.check_match(destination_directory)
# Extract the target file information.
target_filepath = target['filepath']
trusted_length = target['fileinfo']['length']
trusted_hashes = target['fileinfo']['hashes']
# '_get_target_file()' checks every mirror and returns the first target
# that passes verification.
target_file_object = self._get_target_file(target_filepath, trusted_length,
trusted_hashes)
# We acquired a target file object from a mirror. Move the file into place
# (i.e., locally to 'destination_directory'). Note: join() discards
# 'destination_directory' if 'target_path' contains a leading path
# separator (i.e., is treated as an absolute path).
destination = os.path.join(destination_directory,
target_filepath.lstrip(os.sep))
destination = os.path.abspath(destination)
target_dirpath = os.path.dirname(destination)
# When attempting to create the leaf directory of 'target_dirpath', ignore
# any exceptions raised if the root directory already exists. All other
# exceptions potentially thrown by os.makedirs() are re-raised.
# Note: os.makedirs can raise OSError if the leaf directory already exists
# or cannot be created.
try:
os.makedirs(target_dirpath)
except OSError as e:
if e.errno == errno.EEXIST:
pass
else:
raise
target_file_object.move(destination)