From 6da5be4c33e81340b4baa595dacf4ec822687e7c Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Wed, 3 Jun 2026 01:13:47 +0900 Subject: [PATCH 1/2] verify: match email identities case-insensitively policy.Identity matched the expected identity against the certificate SANs with an exact string comparison, so an email identity that differed only in case (FoO@baR.coM vs a foo@bar.com SAN) failed to verify. Email addresses are not case-sensitive in practice and Fulcio normalizes them, so this rejected a legitimate identity. Match email (RFC822Name) SANs case-insensitively via casefold(); URI and other SAN types keep their exact match. Adds a test and a CHANGELOG entry. Reported in sigstore/model-transparency#459. Signed-off-by: Arpit Jain --- CHANGELOG.md | 6 ++++++ sigstore/verify/policy.py | 17 ++++++++++++----- test/unit/verify/test_policy.py | 11 +++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9cfd3336..87e9d0279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ All versions prior to 0.9.0 are untracked. The OIDC redirect server had incomplete HTTP responses and no connection management, causing a keep-alive deadlock with the browser. +* `policy.Identity` now matches email (RFC822Name) SANs case-insensitively, so + an expected identity that differs only in case (for example `FoO@baR.coM` + versus a `foo@bar.com` SAN) verifies successfully. URI and other SAN types + are still matched exactly. + ([sigstore/model-transparency#459](https://github.com/sigstore/model-transparency/issues/459)) + ## [4.2.0] ### Fixed diff --git a/sigstore/verify/policy.py b/sigstore/verify/policy.py index 8fa0b3280..62e55e5d8 100644 --- a/sigstore/verify/policy.py +++ b/sigstore/verify/policy.py @@ -461,11 +461,11 @@ def verify(self, cert: Certificate) -> None: if self._issuer: self._issuer.verify(cert) - # Build a set of all valid identities. + # Build the sets of valid identities from the certificate's SANs. san_ext = cert.extensions.get_extension_for_class(SubjectAlternativeName).value - all_sans = set(san_ext.get_values_for_type(RFC822Name)) - all_sans.update(san_ext.get_values_for_type(UniformResourceIdentifier)) - all_sans.update( + email_sans = set(san_ext.get_values_for_type(RFC822Name)) + other_sans = set(san_ext.get_values_for_type(UniformResourceIdentifier)) + other_sans.update( [ on.value.decode() for on in san_ext.get_values_for_type(OtherName) @@ -473,8 +473,15 @@ def verify(self, cert: Certificate) -> None: ] ) - verified = self._identity in all_sans + # Email (RFC822Name) identities are matched case-insensitively: email + # addresses are not case-sensitive in practice and Fulcio normalizes + # them, so "FoO@baR.coM" should match a "foo@bar.com" SAN. URI and + # "other name" SANs remain exact matches. + verified = self._identity in other_sans or self._identity.casefold() in { + email.casefold() for email in email_sans + } if not verified: + all_sans = email_sans | other_sans raise VerificationError( f"Certificate's SANs do not match {self._identity}; actual SANs: {all_sans}" ) diff --git a/test/unit/verify/test_policy.py b/test/unit/verify/test_policy.py index 68c41a690..180f4a7ff 100644 --- a/test/unit/verify/test_policy.py +++ b/test/unit/verify/test_policy.py @@ -151,6 +151,17 @@ def test_fails_no_san_match(self, signing_bundle): ): policy_.verify(bundle.signing_certificate) + def test_succeeds_email_case_insensitive(self, signing_bundle): + # The certificate's email SAN is "a@tny.town"; an identity that differs + # only in case must still verify (see #459 in sigstore/model-transparency). + _, bundle = signing_bundle("bundle.txt") + policy_ = policy.Identity( + identity="A@TnY.ToWn", + issuer="https://github.com/login/oauth", + ) + + policy_.verify(bundle.signing_certificate) + class TestSingleExtPolicy: def test_succeeds(self, signing_bundle): From 7416dd973443918363f3407e38a64be0e2271a86 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Fri, 5 Jun 2026 10:09:30 +0900 Subject: [PATCH 2/2] verify: narrow email matching to case-insensitive domain only Per maintainer review, only the domain part of an email (RFC822Name) identity is now matched case-insensitively; the local part stays case-sensitive. RFC 5321 allows the local part to be case-significant, so folding it could let an unintended identity verify. The domain is case-insensitive, so 'a@TnY.ToWn' still matches an 'a@tny.town' SAN, while 'A@tny.town' (local part differs only in case) no longer matches. Updates the tests to cover an exact match, a domain-only case difference (matches), and a local-part case difference (does not match), and updates the CHANGELOG accordingly. Signed-off-by: Arpit Jain --- CHANGELOG.md | 9 +++++---- sigstore/verify/policy.py | 22 ++++++++++++++------- test/unit/verify/test_policy.py | 35 +++++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e9d0279..ad0183b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,11 @@ All versions prior to 0.9.0 are untracked. The OIDC redirect server had incomplete HTTP responses and no connection management, causing a keep-alive deadlock with the browser. -* `policy.Identity` now matches email (RFC822Name) SANs case-insensitively, so - an expected identity that differs only in case (for example `FoO@baR.coM` - versus a `foo@bar.com` SAN) verifies successfully. URI and other SAN types - are still matched exactly. +* `policy.Identity` now matches the domain part of email (RFC822Name) SANs + case-insensitively, so an expected identity whose domain differs only in case + (for example `a@TnY.ToWn` versus an `a@tny.town` SAN) verifies successfully. + The local part stays case-sensitive (per RFC 5321 it can be case-significant), + and URI and other SAN types are still matched exactly. ([sigstore/model-transparency#459](https://github.com/sigstore/model-transparency/issues/459)) ## [4.2.0] diff --git a/sigstore/verify/policy.py b/sigstore/verify/policy.py index 62e55e5d8..58dd4e02d 100644 --- a/sigstore/verify/policy.py +++ b/sigstore/verify/policy.py @@ -473,13 +473,21 @@ def verify(self, cert: Certificate) -> None: ] ) - # Email (RFC822Name) identities are matched case-insensitively: email - # addresses are not case-sensitive in practice and Fulcio normalizes - # them, so "FoO@baR.coM" should match a "foo@bar.com" SAN. URI and - # "other name" SANs remain exact matches. - verified = self._identity in other_sans or self._identity.casefold() in { - email.casefold() for email in email_sans - } + # Email (RFC822Name) identities are matched with the domain compared + # case-insensitively, while the local part stays case-sensitive. Per + # RFC 5321 the local part can be case-significant, so we must not fold + # it; the domain is case-insensitive, so "a@TnY.ToWn" matches an + # "a@tny.town" SAN. URI and "other name" SANs remain exact matches. + def _normalize_email(value: str) -> str: + local, sep, domain = value.rpartition("@") + if not sep: + # No "@" present: nothing to normalize, compare as-is. + return value + return f"{local}@{domain.casefold()}" + + verified = self._identity in other_sans or _normalize_email( + self._identity + ) in {_normalize_email(email) for email in email_sans} if not verified: all_sans = email_sans | other_sans raise VerificationError( diff --git a/test/unit/verify/test_policy.py b/test/unit/verify/test_policy.py index 180f4a7ff..632424911 100644 --- a/test/unit/verify/test_policy.py +++ b/test/unit/verify/test_policy.py @@ -151,17 +151,44 @@ def test_fails_no_san_match(self, signing_bundle): ): policy_.verify(bundle.signing_certificate) - def test_succeeds_email_case_insensitive(self, signing_bundle): - # The certificate's email SAN is "a@tny.town"; an identity that differs - # only in case must still verify (see #459 in sigstore/model-transparency). + def test_succeeds_email_domain_case_insensitive(self, signing_bundle): + # The certificate's email SAN is "a@tny.town"; an identity whose domain + # differs only in case must still verify, because the domain part of an + # email address is case-insensitive (see #459 in + # sigstore/model-transparency). _, bundle = signing_bundle("bundle.txt") policy_ = policy.Identity( - identity="A@TnY.ToWn", + identity="a@TnY.ToWn", issuer="https://github.com/login/oauth", ) policy_.verify(bundle.signing_certificate) + def test_succeeds_email_exact_match(self, signing_bundle): + _, bundle = signing_bundle("bundle.txt") + policy_ = policy.Identity( + identity="a@tny.town", + issuer="https://github.com/login/oauth", + ) + + policy_.verify(bundle.signing_certificate) + + def test_fails_email_local_part_case_differs(self, signing_bundle): + # The certificate's email SAN is "a@tny.town". Per RFC 5321 the local + # part can be case-significant, so "A@tny.town" (local part differs only + # in case) must NOT match, even though the domain is identical. + _, bundle = signing_bundle("bundle.txt") + policy_ = policy.Identity( + identity="A@tny.town", + issuer="https://github.com/login/oauth", + ) + + with pytest.raises( + VerificationError, + match="Certificate's SANs do not match", + ): + policy_.verify(bundle.signing_certificate) + class TestSingleExtPolicy: def test_succeeds(self, signing_bundle):