Skip to content

feat(verify): pin signer identity via --san-uri on verify certificate#649

Open
jzeng4 wants to merge 1 commit into
sigstore:mainfrom
jzeng4:feat/verify-certificate-san-uri
Open

feat(verify): pin signer identity via --san-uri on verify certificate#649
jzeng4 wants to merge 1 commit into
sigstore:mainfrom
jzeng4:feat/verify-certificate-san-uri

Conversation

@jzeng4

@jzeng4 jzeng4 commented Jul 16, 2026

Copy link
Copy Markdown

Summary

Adds --san-uri (repeatable) to model_signing verify certificate and an
equivalent expected_san_uris kwarg on
verifying.Config.use_certificate_verifier. When set, verification
additionally requires that every listed URI appear in the leaf certificate’s
SubjectAlternativeName. The check runs on the leaf embedded in the
Sigstore bundle
, after chain-of-trust verification succeeds. If the flag is
omitted, behavior is unchanged.

Motivation — SPIFFE SVIDs carry identity in the URI SAN

Today, verify certificate validates only two things:

  1. The Sigstore bundle’s DSSE signature is valid under the leaf’s public key.
  2. The leaf chains to a root the verifier trusts.

That is necessary but not sufficient for SPIFFE deployments (and more
generally for any internal PKI where multiple workloads share a CA).

Per the SPIFFE X509-SVID spec:

  • The SPIFFE ID MUST be set as a URI-type SAN entry
    (URI:spiffe://<trust-domain>/<path>).
  • Exactly one URI SAN is allowed, and it MUST be the SPIFFE ID.
  • The SPIFFE ID MUST NOT be placed in the Subject/CN — CN is legacy and
    ignored by SPIFFE-aware verifiers.
  • SPIFFE-aware relying parties are required to check the URI SAN.

Chain-of-trust alone proves the SPIRE trust bundle vouched for some
workload; it does not tell the verifier which workload. Without a URI-SAN
check, any SVID issued by the same trust domain — a different team’s job, a
different service, a compromised sibling workload — produces signatures that
verify certificate will silently accept.

The same footgun applies outside SPIFFE:

  • Shared internal PKI. Team A’s release job and Team B’s release job hold
    code-signing certs from the same corporate CA. Team A wants to verify a
    model was signed by Team A’s releaser, not any job in the company.
  • Sibling-cert misissuance / key theft. An attacker with a legitimate
    cert for any subject under the trusted root can produce signatures that
    chain-verify. Pinning the expected identity turns "silently accepts" into
    "rejects with a clear error".
  • Parity with verify sigstore. The Fulcio path already requires
    --identity (a SAN URI). The certificate path — used by users running
    their own PKI or SPIRE — offered no equivalent, which is a footgun for
    anyone migrating between the two.

Today the workaround is an ad-hoc post-verify script that re-parses the
bundle, base64-decodes the leaf, and checks the SAN. That is error-prone
(easy to skip on missing SAN, easy to forget entirely, easy to run before
chain verification) and each user reinvents it. Moving the check into
use_certificate_verifier makes it atomic with chain verification and
impossible to forget.

Design

  • Which certificate is checked. The leaf DER is taken from
    bundle.verification_material.x509_certificate_chain.certificates[0] — the
    exact same bytes the chain verifier just validated. Never a local file.
  • Ordering. The SAN check runs after chain, KeyUsage, ExtendedKeyUsage,
    and signature verification. Reversing the order would let an attacker
    present a self-crafted cert with the "right" SAN and skip trust.
  • Match semantics. For each --san-uri passed, the leaf’s SAN must
    contain that exact string. Multiple flags form a conjunction. Case-sensitive;
    no wildcards. This mirrors sigstore-python and avoids the well-known
    pitfalls of pattern-based identity checks.
  • Missing SAN extension. If the caller pins any identity but the leaf has
    no SAN extension at all, verification fails with a clear error rather than
    silently accepting.
  • Back-compat. No flag → check is skipped. Default is empty
    frozenset(). No behavior change for existing users.

Non-goals

  • Email/DNS/OtherName SAN pinning (URI SAN is the SPIFFE-mandated field and
    the primary use case; the others can follow up if requested).
  • Regex / suffix matching of SAN values (pattern matching on identity is how
    CVEs get filed).
  • Fulcio OIDC claim checks — that is verify sigstore, not this command.

Changes

  • src/model_signing/_signing/sign_certificate.py: Verifier.__init__ gains
    expected_san_uris. New _verify_san_identity() runs after the existing
    KeyUsage/ExtendedKeyUsage checks.
  • src/model_signing/verifying.py: use_certificate_verifier gains
    expected_san_uris, plumbed straight through.
  • src/model_signing/_cli.py: new --san-uri option on verify certificate
    (repeatable), documented in the command help with the SPIFFE rationale.
  • tests/_signing/certificate_test.py (new): five tests covering match,
    mismatch, missing SAN, back-compat, and the sibling-cert attack.

Test plan

$ hatch test tests/_signing/certificate_test.py
5 passed

$ hatch fmt --check
All checks passed!

Example rejection:

$ model_signing verify certificate model.bin \
    --signature model.bundle.json \
    --certificate-chain trust_bundle.pem \
    --san-uri spiffe://prod.example.com/svc/model-releaser
Verification failed with error: Signing certificate SubjectAltName is
missing expected URI(s): [\"spiffe://prod.example.com/svc/model-releaser\"]
(present: [\"spiffe://demo.example.com/signer/demo\"])

Checklist

  • hatch fmt --check clean
  • Tests for the changed area pass
  • Public API addition is keyword-only and defaults to empty for back-compat
  • CLI help text explains what the flag does and why
  • New tests cover positive, negative, and back-compat paths
  • Commit is DCO-signed

Adds --san-uri (repeatable) to `model_signing verify certificate` and an equivalent `expected_san_uris` kwarg on `use_certificate_verifier`. When set, verification additionally requires that every listed URI appear in the leaf certificate's SubjectAlternativeName. The check runs on the leaf embedded in the Sigstore bundle, after chain-of-trust verification succeeds. Default behavior is unchanged when the flag is omitted.

Motivation: SPIFFE SVIDs carry the workload identity in the URI SAN (X509-SVID spec requires exactly one URI SAN, and it MUST be the SPIFFE ID). Chain-of-trust alone proves the CA vouched for *some* leaf, not *which* leaf. Without a URI-SAN check, any certificate issued by the SPIRE trust bundle (or by a shared internal PKI) can produce accepted signatures. This flag turns the SPIFFE-required verification step into a single CLI option, so callers no longer have to write ad-hoc post-verify scripts that re-parse the bundle.
Signed-off-by: Junyuan Zeng <juzeng@linkedin.com>
@jzeng4
jzeng4 requested review from a team as code owners July 16, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant