Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use TUF to download key/cert material #351

Merged
merged 41 commits into from
Dec 22, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a7598df
tuf: Add initial TUF trust root updater
jku Dec 20, 2022
e6d392e
Rekor: Refactor CTKeyring, Use TUF in prod/staging
jku Dec 20, 2022
22a6055
cli: Use TUF for rekor/ctfe keys if not in args
jku Dec 20, 2022
b6325fd
Fix linter issues in TUF related code
jku Dec 20, 2022
c689fdb
tuf: Fetch Fulcio certificates with TUF
jku Dec 20, 2022
d7b75c1
cli: Use production rekor key by default
jku Dec 20, 2022
6386af7
tuf: Enable staging support
jku Dec 20, 2022
29885ff
_store: Add missing staging root.json
jku Dec 20, 2022
e1712a7
verifier: blacken
woodruffw Dec 20, 2022
b62a410
pyproject, sigstore/tuf: use appdirs for local state
woodruffw Dec 20, 2022
a8d1e4e
verifier: unused import
woodruffw Dec 20, 2022
f75c866
_internal/tuf: disambiguate caches correctly
woodruffw Dec 20, 2022
3a8f026
sign, verify, internal: refactor rekor client handling
woodruffw Dec 20, 2022
7d80e93
test/verify: fix TestVerificationMaterials test
woodruffw Dec 20, 2022
1dd9c1f
Refactor RekorClient construction once more
jku Dec 21, 2022
8072f1d
internal: Improve tuf docstrings
jku Dec 21, 2022
e85d6f4
internal: Refactor tuf
jku Dec 21, 2022
238f191
tests: Remove test for _store
jku Dec 21, 2022
8632250
_store: Remove all certificates and keys
jku Dec 21, 2022
d342697
tests: Add mock TUF fetcher for staging
jku Dec 21, 2022
5e5b280
tests: Don't require network in parametrized setup
jku Dec 21, 2022
6a41e3a
cli: Silence python-tuf logging a little
jku Dec 21, 2022
b54ed9f
tests: Add TrustUpdater test
jku Dec 21, 2022
68425ed
tests: Add basic test for TrustUpdater
jku Dec 21, 2022
170096e
Merge branch 'main' into tuf-refactor
woodruffw Dec 21, 2022
4ad04ce
_utils: lintage
woodruffw Dec 21, 2022
ae9df01
test/unit: put TUF assets under assets dir
woodruffw Dec 21, 2022
a210a6f
tests/unit: re-parametrize
woodruffw Dec 21, 2022
bbc6a99
_store, _utils: remove obsolete comment, re-add helper
woodruffw Dec 21, 2022
69f249e
test/unit: re-add store tests
woodruffw Dec 21, 2022
03bdaf7
tuf: re-use our read_embedded helper
woodruffw Dec 21, 2022
476b8f4
README: update `--help` texts
woodruffw Dec 21, 2022
3c88b26
gitignore, test: allow staging-tuf assets
woodruffw Dec 21, 2022
d9aa72c
tuf: Switch to using f-strings for logging
tetsuo-cpp Dec 22, 2022
deadd3c
Merge remote-tracking branch 'origin/main' into tuf-refactor
tetsuo-cpp Dec 22, 2022
b1fdc9f
test: document TUF staging mock better
jku Dec 22, 2022
cf4e46f
_internal/rekor: Mention updater arg in docsstrings
jku Dec 22, 2022
be7a6d7
_internal/tuf: Reword a TODO into a NOTE
jku Dec 22, 2022
b7c0bdb
_internal/tuf: Add nosec for mypy-related assert
jku Dec 22, 2022
e94d78c
Merge branch 'main' into tuf-refactor
woodruffw Dec 22, 2022
4e7f680
_internal/tuf: replace nosec with type ignore
woodruffw Dec 22, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies = [
"pyOpenSSL >= 22.0.0",
"requests",
"securesystemslib",
"tuf >= 2.0.0",
]
requires-python = ">=3.7"

Expand Down
53 changes: 24 additions & 29 deletions sigstore/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -60,22 +56,6 @@
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)

def __repr__(self) -> str:
return f"{self._name} (embedded)"


def _boolify_env(envvar: str) -> bool:
"""
An `argparse` helper for turning an environment variable into a boolean.
Expand Down Expand Up @@ -116,7 +96,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"),
)


Expand Down Expand Up @@ -238,7 +218,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(
Expand Down Expand Up @@ -427,12 +407,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()
jku marked this conversation as resolved.
Show resolved Hide resolved
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:
Expand Down Expand Up @@ -560,10 +549,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(),
),
Expand Down
45 changes: 4 additions & 41 deletions sigstore/_internal/ctfe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 6 additions & 15 deletions sigstore/_internal/rekor/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -411,14 +406,10 @@ def __del__(self) -> None:
self.session.close()

@classmethod
def production(cls) -> RekorClient:
return cls(
DEFAULT_REKOR_URL, _DEFAULT_REKOR_ROOT_PUBKEY, CTKeyring.production()
)

@classmethod
def staging(cls) -> RekorClient:
return cls(STAGING_REKOR_URL, _STAGING_REKOR_ROOT_PUBKEY, CTKeyring.staging())
def with_updater(cls, updater: TrustUpdater) -> RekorClient:
rekor_key = updater.get_rekor_key()
ctfe_keys = updater.get_ctfe_keys()
return cls(DEFAULT_REKOR_URL, rekor_key, CTKeyring(ctfe_keys))
jku marked this conversation as resolved.
Show resolved Hide resolved

@property
def log(self) -> RekorLog:
Expand Down
150 changes: 150 additions & 0 deletions sigstore/_internal/tuf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# 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
import shutil
from importlib import resources
from pathlib import Path
from typing import List, Optional, Tuple
from urllib import parse

from tuf.ngclient import Updater

logger = logging.getLogger(__name__)

DEFAULT_TUF_URL = "https://sigstore-tuf-root.storage.googleapis.com/"
STAGING_TUF_URL = "https://tuf-root-staging.storage.googleapis.com/"


def _get_dirs(url: str) -> Tuple[Path, Path]:
"""Return metadata dir and target cache dir for URL"""
# NOTE: this is not great for windows: should maybe depend on appdirs?
# TODO: there should be URL normalization if URLs come from user
dir = parse.quote(url, safe="")
md_dir = Path.home() / ".local" / "share" / "sigstore-python" / "tuf" / dir
targets_dir = Path.home() / ".cache" / "sigstore-python" / "tuf" / dir
return md_dir, targets_dir
woodruffw marked this conversation as resolved.
Show resolved Hide resolved


class TrustUpdater:
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)
with resources.path("sigstore._store", fname) as res:
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
shutil.copy2(res, tuf_root)

# intialize targets cache dir
# TODO: Pre-populate with any targets we ship with sources
self._targets_dir.mkdir(parents=True, exist_ok=True)

logger.debug("TUF metadata: %s", self._metadata_dir)
tetsuo-cpp marked this conversation as resolved.
Show resolved Hide resolved
logger.debug("TUF targets cache: %s", 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),
)

# 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_ctfe_keys(self) -> List[bytes]:
"""Return the active CTFE public keys contents"""
if not self._updater:
self._updater = self._setup()

ctfes = []
assert self._updater._trusted_set.targets
jku marked this conversation as resolved.
Show resolved Hide resolved
targets = self._updater._trusted_set.targets.signed.targets
for target_info in targets.values():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually would caution against using the custom metadata and use the path filters "rekor*", "fulcio*", "ctfe*", instead as the filtering mechanism. While we'll keep some of the metadata, I think we are aiming to change them (for example, status will be deprecated in favor of explicit NotBefore/NotAfter ranges)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That being said "usage" should match here and be stable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I knew the custom metadata was not a permanent solution but I'm not sure filtering by path is going to be either -- maybe makes sense to update once the target discovery and delegation plans are properly decided

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure filtering by path is going to be either -- maybe makes sense to update once the target discovery and delegation plans are properly decided

That's fair! Yeah, in the end distributing as a single bundle would probably fix that and that likely will be the case next year. The migration to that will be slow anyway (as in, the existing targets will continue to be distributed like this).

custom = target_info.unrecognized_fields["custom"]["sigstore"]
if custom["status"] == "Active" and custom["usage"] == "CTFE":
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:
ctfes.append(f.read())

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"""
if not self._updater:
self._updater = self._setup()

assert self._updater._trusted_set.targets
targets = self._updater._trusted_set.targets.signed.targets
for target, target_info in targets.items():
custom = target_info.unrecognized_fields["custom"]["sigstore"]
if custom["status"] == "Active" and custom["usage"] == "Rekor":
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:
return f.read()

raise Exception("Rekor key not found in TUF metadata")

def get_fulcio_certs(self) -> List[bytes]:
"""Return the active Fulcio certificate contents"""
if not self._updater:
self._updater = self._setup()

certs = []
assert self._updater._trusted_set.targets
targets = self._updater._trusted_set.targets.signed.targets
for target_info in targets.values():
custom = target_info.unrecognized_fields["custom"]["sigstore"]
if custom["status"] == "Active" and custom["usage"] == "Fulcio":
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:
certs.append(f.read())

if not certs:
raise Exception("Fulcio certificates not found in TUF metadata")

return certs
9 changes: 7 additions & 2 deletions sigstore/_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from sigstore._internal.oidc import Identity
from sigstore._internal.rekor 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__)
Expand Down Expand Up @@ -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.with_updater(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.with_updater(updater)
return cls(fulcio=FulcioClient.staging(), rekor=rekor)

def sign(
self,
Expand Down
Loading