From 9ec84593792c40acbfe83bf06e9ae1777e1e9861 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 9 Dec 2022 14:51:53 +0200 Subject: [PATCH 01/10] examples: Add skeleton API endpoints Also use ThreadingHTTPServer: Using Chrome utterly breaks the non-threading server. Signed-off-by: Jussi Kukkonen --- examples/repository/repo | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/examples/repository/repo b/examples/repository/repo index 36115123..c2ff83f5 100755 --- a/examples/repository/repo +++ b/examples/repository/repo @@ -13,7 +13,7 @@ import argparse import logging import sys from datetime import datetime -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from time import time from typing import Dict, List @@ -23,15 +23,36 @@ logger = logging.getLogger(__name__) class ReqHandler(BaseHTTPRequestHandler): - """HTTP handler to serve metadata and targets from a SimpleRepository""" + """HTTP handler for the repository example application + + Serves metadata, targets and a small upload API using a SimpleRepository + """ + + def do_POST(self): + """Handle POST requests, aka the 'API'""" + + content_len = int(self.headers.get('content-length', 0)) + + if self.path.startswith("/api/delegation/"): + role = self.path[len("/api/delegation/"):] + if role: + raise NotImplementedError + elif self.path.startswith("/api/role/"): + role = self.path[len("/api/role/"):] + if role: + raise NotImplementedError + + self.send_error(404) + def do_GET(self): + """Handle GET: metadata and target files""" if self.path.startswith("/metadata/") and self.path.endswith(".json"): self.get_metadata(self.path[len("/metadata/") : -len(".json")]) elif self.path.startswith("/targets/"): self.get_target(self.path[len("/targets/") :]) else: - self.send_error(404, "Only serving /metadata/*.json") + self.send_error(404) def get_metadata(self, ver_and_role: str): repo = self.server.repo @@ -72,7 +93,7 @@ class ReqHandler(BaseHTTPRequestHandler): self.wfile.write(data) -class RepositoryServer(HTTPServer): +class RepositoryServer(ThreadingHTTPServer): def __init__(self, port: int): super().__init__(("127.0.0.1", port), ReqHandler) self.timeout = 1 From efcb3cfb80ef07629a42e8149aaa4d8f3f51fd6b Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 9 Dec 2022 15:16:31 +0200 Subject: [PATCH 02/10] examples: Add further scaffolding for upload API The API doesn't modify the repository yet but the data flow is there now. Signed-off-by: Jussi Kukkonen --- examples/repository/_simplerepo.py | 31 ++++++++++++++++++++++++++++++ examples/repository/repo | 15 +++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/examples/repository/_simplerepo.py b/examples/repository/_simplerepo.py index 927df60a..80b3cd91 100644 --- a/examples/repository/_simplerepo.py +++ b/examples/repository/_simplerepo.py @@ -4,6 +4,7 @@ """Simple example of using the repository library to build a repository""" import copy +import json import logging from collections import defaultdict from datetime import datetime, timedelta @@ -131,3 +132,33 @@ def add_target(self, path: str, content: str) -> None: # update snapshot, timestamp self.snapshot() self.timestamp() + + def submit_delegation(self, role: str, data: bytes) -> bool: + try: + logger.debug(f"Handling new delegation for role {role}") + keyid, keydict = next(iter(json.loads(data).items())) + key = Key.from_dict(keyid, keydict) + + # TODO add delegation and key + raise NotImplementedError + + except Exception as e: + print(e) + return False + + return True + + def submit_role(self, role: str, data: bytes) -> bool: + try: + logger.debug(f"Handling new version for role {role}") + md = Metadata.from_bytes(data) + + # TODO add new metadata version + raise NotImplementedError + + except Exception as e: + print(e) + return False + + return True + diff --git a/examples/repository/repo b/examples/repository/repo index c2ff83f5..ec616d6a 100755 --- a/examples/repository/repo +++ b/examples/repository/repo @@ -32,18 +32,21 @@ class ReqHandler(BaseHTTPRequestHandler): """Handle POST requests, aka the 'API'""" content_len = int(self.headers.get('content-length', 0)) + data = self.rfile.read(content_len) if self.path.startswith("/api/delegation/"): role = self.path[len("/api/delegation/"):] - if role: - raise NotImplementedError + if not self.server.repo.submit_delegation(role, data): + return self.send_error(400, f"Failed to delegate to {role}") elif self.path.startswith("/api/role/"): role = self.path[len("/api/role/"):] - if role: - raise NotImplementedError - - self.send_error(404) + if not self.server.repo.submit_role(role, data): + return self.send_error(400, f"Failed to submit role {role}") + else: + return self.send_error(404) + self.send_response(200) + self.end_headers() def do_GET(self): """Handle GET: metadata and target files""" From 69b30ecadc14169505db05fdcd2c6b8386dc3650 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 9 Dec 2022 15:17:37 +0200 Subject: [PATCH 03/10] examples: Add uploader tool example This tool works with the example repository: it can be used to * Add a delegation (this is an unsafe API corresponding to e.g. project creation in PyPI) * Submit new delegated role version (this requires using signing keys already submitted with the delegation) Signed-off-by: Jussi Kukkonen --- examples/uploader/_localrepo.py | 133 ++++++++++++++++++++++++++++++ examples/uploader/uploader | 140 ++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 examples/uploader/_localrepo.py create mode 100755 examples/uploader/uploader diff --git a/examples/uploader/_localrepo.py b/examples/uploader/_localrepo.py new file mode 100644 index 00000000..d2981bc6 --- /dev/null +++ b/examples/uploader/_localrepo.py @@ -0,0 +1,133 @@ +# Copyright 2021-2022 python-tuf contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +"""A Repository implementation for maintainer and developer tools""" + +import copy +import json +import logging +import os +from datetime import datetime, timedelta +from typing import Dict + +import requests +from securesystemslib import keys +from securesystemslib.signer import SSlibSigner + +from tuf.api.exceptions import RepositoryError +from tuf.api.metadata import Key, Metadata, MetaFile, TargetFile, Targets +from tuf.api.serialization.json import JSONSerializer +from tuf.ngclient import Updater +from tuf.repository import Repository + +logger = logging.getLogger(__name__) + + +class LocalRepository(Repository): + """A repository implementation that fetches data from a remote repository + + This implementation fetches metadata from a remote repository, potentially + creates new versions of metadata, and submits to the remote repository. + + ngclient Updater is used to fetch metadata from remote server: this is good + because we want to make sure the metadata we modify is verified, but also + bad because we need some hacks to access the Updaters metadata. + """ + + expiry_period = timedelta(days=1) + + def __init__(self, metadata_dir: str, key_dir: str, base_url: str): + self.key_dir = key_dir + if not os.path.isdir(self.key_dir): + os.makedirs(self.key_dir) + + self.base_url = base_url + + self.updater = Updater( + metadata_dir=metadata_dir, + metadata_base_url=f"{base_url}/metadata/", + ) + self.updater.refresh() + + @property + def targets_infos(self) -> Dict[str, MetaFile]: + raise NotImplementedError # we never call snapshot + + @property + def snapshot_info(self) -> MetaFile: + raise NotImplementedError # we never call timestamp + + def open(self, role: str) -> Metadata: + """Return cached (or fetched) metadata""" + + # if there is a metadata version fetched from remote, use that + # HACK: access Updater internals + # pylint: disable=protected-access + if role in self.updater._trusted_set: + return copy.deepcopy(self.updater._trusted_set[role]) + + # otherwise we're creating metadata from scratch + md = Metadata(Targets()) + # this makes version bumping in close() simpler + md.signed.version = 0 + return md + + def close(self, role: str, md: Metadata) -> None: + """Store a version of metadata. Handle version bumps, expiry, signing""" + md.signed.version += 1 + md.signed.expires = datetime.utcnow() + self.expiry_period + + with open(f"{self.key_dir}/{role}", "rt", encoding="utf-8") as f: + signer = SSlibSigner(json.loads(f.read())) + + md.sign(signer, append=False) + + # Upload using "api/role" + uri = f"{self.base_url}/api/role/{role}" + r = requests.post(uri, data=md.to_bytes(JSONSerializer()), timeout=5) + r.raise_for_status() + + def add_target(self, role: str, targetpath: str) -> bool: + """Add target to roles metadata and submit new metadata version""" + + # HACK: make sure we have the roles metadata in updater._trusted_set + # (or that we're publishing the first version) + try: + self.updater.get_targetinfo(targetpath) + except RepositoryError: + # HACK Assume this is because we're just publishing version 1 + # (so the roles metadata does not exist on server yet) + pass + + data = bytes(targetpath, "utf-8") + targetfile = TargetFile.from_data(targetpath, data) + try: + with self.edit(role) as delegated: + delegated.targets[targetpath] = targetfile + + except Exception as e: # pylint: disable=broad-except + print(f"Failed to submit new {role} with added target: {e}") + return False + + print(f"Uploaded role {role} v{delegated.version}") + return True + + def add_delegation(self, role: str) -> bool: + """Use the (unauthenticated) delegation adding API endpoint""" + keydict = keys.generate_ed25519_key() + pubkey = Key.from_securesystemslib_key(keydict) + + data = {pubkey.keyid: pubkey.to_dict()} + url = f"{self.base_url}/api/delegation/{role}" + r = requests.post(url, data=json.dumps(data), timeout=5) + if r.status_code != 200: + print(f"delegation failed with {r}") + return False + + # TODO: Once the Signer API is ready, use the priv key uri, store the file encrypted etc + # Store the private key using rolename as filename + with open(f"{self.key_dir}/{role}", "wt", encoding="utf-8") as f: + f.write(json.dumps(keydict)) + + print(f"Uploaded new delegation, stored key in {self.key_dir}/{role}") + return True diff --git a/examples/uploader/uploader b/examples/uploader/uploader new file mode 100755 index 00000000..3f308449 --- /dev/null +++ b/examples/uploader/uploader @@ -0,0 +1,140 @@ +#!/usr/bin/env python +# Copyright 2021-2022 python-tuf contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +"""Simple uploader tool example + +Uploader is a maintainer application that communicates with the repository +example. Uploader controls offline signing keys and produces signed metadata +that it sends to the repository application so that the metadata can be added +to the repository. +""" + +import argparse +import logging +import os +import sys +from hashlib import sha256 +from pathlib import Path +from typing import List, Optional +from urllib import request + +from _localrepo import LocalRepository + +logger = logging.getLogger(__name__) + + +def build_metadata_dir(base_url: str) -> str: + """build a unique and reproducible metadata dirname for the repo url""" + name = sha256(base_url.encode()).hexdigest()[:8] + # TODO: Make this not windows hostile? + return f"{Path.home()}/.local/share/tuf-upload-example/{name}" + + +def build_key_dir(base_url: str) -> str: + """build a unique and reproducible private key dir for the repository url""" + name = sha256(base_url.encode()).hexdigest()[:8] + # TODO: Make this not windows hostile? + return f"{Path.home()}/.config/tuf-upload-example/{name}" + + +def init_tofu(base_url: str) -> bool: + """Initialize local trusted metadata (Trust-On-First-Use)""" + metadata_dir = build_metadata_dir(base_url) + + if not os.path.isdir(metadata_dir): + os.makedirs(metadata_dir) + + root_url = f"{base_url}/metadata/1.root.json" + try: + request.urlretrieve(root_url, f"{metadata_dir}/root.json") + except OSError: + print(f"Failed to download initial root from {root_url}") + return False + + print(f"Trust-on-First-Use: Initialized new root in {metadata_dir}") + return True + + +def init(base_url: str) -> Optional[LocalRepository]: + """Initialize a LocalRepository: local root.json must already exist""" + metadata_dir = build_metadata_dir(base_url) + keydir = build_key_dir(base_url) + + if not os.path.isfile(f"{metadata_dir}/root.json"): + print( + "Trusted local root not found. Use 'tofu' command to " + "Trust-On-First-Use or copy trusted root metadata to " + f"{metadata_dir}/root.json" + ) + return None + + print(f"Using trusted root in {metadata_dir}") + return LocalRepository(metadata_dir, keydir, base_url) + + +def main(argv: List[str]) -> None: + """Example uploader tool""" + + parser = argparse.ArgumentParser() + parser.add_argument("-v", "--verbose", action="count", default=0) + parser.add_argument( + "-u", + "--url", + help="Base repository URL", + default="http://127.0.0.1:8001", + ) + + subparsers = parser.add_subparsers(dest="sub_command") + + tofu_cmd = subparsers.add_parser( + "tofu", + help="Initialize client with Trust-On-First-Use", + ) + + add_delegation_cmd = subparsers.add_parser( + "add-delegation", + help="Create a delegation and signing key", + ) + add_delegation_cmd.add_argument("rolename") + + add_target_cmd = subparsers.add_parser( + "add-target", + help="Add a target to a delegated role", + ) + add_target_cmd.add_argument("rolename") + add_target_cmd.add_argument("targetpath") + + args = parser.parse_args() + + if args.verbose == 0: + loglevel = logging.ERROR + elif args.verbose == 1: + loglevel = logging.WARNING + elif args.verbose == 2: + loglevel = logging.INFO + else: + loglevel = logging.DEBUG + logging.basicConfig(level=loglevel) + + if args.sub_command == "tofu": + if not init_tofu(args.url): + return "Failed to initialize local repository" + elif args.sub_command == "add-delegation": + repo = init(args.url) + if not repo: + return "Failed to initialize" + if not repo.add_delegation(args.rolename): + return "failed to add delegation" + elif args.sub_command == "add-target": + repo = init(args.url) + if not repo: + return "Failed to initialize" + if not repo.add_target(args.rolename, args.targetpath): + return "Failed to add target" + else: + parser.print_help() + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 92e03d2d2080934f1472ab0d91e533a400715117 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 9 Dec 2022 16:29:00 +0200 Subject: [PATCH 04/10] examples: Implement the upload API uploader API has two POST endpoints /api/delegation/ Accepts new delegation keys for targetpath "/*" to role . This data is not signed in any way: In a real service this action would require some external authentication. POST content: { : } /api/role/ accepts uploads of new versions of metadata. The metadata must be correctly signed by the keys assigned to this delegation. POST content: TUF targets metadata as json Signed-off-by: Jussi Kukkonen --- examples/repository/_simplerepo.py | 71 ++++++++++++++++++++++++------ examples/repository/repo | 59 +++++++++++++------------ examples/uploader/_localrepo.py | 6 +-- 3 files changed, 92 insertions(+), 44 deletions(-) diff --git a/examples/repository/_simplerepo.py b/examples/repository/_simplerepo.py index 80b3cd91..b7a86990 100644 --- a/examples/repository/_simplerepo.py +++ b/examples/repository/_simplerepo.py @@ -11,9 +11,12 @@ from typing import Dict, List from securesystemslib import keys -from securesystemslib.signer import Signer, SSlibKey, SSlibSigner +from securesystemslib.signer import Key, Signer, SSlibKey, SSlibSigner +from tuf.api.exceptions import RepositoryError from tuf.api.metadata import ( + DelegatedRole, + Delegations, Metadata, MetaFile, Root, @@ -117,7 +120,7 @@ def close(self, role: str, md: Metadata) -> None: self._targets_infos[f"{role}.json"].version = md.signed.version def add_target(self, path: str, content: str) -> None: - """Add a target to repository""" + """Add a target to top-level targets metadata""" data = bytes(content, "utf-8") # add content to cache for serving to clients @@ -133,32 +136,72 @@ def add_target(self, path: str, content: str) -> None: self.snapshot() self.timestamp() - def submit_delegation(self, role: str, data: bytes) -> bool: + def submit_delegation(self, rolename: str, data: bytes) -> bool: + """Add a delegation to a (offline signed) delegated targets metadata""" try: - logger.debug(f"Handling new delegation for role {role}") + logger.debug("Processing new delegation to role %s", rolename) keyid, keydict = next(iter(json.loads(data).items())) key = Key.from_dict(keyid, keydict) - # TODO add delegation and key - raise NotImplementedError + # add delegation and key + role = DelegatedRole(rolename, [], 1, True, [f"{rolename}/*"]) + with self.edit("targets") as targets: + if targets.delegations is None: + targets.delegations = Delegations({}, {}) - except Exception as e: - print(e) + targets.delegations.roles[rolename] = role + targets.add_key(key, rolename) + + except (RepositoryError, json.JSONDecodeError) as e: + logger.info("Failed to add delegation for %s: %s", rolename, e) return False + logger.debug("Targets v%d", targets.version) + + # update snapshot, timestamp + self.snapshot() + self.timestamp() + return True def submit_role(self, role: str, data: bytes) -> bool: + """Add a new version of a delegated roles metadata""" try: - logger.debug(f"Handling new version for role {role}") + logger.debug("Processing new version for role %s", role) + if role in ["root", "snapshot", "timestamp", "targets"]: + raise ValueError("Only delegated targets are accepted") + md = Metadata.from_bytes(data) + for targetpath in md.signed.targets: + if not targetpath.startswith(f"{role}/"): + raise ValueError(f"targets allowed under {role}/ only") - # TODO add new metadata version - raise NotImplementedError + targets_md = self.role_cache["targets"][-1] + targets_md.verify_delegate(role, md) + if role in self.role_cache: + current_md = self.role_cache[role][-1] + current_ver = current_md.signed.version + else: + current_ver = 0 - except Exception as e: - print(e) + if md.signed.version != current_ver + 1: + raise ValueError("Invalid version {md.signed.version}") + + except (RepositoryError, ValueError) as e: + logger.info("Failed to add new version for %s: %s", role, e) return False - return True + # Checks passed: Add new delegated role version + self.role_cache[role].append(md) + self._targets_infos[f"{role}.json"].version = md.signed.version + logger.debug("%s v%d", role, md.signed.version) + # To keep it simple, target content is generated from targetpath + for targetpath in md.signed.targets: + self.target_cache[targetpath] = bytes(f"{targetpath}", "utf-8") + + # update snapshot, timestamp + self.snapshot() + self.timestamp() + + return True diff --git a/examples/repository/repo b/examples/repository/repo index ec616d6a..89ccf377 100755 --- a/examples/repository/repo +++ b/examples/repository/repo @@ -19,6 +19,8 @@ from typing import Dict, List from _simplerepo import SimpleRepository +from tuf.api.serialization.json import JSONSerializer + logger = logging.getLogger(__name__) @@ -29,19 +31,19 @@ class ReqHandler(BaseHTTPRequestHandler): """ def do_POST(self): - """Handle POST requests, aka the 'API'""" + """Handle POST requests, aka the 'uploader API'""" - content_len = int(self.headers.get('content-length', 0)) + content_len = int(self.headers.get("content-length", 0)) data = self.rfile.read(content_len) if self.path.startswith("/api/delegation/"): - role = self.path[len("/api/delegation/"):] + role = self.path[len("/api/delegation/") :] if not self.server.repo.submit_delegation(role, data): return self.send_error(400, f"Failed to delegate to {role}") elif self.path.startswith("/api/role/"): - role = self.path[len("/api/role/"):] + role = self.path[len("/api/role/") :] if not self.server.repo.submit_role(role, data): - return self.send_error(400, f"Failed to submit role {role}") + return self.send_error(400, f"Failed to submit role {role}") else: return self.send_error(404) @@ -50,12 +52,22 @@ class ReqHandler(BaseHTTPRequestHandler): def do_GET(self): """Handle GET: metadata and target files""" + data = None + if self.path.startswith("/metadata/") and self.path.endswith(".json"): - self.get_metadata(self.path[len("/metadata/") : -len(".json")]) + data = self.get_metadata( + self.path[len("/metadata/") : -len(".json")] + ) elif self.path.startswith("/targets/"): - self.get_target(self.path[len("/targets/") :]) - else: + data = self.get_target(self.path[len("/targets/") :]) + + if data is None: self.send_error(404) + else: + self.send_response(200) + self.send_header("Content-length", len(data)) + self.end_headers() + self.wfile.write(data) def get_metadata(self, ver_and_role: str): repo = self.server.repo @@ -68,32 +80,25 @@ class ReqHandler(BaseHTTPRequestHandler): ver = int(ver_str) if role not in repo.role_cache or ver > len(repo.role_cache[role]): - self.send_error(404, f"Role {role} version {ver} not found") - return + return None - # send the metadata json - data = repo.role_cache[role][ver - 1].to_bytes() - self.send_response(200) - self.send_header("Content-length", len(data)) - self.end_headers() - self.wfile.write(data) + # return metadata + return repo.role_cache[role][ver - 1].to_bytes(JSONSerializer()) def get_target(self, targetpath: str): - repo: SimpleRepository = self.server.repo - _hash, _, target = targetpath.partition(".") + repo = self.server.repo + + # unimplement the dumb hashing scheme + # TODO: maybe use hashed paths as the target_cache key + dir, sep, hashname = targetpath.rpartition("/") + _, _, name = hashname.partition(".") + target = f"{dir}{sep}{name}" if target not in repo.target_cache: - self.send_error(404, f"target {targetpath} not found") - return - - # TODO: check that hash actually matches -- or use hash.targetpath as target_cache keys? + return None # send the target content - data = repo.target_cache[target] - self.send_response(200) - self.send_header("Content-length", len(data)) - self.end_headers() - self.wfile.write(data) + return repo.target_cache[target] class RepositoryServer(ThreadingHTTPServer): diff --git a/examples/uploader/_localrepo.py b/examples/uploader/_localrepo.py index d2981bc6..4d5a065d 100644 --- a/examples/uploader/_localrepo.py +++ b/examples/uploader/_localrepo.py @@ -12,10 +12,10 @@ import requests from securesystemslib import keys -from securesystemslib.signer import SSlibSigner +from securesystemslib.signer import SSlibKey, SSlibSigner from tuf.api.exceptions import RepositoryError -from tuf.api.metadata import Key, Metadata, MetaFile, TargetFile, Targets +from tuf.api.metadata import Metadata, MetaFile, TargetFile, Targets from tuf.api.serialization.json import JSONSerializer from tuf.ngclient import Updater from tuf.repository import Repository @@ -115,7 +115,7 @@ def add_target(self, role: str, targetpath: str) -> bool: def add_delegation(self, role: str) -> bool: """Use the (unauthenticated) delegation adding API endpoint""" keydict = keys.generate_ed25519_key() - pubkey = Key.from_securesystemslib_key(keydict) + pubkey = SSlibKey.from_securesystemslib_key(keydict) data = {pubkey.keyid: pubkey.to_dict()} url = f"{self.base_url}/api/delegation/{role}" From 0998c20731a17be06cfcc81db4b28d69d9036ef4 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 9 Dec 2022 20:13:33 +0200 Subject: [PATCH 05/10] examples: Explain uploader tool in READMEs Signed-off-by: Jussi Kukkonen --- examples/repository/README.md | 3 +++ examples/uploader/README.md | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 examples/uploader/README.md diff --git a/examples/repository/README.md b/examples/repository/README.md index 9b9b9262..8339b766 100644 --- a/examples/repository/README.md +++ b/examples/repository/README.md @@ -9,6 +9,9 @@ This TUF Repository Application Example has the following features: - Serves metadata and targets on localhost (default port 8001) - Simulates a live repository by automatically adding a new target file every 10 seconds. +- Exposes a small API for the uploader tool example. API POST endpoints are: + /api/role/ + /api/delegation/ ### Usage diff --git a/examples/uploader/README.md b/examples/uploader/README.md new file mode 100644 index 00000000..fb0f35fb --- /dev/null +++ b/examples/uploader/README.md @@ -0,0 +1,44 @@ +# TUF Uploader Tool Example + +:warning: This example uses the repository module which is not considered +part of the python-tuf stable API quite yet. + +This is an example maintainer tool: It makes it possible to add delegations to +a remote repository, and then to upload delegated metadata to the repository. + +Features: + - Initialization (like the client example) + - Claim delegation: this uses "unsafe repository API" in the sense that the + uploader sends repository unsigned data. This operation can be + compared to claiming a project name on PyPI.org + - Add targetfile: Here uploader uses signing keys that were added to the + delegation in the previous step to create a new version of the delegated + metadata + +The used TUF repository can be set with `--url` (default repository is +"http://127.0.0.1:8001" which is also the default for the repository example). +In practice the uploader tool is only useful with the repository example. + +### Usage with the repository example + +In one terminal, run the repository example and leave it running: +```console +examples/repository/repo +``` + +In another terminal, run uploader: + +```console +# initialize with Trust-On-First-Use +./uploader tofu + +# Then claim a delegation for yourself: +./uploader add-delegation myrole + +# Then add targets to download: +./uploader add-target myrole myrole/mytargetfile +``` + +At this point "myrole/mytargetfile" is downloadable from the repository +with the client example: To keep the code simple, the content of the file +is just the targetpath as a string. From d36c0cfa025f497637ba6ed4356fd0c0fd7df33d Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Fri, 9 Dec 2022 22:32:46 +0200 Subject: [PATCH 06/10] examples: Rename client example directory Signed-off-by: Jussi Kukkonen --- examples/README.md | 5 +++-- examples/{client_example => client}/README.md | 0 examples/{client_example => client}/client | 0 examples/repository/README.md | 2 +- tuf/ngclient/updater.py | 4 ++-- 5 files changed, 6 insertions(+), 5 deletions(-) rename examples/{client_example => client}/README.md (100%) rename examples/{client_example => client}/client (100%) diff --git a/examples/README.md b/examples/README.md index 2ad5a327..9cfba24d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,6 @@ # Usage examples * [repository](repository) -* [client](client_example) -* [repository built with low-level Metadata API](manual_repo) +* [client](client) +* [uploader tool](uploader) +* [Low-level Metadata API examples](manual_repo) diff --git a/examples/client_example/README.md b/examples/client/README.md similarity index 100% rename from examples/client_example/README.md rename to examples/client/README.md diff --git a/examples/client_example/client b/examples/client/client similarity index 100% rename from examples/client_example/client rename to examples/client/client diff --git a/examples/repository/README.md b/examples/repository/README.md index 8339b766..d91ee31e 100644 --- a/examples/repository/README.md +++ b/examples/repository/README.md @@ -21,4 +21,4 @@ This TUF Repository Application Example has the following features: ``` Your repository is now running and is accessible on localhost, See e.g. http://127.0.0.1:8001/metadata/1.root.json. The -[client example](../client_example/README.md) uses this address by default. +[client example](../client/README.md) uses this address by default. diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index cf93219e..49cf1c20 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -33,8 +33,8 @@ the same time is not supported. A simple example of using the Updater to implement a Python TUF client that -downloads target files is available in `examples/client_example -`_. +downloads target files is available in `examples/client +`_. """ import logging From 46930e56c4489df47653a082c4b9857d58f8cb9e Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Wed, 8 Feb 2023 10:27:33 +0200 Subject: [PATCH 07/10] examples: Improve repository README Signed-off-by: Jussi Kukkonen --- examples/repository/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/repository/README.md b/examples/repository/README.md index d91ee31e..b2b15022 100644 --- a/examples/repository/README.md +++ b/examples/repository/README.md @@ -10,9 +10,10 @@ This TUF Repository Application Example has the following features: - Simulates a live repository by automatically adding a new target file every 10 seconds. - Exposes a small API for the uploader tool example. API POST endpoints are: - /api/role/ - /api/delegation/ - + - `/api/role/`: For uploading new delegated targets metadata. Payload + is new version of ROLEs metadata + - `/api/delegation/`: For modifying or creating a delegation for ROLE. + Payload is a dict with one keyid:Key pair ### Usage From 26495a5d0ae51270a9cca059dfede0573329d832 Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Wed, 8 Feb 2023 10:46:38 +0200 Subject: [PATCH 08/10] examples: Improve uploader docs/messages Signed-off-by: Jussi Kukkonen --- examples/uploader/README.md | 18 ++++++++++-------- examples/uploader/_localrepo.py | 1 - examples/uploader/uploader | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/examples/uploader/README.md b/examples/uploader/README.md index fb0f35fb..4eec3802 100644 --- a/examples/uploader/README.md +++ b/examples/uploader/README.md @@ -7,13 +7,13 @@ This is an example maintainer tool: It makes it possible to add delegations to a remote repository, and then to upload delegated metadata to the repository. Features: - - Initialization (like the client example) + - Initialization (much like the [client example](../client/)) - Claim delegation: this uses "unsafe repository API" in the sense that the uploader sends repository unsigned data. This operation can be compared to claiming a project name on PyPI.org - Add targetfile: Here uploader uses signing keys that were added to the delegation in the previous step to create a new version of the delegated - metadata + metadata. The repository will verify signatures on this metadata. The used TUF repository can be set with `--url` (default repository is "http://127.0.0.1:8001" which is also the default for the repository example). @@ -21,7 +21,7 @@ In practice the uploader tool is only useful with the repository example. ### Usage with the repository example -In one terminal, run the repository example and leave it running: +In one terminal, run the [repository example](../repository/) and leave it running: ```console examples/repository/repo ``` @@ -29,16 +29,18 @@ examples/repository/repo In another terminal, run uploader: ```console -# initialize with Trust-On-First-Use +# Initialize with Trust-On-First-Use ./uploader tofu -# Then claim a delegation for yourself: +# Then claim a delegation for yourself (this also creates and stores a new signing key): ./uploader add-delegation myrole -# Then add targets to download: +# Then add a new downloadable target file to your delegated role[^1]: ./uploader add-target myrole myrole/mytargetfile ``` At this point "myrole/mytargetfile" is downloadable from the repository -with the client example: To keep the code simple, the content of the file -is just the targetpath as a string. +with the [client example](../client/). + +[^1]: To keep the example simple, the content of every target file is the targetpath +as a string: This is why the upload of actual content is not implemented. diff --git a/examples/uploader/_localrepo.py b/examples/uploader/_localrepo.py index 4d5a065d..f0ddda4a 100644 --- a/examples/uploader/_localrepo.py +++ b/examples/uploader/_localrepo.py @@ -124,7 +124,6 @@ def add_delegation(self, role: str) -> bool: print(f"delegation failed with {r}") return False - # TODO: Once the Signer API is ready, use the priv key uri, store the file encrypted etc # Store the private key using rolename as filename with open(f"{self.key_dir}/{role}", "wt", encoding="utf-8") as f: f.write(json.dumps(keydict)) diff --git a/examples/uploader/uploader b/examples/uploader/uploader index 3f308449..aaf610df 100755 --- a/examples/uploader/uploader +++ b/examples/uploader/uploader @@ -125,7 +125,7 @@ def main(argv: List[str]) -> None: if not repo: return "Failed to initialize" if not repo.add_delegation(args.rolename): - return "failed to add delegation" + return "Failed to add delegation" elif args.sub_command == "add-target": repo = init(args.url) if not repo: From b6465ddedf5d5a53304d60c2f06a2953067e3c4f Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Wed, 8 Feb 2023 10:53:59 +0200 Subject: [PATCH 09/10] examples: Add missing link in repository README Signed-off-by: Jussi Kukkonen --- examples/repository/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/repository/README.md b/examples/repository/README.md index b2b15022..c4b1747b 100644 --- a/examples/repository/README.md +++ b/examples/repository/README.md @@ -9,7 +9,7 @@ This TUF Repository Application Example has the following features: - Serves metadata and targets on localhost (default port 8001) - Simulates a live repository by automatically adding a new target file every 10 seconds. -- Exposes a small API for the uploader tool example. API POST endpoints are: +- Exposes a small API for the [uploader tool example](../uploader/). API POST endpoints are: - `/api/role/`: For uploading new delegated targets metadata. Payload is new version of ROLEs metadata - `/api/delegation/`: For modifying or creating a delegation for ROLE. From 5a944f9ba2bf10457e47eee1e26a5d5f4d79fa6a Mon Sep 17 00:00:00 2001 From: Jussi Kukkonen Date: Wed, 8 Feb 2023 11:01:07 +0200 Subject: [PATCH 10/10] examples: More tweaks to uploader README Signed-off-by: Jussi Kukkonen --- examples/uploader/README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/uploader/README.md b/examples/uploader/README.md index 4eec3802..670b2b6d 100644 --- a/examples/uploader/README.md +++ b/examples/uploader/README.md @@ -32,15 +32,13 @@ In another terminal, run uploader: # Initialize with Trust-On-First-Use ./uploader tofu -# Then claim a delegation for yourself (this also creates and stores a new signing key): +# Then claim a delegation for yourself (this also creates a new signing key): ./uploader add-delegation myrole -# Then add a new downloadable target file to your delegated role[^1]: +# Then add a new downloadable target file to your delegated role (to keep the +# example simple, the target file content is always the targetpath): ./uploader add-target myrole myrole/mytargetfile ``` At this point "myrole/mytargetfile" is downloadable from the repository with the [client example](../client/). - -[^1]: To keep the example simple, the content of every target file is the targetpath -as a string: This is why the upload of actual content is not implemented.