Skip to content

Commit 59ae3fe

Browse files
Support verifying embedded SCTs (sigstore#84)
* WIP * Fix issuer key hash * fulcio/client: fix cryptography imports Signed-off-by: William Woodruff <william@trailofbits.com> * Use extension bytes property * sct: add some annotations Signed-off-by: William Woodruff <william@trailofbits.com> * debugging assistance Signed-off-by: William Woodruff <william@trailofbits.com> * _internal: hackety hack Signed-off-by: William Woodruff <william@trailofbits.com> * test/internal: add a test for _pack_digitally_signed Signed-off-by: William Woodruff <william@trailofbits.com> * sct: remove debugging Signed-off-by: William Woodruff <william@trailofbits.com> * test: add a issuer public key hash test Signed-off-by: William Woodruff <william@trailofbits.com> * test/sct: add a testvector for the issuer key hash Got this by modifying the Trillian test suite. Signed-off-by: William Woodruff <william@trailofbits.com> * sct: support both ECDSA and RSA Signed-off-by: William Woodruff <william@trailofbits.com> * oauth: don't open the browser until we're serving Signed-off-by: William Woodruff <william@trailofbits.com> * test/fulcio: fix client tests Signed-off-by: William Woodruff <william@trailofbits.com> * sct: remove unused helper Signed-off-by: William Woodruff <william@trailofbits.com> * sct: improve docs Signed-off-by: William Woodruff <william@trailofbits.com> * cli: change to sigstage temporarily Signed-off-by: William Woodruff <william@trailofbits.com> * sct: use a better type for formatting Signed-off-by: William Woodruff <william@trailofbits.com> * sign: remove premature exit Signed-off-by: William Woodruff <william@trailofbits.com> * Makefile: support `make test T=...` Signed-off-by: William Woodruff <william@trailofbits.com> * sct: temporary debugging Signed-off-by: William Woodruff <william@trailofbits.com> * sct: fix timestamp by normalizing timezone This is the source of all our problems: it worked on GCP and in Docker because they keep UTC as their default timezone, while our development machines had local timezones. Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore, test: split CTFE staging key, add `sigstore sign --staging` Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore: make the prod instance work again Signed-off-by: William Woodruff <william@trailofbits.com> * sct: use ExtensionOID.CERTIFICATE_TRANSPARENCY Signed-off-by: William Woodruff <william@trailofbits.com> * sct: update to match cryptography changes Signed-off-by: William Woodruff <william@trailofbits.com> * Check SCT signature type * Check signature hash type * Fix hash algorithm check * Update sigstore/_internal/sct.py Co-authored-by: William Woodruff <william@trailofbits.com> * _cli: Remove `--staging` flag * _store: Remove CTFE staging key * sigstore: hack around missing cryptography APIs Signed-off-by: William Woodruff <william@trailofbits.com> * sct: hack the rest into place Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore, test: fix tests Signed-off-by: William Woodruff <william@trailofbits.com> * fulcio/client: add a TODO Signed-off-by: William Woodruff <william@trailofbits.com> * pyproject, sigstore: document every hack Signed-off-by: William Woodruff <william@trailofbits.com> * test: update tests Signed-off-by: William Woodruff <william@trailofbits.com> Co-authored-by: William Woodruff <william@trailofbits.com>
1 parent b58ee35 commit 59ae3fe

File tree

11 files changed

+488
-101
lines changed

11 files changed

+488
-101
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ lint:
5555
.PHONY: test
5656
test:
5757
. env/bin/activate && \
58-
pytest --cov=$(PY_MODULE) test/ $(TEST_ARGS) && \
58+
pytest --cov=$(PY_MODULE) test/ $(T) $(TEST_ARGS) && \
5959
python -m coverage report -m $(COV_ARGS)
6060

6161
.PHONY: doc

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ dependencies = [
3333
"pyOpenSSL",
3434
"requests",
3535
"securesystemslib",
36+
# HACK(#84): Remove these dependencies.
37+
"pyasn1",
38+
"pyasn1-modules",
3639
]
3740
requires-python = ">=3.7"
3841

sigstore/_cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import logging
16+
import os
1617
import sys
1718
from importlib import resources
1819
from typing import BinaryIO, List, Optional, TextIO
@@ -27,6 +28,7 @@
2728
from sigstore._internal.oidc.ambient import detect_credential
2829
from sigstore._internal.oidc.issuer import Issuer
2930
from sigstore._internal.oidc.oauth import (
31+
DEFAULT_OAUTH_ISSUER,
3032
STAGING_OAUTH_ISSUER,
3133
get_identity_token,
3234
)
@@ -38,6 +40,7 @@
3840
from sigstore._verify import verify
3941

4042
logger = logging.getLogger(__name__)
43+
logging.basicConfig(level=os.environ.get("SIGSTORE_LOGLEVEL", "INFO").upper())
4144

4245

4346
@click.group()
@@ -82,7 +85,7 @@ def main() -> None:
8285
"--oidc-issuer",
8386
metavar="URL",
8487
type=click.STRING,
85-
default="https://oauth2.sigstore.dev/auth",
88+
default=DEFAULT_OAUTH_ISSUER,
8689
help="The custom OpenID Connect issuer to use (conflicts with --staging)",
8790
)
8891
@click.option(

sigstore/_internal/fulcio/client.py

Lines changed: 95 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,34 @@
1919
import base64
2020
import datetime
2121
import json
22+
import logging
2223
import struct
2324
from abc import ABC
2425
from dataclasses import dataclass
2526
from enum import IntEnum
26-
from typing import List
27+
from typing import List, Optional
2728
from urllib.parse import urljoin
2829

2930
import pem
3031
import requests
31-
from cryptography.hazmat.primitives import serialization
32+
from cryptography.hazmat.primitives import hashes, serialization
3233
from cryptography.hazmat.primitives.asymmetric import ec
33-
from cryptography.x509 import Certificate, load_pem_x509_certificate
34+
from cryptography.x509 import (
35+
Certificate,
36+
ExtensionNotFound,
37+
PrecertificateSignedCertificateTimestamps,
38+
load_pem_x509_certificate,
39+
)
3440
from cryptography.x509.certificate_transparency import (
3541
LogEntryType,
3642
SignedCertificateTimestamp,
3743
Version,
3844
)
45+
from pyasn1.codec.der.decoder import decode as asn1_decode
3946
from pydantic import BaseModel, Field, validator
4047

48+
logger = logging.getLogger(__name__)
49+
4150
DEFAULT_FULCIO_URL = "https://fulcio.sigstore.dev"
4251
STAGING_FULCIO_URL = "https://fulcio.sigstage.dev"
4352
SIGNING_CERT_ENDPOINT = "/api/v1/signingCert"
@@ -61,20 +70,11 @@ class SCTHashAlgorithm(IntEnum):
6170
SHA384 = 5
6271
SHA512 = 6
6372

73+
def to_cryptography(self) -> hashes.HashAlgorithm:
74+
if self != SCTHashAlgorithm.SHA256:
75+
raise FulcioSCTError(f"unexpected hash algorithm: {self!r}")
6476

65-
class SCTSignatureAlgorithm(IntEnum):
66-
"""
67-
Signature algorithms that are valid for SCTs.
68-
69-
These are exactly the same as the SignatureAlgorithm enum in RFC 5246 (TLS 1.2).
70-
71-
See: https://datatracker.ietf.org/doc/html/rfc5246#section-7.4.1.4.1
72-
"""
73-
74-
ANONYMOUS = 0
75-
RSA = 1
76-
DSA = 2
77-
ECDSA = 3
77+
return hashes.SHA256()
7878

7979

8080
class FulcioSCTError(Exception):
@@ -94,7 +94,7 @@ class DetachedFulcioSCT(BaseModel):
9494
log_id: bytes = Field(..., alias="id")
9595
timestamp: datetime.datetime
9696
digitally_signed: bytes = Field(..., alias="signature")
97-
extensions: bytes
97+
extension_bytes: bytes = Field(..., alias="extensions")
9898

9999
class Config:
100100
allow_population_by_field_name = True
@@ -113,7 +113,7 @@ def _validate_digitally_signed(cls, v: bytes) -> bytes:
113113
def _validate_log_id(cls, v: bytes) -> bytes:
114114
return base64.b64decode(v)
115115

116-
@validator("extensions", pre=True)
116+
@validator("extension_bytes", pre=True)
117117
def _validate_extensions(cls, v: bytes) -> bytes:
118118
return base64.b64decode(v)
119119

@@ -122,15 +122,14 @@ def entry_type(self) -> LogEntryType:
122122
return LogEntryType.X509_CERTIFICATE
123123

124124
@property
125-
def signature_hash_algorithm(self) -> int:
126-
# TODO(ww): This should become a cryptography `Hash` subclass
127-
# instance once cryptography adds this API.
128-
return self.digitally_signed[0]
125+
def signature_hash_algorithm(self) -> hashes.HashAlgorithm:
126+
hash_ = SCTHashAlgorithm(self.digitally_signed[0])
127+
return hash_.to_cryptography()
129128

130129
@property
131130
def signature_algorithm(self) -> int:
132-
# TODO(ww): This should become a SignatureAlgorithm variant
133-
# once cryptography adds this API.
131+
# TODO(ww): This method will need to return a SignatureAlgorithm
132+
# variant instead, for consistency with cryptography's interface.
134133
return self.digitally_signed[1]
135134

136135
@property
@@ -177,7 +176,9 @@ class FulcioCertificateSigningResponse:
177176

178177
cert: Certificate
179178
chain: List[Certificate]
180-
sct: DetachedFulcioSCT
179+
sct: SignedCertificateTimestamp
180+
# HACK(#84): Remove entirely.
181+
raw_sct: Optional[bytes]
181182

182183

183184
@dataclass(frozen=True)
@@ -229,24 +230,6 @@ def post(
229230
except (AttributeError, KeyError):
230231
raise FulcioClientError from http_error
231232

232-
# The SCT header is a base64-encoded payload, which in turn
233-
# is a JSON representation of the SignedCertificateTimestamp
234-
# in RFC 6962 (subsec. 3.2).
235-
sct_b64 = resp.headers.get("SCT")
236-
if sct_b64 is None:
237-
raise FulcioClientError("Fulcio response did not include a SCT header")
238-
239-
try:
240-
sct_json = json.loads(base64.b64decode(sct_b64).decode())
241-
except ValueError as exc:
242-
raise FulcioClientError from exc
243-
244-
try:
245-
sct = DetachedFulcioSCT.parse_obj(sct_json)
246-
except Exception as exc:
247-
# Ideally we'd catch something less generic here.
248-
raise FulcioClientError from exc
249-
250233
# Cryptography doesn't have chain verification/building built in
251234
# https://github.com/pyca/cryptography/issues/2381
252235
try:
@@ -256,7 +239,74 @@ def post(
256239
except ValueError:
257240
raise FulcioClientError(f"Did not find a cert in Fulcio response: {resp}")
258241

259-
return FulcioCertificateSigningResponse(cert, chain, sct)
242+
try:
243+
# Try to retrieve the embedded SCTs within the cert.
244+
precert_scts_extension = cert.extensions.get_extension_for_class(
245+
PrecertificateSignedCertificateTimestamps
246+
).value
247+
248+
if len(precert_scts_extension) != 1:
249+
raise FulcioClientError(
250+
f"Unexpected embedded SCT count in response: {len(precert_scts_extension)} != 1"
251+
)
252+
253+
# HACK(#84): Remove entirely.
254+
# HACK: Until cryptography is released, we don't have direct access
255+
# to each SCT's internals (signature, extensions, etc.)
256+
# Instead, we do something really nasty here: we decode the ASN.1,
257+
# unwrap the underlying TLS structures, and stash the raw SCT
258+
# for later use.
259+
parsed_sct_extension = asn1_decode(precert_scts_extension.public_bytes())
260+
261+
def _opaque16(value: bytes) -> bytes:
262+
# invariant: there have to be at least two bytes, for the length.
263+
if len(value) < 2:
264+
raise FulcioClientError(
265+
"malformed TLS encoding in response (length)"
266+
)
267+
268+
(length,) = struct.unpack("!H", value[0:2])
269+
270+
if length != len(value[2:]):
271+
raise FulcioClientError(
272+
"malformed TLS encoding in response (payload)"
273+
)
274+
275+
return value[2:]
276+
277+
# This is a TLS-encoded `opaque<0..2^16-1>` for the list,
278+
# which itself contains an `opaque<0..2^16-1>` for the SCT.
279+
raw_sct_list_bytes = bytes(parsed_sct_extension[0])
280+
raw_sct = _opaque16(_opaque16(raw_sct_list_bytes))
281+
282+
sct = precert_scts_extension[0]
283+
except ExtensionNotFound:
284+
# If we don't have any embedded SCTs, then we might be dealing
285+
# with a Fulcio instance that provides detached SCTs.
286+
287+
# The SCT header is a base64-encoded payload, which in turn
288+
# is a JSON representation of the SignedCertificateTimestamp
289+
# in RFC 6962 (subsec. 3.2).
290+
sct_b64 = resp.headers.get("SCT")
291+
if sct_b64 is None:
292+
raise FulcioClientError("Fulcio response did not include a SCT header")
293+
294+
try:
295+
sct_json = json.loads(base64.b64decode(sct_b64).decode())
296+
except ValueError as exc:
297+
raise FulcioClientError from exc
298+
299+
try:
300+
sct = DetachedFulcioSCT.parse_obj(sct_json)
301+
except Exception as exc:
302+
# Ideally we'd catch something less generic here.
303+
raise FulcioClientError from exc
304+
305+
# HACK(#84): Remove entirely.
306+
# The terrible hack above doesn't apply to detached SCTs.
307+
raw_sct = None
308+
309+
return FulcioCertificateSigningResponse(cert, chain, sct, raw_sct)
260310

261311

262312
class FulcioRootCert(Endpoint):
@@ -276,6 +326,7 @@ class FulcioClient:
276326

277327
def __init__(self, url: str = DEFAULT_FULCIO_URL) -> None:
278328
"""Initialize the client"""
329+
logger.debug(f"Fulcio client using URL: {url}")
279330
self.url = url
280331
self.session = requests.Session()
281332

sigstore/_internal/oidc/oauth.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ def get_identity_token(client_id: str, client_secret: str, issuer: Issuer) -> st
150150

151151
code: str
152152
with RedirectServer(client_id, client_secret, issuer) as server:
153+
thread = threading.Thread(
154+
target=lambda server: server.serve_forever(),
155+
args=(server,),
156+
)
157+
thread.start()
158+
153159
# Launch web browser
154160
if webbrowser.open(server.base_uri):
155161
print(f"Your browser will now be opened to:\n{server.auth_request()}\n")
@@ -159,12 +165,6 @@ def get_identity_token(client_id: str, client_secret: str, issuer: Issuer) -> st
159165
f"Go to the following link in a browser:\n\n\t{server.auth_request()}"
160166
)
161167

162-
thread = threading.Thread(
163-
target=lambda server: server.serve_forever(),
164-
args=(server,),
165-
)
166-
thread.start()
167-
168168
if not server.is_oob:
169169
# Wait until the redirect server populates the response
170170
while server.auth_response is None:

0 commit comments

Comments
 (0)