diff --git a/.gitignore b/.gitignore index a8bdcd311..4533ac602 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,5 @@ build !sigstore/_store/*.crt !sigstore/_store/*.pem !sigstore/_store/*.pub -!test/unit/assets/*.txt -!test/unit/assets/*.crt -!test/unit/assets/*.sig -!test/unit/assets/*.rekor +!test/unit/assets/* +!test/unit/assets/staging-tuf/* diff --git a/README.md b/README.md index ba403744e..ce32508a2 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,11 @@ Sigstore instance options: (default: https://rekor.sigstore.dev) --rekor-root-pubkey FILE A PEM-encoded root public key for Rekor itself - (conflicts with --staging) (default: rekor.pub - (embedded)) + (conflicts with --staging) (default: None) --fulcio-url URL The Fulcio instance to use (conflicts with --staging) (default: https://fulcio.sigstore.dev) --ctfe FILE A PEM-encoded public key for the CT log (conflicts - with --staging) (default: ctfe.pub (embedded)) + with --staging) (default: None) ``` @@ -198,8 +197,7 @@ Sigstore instance options: (default: https://rekor.sigstore.dev) --rekor-root-pubkey FILE A PEM-encoded root public key for Rekor itself - (conflicts with --staging) (default: rekor.pub - (embedded)) + (conflicts with --staging) (default: None) ``` diff --git a/pyproject.toml b/pyproject.toml index e8fc7c01c..cd04cdf14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ "Topic :: Security :: Cryptography", ] dependencies = [ + "appdirs ~= 1.4", "cryptography >= 38", "importlib_resources ~= 5.7; python_version < '3.11'", "pydantic", @@ -33,6 +34,7 @@ dependencies = [ "pyOpenSSL >= 22.0.0", "requests", "securesystemslib", + "tuf >= 2.0.0", ] requires-python = ">=3.7" diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 1dc9562a3..9c7c20d60 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -40,13 +40,9 @@ RekorClient, RekorEntry, ) +from sigstore._internal.tuf import TrustUpdater from sigstore._sign import Signer -from sigstore._utils import ( - SplitCertificateChainError, - load_pem_public_key, - read_embedded, - split_certificate_chain, -) +from sigstore._utils import SplitCertificateChainError, split_certificate_chain from sigstore._verify import ( CertificateVerificationFailure, RekorEntryMissing, @@ -57,23 +53,12 @@ ) logger = logging.getLogger(__name__) -logging.basicConfig(level=os.environ.get("SIGSTORE_LOGLEVEL", "INFO").upper()) - - -class _Embedded: - """ - A repr-wrapper for reading embedded resources, needed to help `argparse` - render defaults correctly. - """ - - def __init__(self, name: str) -> None: - self._name = name - - def read(self) -> bytes: - return read_embedded(self._name) +level = os.environ.get("SIGSTORE_LOGLEVEL", "INFO").upper() +logging.basicConfig(level=level) - def __repr__(self) -> str: - return f"{self._name} (embedded)" +# workaround to make tuf less verbose https://github.com/theupdateframework/python-tuf/pull/2243 +if level == "INFO": + logging.getLogger("tuf").setLevel("WARNING") def _boolify_env(envvar: str) -> bool: @@ -116,7 +101,7 @@ def _add_shared_instance_options(group: argparse._ArgumentGroup) -> None: metavar="FILE", type=argparse.FileType("rb"), help="A PEM-encoded root public key for Rekor itself (conflicts with --staging)", - default=os.getenv("SIGSTORE_REKOR_ROOT_PUBKEY", _Embedded("rekor.pub")), + default=os.getenv("SIGSTORE_REKOR_ROOT_PUBKEY"), ) @@ -238,7 +223,7 @@ def _parser() -> argparse.ArgumentParser: metavar="FILE", type=argparse.FileType("rb"), help="A PEM-encoded public key for the CT log (conflicts with --staging)", - default=os.getenv("SIGSTORE_CTFE", _Embedded("ctfe.pub")), + default=os.getenv("SIGSTORE_CTFE"), ) sign.add_argument( @@ -427,12 +412,21 @@ def _sign(args: argparse.Namespace) -> None: elif args.fulcio_url == DEFAULT_FULCIO_URL and args.rekor_url == DEFAULT_REKOR_URL: signer = Signer.production() else: - ct_keyring = CTKeyring([load_pem_public_key(args.ctfe_pem.read())]) + # Assume "production" keys if none are given as arguments + updater = TrustUpdater.production() + if args.ctfe_pem is not None: + ctfe_keys = [args.ctfe_pem.read()] + else: + ctfe_keys = updater.get_ctfe_keys() + if args.rekor_root_pubkey is not None: + rekor_key = args.rekor_root_pubkey.read() + else: + rekor_key = updater.get_rekor_key() + + ct_keyring = CTKeyring(ctfe_keys) signer = Signer( fulcio=FulcioClient(args.fulcio_url), - rekor=RekorClient( - args.rekor_url, args.rekor_root_pubkey.read(), ct_keyring - ), + rekor=RekorClient(args.rekor_url, rekor_key, ct_keyring), ) # The order of precedence is as follows: @@ -560,10 +554,16 @@ def _verify(args: argparse.Namespace) -> None: except SplitCertificateChainError as error: args._parser.error(f"Failed to parse certificate chain: {error}") + if args.rekor_root_pubkey is not None: + rekor_key = args.rekor_root_pubkey.read() + else: + updater = TrustUpdater.production() + rekor_key = updater.get_rekor_key() + verifier = Verifier( rekor=RekorClient( url=args.rekor_url, - pubkey=args.rekor_root_pubkey.read(), + pubkey=rekor_key, # We don't use the CT keyring in verification so we can supply an empty keyring ct_keyring=CTKeyring(), ), diff --git a/sigstore/_internal/ctfe.py b/sigstore/_internal/ctfe.py index a1fcfdd00..e0eaf0e32 100644 --- a/sigstore/_internal/ctfe.py +++ b/sigstore/_internal/ctfe.py @@ -25,12 +25,7 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec, rsa -from sigstore._utils import ( - PublicKey, - key_id, - load_pem_public_key, - read_embedded, -) +from sigstore._utils import key_id, load_pem_public_key class CTKeyringError(Exception): @@ -58,48 +53,16 @@ class CTKeyring: This structure exists to facilitate key rotation in a CT log. """ - def __init__(self, keys: List[PublicKey] = []): + def __init__(self, keys: List[bytes] = []): """ Create a new `CTKeyring`, with `keys` as the initial set of signing keys. """ self._keyring = {} - for key in keys: + for key_bytes in keys: + key = load_pem_public_key(key_bytes) self._keyring[key_id(key)] = key - @classmethod - def staging(cls) -> CTKeyring: - """ - Returns a `CTKeyring` instance capable of verifying SCTs from - Sigstore's staging deployment. - """ - keyring = cls() - keyring._add_resource("ctfe.staging.pub") - keyring._add_resource("ctfe_2022.staging.pub") - keyring._add_resource("ctfe_2022.2.staging.pub") - - return keyring - - @classmethod - def production(cls) -> CTKeyring: - """ - Returns a `CTKeyring` instance capable of verifying SCTs from - Sigstore's production deployment. - """ - keyring = cls() - keyring._add_resource("ctfe.pub") - keyring._add_resource("ctfe_2022.pub") - - return keyring - - def _add_resource(self, name: str) -> None: - """ - Adds a key to the current keyring, as identified by its - resource name under `sigstore._store`. - """ - key_pem = read_embedded(name) - self.add(key_pem) - def add(self, key_pem: bytes) -> None: """ Adds a PEM-encoded key to the current keyring. diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index 1aa026bf2..95fedb9e6 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -33,19 +33,14 @@ from securesystemslib.formats import encode_canonical from sigstore._internal.ctfe import CTKeyring -from sigstore._utils import base64_encode_pem_cert, read_embedded +from sigstore._internal.tuf import TrustUpdater +from sigstore._utils import base64_encode_pem_cert logger = logging.getLogger(__name__) DEFAULT_REKOR_URL = "https://rekor.sigstore.dev" STAGING_REKOR_URL = "https://rekor.sigstage.dev" -_DEFAULT_REKOR_ROOT_PUBKEY = read_embedded("rekor.pub") -_STAGING_REKOR_ROOT_PUBKEY = read_embedded("rekor.staging.pub") - -_DEFAULT_REKOR_CTFE_PUBKEY = read_embedded("ctfe.pub") -_STAGING_REKOR_CTFE_PUBKEY = read_embedded("ctfe.staging.pub") - class RekorBundle(BaseModel): """ @@ -466,20 +461,28 @@ def __del__(self) -> None: self.session.close() @classmethod - def production(cls) -> RekorClient: + def production(cls, updater: TrustUpdater) -> RekorClient: """ Returns a `RekorClient` populated with the default Rekor production instance. + + updater must be a `TrustUpdater` for the production TUF repository. """ - return cls( - DEFAULT_REKOR_URL, _DEFAULT_REKOR_ROOT_PUBKEY, CTKeyring.production() - ) + rekor_key = updater.get_rekor_key() + ctfe_keys = updater.get_ctfe_keys() + + return cls(DEFAULT_REKOR_URL, rekor_key, CTKeyring(ctfe_keys)) @classmethod - def staging(cls) -> RekorClient: + def staging(cls, updater: TrustUpdater) -> RekorClient: """ Returns a `RekorClient` populated with the default Rekor staging instance. + + updater must be a `TrustUpdater` for the staging TUF repository. """ - return cls(STAGING_REKOR_URL, _STAGING_REKOR_ROOT_PUBKEY, CTKeyring.staging()) + rekor_key = updater.get_rekor_key() + ctfe_keys = updater.get_ctfe_keys() + + return cls(STAGING_REKOR_URL, rekor_key, CTKeyring(ctfe_keys)) @property def log(self) -> RekorLog: diff --git a/sigstore/_internal/tuf.py b/sigstore/_internal/tuf.py new file mode 100644 index 000000000..8d05031e2 --- /dev/null +++ b/sigstore/_internal/tuf.py @@ -0,0 +1,160 @@ +# Copyright 2022 The Sigstore Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from pathlib import Path +from typing import List, Optional, Tuple +from urllib import parse + +import appdirs +from tuf.ngclient import Updater + +from sigstore._utils import read_embedded + +logger = logging.getLogger(__name__) + +DEFAULT_TUF_URL = "https://sigstore-tuf-root.storage.googleapis.com/" +STAGING_TUF_URL = "https://tuf-root-staging.storage.googleapis.com/" + +# for tests to override +_fetcher = None + + +def _get_dirs(url: str) -> Tuple[Path, Path]: + """ + Given a TUF repository URL, return suitable local metadata and cache directories. + + These directories are not guaranteed to already exist. + """ + + builder = appdirs.AppDirs("sigstore-python", "sigstore") + tuf_base = parse.quote(url, safe="") + + data_dir = Path(builder.user_data_dir) + cache_dir = Path(builder.user_cache_dir) + + return (data_dir / tuf_base), (cache_dir / tuf_base) + + +class TrustUpdater: + """Internal trust root (certificates and keys) downloader. + + TrustUpdater discovers the currently valid certificates and keys and + securely downloads them from the remote TUF repository at 'url'. + + TrustUpdater expects to find an initial root.json in either the local + metadata directory for this URL, or (as special case for the sigstore.dev + production and staging instances) in the application resources. + """ + + def __init__(self, url: str) -> None: + self._repo_url = url + self._updater: Optional[Updater] = None + + self._metadata_dir, self._targets_dir = _get_dirs(url) + + # intialize metadata dir + tuf_root = self._metadata_dir / "root.json" + if not tuf_root.exists(): + if self._repo_url == DEFAULT_TUF_URL: + fname = "root.json" + elif self._repo_url == STAGING_TUF_URL: + fname = "staging-root.json" + else: + raise Exception(f"TUF root not found in {tuf_root}") + + self._metadata_dir.mkdir(parents=True, exist_ok=True) + root_json = read_embedded(fname) + with tuf_root.open("wb") as io: + io.write(root_json) + + # Initialize targets cache dir + # NOTE: Could prime the cache here with any embedded certs/keys + self._targets_dir.mkdir(parents=True, exist_ok=True) + + logger.debug(f"TUF metadata: {self._metadata_dir}") + logger.debug(f"TUF targets cache: {self._targets_dir}") + + @classmethod + def production(cls) -> "TrustUpdater": + return cls(DEFAULT_TUF_URL) + + @classmethod + def staging(cls) -> "TrustUpdater": + return cls(STAGING_TUF_URL) + + def _setup(self) -> Updater: + """Initialize and update the toplevel TUF metadata""" + updater = Updater( + metadata_dir=str(self._metadata_dir), + metadata_base_url=f"{self._repo_url}", + target_base_url=f"{self._repo_url}targets/", + target_dir=str(self._targets_dir), + fetcher=_fetcher, + ) + + # NOTE: we would like to avoid refresh if the toplevel metadata is valid. + # https://github.com/theupdateframework/python-tuf/issues/2225 + updater.refresh() + return updater + + def _get(self, usage: str) -> List[bytes]: + """Return all active targets with given usage""" + if not self._updater: + self._updater = self._setup() + + data = [] + + # NOTE: _updater has been fully initialized at this point, but mypy can't see that. + targets = self._updater._trusted_set.targets.signed.targets # type: ignore[union-attr] + for target_info in targets.values(): + custom = target_info.unrecognized_fields["custom"]["sigstore"] + if custom["status"] == "Active" and custom["usage"] == usage: + path = self._updater.find_cached_target(target_info) + if path is None: + path = self._updater.download_target(target_info) + with open(path, "rb") as f: + data.append(f.read()) + + return data + + def get_ctfe_keys(self) -> List[bytes]: + """Return the active CTFE public keys contents. + + May download files from the remote repository. + """ + ctfes = self._get("CTFE") + if not ctfes: + raise Exception("CTFE keys not found in TUF metadata") + return ctfes + + def get_rekor_key(self) -> bytes: + """Return the rekor public key content. + + May download files from the remote repository. + """ + keys = self._get("Rekor") + if len(keys) != 1: + raise Exception("Did not find one active Rekor key in TUF metadata") + return keys[0] + + def get_fulcio_certs(self) -> List[bytes]: + """Return the active Fulcio certificate contents. + + May download files from the remote repository. + """ + certs = self._get("Fulcio") + if not certs: + raise Exception("Fulcio certificates not found in TUF metadata") + return certs diff --git a/sigstore/_sign.py b/sigstore/_sign.py index 4360b1cf1..71e667869 100644 --- a/sigstore/_sign.py +++ b/sigstore/_sign.py @@ -31,8 +31,9 @@ from sigstore._internal.fulcio import FulcioClient from sigstore._internal.oidc import Identity -from sigstore._internal.rekor import RekorClient, RekorEntry +from sigstore._internal.rekor.client import RekorClient, RekorEntry from sigstore._internal.sct import verify_sct +from sigstore._internal.tuf import TrustUpdater from sigstore._utils import sha256_streaming logger = logging.getLogger(__name__) @@ -61,14 +62,18 @@ def production(cls) -> Signer: """ Return a `Signer` instance configured against Sigstore's production-level services. """ - return cls(fulcio=FulcioClient.production(), rekor=RekorClient.production()) + updater = TrustUpdater.production() + rekor = RekorClient.production(updater) + return cls(fulcio=FulcioClient.production(), rekor=rekor) @classmethod def staging(cls) -> Signer: """ Return a `Signer` instance configured against Sigstore's staging-level services. """ - return cls(fulcio=FulcioClient.staging(), rekor=RekorClient.staging()) + updater = TrustUpdater.staging() + rekor = RekorClient.staging(updater) + return cls(fulcio=FulcioClient.staging(), rekor=rekor) def sign( self, diff --git a/sigstore/_store/__init__.py b/sigstore/_store/__init__.py index 1b42e49a5..1f5f2d0b9 100644 --- a/sigstore/_store/__init__.py +++ b/sigstore/_store/__init__.py @@ -26,21 +26,3 @@ # Why do we bother with `importlib` at all? Because we might be installed as a # ZIP file or an Egg, which in turn means that our resource files don't actually # exist separately on disk. `importlib` is the only reliable way to access them. - - -# Index of files by source: -# -# https://storage.googleapis.com/tuf-root-staging -# * ctfe.staging.pub -# * ctfe_2022.staging.pub -# * ctfe_2022.2.staging.pub -# * fulcio.crt.staging.pem -# * fulcio_intermediate.crt.staging.pem -# * rekor.staging.pub -# -# https://storage.googleapis.com/sigstore-tuf-root -# * ctfe.pub -# * ctfe_2022.pub -# * fulcio.crt.pem -# * fulcio_intermediate.crt.pem -# * rekor.pub diff --git a/sigstore/_store/ctfe.pub b/sigstore/_store/ctfe.pub deleted file mode 100644 index 75df6bbb9..000000000 --- a/sigstore/_store/ctfe.pub +++ /dev/null @@ -1,4 +0,0 @@ ------BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3Pyu -dDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w== ------END PUBLIC KEY----- diff --git a/sigstore/_store/ctfe_2022.pub b/sigstore/_store/ctfe_2022.pub deleted file mode 100644 index 32fa2ad10..000000000 --- a/sigstore/_store/ctfe_2022.pub +++ /dev/null @@ -1,4 +0,0 @@ ------BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNK -AaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw== ------END PUBLIC KEY----- diff --git a/sigstore/_store/fulcio.crt.pem b/sigstore/_store/fulcio.crt.pem deleted file mode 100644 index 3afc46bb6..000000000 --- a/sigstore/_store/fulcio.crt.pem +++ /dev/null @@ -1,13 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw -KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y -MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl -LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7 -XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex -X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j -YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY -wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ -KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM -WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9 -TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ ------END CERTIFICATE----- \ No newline at end of file diff --git a/sigstore/_store/fulcio_intermediate.crt.pem b/sigstore/_store/fulcio_intermediate.crt.pem deleted file mode 100644 index 6d1c298ba..000000000 --- a/sigstore/_store/fulcio_intermediate.crt.pem +++ /dev/null @@ -1,14 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw -KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y -MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl -LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C -AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7 -7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS -0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB -BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp -KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI -zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR -nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP -mygUY7Ii2zbdCdliiow= ------END CERTIFICATE----- \ No newline at end of file diff --git a/sigstore/_store/rekor.pub b/sigstore/_store/rekor.pub deleted file mode 100644 index 050ef6014..000000000 --- a/sigstore/_store/rekor.pub +++ /dev/null @@ -1,4 +0,0 @@ ------BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr -kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw== ------END PUBLIC KEY----- diff --git a/sigstore/_store/root.json b/sigstore/_store/root.json new file mode 100644 index 000000000..38f80f940 --- /dev/null +++ b/sigstore/_store/root.json @@ -0,0 +1,156 @@ +{ + "signed": { + "_type": "root", + "spec_version": "1.0", + "version": 5, + "expires": "2023-04-18T18:13:43Z", + "keys": { + "25a0eb450fd3ee2bd79218c963dce3f1cc6118badf251bf149f0bd07d5cabe99": { + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEXsz3SZXFb8jMV42j6pJlyjbjR8K\nN3Bwocexq6LMIb5qsWKOQvLN16NUefLc4HswOoumRsVVaajSpQS6fobkRw==\n-----END PUBLIC KEY-----\n" + } + }, + "2e61cd0cbf4a8f45809bda9f7f78c0d33ad11842ff94ae340873e2664dc843de": { + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0ghrh92Lw1Yr3idGV5WqCtMDB8Cx\n+D8hdC4w2ZLNIplVRoVGLskYa3gheMyOjiJ8kPi15aQ2//7P+oj7UvJPGw==\n-----END PUBLIC KEY-----\n" + } + }, + "45b283825eb184cabd582eb17b74fc8ed404f68cf452acabdad2ed6f90ce216b": { + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELrWvNt94v4R085ELeeCMxHp7PldF\n0/T1GxukUh2ODuggLGJE0pc1e8CSBf6CS91Fwo9FUOuRsjBUld+VqSyCdQ==\n-----END PUBLIC KEY-----\n" + } + }, + "7f7513b25429a64473e10ce3ad2f3da372bbdd14b65d07bbaf547e7c8bbbe62b": { + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEinikSsAQmYkNeH5eYq/CnIzLaacO\nxlSaawQDOwqKy/tCqxq5xxPSJc21K4WIhs9GyOkKfzueY3GILzcMJZ4cWw==\n-----END PUBLIC KEY-----\n" + } + }, + "e1863ba02070322ebc626dcecf9d881a3a38c35c3b41a83765b6ad6c37eaec2a": { + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWRiGr5+j+3J5SsH+Ztr5nE2H2wO7\nBV+nO3s93gLca18qTOzHY1oWyAGDykMSsGTUBSt9D+An0KfKsD2mfSM42Q==\n-----END PUBLIC KEY-----\n" + } + }, + "f5312f542c21273d9485a49394386c4575804770667f2ddb59b3bf0669fddd2f": { + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzBzVOmHCPojMVLSI364WiiV8NPrD\n6IgRxVliskz/v+y3JER5mcVGcONliDcWMC5J2lfHmjPNPhb4H7xm8LzfSA==\n-----END PUBLIC KEY-----\n" + } + }, + "ff51e17fcf253119b7033f6f57512631da4a0969442afcf9fc8b141c7f2be99c": { + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEy8XKsmhBYDI8Jc0GwzBxeKax0cm5\nSTKEU65HPFunUn41sT8pi0FjM4IkHz/YUmwmLUO0Wt7lxhj6BkLIK4qYAw==\n-----END PUBLIC KEY-----\n" + } + } + }, + "roles": { + "root": { + "keyids": [ + "ff51e17fcf253119b7033f6f57512631da4a0969442afcf9fc8b141c7f2be99c", + "25a0eb450fd3ee2bd79218c963dce3f1cc6118badf251bf149f0bd07d5cabe99", + "f5312f542c21273d9485a49394386c4575804770667f2ddb59b3bf0669fddd2f", + "7f7513b25429a64473e10ce3ad2f3da372bbdd14b65d07bbaf547e7c8bbbe62b", + "2e61cd0cbf4a8f45809bda9f7f78c0d33ad11842ff94ae340873e2664dc843de" + ], + "threshold": 3 + }, + "snapshot": { + "keyids": [ + "45b283825eb184cabd582eb17b74fc8ed404f68cf452acabdad2ed6f90ce216b" + ], + "threshold": 1 + }, + "targets": { + "keyids": [ + "ff51e17fcf253119b7033f6f57512631da4a0969442afcf9fc8b141c7f2be99c", + "25a0eb450fd3ee2bd79218c963dce3f1cc6118badf251bf149f0bd07d5cabe99", + "f5312f542c21273d9485a49394386c4575804770667f2ddb59b3bf0669fddd2f", + "7f7513b25429a64473e10ce3ad2f3da372bbdd14b65d07bbaf547e7c8bbbe62b", + "2e61cd0cbf4a8f45809bda9f7f78c0d33ad11842ff94ae340873e2664dc843de" + ], + "threshold": 3 + }, + "timestamp": { + "keyids": [ + "e1863ba02070322ebc626dcecf9d881a3a38c35c3b41a83765b6ad6c37eaec2a" + ], + "threshold": 1 + } + }, + "consistent_snapshot": true + }, + "signatures": [ + { + "keyid": "ff51e17fcf253119b7033f6f57512631da4a0969442afcf9fc8b141c7f2be99c", + "sig": "3045022100fc1c2be509ce50ea917bbad1d9efe9d96c8c2ebea04af2717aa3d9c6fe617a75022012eef282a19f2d8bd4818aa333ef48a06489f49d4d34a20b8fe8fc867bb25a7a" + }, + { + "keyid": "25a0eb450fd3ee2bd79218c963dce3f1cc6118badf251bf149f0bd07d5cabe99", + "sig": "30450221008a4392ae5057fc00778b651e61fea244766a4ae58db84d9f1d3810720ab0f3b702207c49e59e8031318caf02252ecea1281cecc1e5986c309a9cef61f455ecf7165d" + }, + { + "keyid": "7f7513b25429a64473e10ce3ad2f3da372bbdd14b65d07bbaf547e7c8bbbe62b", + "sig": "3046022100da1b8dc5d53aaffbbfac98de3e23ee2d2ad3446a7bed09fac0f88bae19be2587022100b681c046afc3919097dfe794e0d819be891e2e850aade315bec06b0c4dea221b" + }, + { + "keyid": "2e61cd0cbf4a8f45809bda9f7f78c0d33ad11842ff94ae340873e2664dc843de", + "sig": "3046022100b534e0030e1b271133ecfbdf3ba9fbf3becb3689abea079a2150afbb63cdb7c70221008c39a718fd9495f249b4ab8788d5b9dc269f0868dbe38b272f48207359d3ded9" + }, + { + "keyid": "2f64fb5eac0cf94dd39bb45308b98920055e9a0d8e012a7220787834c60aef97", + "sig": "3045022100fc1c2be509ce50ea917bbad1d9efe9d96c8c2ebea04af2717aa3d9c6fe617a75022012eef282a19f2d8bd4818aa333ef48a06489f49d4d34a20b8fe8fc867bb25a7a" + }, + { + "keyid": "eaf22372f417dd618a46f6c627dbc276e9fd30a004fc94f9be946e73f8bd090b", + "sig": "30450221008a4392ae5057fc00778b651e61fea244766a4ae58db84d9f1d3810720ab0f3b702207c49e59e8031318caf02252ecea1281cecc1e5986c309a9cef61f455ecf7165d" + }, + { + "keyid": "f505595165a177a41750a8e864ed1719b1edfccd5a426fd2c0ffda33ce7ff209", + "sig": "3046022100da1b8dc5d53aaffbbfac98de3e23ee2d2ad3446a7bed09fac0f88bae19be2587022100b681c046afc3919097dfe794e0d819be891e2e850aade315bec06b0c4dea221b" + }, + { + "keyid": "75e867ab10e121fdef32094af634707f43ddd79c6bab8ad6c5ab9f03f4ea8c90", + "sig": "3046022100b534e0030e1b271133ecfbdf3ba9fbf3becb3689abea079a2150afbb63cdb7c70221008c39a718fd9495f249b4ab8788d5b9dc269f0868dbe38b272f48207359d3ded9" + } + ] +} \ No newline at end of file diff --git a/sigstore/_store/staging-root.json b/sigstore/_store/staging-root.json new file mode 100644 index 000000000..cfdffc13a --- /dev/null +++ b/sigstore/_store/staging-root.json @@ -0,0 +1,87 @@ +{ + "signatures": [ + { + "keyid": "e864b064b09791888913104e7f99fec1526df8047aba7170e767534cce0b60bb", + "sig": "d867ae1e99c21ac5e44fb09413d9351fefa37147f56573dc6923651f7badbcefd7a0d5421aacd66ba9394959862930aa5f71f511edaa4dbc4c6de0aaffcd3306" + } + ], + "signed": { + "_type": "root", + "consistent_snapshot": false, + "expires": "2032-10-20T18:54:05Z", + "keys": { + "5d2da8f9ad58e2006befdc5724defb2bddca032c4c20934a48365c8af9fe91c4": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ed25519", + "keyval": { + "public": "c5319e3c1f5c89b680fb5ab7fd60f44ee0fa25a15270a667d908c7c74e1f5bd8" + }, + "scheme": "ed25519" + }, + "77ae02bf54c38218f28158551062a86f7a9320574ab6ae63e5c96a14c801efa3": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ed25519", + "keyval": { + "public": "bb15adf3924c08d23b78f093f7131c1dc5a0716f706d02b7ae46dd6756894b79" + }, + "scheme": "ed25519" + }, + "8132b9a0526173757a3341d08079e4882c1d9b084f164fc397a572690183516b": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ed25519", + "keyval": { + "public": "f77a1b58274a212cf1947d21eb61c6dbd21aee95a7a579d605d1cbdb510574a6" + }, + "scheme": "ed25519" + }, + "e864b064b09791888913104e7f99fec1526df8047aba7170e767534cce0b60bb": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ed25519", + "keyval": { + "public": "21eaa32c2a328cbcbf6a254b884eea142f09ef275c8da135989eed6105707336" + }, + "scheme": "ed25519" + } + }, + "roles": { + "root": { + "keyids": [ + "e864b064b09791888913104e7f99fec1526df8047aba7170e767534cce0b60bb" + ], + "threshold": 1 + }, + "snapshot": { + "keyids": [ + "77ae02bf54c38218f28158551062a86f7a9320574ab6ae63e5c96a14c801efa3" + ], + "threshold": 1 + }, + "targets": { + "keyids": [ + "5d2da8f9ad58e2006befdc5724defb2bddca032c4c20934a48365c8af9fe91c4" + ], + "threshold": 1 + }, + "timestamp": { + "keyids": [ + "8132b9a0526173757a3341d08079e4882c1d9b084f164fc397a572690183516b" + ], + "threshold": 1 + } + }, + "spec_version": "1.0", + "version": 1 + } +} \ No newline at end of file diff --git a/sigstore/_verify/verifier.py b/sigstore/_verify/verifier.py index 58b11fa29..85af6358d 100644 --- a/sigstore/_verify/verifier.py +++ b/sigstore/_verify/verifier.py @@ -18,6 +18,7 @@ from __future__ import annotations +import base64 import datetime import logging from typing import List, cast @@ -43,9 +44,9 @@ InvalidInclusionProofError, verify_merkle_inclusion, ) -from sigstore._internal.rekor import RekorClient +from sigstore._internal.rekor.client import RekorClient from sigstore._internal.set import InvalidSetError, verify_set -from sigstore._utils import read_embedded +from sigstore._internal.tuf import TrustUpdater from sigstore._verify.models import InvalidRekorEntry as InvalidRekorEntryError from sigstore._verify.models import RekorEntryMissing as RekorEntryMissingError from sigstore._verify.models import ( @@ -58,12 +59,6 @@ logger = logging.getLogger(__name__) -_DEFAULT_FULCIO_ROOT_CERT = read_embedded("fulcio.crt.pem") -_DEFAULT_FULCIO_INTERMEDIATE_CERT = read_embedded("fulcio_intermediate.crt.pem") - -_STAGING_FULCIO_ROOT_CERT = read_embedded("fulcio.crt.staging.pem") -_STAGING_FULCIO_INTERMEDIATE_CERT = read_embedded("fulcio_intermediate.crt.staging.pem") - class RekorEntryMissing(VerificationFailure): """ @@ -72,8 +67,16 @@ class RekorEntryMissing(VerificationFailure): """ reason: str = "Rekor has no entry for the given verification materials" + signature: str + """ + The signature present during lookup failure, encoded with base64. + """ + artifact_hash: str + """ + The artifact hash present during lookup failure, encoded as a hex string. + """ class CertificateVerificationFailure(VerificationFailure): @@ -119,12 +122,10 @@ def production(cls) -> Verifier: """ Return a `Verifier` instance configured against Sigstore's production-level services. """ + updater = TrustUpdater.production() return cls( - rekor=RekorClient.production(), - fulcio_certificate_chain=[ - _DEFAULT_FULCIO_ROOT_CERT, - _DEFAULT_FULCIO_INTERMEDIATE_CERT, - ], + rekor=RekorClient.production(updater), + fulcio_certificate_chain=updater.get_fulcio_certs(), ) @classmethod @@ -132,12 +133,10 @@ def staging(cls) -> Verifier: """ Return a `Verifier` instance configured against Sigstore's staging-level services. """ + updater = TrustUpdater.staging() return cls( - rekor=RekorClient.staging(), - fulcio_certificate_chain=[ - _STAGING_FULCIO_ROOT_CERT, - _STAGING_FULCIO_INTERMEDIATE_CERT, - ], + rekor=RekorClient.staging(updater), + fulcio_certificate_chain=updater.get_fulcio_certs(), ) def verify( @@ -237,7 +236,7 @@ def verify( entry = materials.rekor_entry(self._rekor) except RekorEntryMissingError: return RekorEntryMissing( - signature=materials.signature, + signature=base64.b64encode(materials.signature).decode(), artifact_hash=materials.input_digest.hex(), ) except InvalidRekorEntryError: diff --git a/test/unit/assets/staging-tuf/1.root.json b/test/unit/assets/staging-tuf/1.root.json new file mode 100644 index 000000000..cfdffc13a --- /dev/null +++ b/test/unit/assets/staging-tuf/1.root.json @@ -0,0 +1,87 @@ +{ + "signatures": [ + { + "keyid": "e864b064b09791888913104e7f99fec1526df8047aba7170e767534cce0b60bb", + "sig": "d867ae1e99c21ac5e44fb09413d9351fefa37147f56573dc6923651f7badbcefd7a0d5421aacd66ba9394959862930aa5f71f511edaa4dbc4c6de0aaffcd3306" + } + ], + "signed": { + "_type": "root", + "consistent_snapshot": false, + "expires": "2032-10-20T18:54:05Z", + "keys": { + "5d2da8f9ad58e2006befdc5724defb2bddca032c4c20934a48365c8af9fe91c4": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ed25519", + "keyval": { + "public": "c5319e3c1f5c89b680fb5ab7fd60f44ee0fa25a15270a667d908c7c74e1f5bd8" + }, + "scheme": "ed25519" + }, + "77ae02bf54c38218f28158551062a86f7a9320574ab6ae63e5c96a14c801efa3": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ed25519", + "keyval": { + "public": "bb15adf3924c08d23b78f093f7131c1dc5a0716f706d02b7ae46dd6756894b79" + }, + "scheme": "ed25519" + }, + "8132b9a0526173757a3341d08079e4882c1d9b084f164fc397a572690183516b": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ed25519", + "keyval": { + "public": "f77a1b58274a212cf1947d21eb61c6dbd21aee95a7a579d605d1cbdb510574a6" + }, + "scheme": "ed25519" + }, + "e864b064b09791888913104e7f99fec1526df8047aba7170e767534cce0b60bb": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ed25519", + "keyval": { + "public": "21eaa32c2a328cbcbf6a254b884eea142f09ef275c8da135989eed6105707336" + }, + "scheme": "ed25519" + } + }, + "roles": { + "root": { + "keyids": [ + "e864b064b09791888913104e7f99fec1526df8047aba7170e767534cce0b60bb" + ], + "threshold": 1 + }, + "snapshot": { + "keyids": [ + "77ae02bf54c38218f28158551062a86f7a9320574ab6ae63e5c96a14c801efa3" + ], + "threshold": 1 + }, + "targets": { + "keyids": [ + "5d2da8f9ad58e2006befdc5724defb2bddca032c4c20934a48365c8af9fe91c4" + ], + "threshold": 1 + }, + "timestamp": { + "keyids": [ + "8132b9a0526173757a3341d08079e4882c1d9b084f164fc397a572690183516b" + ], + "threshold": 1 + } + }, + "spec_version": "1.0", + "version": 1 + } +} \ No newline at end of file diff --git a/sigstore/_store/ctfe.staging.pub b/test/unit/assets/staging-tuf/ctfe.pub similarity index 100% rename from sigstore/_store/ctfe.staging.pub rename to test/unit/assets/staging-tuf/ctfe.pub diff --git a/sigstore/_store/ctfe_2022.staging.pub b/test/unit/assets/staging-tuf/ctfe_2022.pub similarity index 100% rename from sigstore/_store/ctfe_2022.staging.pub rename to test/unit/assets/staging-tuf/ctfe_2022.pub diff --git a/sigstore/_store/ctfe_2022.2.staging.pub b/test/unit/assets/staging-tuf/ctfe_2022_2.pub similarity index 100% rename from sigstore/_store/ctfe_2022.2.staging.pub rename to test/unit/assets/staging-tuf/ctfe_2022_2.pub diff --git a/sigstore/_store/fulcio.crt.staging.pem b/test/unit/assets/staging-tuf/fulcio.crt.pem similarity index 100% rename from sigstore/_store/fulcio.crt.staging.pem rename to test/unit/assets/staging-tuf/fulcio.crt.pem diff --git a/sigstore/_store/fulcio_intermediate.crt.staging.pem b/test/unit/assets/staging-tuf/fulcio_intermediate.crt.pem similarity index 100% rename from sigstore/_store/fulcio_intermediate.crt.staging.pem rename to test/unit/assets/staging-tuf/fulcio_intermediate.crt.pem diff --git a/sigstore/_store/rekor.staging.pub b/test/unit/assets/staging-tuf/rekor.pub similarity index 100% rename from sigstore/_store/rekor.staging.pub rename to test/unit/assets/staging-tuf/rekor.pub diff --git a/test/unit/assets/staging-tuf/snapshot.json b/test/unit/assets/staging-tuf/snapshot.json new file mode 100644 index 000000000..3fbc53aad --- /dev/null +++ b/test/unit/assets/staging-tuf/snapshot.json @@ -0,0 +1,30 @@ +{ + "signatures": [ + { + "keyid": "77ae02bf54c38218f28158551062a86f7a9320574ab6ae63e5c96a14c801efa3", + "sig": "2bbce0ade009e7dd1160e9ea4e8610c8603dd53c256f4105f297b5b085ebdde292503dd3c462d413aeb6db3e56534f04c0b0b2ce7e7e21194723aa09d6676700" + } + ], + "signed": { + "_type": "snapshot", + "expires": "2032-10-20T18:54:05Z", + "meta": { + "root.json": { + "hashes": { + "sha512": "5e437c93331d12b3e54c1f1dabd61a5c000d08a29b85e35f71f49cc52c37ca8df90fad6a598f1e597617ecf580b18191d924e9f275e78adc6aaf9c5f1ad6c49c" + }, + "length": 2482, + "version": 1 + }, + "targets.json": { + "hashes": { + "sha512": "df7ffa4f0634db1723dc5cee5a0b65438aeed2c37be5a76dd0f5bd2a01d5522f7a4bf1257aeed1c303d3b0775e2ad173434fe24fff13b2c9582a4deacf937aac" + }, + "length": 2616, + "version": 1 + } + }, + "spec_version": "1.0", + "version": 1 + } +} \ No newline at end of file diff --git a/test/unit/assets/staging-tuf/targets.json b/test/unit/assets/staging-tuf/targets.json new file mode 100644 index 000000000..ec7f674ef --- /dev/null +++ b/test/unit/assets/staging-tuf/targets.json @@ -0,0 +1,88 @@ +{ + "signatures": [ + { + "keyid": "5d2da8f9ad58e2006befdc5724defb2bddca032c4c20934a48365c8af9fe91c4", + "sig": "f8e9b911e87e875c56511dae7fc30512305de2d5be437b78b07c532396f08750445bb423b9972e103f84ddf871c656fa3221b26f3655c57c1b47e45a190bcf07" + } + ], + "signed": { + "_type": "targets", + "expires": "2032-10-20T18:54:05Z", + "spec_version": "1.0", + "targets": { + "ctfe.pub": { + "custom": { + "sigstore": { + "status": "Active", + "usage": "CTFE" + } + }, + "hashes": { + "sha512": "b861189e48df51186a39612230fba6b02af951f7b35ad9375e8ca182d0e085d470e26d69f7cd4d7450a0f223991e8e5a4ddf8f1968caa15255de8e37035af43a" + }, + "length": 775 + }, + "ctfe_2022.pub": { + "custom": { + "sigstore": { + "status": "Active", + "usage": "CTFE" + } + }, + "hashes": { + "sha512": "ab975a75600fc366a837536d0dcba841b755552d21bb114498ff8ac9d2403f76643f5b91269bce5d124a365514719a3edee9dcc2b046cb173f51af659911fcd3" + }, + "length": 178 + }, + "ctfe_2022_2.pub": { + "custom": { + "sigstore": { + "status": "Active", + "usage": "CTFE" + } + }, + "hashes": { + "sha512": "3d035f94e1b14ac84627a28afdbed9a34861fb84239f76d73aa1a99f52262bfd95c4fa0ee71f1fd7e3bfb998d89cd5e0f0eafcff9fa7fa87c6e23484fc1e0cec" + }, + "length": 178 + }, + "fulcio.crt.pem": { + "custom": { + "sigstore": { + "status": "Active", + "usage": "Fulcio" + } + }, + "hashes": { + "sha512": "c69ae618883a0c89c282c0943a1ad0c16b0a7788f74e47a1adefc631dac48a0c4449d8c3de7455ae7d772e43c4a87e341f180b0614a46a86006969f8a7b84532" + }, + "length": 741 + }, + "fulcio_intermediate.crt.pem": { + "custom": { + "sigstore": { + "status": "Active", + "usage": "Fulcio" + } + }, + "hashes": { + "sha512": "90659875a02f73d1026055427c6d857c556e410e23748ff88aeb493227610fd2f5fbdd95ef2a21565f91438dfb3e073f50c4c9dd06f9a601b5d9b064d5cb60b4" + }, + "length": 790 + }, + "rekor.pub": { + "custom": { + "sigstore": { + "status": "Active", + "usage": "Rekor" + } + }, + "hashes": { + "sha512": "09ab08698a67354a95d3b8897d9ce7eaef05f06f5ed5f0202d79c228579858ecc5816b7e1b7cc6786abe7d6aaa758e1fcb05900cb749235186c3bf9522d6d7ce" + }, + "length": 178 + } + }, + "version": 1 + } +} \ No newline at end of file diff --git a/test/unit/assets/staging-tuf/timestamp.json b/test/unit/assets/staging-tuf/timestamp.json new file mode 100644 index 000000000..acd7ca722 --- /dev/null +++ b/test/unit/assets/staging-tuf/timestamp.json @@ -0,0 +1,23 @@ +{ + "signatures": [ + { + "keyid": "8132b9a0526173757a3341d08079e4882c1d9b084f164fc397a572690183516b", + "sig": "d30a25f032304ce89235ec468781fecbe82e561a87d7fb531bfd94f4d312c6464b7dfa3d6774fbd9c47462e773868a2efeb654e8806f6d17efca18e1de5b6b03" + } + ], + "signed": { + "_type": "timestamp", + "expires": "2032-10-20T18:54:05Z", + "meta": { + "snapshot.json": { + "hashes": { + "sha512": "8eeb2728174fa67f6e8300f7c85f3954d6d1235b415af32eee89390b921ca8c7b448435ad2954da2732f2b2ac9bbc126f69dbc99051289846d478e0ad5509557" + }, + "length": 928, + "version": 1 + } + }, + "spec_version": "1.0", + "version": 1 + } +} \ No newline at end of file diff --git a/test/unit/conftest.py b/test/unit/conftest.py index 525e0ba1d..4ec7d0abc 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -14,11 +14,15 @@ import base64 import os +from collections import defaultdict from pathlib import Path -from typing import Tuple +from typing import Iterator, Tuple import pytest +from tuf.api.exceptions import DownloadHTTPError +from tuf.ngclient import FetcherInterface +from sigstore._internal import tuf from sigstore._internal.oidc.ambient import ( AmbientCredentialError, GitHubOidcPermissionCredentialError, @@ -31,6 +35,9 @@ _ASSETS = (Path(__file__).parent / "assets").resolve() assert _ASSETS.is_dir() +_TUF_ASSETS = (_ASSETS / "staging-tuf").resolve() +assert _TUF_ASSETS.is_dir() + def _is_ambient_env(): try: @@ -88,6 +95,14 @@ def _asset(name: str) -> Path: return _asset +@pytest.fixture +def tuf_asset(): + def _tuf_asset(name: str) -> Path: + return _TUF_ASSETS / name + + return _tuf_asset + + @pytest.fixture def signing_materials(): def _signing_materials(name: str) -> Tuple[bytes, bytes, bytes]: @@ -121,3 +136,36 @@ def verify(self, cert): return VerificationSuccess() return NullPolicy() + + +@pytest.fixture +def mock_staging_tuf(monkeypatch): + """Mock that prevents tuf module from making requests: it returns staging + assets from a local directory instead + + Return a tuple of dicts with the requested files and counts""" + + success = defaultdict(int) + failure = defaultdict(int) + + class MockFetcher(FetcherInterface): + def _fetch(self, url: str) -> Iterator[bytes]: + filename = os.path.basename(url) + filepath = _TUF_ASSETS / filename + if filepath.is_file(): + success[filename] += 1 + # NOTE: leaves file open: could return a function yielding contents + return open(filepath, "rb") + + failure[filename] += 1 + raise DownloadHTTPError("File not found", 404) + + monkeypatch.setattr(tuf, "_fetcher", MockFetcher()) + + return success, failure + + +@pytest.fixture +def temp_home(monkeypatch, tmp_path: Path): + """Set HOME to point to a test-specific tmp directory""" + monkeypatch.setenv("HOME", str(tmp_path)) diff --git a/test/unit/internal/test_ctfe.py b/test/unit/internal/test_ctfe.py index 031fe4a95..1b28ad5fc 100644 --- a/test/unit/internal/test_ctfe.py +++ b/test/unit/internal/test_ctfe.py @@ -15,46 +15,20 @@ import pretend import pytest -from sigstore._internal.ctfe import ( - CTKeyring, - CTKeyringError, - CTKeyringLookupError, -) -from sigstore._utils import key_id +from sigstore._internal.ctfe import CTKeyring, CTKeyringLookupError class TestCTKeyring: def test_keyring_init(self): - pubkey = pretend.stub( - public_bytes=pretend.call_recorder(lambda encoding, format: bytes(0)) + keybytes = ( + b"-----BEGIN PUBLIC KEY-----\n" + b"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3Pyu\n" + b"dDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==\n" + b"-----END PUBLIC KEY-----" ) - ctkeyring = CTKeyring([pubkey]) + ctkeyring = CTKeyring([keybytes]) assert len(ctkeyring._keyring) == 1 - def test_keyring_cardinalities(self): - production = CTKeyring.production() - staging = CTKeyring.staging() - - assert len(production._keyring) == 2 - assert len(staging._keyring) == 3 - - def test_production_staging_both_initialize(self): - keyrings = [CTKeyring.production(), CTKeyring.staging()] - for keyring in keyrings: - assert keyring is not None - - def test_production_staging_keyrings_are_disjoint(self): - production = CTKeyring.production() - staging = CTKeyring.staging() - - production_key_ids = production._keyring.keys() - staging_key_ids = staging._keyring.keys() - - # The key IDs (and therefore keys) in the production and staging instances - # should never overlap. Overlapping would imply loading keys intended - # for the wrong instance. - assert production_key_ids.isdisjoint(staging_key_ids) - def test_verify_fail_empty_keyring(self): ctkeyring = CTKeyring() key_id = pretend.stub(hex=pretend.call_recorder(lambda: pretend.stub())) @@ -63,15 +37,3 @@ def test_verify_fail_empty_keyring(self): with pytest.raises(CTKeyringLookupError, match="no known key for key ID?"): ctkeyring.verify(key_id=key_id, signature=signature, data=data) - - def test_verify_fail_keytype(self): - psuedo_key = pretend.stub( - public_bytes=pretend.call_recorder(lambda encoding, format: bytes(0)) - ) - ctkeyring = CTKeyring([psuedo_key]) - - signature = pretend.stub() - data = pretend.stub() - - with pytest.raises(CTKeyringError, match="unsupported key type:?"): - ctkeyring.verify(key_id=key_id(psuedo_key), signature=signature, data=data) diff --git a/test/unit/internal/test_tuf.py b/test/unit/internal/test_tuf.py new file mode 100644 index 000000000..6a05be3da --- /dev/null +++ b/test/unit/internal/test_tuf.py @@ -0,0 +1,87 @@ +# Copyright 2022 The Sigstore Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os + +from sigstore._internal.tuf import STAGING_TUF_URL, TrustUpdater, _get_dirs + + +def test_updater_staging_caches_and_requests(mock_staging_tuf, temp_home): + # start with empty target cache, empty local metadata dir + data_dir, cache_dir = _get_dirs(STAGING_TUF_URL) + + # keep track of successful and failed requests TrustUpdater makes + reqs, fail_reqs = mock_staging_tuf + + updater = TrustUpdater.staging() + # Expect root.json bootstrapped from _store + assert sorted(os.listdir(data_dir)) == ["root.json"] + # Expect no requests happened + assert reqs == {} + assert fail_reqs == {} + + updater.get_ctfe_keys() + # Expect local metadata to now contain all top-level metadata files + expected = ["root.json", "snapshot.json", "targets.json", "timestamp.json"] + assert sorted(os.listdir(data_dir)) == expected + # Expect requests of top-level metadata, and the ctfe targets + expected_requests = { + "ctfe.pub": 1, + "ctfe_2022.pub": 1, + "ctfe_2022_2.pub": 1, + "snapshot.json": 1, + "targets.json": 1, + "timestamp.json": 1, + } + expected_fail_reqs = {"2.root.json": 1} + assert reqs == expected_requests + # Expect 404 from the next root version + assert fail_reqs == expected_fail_reqs + + updater.get_rekor_key() + # Expect request of the rekor key but nothing else + expected_requests["rekor.pub"] = 1 + assert reqs == expected_requests + assert fail_reqs == expected_fail_reqs + + updater.get_rekor_key() + # Expect no requests + assert reqs == expected_requests + assert fail_reqs == expected_fail_reqs + + # New Updater instance, same cache dirs + updater = TrustUpdater.staging() + # Expect no requests happened + assert reqs == expected_requests + assert fail_reqs == expected_fail_reqs + + updater.get_ctfe_keys() + # Expect new timestamp and root requests + expected_requests["timestamp.json"] += 1 + expected_fail_reqs["2.root.json"] += 1 + assert reqs == expected_requests + assert fail_reqs == expected_fail_reqs + + updater.get_rekor_key() + # Expect no requests + assert reqs == expected_requests + assert fail_reqs == expected_fail_reqs + + +def test_updater_staging_get(mock_staging_tuf, temp_home, tuf_asset): + """Test that one of the get-methods returns the expected content""" + updater = TrustUpdater.staging() + with open(tuf_asset("rekor.pub"), "rb") as f: + assert updater.get_rekor_key() == f.read() diff --git a/test/unit/test_sign.py b/test/unit/test_sign.py index 50d1660a2..cce76f528 100644 --- a/test/unit/test_sign.py +++ b/test/unit/test_sign.py @@ -21,20 +21,25 @@ from sigstore._sign import Signer +@pytest.mark.online def test_signer_production(): signer = Signer.production() assert signer is not None -def test_signer_staging(): +def test_signer_staging(mock_staging_tuf): signer = Signer.staging() assert signer is not None @pytest.mark.online @pytest.mark.ambient_oidc -@pytest.mark.parametrize("signer", [Signer.production(), Signer.staging()]) -def test_sign_rekor_entry_consistent(signer): +@pytest.mark.parametrize("signer", [Signer.production, Signer.staging]) +def test_sign_rekor_entry_consistent_production(signer): + # NOTE: The actual signer instance is produced lazily, so that parameter + # expansion doesn't fail in offline tests. + signer = signer() + token = detect_credential() assert token is not None diff --git a/test/unit/test_store.py b/test/unit/test_store.py index 494ff23c9..6f6ac1ae0 100644 --- a/test/unit/test_store.py +++ b/test/unit/test_store.py @@ -12,20 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from sigstore._utils import read_embedded - +import json -def test_store_reads_fulcio_root_cert(): - fulcio_crt = read_embedded("fulcio.crt.pem").strip() - lines = fulcio_crt.split(b"\n") +from sigstore._utils import read_embedded - assert lines[0].startswith(b"-----BEGIN CERTIFICATE-----") - assert lines[-1].startswith(b"-----END CERTIFICATE-----") +def test_store_reads_root_json(): + root_json = read_embedded("root.json") + assert json.loads(root_json) -def test_store_reads_ctfe_pub(): - ctfe_pub = read_embedded("ctfe.pub").strip() - lines = ctfe_pub.split(b"\n") - assert lines[0].startswith(b"-----BEGIN PUBLIC KEY-----") - assert lines[-1].startswith(b"-----END PUBLIC KEY-----") +def test_store_reads_staging_root_json(): + root_json = read_embedded("staging-root.json") + assert json.loads(root_json) diff --git a/test/unit/verify/test_models.py b/test/unit/verify/test_models.py index c0cf53d04..f51783338 100644 --- a/test/unit/verify/test_models.py +++ b/test/unit/verify/test_models.py @@ -15,7 +15,8 @@ import pretend import pytest -from sigstore._internal.rekor import RekorClient +from sigstore._internal.rekor.client import RekorClient +from sigstore._internal.tuf import TrustUpdater from sigstore._verify.models import InvalidRekorEntry @@ -36,6 +37,7 @@ def test_verification_materials_retrieves_rekor_entry(self, signing_materials): materials = signing_materials("a.txt") assert materials._offline_rekor_entry is None - client = RekorClient.staging() + tuf = TrustUpdater.staging() + client = RekorClient.staging(tuf) entry = materials.rekor_entry(client) assert entry is not None diff --git a/test/unit/verify/test_verifier.py b/test/unit/verify/test_verifier.py index 7017620f7..c04c59e59 100644 --- a/test/unit/verify/test_verifier.py +++ b/test/unit/verify/test_verifier.py @@ -19,12 +19,13 @@ from sigstore._verify.verifier import CertificateVerificationFailure, Verifier +@pytest.mark.online def test_verifier_production(): verifier = Verifier.production() assert verifier is not None -def test_verifier_staging(): +def test_verifier_staging(mock_staging_tuf): verifier = Verifier.staging() assert verifier is not None @@ -47,7 +48,9 @@ def test_verifier_multiple_verifications(signing_materials, null_policy): assert verifier.verify(materials, null_policy) -def test_verifier_offline_rekor_bundle(signing_materials, null_policy): +def test_verifier_offline_rekor_bundle( + signing_materials, null_policy, mock_staging_tuf +): materials = signing_materials("offline-rekor.txt") verifier = Verifier.staging()