Skip to content

Commit 06e0ae2

Browse files
authored
trust: Support operator field, support multiple services (#1407)
* trust: Support operator field, support multiple services * Change the SigningConfig API so that it returns actual clients (like RekorClient): this may seem unusual but makes sense because SigningConfig must know which services this client supports, this means the code is simplest if it directly returns correct clients (e.g. when rekor has two separate clients for v1 and v2). This allows keeping version, operator, etc as SigningConfig imlementation details * There is one exception to previous point: get_oidc_url() returns string, not Issuer object. I could make this change as well to be consistent, it just requires a small refactor (because currently Issuer makes a http request on construction and that seems bad, especially for testing) * Support operator field: This is used to ensure we only return one service version per operator Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * trust: zero is a valid number of TSAs for now Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * trust: Support ServiceSelector fully I don't think we'll be seeing anything else than ANY for a while but for completeness, support all selector modes for TSA and Rekor. Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * trust: Improve docstrings Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * trust: Refactor based on review comments Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * trust: Refactor service configuration * Abstract the ServiceConfiguration handling as suggested in review (so both tsa and rekor are handled in the same way) * This creates some issues as TSAs are still optional... I decided that it is reasonable to require "ANY" selector to be used with at least one service, meaning I have to change the placeholder signingconfig for production. Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * CHANGELOG: Add entry for SigningConfig API changes Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * trust: Fix multiple issues in _get_valid_services() * we want a single service per operator * selector UNDEFINED should be an error * variable in this function should not refer to "logs" but "services" * Method is actually static Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * trust: Better docstrings Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * tests: Add tests for service selection Test the service selection with SigningConfig._get_valid_services() Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> * tests: Refactor based on review comments Signed-off-by: Jussi Kukkonen <jkukkonen@google.com> --------- Signed-off-by: Jussi Kukkonen <jkukkonen@google.com>
1 parent 6f52d7c commit 06e0ae2

File tree

5 files changed

+224
-53
lines changed

5 files changed

+224
-53
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ All versions prior to 0.9.0 are untracked.
5050
* ClientTrustConfig now provides methods `production()`, `staging()`and `from_tuf()`
5151
to get access to current client configuration (trusted keys & certificates,
5252
URLs and their validity periods). [#1363](https://github.com/sigstore/sigstore-python/pull/1363)
53+
* SigningConfig now has methods that return actual clients (like `RekorClient`) instead of
54+
just URLs. The returned clients are also filtered according to SigningConfig contents.
55+
[#1407](https://github.com/sigstore/sigstore-python/pull/1407)
5356
* `--trust-config` now requires a file with SigningConfig v0.2, and is able to fully
5457
configure the used Sigstore instance [#1358]/(https://github.com/sigstore/sigstore-python/pull/1358)
5558
* By default (when `--trust-config` is not used) the whole trust configuration now

sigstore/_internal/trust.py

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from __future__ import annotations
2020

21+
from collections import defaultdict
2122
from collections.abc import Iterable
2223
from dataclasses import dataclass
2324
from datetime import datetime, timezone
@@ -46,6 +47,7 @@
4647
)
4748
from sigstore_protobuf_specs.dev.sigstore.trustroot.v1 import (
4849
Service,
50+
ServiceConfiguration,
4951
ServiceSelector,
5052
TransparencyLogInstance,
5153
)
@@ -56,6 +58,9 @@
5658
TrustedRoot as _TrustedRoot,
5759
)
5860

61+
from sigstore._internal.fulcio.client import FulcioClient
62+
from sigstore._internal.rekor.client import RekorClient
63+
from sigstore._internal.timestamp import TimestampAuthorityClient
5964
from sigstore._internal.tuf import DEFAULT_TUF_URL, STAGING_TUF_URL, TrustUpdater
6065
from sigstore._utils import (
6166
KeyID,
@@ -66,6 +71,12 @@
6671
)
6772
from sigstore.errors import Error, MetadataError, TUFError, VerificationError
6873

74+
# Versions supported by this client
75+
REKOR_VERSIONS = [1]
76+
TSA_VERSIONS = [1]
77+
FULCIO_VERSIONS = [1]
78+
OIDC_VERSIONS = [1]
79+
6980

7081
def _is_timerange_valid(period: TimeRange | None, *, allow_expired: bool) -> bool:
7182
"""
@@ -323,28 +334,34 @@ def __init__(self, inner: _SigningConfig):
323334
@api private
324335
"""
325336
self._inner = inner
326-
self._verify()
327-
328-
def _verify(self) -> None:
329-
"""
330-
Performs various feats of heroism to ensure that the signing config
331-
is well-formed.
332-
"""
333337

334338
# must have a recognized media type.
335339
try:
336340
SigningConfig.SigningConfigType(self._inner.media_type)
337341
except ValueError:
338342
raise Error(f"unsupported signing config format: {self._inner.media_type}")
339343

340-
# currently not supporting other select modes
341-
# TODO: Support other modes ensuring tsa_urls() and tlog_urls() work
342-
if self._inner.rekor_tlog_config.selector != ServiceSelector.ANY:
343-
raise Error(
344-
f"unsupported tlog selector {self._inner.rekor_tlog_config.selector}"
345-
)
346-
if self._inner.tsa_config.selector != ServiceSelector.ANY:
347-
raise Error(f"unsupported TSA selector {self._inner.tsa_config.selector}")
344+
# Create lists of service protos that are valid, selected by the service
345+
# configuration & supported by this client
346+
self._tlogs = self._get_valid_services(
347+
self._inner.rekor_tlog_urls, REKOR_VERSIONS, self._inner.rekor_tlog_config
348+
)
349+
if not self._tlogs:
350+
raise Error("No valid Rekor transparency log found in signing config")
351+
352+
self._tsas = self._get_valid_services(
353+
self._inner.tsa_urls, TSA_VERSIONS, self._inner.tsa_config
354+
)
355+
356+
self._fulcios = self._get_valid_services(
357+
self._inner.ca_urls, FULCIO_VERSIONS, None
358+
)
359+
if not self._fulcios:
360+
raise Error("No valid Fulcio CA found in signing config")
361+
362+
self._oidcs = self._get_valid_services(
363+
self._inner.oidc_urls, OIDC_VERSIONS, None
364+
)
348365

349366
@classmethod
350367
def from_file(
@@ -356,54 +373,73 @@ def from_file(
356373
return cls(inner)
357374

358375
@staticmethod
359-
def _get_valid_service_url(services: list[Service]) -> str | None:
376+
def _get_valid_services(
377+
services: list[Service],
378+
supported_versions: list[int],
379+
config: ServiceConfiguration | None,
380+
) -> list[Service]:
381+
"""Return supported services, taking SigningConfig restrictions into account"""
382+
383+
# split services by operator, only include valid services
384+
services_by_operator: dict[str, list[Service]] = defaultdict(list)
360385
for service in services:
361-
if service.major_api_version != 1:
386+
if service.major_api_version not in supported_versions:
362387
continue
363388

364389
if not _is_timerange_valid(service.valid_for, allow_expired=False):
365390
continue
366-
return service.url
367-
return None
368391

369-
def get_tlog_urls(self) -> list[str]:
392+
services_by_operator[service.operator].append(service)
393+
394+
# build a list of services but make sure we only include one service per operator
395+
# and use the highest version available for that operator
396+
result: list[Service] = []
397+
for op_services in services_by_operator.values():
398+
op_services.sort(key=lambda s: s.major_api_version)
399+
result.append(op_services[-1])
400+
401+
# Depending on ServiceSelector, prune the result list
402+
if not config or config.selector == ServiceSelector.ALL:
403+
return result
404+
405+
if config.selector == ServiceSelector.UNDEFINED:
406+
raise ValueError("Undefined is not a valid signing config ServiceSelector")
407+
408+
# handle EXACT and ANY selectors
409+
count = config.count if config.selector == ServiceSelector.EXACT else 1
410+
if len(result) < count:
411+
raise ValueError(
412+
f"Expected {count} services in signing config, found {len(result)}"
413+
)
414+
415+
return result[:count]
416+
417+
def get_tlogs(self) -> list[RekorClient]:
370418
"""
371-
Returns the rekor transparency logs that client should sign with.
372-
Currently only returns a single one but could in future return several
419+
Returns the rekor transparency log clients to sign with.
373420
"""
421+
return [RekorClient(tlog.url) for tlog in self._tlogs]
374422

375-
url = self._get_valid_service_url(self._inner.rekor_tlog_urls)
376-
if not url:
377-
raise Error("No valid Rekor transparency log found in signing config")
378-
return [url]
379-
380-
def get_fulcio_url(self) -> str:
423+
def get_fulcio(self) -> FulcioClient:
381424
"""
382-
Returns url for the fulcio instance that client should use to get a
383-
signing certificate from
425+
Returns a Fulcio client to get a signing certificate from
384426
"""
385-
url = self._get_valid_service_url(self._inner.ca_urls)
386-
if not url:
387-
raise Error("No valid Fulcio CA found in signing config")
388-
return url
427+
return FulcioClient(self._fulcios[0].url)
389428

390429
def get_oidc_url(self) -> str:
391430
"""
392431
Returns url for the OIDC provider that client should use to interactively
393432
authenticate.
394433
"""
395-
url = self._get_valid_service_url(self._inner.oidc_urls)
396-
if not url:
434+
if not self._oidcs:
397435
raise Error("No valid OIDC provider found in signing config")
398-
return url
436+
return self._oidcs[0].url
399437

400-
def get_tsa_urls(self) -> list[str]:
438+
def get_tsas(self) -> list[TimestampAuthorityClient]:
401439
"""
402-
Returns timestamp authority API end points. Currently returns a single one
403-
but may return more in future.
440+
Returns timestamp authority clients for urls configured in signing config.
404441
"""
405-
url = self._get_valid_service_url(self._inner.tsa_urls)
406-
return [] if url is None else [url]
442+
return [TimestampAuthorityClient(s.url) for s in self._tsas]
407443

408444

409445
class TrustedRoot:

sigstore/_store/https%3A%2F%2Ftuf-repo-cdn.sigstore.dev/signing_config.v0.2.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@
3434
"selector": "ANY"
3535
},
3636
"tsaConfig": {
37-
"selector": "ANY"
37+
"selector": "ALL"
3838
}
3939
}

sigstore/sign.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,10 @@ def from_trust_config(cls, trust_config: ClientTrustConfig) -> SigningContext:
332332
"""
333333
signing_config = trust_config.signing_config
334334
return cls(
335-
fulcio=FulcioClient(signing_config.get_fulcio_url()),
336-
rekor=RekorClient(signing_config.get_tlog_urls()[0]),
335+
fulcio=signing_config.get_fulcio(),
336+
rekor=signing_config.get_tlogs()[0],
337337
trusted_root=trust_config.trusted_root,
338-
tsa_clients=[
339-
TimestampAuthorityClient(url) for url in signing_config.get_tsa_urls()
340-
],
338+
tsa_clients=signing_config.get_tsas(),
341339
)
342340

343341
@contextmanager

test/unit/internal/test_trust.py

Lines changed: 139 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@
2020
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
2121
from cryptography.x509 import load_pem_x509_certificate
2222
from sigstore_protobuf_specs.dev.sigstore.common.v1 import TimeRange
23+
from sigstore_protobuf_specs.dev.sigstore.trustroot.v1 import (
24+
Service,
25+
ServiceConfiguration,
26+
ServiceSelector,
27+
)
2328

29+
from sigstore._internal.fulcio.client import FulcioClient
30+
from sigstore._internal.rekor.client import RekorClient
31+
from sigstore._internal.timestamp import TimestampAuthorityClient
2432
from sigstore._internal.trust import (
2533
CertificateAuthority,
2634
ClientTrustConfig,
@@ -32,6 +40,19 @@
3240
from sigstore._utils import load_pem_public_key
3341
from sigstore.errors import Error
3442

43+
# Test data for TestSigningcconfig
44+
_service_v1_op1 = Service("url1", major_api_version=1, operator="op1")
45+
_service2_v1_op1 = Service("url2", major_api_version=1, operator="op1")
46+
_service_v2_op1 = Service("url3", major_api_version=2, operator="op1")
47+
_service_v1_op2 = Service("url4", major_api_version=1, operator="op2")
48+
_service_v1_op3 = Service("url5", major_api_version=1, operator="op3")
49+
_service_v1_op4 = Service(
50+
"url6",
51+
major_api_version=1,
52+
operator="op4",
53+
valid_for=TimeRange(datetime(3000, 1, 1, tzinfo=timezone.utc)),
54+
)
55+
3556

3657
class TestCertificateAuthority:
3758
def test_good(self, asset):
@@ -56,12 +77,125 @@ def test_good(self, asset):
5677
signing_config._inner.media_type
5778
== SigningConfig.SigningConfigType.SIGNING_CONFIG_0_2.value
5879
)
59-
assert signing_config.get_fulcio_url() == "https://fulcio.example.com"
80+
81+
fulcio = signing_config.get_fulcio()
82+
assert isinstance(fulcio, FulcioClient)
83+
assert fulcio.url == "https://fulcio.example.com"
6084
assert signing_config.get_oidc_url() == "https://oauth2.example.com/auth"
61-
assert signing_config.get_tlog_urls() == ["https://rekor.example.com"]
62-
assert signing_config.get_tsa_urls() == [
63-
"https://timestamp.example.com/api/v1/timestamp"
64-
]
85+
86+
tlogs = signing_config.get_tlogs()
87+
assert len(tlogs) == 1
88+
assert isinstance(tlogs[0], RekorClient)
89+
assert tlogs[0].url == "https://rekor.example.com/api/v1"
90+
91+
tsas = signing_config.get_tsas()
92+
assert len(tsas) == 1
93+
assert isinstance(tsas[0], TimestampAuthorityClient)
94+
assert tsas[0].url == "https://timestamp.example.com/api/v1/timestamp"
95+
96+
@pytest.mark.parametrize(
97+
"services, versions, config, expected_result",
98+
[
99+
pytest.param(
100+
[_service_v1_op1],
101+
[1],
102+
ServiceConfiguration(ServiceSelector.ALL),
103+
[_service_v1_op1],
104+
id="base case",
105+
),
106+
pytest.param(
107+
[_service_v1_op1, _service2_v1_op1],
108+
[1],
109+
ServiceConfiguration(ServiceSelector.ALL),
110+
[_service2_v1_op1],
111+
id="multiple services, same operator: expect 1 service in result",
112+
),
113+
pytest.param(
114+
[_service_v1_op1, _service_v1_op2],
115+
[1],
116+
ServiceConfiguration(ServiceSelector.ALL),
117+
[_service_v1_op1, _service_v1_op2],
118+
id="2 services, different operator: expect 2 services in result",
119+
),
120+
pytest.param(
121+
[_service_v1_op1, _service_v1_op2, _service_v1_op4],
122+
[1],
123+
ServiceConfiguration(ServiceSelector.ALL),
124+
[_service_v1_op1, _service_v1_op2],
125+
id="3 services, one is not yet valid: expect 2 services in result",
126+
),
127+
pytest.param(
128+
[_service_v1_op1, _service_v1_op2],
129+
[1],
130+
ServiceConfiguration(ServiceSelector.ANY),
131+
[_service_v1_op1],
132+
id="ANY selector: expect 1 service only in result",
133+
),
134+
pytest.param(
135+
[_service_v1_op1, _service_v1_op2, _service_v1_op3],
136+
[1],
137+
ServiceConfiguration(ServiceSelector.EXACT, 2),
138+
[_service_v1_op1, _service_v1_op2],
139+
id="EXACT selector: expect configured number of services in result",
140+
),
141+
pytest.param(
142+
[_service_v1_op1, _service_v2_op1],
143+
[1, 2],
144+
ServiceConfiguration(ServiceSelector.ALL),
145+
[_service_v2_op1],
146+
id="services with different version: expect highest version",
147+
),
148+
pytest.param(
149+
[_service_v1_op1, _service_v2_op1],
150+
[1],
151+
ServiceConfiguration(ServiceSelector.ALL),
152+
[_service_v1_op1],
153+
id="services with different version: expect the supported version",
154+
),
155+
pytest.param(
156+
[_service_v1_op1, _service_v1_op2],
157+
[2],
158+
ServiceConfiguration(ServiceSelector.ALL),
159+
[],
160+
id="No supported versions: expect no results",
161+
),
162+
pytest.param(
163+
[_service_v1_op1, _service_v2_op1, _service_v1_op2],
164+
[1],
165+
None,
166+
[_service_v1_op1, _service_v1_op2],
167+
id="services without ServiceConfiguration: expect all supported",
168+
),
169+
],
170+
)
171+
def test_get_valid_services(self, services, versions, config, expected_result):
172+
result = SigningConfig._get_valid_services(services, versions, config)
173+
174+
assert result == expected_result
175+
176+
@pytest.mark.parametrize(
177+
"services, versions, config",
178+
[
179+
( # ANY selector without services
180+
[],
181+
[1],
182+
ServiceConfiguration(ServiceSelector.ANY),
183+
),
184+
( # EXACT selector without enough services
185+
[_service_v1_op1],
186+
[1],
187+
ServiceConfiguration(ServiceSelector.EXACT, 2),
188+
),
189+
( # UNDEFINED selector
190+
[_service_v1_op1],
191+
[1],
192+
ServiceConfiguration(ServiceSelector.UNDEFINED, 1),
193+
),
194+
],
195+
)
196+
def test_get_valid_services_fail(self, services, versions, config):
197+
with pytest.raises(ValueError):
198+
SigningConfig._get_valid_services(services, versions, config)
65199

66200

67201
class TestTrustedRoot:

0 commit comments

Comments
 (0)