There are 2 classes of interesting TUF repository compromise scenarios and corresponding audit questions:
-
Repository hosting compromised and/or MITM, signing keys safe --> "Can the attacker affect the client in any way?"
-
Repository hosting compromised and/or MITM, signing keys compromised --> "Can the attacker affect the client beyond the capability of the compromised key in any unforeseen way"?
Note, the latter would be interesting for different combinations of compromised keys, but above all for the compromise that allows an attacker to serve arbitrary target files to the client.
The following example shows a typical basic tuf setup, with top-level roles: root, timestamp, snapshot, targets, and one delegated targets role that is trusted to sign for the one target file available in the repository, i.e. "file.txt".
The example walks through:
- creating the repository (target file, keys, metadata)
- using the client to download the target file
- updating the target file and corresponding metadata on the repository
- using the client to update the target file
# Bootstrap example environment
mkdir tuf-audit && cd tuf-audit
python3 -m venv tuf-audit-env && source tuf-audit-env/bin/activate
pip install securesystemslib[crypto,pynacl] tuf==2.*
# Bootstrap repository
mkdir -p repo/keys repo/metadata repo/targets
# Create content to protect, i.e. a target file
echo "some content to protect" > repo/targets/file.txt
# Drop onto Python shell to setup repository metadata
python
import os
from datetime import datetime, timedelta
from pathlib import Path
from securesystemslib.interface import import_ed25519_privatekey_from_file, generate_and_write_unencrypted_ed25519_keypair
from securesystemslib.signer import SSlibSigner
from tuf.api.metadata import DelegatedRole, Delegations, Key, Metadata, MetaFile, Root, Snapshot, TargetFile, Targets, Timestamp, TOP_LEVEL_ROLE_NAMES
from tuf.api.serialization.json import JSONSerializer
# Define paths to keys, metadata, and target files in repository
REPO_DIR = Path("repo")
KEY_DIR = REPO_DIR / "keys"
METADATA_DIR = REPO_DIR / "metadata"
TARGET_DIR = REPO_DIR / "targets"
# Define filename and path for target file in repository
TARGET_NAME = "file.txt"
TARGET_PATH = TARGET_DIR / TARGET_NAME
# Define name for custom delegated targets role
DELEGATED_ROLE = "developer"
# Define metadata expiration date as 7 days from now
# NOTE: Usually, different types of metadata expire at different times
# depending on their responsibility and availability
EXPIRY = datetime.utcnow().replace(microsecond=0) + timedelta(days=7)
roles = {}
keys = {}
# Create top-level roles
roles["root"] = Root()
roles["timestamp"] = Timestamp()
roles["snapshot"] = Snapshot()
roles["targets"] = Targets()
# Create delegated targets role
roles[DELEGATED_ROLE] = Targets()
# Create one key pair per role
# NOTE: For some roles, e.g. root, usually a threshold of multiple signing keys is used
for role_name in roles.keys():
path_pub = (KEY_DIR / role_name).as_posix()
path_priv = generate_and_write_unencrypted_ed25519_keypair(path_pub)
keys[role_name] = import_ed25519_privatekey_from_file(path_priv)
# Perform top-level delegation, i.e. authorize signing keys for top-level roles in root
for role_name in TOP_LEVEL_ROLE_NAMES:
pub = Key.from_securesystemslib_key(keys[role_name])
roles["root"].add_key(pub, role_name)
# Perform targets delegation, i.e. authorize signing keys for delegated targets role and
# define target file responsibility (see `paths`) in top-level targets
roles["targets"].delegations = Delegations(
keys = {
keys[DELEGATED_ROLE]["keyid"]: Key.from_securesystemslib_key(
keys[DELEGATED_ROLE]
)
},
roles = {
DELEGATED_ROLE: DelegatedRole(
name=DELEGATED_ROLE,
keyids=[keys[DELEGATED_ROLE]["keyid"]],
threshold=1,
terminating=True,
paths=[TARGET_NAME],
),
},
)
# Add information about delegated targets role to snapshot
roles["snapshot"].meta[f"{DELEGATED_ROLE}.json"] = MetaFile(version=1)
# Add info about target file to delegated targets role, and create hash prefixed
# symlinks to the target file in the repository, which is required by the client for
# target file path resolution
target_file_info = TargetFile.from_file(TARGET_NAME, TARGET_PATH)
for digest in target_file_info.hashes.values():
TARGET_ALIAS = TARGET_DIR / f"{digest}.{TARGET_NAME}"
os.symlink(TARGET_NAME, TARGET_ALIAS)
roles[DELEGATED_ROLE].targets = {
TARGET_NAME: target_file_info
}
# Set expiration, and sign and persist all metadata. All metadata filenames are prefixed
# with their version number except timestamp.
PRETTY = JSONSerializer(compact=False)
for role_name, role in roles.items():
role.expires = EXPIRY
metadata = Metadata(role)
signer = SSlibSigner(keys[role_name])
metadata.sign(signer)
if role_name == "timestamp":
filename = "timestamp.json"
else:
filename = f"{role.version}.{role_name}.json"
path = METADATA_DIR / filename
metadata.to_file(path, serializer=PRETTY)
# Leave Python session open (for repo-side update below)
In a different terminal at the same path as above
# Serve repository metadata and target files
python3 -m http.server -d repo
In a different terminal at the same path as above
source tuf-audit-env/bin/activate
# Bootstrap client
mkdir -p client/downloads client/metadata
# Bootstrap trust (usually TOFU or prepackaged initial root)
cp repo/metadata/1.root.json client/metadata/root.json
# Drop onto Python shell to setup and run client to download "file.txt"
python
from tuf.ngclient import Updater
REPO_URL = "http://127.0.0.1:8000"
DOWNLOAD_DIR = "client/downloads"
METADATA_DIR = "client/metadata"
TARGET_NAME = "file.txt"
updater = Updater(
metadata_dir=METADATA_DIR,
metadata_base_url=f"{REPO_URL}/metadata/",
target_base_url=f"{REPO_URL}/targets/",
target_dir=DOWNLOAD_DIR)
updater.refresh()
info = updater.get_targetinfo(TARGET_NAME)
updater.download_target(info)
# Leave Python session open (to fetch an updated "file.txt" below)
In a different terminal at the same path as above
# Perform a repo-side update of the target file
echo "some updated content to protect" > repo/targets/file.txt
Back in the repository Python session from above
# Update target file info in delegated targets role, create symlinks, and bump version
target_file_info = TargetFile.from_file(TARGET_NAME, TARGET_PATH)
for digest in target_file_info.hashes.values():
TARGET_ALIAS = TARGET_DIR / f"{digest}.{TARGET_NAME}"
os.symlink(TARGET_NAME, TARGET_ALIAS)
roles[DELEGATED_ROLE].targets = {
TARGET_NAME: target_file_info
}
roles[DELEGATED_ROLE].version += 1
# Update delegated targets version in snapshot and bump snapshot version
roles["snapshot"].meta[f"{DELEGATED_ROLE}.json"].version = roles[DELEGATED_ROLE].version
roles["snapshot"].version += 1
# Update snapshot version in timestamp and bump timestamp version
roles["timestamp"].snapshot_meta.version = roles["snapshot"].version
roles["timestamp"].version += 1
# Sign and persist all changed metadata
for role_name in [DELEGATED_ROLE, "snapshot", "timestamp"]:
metadata = Metadata(roles[role_name])
signer = SSlibSigner(keys[role_name])
metadata.sign(signer)
if role_name == "timestamp":
filename = "timestamp.json"
else:
filename = f"{roles[role_name].version}.{role_name}.json"
path = METADATA_DIR / filename
metadata.to_file(path, serializer=PRETTY)
Back in the client Python session from above
# NOTE: Needs new Updater instance for subsequent refresh to reset state machine
updater = Updater(
metadata_dir=METADATA_DIR,
metadata_base_url=f"{REPO_URL}/metadata/",
target_base_url=f"{REPO_URL}/targets/",
target_dir=DOWNLOAD_DIR)
updater.refresh()
info = updater.get_targetinfo(TARGET_NAME)
updater.download_target(info)
In a different terminal at the same path as above
cd .. && rm -rf tuf-audit