From e52f0cc473b0f151b0caec2cb54bfdc1b6b2e584 Mon Sep 17 00:00:00 2001 From: Vishnu Kosuri Date: Sat, 14 Mar 2026 11:47:24 +0530 Subject: [PATCH 1/3] fix(sign): use stub ASCII value in CSR subject to handle non-ASCII identities Fulcio ignores the CSR subject field entirely and derives the certificate identity from the OIDC token directly. Embedding the actual identity claim in the CSR's EMAIL_ADDRESS attribute causes failures when the claim contains non-ASCII characters (e.g. emojis in GitHub Actions environment names), since the field is encoded as IA5String which only allows ASCII. Replace the identity value with a fixed stub "user@example.com" so that CSR construction succeeds regardless of the claim content. Fixes #1507 Signed-off-by: Vishnu Kosuri --- sigstore/sign.py | 11 +++++-- test/unit/test_sign.py | 66 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/sigstore/sign.py b/sigstore/sign.py index 8a517e7b7..c0b0248c3 100644 --- a/sigstore/sign.py +++ b/sigstore/sign.py @@ -141,14 +141,21 @@ def _signing_cert( else: _logger.debug("Retrieving signed certificate...") - # Build an X.509 Certificate Signing Request + # Build an X.509 Certificate Signing Request. + # Fulcio ignores the CSR subject entirely; it derives the + # certificate's identity from the OIDC token, not from this + # field. We use a fixed stub value here instead of the actual + # identity claim so that non-ASCII characters (e.g. emojis in + # GitHub Actions environment names) don't produce an invalid + # IA5String-encoded email attribute and cause a Fulcio 400. + # See: https://github.com/sigstore/fulcio/issues/863 builder = ( x509.CertificateSigningRequestBuilder() .subject_name( x509.Name( [ x509.NameAttribute( - NameOID.EMAIL_ADDRESS, self._identity_token._identity + NameOID.EMAIL_ADDRESS, "user@example.com" ), ] ) diff --git a/test/unit/test_sign.py b/test/unit/test_sign.py index 0b039b119..6a52c15d5 100644 --- a/test/unit/test_sign.py +++ b/test/unit/test_sign.py @@ -11,24 +11,88 @@ # 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 datetime import hashlib import logging import secrets import pretend import pytest +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID from sigstore_models.common.v1 import HashAlgorithm import sigstore.oidc +import sigstore.sign +from sigstore._internal.fulcio.client import FulcioCertificateSigningResponse from sigstore._internal.timestamp import TimestampAuthorityClient from sigstore.dsse import StatementBuilder, Subject from sigstore.errors import VerificationError from sigstore.hashes import Hashed from sigstore.models import ClientTrustConfig -from sigstore.sign import SigningContext +from sigstore.sign import Signer, SigningContext from sigstore.verify.policy import UnsafeNoOp +def test_csr_subject_is_stub_for_non_ascii_identity(dummy_jwt, monkeypatch): + # Regression test for https://github.com/sigstore/sigstore-python/issues/1507. + # The CSR subject must be a fixed ASCII stub regardless of the identity + # claim value, because Fulcio ignores the CSR subject entirely and + # non-ASCII values (e.g. emojis in GHA environment names) produce an + # invalid IA5String encoding that Fulcio rejects with a 400. + now = int(datetime.datetime.now().timestamp()) + token_str = dummy_jwt( + { + "aud": "sigstore", + "sub": "repo:org/repo:environment:deploy\U0001f680", + "iat": now, + "nbf": now, + "exp": now + 600, + "iss": "hxxps://unknown.issuer.example.com/auth", + } + ) + identity_token = sigstore.oidc.IdentityToken(token_str) + # The identity contains a non-ASCII emoji character. + assert "\U0001f680" in identity_token.identity + + captured_csrs: list = [] + + def fake_post(csr, token): + captured_csrs.append(csr) + # Return a minimal fake response; _signing_cert will try to call + # verify_sct on it, which we also patch out below. + fake_cert = pretend.stub( + not_valid_after_utc=datetime.datetime( + 9999, 1, 1, tzinfo=datetime.timezone.utc + ) + ) + return FulcioCertificateSigningResponse(cert=fake_cert, chain=[]) + + monkeypatch.setattr(sigstore.sign, "verify_sct", lambda *a, **kw: None) + + fake_signing_cert_endpoint = pretend.stub(post=fake_post) + fake_fulcio = pretend.stub(signing_cert=fake_signing_cert_endpoint) + fake_trusted_root = pretend.stub( + ct_keyring=lambda *a: pretend.stub(verify=lambda *a: None) + ) + fake_rekor = pretend.stub() + ctx = SigningContext( + fulcio=fake_fulcio, + rekor=fake_rekor, + trusted_root=fake_trusted_root, + ) + + signer = Signer(identity_token, ctx, cache=False) + signer._Signer__cached_private_key = ec.generate_private_key(ec.SECP256R1()) + signer._signing_cert() + + assert len(captured_csrs) == 1 + csr = captured_csrs[0] + email_attrs = csr.subject.get_attributes_for_oid(NameOID.EMAIL_ADDRESS) + assert len(email_attrs) == 1 + assert email_attrs[0].value == "user@example.com" + + # only check the log contents for production: staging is already on # rekor v2 and we don't currently support log lookups on rekor v2. # This test can likely be removed once prod also uses rekor v2 From 7ea81aaeb1c844249b20434da4b6c08f16abf74a Mon Sep 17 00:00:00 2001 From: Vishnu Kosuri Date: Sat, 14 Mar 2026 13:57:45 +0530 Subject: [PATCH 2/3] fix(models): remove dead try/except in ClientTrustConfig.from_tuf The try/except block around get_signing_config_path() caught TUFError only to re-raise it with `raise e`, which resets the traceback to the re-raise site and hides the actual failure location. Since nothing else is done in the handler, the block serves no purpose. Remove it and drop the now-unused TUFError import. Co-Authored-By: Claude Sonnet 4.6 --- sigstore/models.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/sigstore/models.py b/sigstore/models.py index 2237b772e..19d12e8d3 100644 --- a/sigstore/models.py +++ b/sigstore/models.py @@ -63,7 +63,7 @@ ) from sigstore._internal.tuf import DEFAULT_TUF_URL, STAGING_TUF_URL, TrustUpdater from sigstore._utils import KeyID, cert_is_leaf, cert_is_root_ca, is_timerange_valid -from sigstore.errors import Error, MetadataError, TUFError, VerificationError +from sigstore.errors import Error, MetadataError, VerificationError # Versions supported by this client REKOR_VERSIONS = [1, 2] @@ -965,11 +965,8 @@ def from_tuf( tr_path = updater.get_trusted_root_path() inner_tr = trustroot_v1.TrustedRoot.from_json(Path(tr_path).read_bytes()) - try: - sc_path = updater.get_signing_config_path() - inner_sc = trustroot_v1.SigningConfig.from_json(Path(sc_path).read_bytes()) - except TUFError as e: - raise e + sc_path = updater.get_signing_config_path() + inner_sc = trustroot_v1.SigningConfig.from_json(Path(sc_path).read_bytes()) return cls( trustroot_v1.ClientTrustConfig( From 7ef27f51831760983067f7a33c9d6be0a3fe05b3 Mon Sep 17 00:00:00 2001 From: Vishnu Date: Sat, 14 Mar 2026 16:47:16 +0530 Subject: [PATCH 3/3] fix(sct): return cert_is_ca result in _cert_is_ca _cert_is_ca called cert_is_ca() but discarded its return value, unconditionally returning True whenever no VerificationError was raised. cert_is_ca() returns False (without raising) for certificates that lack a BasicConstraints extension. A non-CA certificate in that state would pass the issuer CA check in SCT verification. Fix by propagating the return value with `return cert_is_ca(cert)`. Signed-off-by: Vishnu --- CHANGELOG.md | 4 ++++ sigstore/_internal/sct.py | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 622ec6642..50f1c18ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All versions prior to 0.9.0 are untracked. ### Fixed +* Fixed `_cert_is_ca` in SCT verification ignoring the return value of + `cert_is_ca`, which caused certificates without a `BasicConstraints` + extension to pass the issuer CA check incorrectly. + * Fixed ~60s hang after completing browser-based OIDC authentication. The OIDC redirect server had incomplete HTTP responses and no connection management, causing a keep-alive deadlock with the browser. diff --git a/sigstore/_internal/sct.py b/sigstore/_internal/sct.py index 8267f8ff9..61795ec33 100644 --- a/sigstore/_internal/sct.py +++ b/sigstore/_internal/sct.py @@ -170,11 +170,10 @@ def _get_signed_certificate_timestamp( def _cert_is_ca(cert: Certificate) -> bool: _logger.debug(f"Found {cert.subject} as issuer, verifying if it is a ca") try: - cert_is_ca(cert) + return cert_is_ca(cert) except VerificationError as e: _logger.debug(f"Invalid {cert.subject}: failed to validate as a CA: {e}") return False - return True def verify_sct(