Skip to content

Commit 7b390f4

Browse files
tnytownwoodruffw
andauthored
tuf: use bundled trusted root if available (#542)
* tuf: use "trusted root" if available Signed-off-by: Andrew Pan <a@tny.town> * tuf, test_tuf: adapt tests for bundled root Signed-off-by: Andrew Pan <a@tny.town> * keyring, _utils, test_utils: DER cert loading Signed-off-by: Andrew Pan <a@tny.town> * tuf: allow missing expiries in key validity period Signed-off-by: Andrew Pan <a@tny.town> * tuf, test_tuf: `_get` should never return `None` Signed-off-by: Andrew Pan <a@tny.town> * tuf: revert enums Signed-off-by: Andrew Pan <a@tny.town> * test_tuf: test_is_timerange_valid Signed-off-by: Andrew Pan <a@tny.town> * _utils: _errors -> errors Signed-off-by: Andrew Pan <a@tny.town> * CHANGELOG: blurb Signed-off-by: Andrew Pan <a@tny.town> * keyring: handle all errors in except cases Signed-off-by: Andrew Pan <a@tny.town> * Apply suggestions from code review Co-authored-by: William Woodruff <william@yossarian.net> Signed-off-by: Andrew Pan <3821575+tnytown@users.noreply.github.com> * Implement suggestions from code review Signed-off-by: Andrew Pan <a@tny.town> * Apply suggestions from code review Co-authored-by: William Woodruff <william@yossarian.net> Signed-off-by: Andrew Pan <3821575+tnytown@users.noreply.github.com> --------- Signed-off-by: Andrew Pan <a@tny.town> Signed-off-by: Andrew Pan <3821575+tnytown@users.noreply.github.com> Co-authored-by: William Woodruff <william@yossarian.net>
1 parent 7904283 commit 7b390f4

File tree

6 files changed

+194
-25
lines changed

6 files changed

+194
-25
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ All versions prior to 0.9.0 are untracked.
2727
([#535](https://github.com/sigstore/sigstore-python/pull/535))
2828
* Revamped error diagnostics reporting. All errors with diagnostics now implement
2929
`sigstore.errors.Error`.
30+
* Trust root materials are now retrieved from a single trust bundle,
31+
if it is available via TUF
32+
([#542](https://github.com/sigstore/sigstore-python/pull/542))
3033
* Improved diagnostics around Signed Certificate Timestamp verification failures.
3134
([#555](https://github.com/sigstore/sigstore-python/pull/555))
3235

sigstore/_internal/keyring.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@
2424
from cryptography.hazmat.primitives import hashes
2525
from cryptography.hazmat.primitives.asymmetric import ec, rsa
2626

27-
from sigstore._utils import KeyID, key_id, load_pem_public_key
27+
from sigstore._utils import (
28+
InvalidKeyError,
29+
KeyID,
30+
UnexpectedKeyFormatError,
31+
key_id,
32+
load_der_public_key,
33+
load_pem_public_key,
34+
)
2835

2936

3037
class KeyringError(Exception):
@@ -61,11 +68,19 @@ class Keyring:
6168
def __init__(self, keys: List[bytes] = []):
6269
"""
6370
Create a new `Keyring`, with `keys` as the initial set of signing
64-
keys.
71+
keys. These `keys` can be in either DER format or PEM encoded.
6572
"""
6673
self._keyring = {}
6774
for key_bytes in keys:
68-
key = load_pem_public_key(key_bytes)
75+
key = None
76+
77+
try:
78+
key = load_pem_public_key(key_bytes)
79+
except UnexpectedKeyFormatError as e:
80+
raise e
81+
except InvalidKeyError:
82+
key = load_der_public_key(key_bytes)
83+
6984
self._keyring[key_id(key)] = key
7085

7186
def add(self, key_pem: bytes) -> None:

sigstore/_internal/tuf.py

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,20 @@
2020

2121
import logging
2222
import os
23+
from datetime import datetime, timezone
2324
from functools import lru_cache
2425
from pathlib import Path
26+
from typing import Optional
2527
from urllib import parse
2628

2729
import appdirs
28-
from cryptography.x509 import Certificate, load_pem_x509_certificate
30+
from cryptography.x509 import (
31+
Certificate,
32+
load_der_x509_certificate,
33+
load_pem_x509_certificate,
34+
)
35+
from sigstore_protobuf_specs.dev.sigstore.common.v1 import TimeRange
36+
from sigstore_protobuf_specs.dev.sigstore.trustroot.v1 import TrustedRoot
2937
from tuf.api import exceptions as TUFExceptions
3038
from tuf.ngclient import RequestsFetcher, Updater
3139

@@ -66,6 +74,28 @@ def _get_dirs(url: str) -> tuple[Path, Path]:
6674
return (tuf_data_dir / repo_base), (tuf_cache_dir / repo_base)
6775

6876

77+
def _is_timerange_valid(period: TimeRange | None, *, allow_expired: bool) -> bool:
78+
"""
79+
Given a `period`, checks that the the current time is not before `start`. If
80+
`allow_expired` is `False`, also checks that the current time is not after
81+
`end`.
82+
"""
83+
now = datetime.now(timezone.utc)
84+
85+
# If there was no validity period specified, the key is always valid.
86+
if not period:
87+
return True
88+
89+
# Active: if the current time is before the starting period, we are not yet
90+
# valid.
91+
if now < period.start:
92+
return False
93+
94+
# If we want Expired keys, the key is valid at this point. Otherwise, check
95+
# that we are within range.
96+
return allow_expired or (period.end is None or now <= period.end)
97+
98+
6999
class TrustUpdater:
70100
"""Internal trust root (certificates and keys) downloader.
71101
@@ -85,8 +115,6 @@ def __init__(self, url: str) -> None:
85115
roots, i.e. for the production or staging Sigstore TUF repos.
86116
"""
87117
self._repo_url = url
88-
self._updater: Updater | None = None
89-
90118
self._metadata_dir, self._targets_dir = _get_dirs(url)
91119

92120
# Initialize metadata dir
@@ -125,7 +153,8 @@ def staging(cls) -> TrustUpdater:
125153
"""
126154
return cls(STAGING_TUF_URL)
127155

128-
def _setup(self) -> Updater:
156+
@lru_cache()
157+
def _updater(self) -> Updater:
129158
"""Initialize and update the toplevel TUF metadata"""
130159
updater = Updater(
131160
metadata_dir=str(self._metadata_dir),
@@ -144,25 +173,39 @@ def _setup(self) -> Updater:
144173

145174
return updater
146175

176+
@lru_cache()
177+
def _get_trusted_root(self) -> Optional[TrustedRoot]:
178+
root_info = self._updater().get_targetinfo("trusted_root.json")
179+
if root_info is None:
180+
return None
181+
path = self._updater().find_cached_target(root_info)
182+
if path is None:
183+
try:
184+
path = self._updater().download_target(root_info)
185+
except (
186+
TUFExceptions.DownloadError,
187+
TUFExceptions.RepositoryError,
188+
) as e:
189+
raise TUFError("Failed to download trusted key bundle") from e
190+
191+
return TrustedRoot().from_json(Path(path).read_bytes())
192+
147193
def _get(self, usage: str, statuses: list[str]) -> list[bytes]:
148194
"""Return all targets with given usage and any of the statuses"""
149-
if not self._updater:
150-
self._updater = self._setup()
151-
152195
data = []
153196

154-
targets = self._updater._trusted_set.targets.signed.targets
197+
targets = self._updater()._trusted_set.targets.signed.targets
155198
for target_info in targets.values():
156199
custom = target_info.unrecognized_fields.get("custom", {}).get("sigstore")
157200
if (
158201
custom
159202
and custom.get("status") in statuses
160203
and custom.get("usage") == usage
161204
):
162-
path = self._updater.find_cached_target(target_info)
205+
path = self._updater().find_cached_target(target_info)
163206
if path is None:
164207
try:
165-
path = self._updater.download_target(target_info)
208+
path = self._updater().download_target(target_info)
166209
except (
167210
TUFExceptions.DownloadError,
168211
TUFExceptions.RepositoryError,
@@ -187,7 +230,21 @@ def get_ctfe_keys(self) -> list[bytes]:
187230
188231
May download files from the remote repository.
189232
"""
190-
ctfes = self._get("CTFE", ["Active"])
233+
ctfes = []
234+
235+
trusted_root = self._get_trusted_root()
236+
if trusted_root:
237+
for key in trusted_root.ctlogs:
238+
if not _is_timerange_valid(
239+
key.public_key.valid_for, allow_expired=False
240+
):
241+
continue
242+
key_bytes = key.public_key.raw_bytes
243+
if key_bytes:
244+
ctfes.append(key_bytes)
245+
else:
246+
ctfes = self._get("CTFE", ["Active"])
247+
191248
if not ctfes:
192249
raise MetadataError("CTFE keys not found in TUF metadata")
193250
return ctfes
@@ -197,7 +254,21 @@ def get_rekor_keys(self) -> list[bytes]:
197254
198255
May download files from the remote repository.
199256
"""
200-
keys = self._get("Rekor", ["Active"])
257+
keys = []
258+
259+
trusted_root = self._get_trusted_root()
260+
if trusted_root:
261+
for key in trusted_root.tlogs:
262+
if not _is_timerange_valid(
263+
key.public_key.valid_for, allow_expired=False
264+
):
265+
continue
266+
key_bytes = key.public_key.raw_bytes
267+
if key_bytes:
268+
keys.append(key_bytes)
269+
else:
270+
keys = self._get("Rekor", ["Active"])
271+
201272
if len(keys) != 1:
202273
raise MetadataError("Did not find one active Rekor key in TUF metadata")
203274
return keys
@@ -207,9 +278,29 @@ def get_fulcio_certs(self) -> list[Certificate]:
207278
208279
May download files from the remote repository.
209280
"""
281+
certs = []
282+
283+
trusted_root = self._get_trusted_root()
210284
# Return expired certificates too: they are expired now but may have
211285
# been active when the certificate was used to sign.
212-
certs = self._get("Fulcio", ["Active", "Expired"])
286+
if trusted_root:
287+
for ca in trusted_root.certificate_authorities:
288+
if not _is_timerange_valid(ca.valid_for, allow_expired=True):
289+
continue
290+
certs.extend(
291+
[
292+
load_der_x509_certificate(cert.raw_bytes)
293+
for cert in ca.cert_chain.certificates
294+
]
295+
)
296+
else:
297+
certs = [
298+
load_pem_x509_certificate(c)
299+
for c in self._get(
300+
"Fulcio",
301+
["Active", "Expired"],
302+
)
303+
]
213304
if not certs:
214305
raise MetadataError("Fulcio certificates not found in TUF metadata")
215-
return [load_pem_x509_certificate(c) for c in certs]
306+
return certs

sigstore/_utils.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
from cryptography.hazmat.primitives.asymmetric import ec, rsa
2828
from cryptography.x509 import Certificate
2929

30+
from sigstore.errors import Error
31+
3032
if sys.version_info < (3, 11):
3133
import importlib_resources as resources
3234
else:
@@ -57,28 +59,52 @@
5759
"""
5860

5961

60-
class InvalidKey(Exception):
62+
class InvalidKeyError(Error):
6163
"""
6264
Raised when loading a key fails.
6365
"""
6466

6567
pass
6668

6769

70+
class UnexpectedKeyFormatError(InvalidKeyError):
71+
"""
72+
Raised when loading a key produces a key of an unexpected type.
73+
"""
74+
75+
pass
76+
77+
6878
def load_pem_public_key(key_pem: bytes) -> PublicKey:
6979
"""
7080
A specialization of `cryptography`'s `serialization.load_pem_public_key`
71-
with a uniform exception type (`InvalidKey`) and additional restrictions
81+
with a uniform exception type (`InvalidKeyError`) and additional restrictions
7282
on key validity (only RSA and ECDSA keys are valid).
7383
"""
7484

7585
try:
7686
key = serialization.load_pem_public_key(key_pem)
7787
except Exception as exc:
78-
raise InvalidKey("could not load PEM-formatted public key") from exc
88+
raise InvalidKeyError("could not load PEM-formatted public key") from exc
89+
90+
if not isinstance(key, (rsa.RSAPublicKey, ec.EllipticCurvePublicKey)):
91+
raise UnexpectedKeyFormatError(f"invalid key format (not ECDSA or RSA): {key}")
92+
93+
return key
94+
95+
96+
def load_der_public_key(key_der: bytes) -> PublicKey:
97+
"""
98+
The `load_pem_public_key` specialization, but DER.
99+
"""
100+
101+
try:
102+
key = serialization.load_der_public_key(key_der)
103+
except Exception as exc:
104+
raise InvalidKeyError("could not load DER-formatted public key") from exc
79105

80106
if not isinstance(key, (rsa.RSAPublicKey, ec.EllipticCurvePublicKey)):
81-
raise InvalidKey(f"invalid key format (not ECDSA or RSA): {key}")
107+
raise UnexpectedKeyFormatError(f"invalid key format (not ECDSA or RSA): {key}")
82108

83109
return key
84110

test/unit/internal/test_tuf.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414

1515

1616
import os
17+
from datetime import datetime, timedelta, timezone
1718

1819
import pytest
20+
from sigstore_protobuf_specs.dev.sigstore.common.v1 import TimeRange
1921

20-
from sigstore._internal.tuf import TrustUpdater
22+
from sigstore._internal.tuf import TrustUpdater, _is_timerange_valid
2123

2224

2325
def test_updater_staging_caches_and_requests(mock_staging_tuf, tuf_dirs):
@@ -82,6 +84,35 @@ def test_updater_staging_caches_and_requests(mock_staging_tuf, tuf_dirs):
8284
assert fail_reqs == expected_fail_reqs
8385

8486

87+
def test_is_timerange_valid():
88+
def range_from(offset_lower=0, offset_upper=0):
89+
base = datetime.now(timezone.utc)
90+
return TimeRange(
91+
base + timedelta(minutes=offset_lower),
92+
base + timedelta(minutes=offset_upper),
93+
)
94+
95+
# Test None should always be valid
96+
assert _is_timerange_valid(None, allow_expired=False)
97+
assert _is_timerange_valid(None, allow_expired=True)
98+
99+
# Test lower bound conditions
100+
assert _is_timerange_valid(
101+
range_from(-1, 1), allow_expired=False
102+
) # Valid: 1 ago, 1 from now
103+
assert not _is_timerange_valid(
104+
range_from(1, 1), allow_expired=False
105+
) # Invalid: 1 from now, 1 from now
106+
107+
# Test upper bound conditions
108+
assert not _is_timerange_valid(
109+
range_from(-1, -1), allow_expired=False
110+
) # Invalid: 1 ago, 1 ago
111+
assert _is_timerange_valid(
112+
range_from(-1, -1), allow_expired=True
113+
) # Valid: 1 ago, 1 ago
114+
115+
85116
def test_updater_staging_get(mock_staging_tuf, tuf_asset):
86117
"""Test that one of the get-methods returns the expected content"""
87118
updater = TrustUpdater.staging()
@@ -98,6 +129,7 @@ def test_updater_ctfe_keys_error(monkeypatch):
98129
updater = TrustUpdater.staging()
99130
# getter returns no keys.
100131
monkeypatch.setattr(updater, "_get", lambda usage, statuses: [])
132+
monkeypatch.setattr(updater, "_get_trusted_root", lambda: None)
101133
with pytest.raises(Exception, match="CTFE keys not found in TUF metadata"):
102134
updater.get_ctfe_keys()
103135

@@ -112,6 +144,7 @@ def test_updater_rekor_keys_error(tuf_asset, monkeypatch):
112144
"_get",
113145
lambda usage, statuses: [rekor_key, rekor_key],
114146
)
147+
monkeypatch.setattr(updater, "_get_trusted_root", lambda: None)
115148

116149
with pytest.raises(
117150
Exception, match="Did not find one active Rekor key in TUF metadata"
@@ -122,7 +155,8 @@ def test_updater_rekor_keys_error(tuf_asset, monkeypatch):
122155
def test_updater_fulcio_certs_error(tuf_asset, monkeypatch):
123156
updater = TrustUpdater.staging()
124157
# getter returns no fulcio certs.
125-
monkeypatch.setattr(updater, "_get", lambda usage, statuses: None)
158+
monkeypatch.setattr(updater, "_get", lambda usage, statuses: [])
159+
monkeypatch.setattr(updater, "_get_trusted_root", lambda: None)
126160
with pytest.raises(
127161
Exception, match="Fulcio certificates not found in TUF metadata"
128162
):

0 commit comments

Comments
 (0)