From 886efd78ab03e8ac92294c1e6f25493166e91959 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Thu, 4 Jun 2026 07:46:07 +0900 Subject: [PATCH 1/2] Print signing identity and issuer to stderr after signing After a successful sign, gitsign now reports the certificate-identity and certificate-oidc-issuer recorded in the Fulcio certificate, along with a ready-to-run gitsign verify command. This addresses the common confusion where a user does not know which values to pass to the verify flags (for example that the GitHub issuer is https://github.com/login/oauth, and that the identity is the private email, not the privacy-sparing address). The message is written to TTYOut (a TTY, or stderr when no TTY is present) and never to stdout, so tools that parse gitsign's stdout signature output are unaffected. The identity is taken from the certificate subject alternative names and the issuer from the Fulcio OIDC issuer extension, the same sources the verify command reads back. Extraction is factored into internal. NewSigningIdentity with unit tests. Fixes #693 Signed-off-by: Arpit Jain --- internal/commands/root/sign.go | 9 ++++ internal/utils.go | 41 ++++++++++++++ internal/utils_test.go | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/internal/commands/root/sign.go b/internal/commands/root/sign.go index 7efea129..8ca4812e 100644 --- a/internal/commands/root/sign.go +++ b/internal/commands/root/sign.go @@ -23,6 +23,7 @@ import ( "io" "os" + "github.com/sigstore/gitsign/internal" "github.com/sigstore/gitsign/internal/fulcio" "github.com/sigstore/gitsign/internal/git" "github.com/sigstore/gitsign/internal/gpg" @@ -109,6 +110,14 @@ func commandSign(o *options, s *gsio.Streams, args ...string) error { gpgout.EmitSigCreated(resp.Cert, o.FlagDetachedSignature) + // Tell the user which identity and OIDC issuer the signature was made with + // so they know what to pass to `gitsign verify`. This is written to TTYOut + // (a TTY, or stderr when no TTY is present) and never to s.Out, so machine + // parsers consuming gitsign's stdout signature output are not affected. + if resp.Cert != nil { + fmt.Fprintln(s.TTYOut, internal.NewSigningIdentity(resp.Cert).String()) // nolint:errcheck + } + if _, err := s.Out.Write(resp.Signature); err != nil { return errors.New("failed to write signature") } diff --git a/internal/utils.go b/internal/utils.go index 4c94656e..ff552e95 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -19,7 +19,12 @@ import ( "crypto/sha1" // #nosec G505 "crypto/x509" "encoding/hex" + "fmt" "net/url" + "strings" + + "github.com/sigstore/cosign/v3/pkg/cosign" + "github.com/sigstore/sigstore/pkg/cryptoutils" ) // certHexFingerprint calculated the hex SHA1 fingerprint of a certificate. @@ -37,6 +42,42 @@ func certFingerprint(cert *x509.Certificate) []byte { return fpr[:] } +// SigningIdentity holds the certificate-identity and certificate-oidc-issuer +// values that were used to sign, so a user knows what to pass to the matching +// `gitsign verify` flags. These are read from the same certificate extensions +// that the verify path reads back. +type SigningIdentity struct { + // Identity is the subject the signing certificate was issued to. For the + // GitHub OIDC issuer this is the user's (possibly private) email address. + // It corresponds to the `--certificate-identity` verify flag. + Identity string + // Issuer is the OIDC issuer URI recorded in the certificate. It corresponds + // to the `--certificate-oidc-issuer` verify flag. + Issuer string +} + +// NewSigningIdentity extracts the certificate-identity and +// certificate-oidc-issuer from a signing certificate. The identity is taken +// from the certificate's subject alternative names (the same source the verify +// command prints), and the issuer is read from the Fulcio OIDC issuer +// extension. If a cert carries more than one SAN, they are joined so nothing is +// silently dropped. +func NewSigningIdentity(cert *x509.Certificate) SigningIdentity { + identity := strings.Join(cryptoutils.GetSubjectAlternateNames(cert), ", ") + ce := cosign.CertExtensions{Cert: cert} + return SigningIdentity{ + Identity: identity, + Issuer: ce.GetIssuer(), + } +} + +// String formats the signing identity as guidance for the verify command, +// pointing the user at the exact flag values to pass. +func (s SigningIdentity) String() string { + return fmt.Sprintf("gitsign: signed with identity %q (issuer %q)\ngitsign: to verify, run: gitsign verify --certificate-identity %q --certificate-oidc-issuer %q", + s.Identity, s.Issuer, s.Identity, s.Issuer) +} + // StripURL returns the baseHost with the basePath given a full endpoint func StripURL(endpoint string) (string, string) { u, err := url.Parse(endpoint) diff --git a/internal/utils_test.go b/internal/utils_test.go index 55fa5da4..8cb65062 100644 --- a/internal/utils_test.go +++ b/internal/utils_test.go @@ -15,6 +15,11 @@ package internal import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "net/url" + "strings" "testing" ) @@ -25,3 +30,95 @@ func TestStripUrl(t *testing.T) { t.Fatalf("Host and/or BasePath are not correct") } } + +// oidcIssuerOID is the Fulcio certificate extension that records the OIDC +// issuer (1.3.6.1.4.1.57264.1.1). +var oidcIssuerOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1} + +func issuerExtension(issuer string) pkix.Extension { + return pkix.Extension{Id: oidcIssuerOID, Value: []byte(issuer)} +} + +func mustParseURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parsing %q: %v", raw, err) + } + return u +} + +func TestNewSigningIdentity(t *testing.T) { + for _, tc := range []struct { + name string + cert *x509.Certificate + wantIdentity string + wantIssuer string + }{ + { + name: "email SAN with GitHub issuer", + cert: &x509.Certificate{ + EmailAddresses: []string{"foo@example.com"}, + Extensions: []pkix.Extension{issuerExtension("https://github.com/login/oauth")}, + }, + wantIdentity: "foo@example.com", + wantIssuer: "https://github.com/login/oauth", + }, + { + name: "uri SAN with issuer", + cert: &x509.Certificate{ + URIs: []*url.URL{mustParseURL(t, "https://github.com/foo/bar/.github/workflows/ci.yml@refs/heads/main")}, + Extensions: []pkix.Extension{issuerExtension("https://token.actions.githubusercontent.com")}, + }, + wantIdentity: "https://github.com/foo/bar/.github/workflows/ci.yml@refs/heads/main", + wantIssuer: "https://token.actions.githubusercontent.com", + }, + { + name: "multiple SANs are joined, not dropped", + cert: &x509.Certificate{ + EmailAddresses: []string{"a@example.com", "b@example.com"}, + Extensions: []pkix.Extension{issuerExtension("https://accounts.google.com")}, + }, + wantIdentity: "a@example.com, b@example.com", + wantIssuer: "https://accounts.google.com", + }, + { + name: "no SAN, no issuer extension", + cert: &x509.Certificate{}, + wantIdentity: "", + wantIssuer: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := NewSigningIdentity(tc.cert) + if got.Identity != tc.wantIdentity { + t.Errorf("Identity = %q, want %q", got.Identity, tc.wantIdentity) + } + if got.Issuer != tc.wantIssuer { + t.Errorf("Issuer = %q, want %q", got.Issuer, tc.wantIssuer) + } + }) + } +} + +func TestSigningIdentityString(t *testing.T) { + s := SigningIdentity{ + Identity: "foo@example.com", + Issuer: "https://github.com/login/oauth", + } + out := s.String() + + // The formatted guidance must surface both values and point at the verify + // flags so a user can copy them directly. + for _, want := range []string{ + "foo@example.com", + "https://github.com/login/oauth", + "--certificate-identity", + "--certificate-oidc-issuer", + "gitsign verify", + } { + if !strings.Contains(out, want) { + t.Errorf("String() output missing %q\ngot: %s", want, out) + } + } +} From 98340df53bb460864467c6a9f4b7086c14648f78 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Wed, 10 Jun 2026 10:25:07 +0900 Subject: [PATCH 2/2] internal: drop the verify-command line from signing-identity output Per review, the second line (the full 'gitsign verify --certificate-identity ...' command) is verbose to show on every signing. Keep just the identity and issuer line; users who want the verify command can derive it from those values. Signed-off-by: Arpit Jain --- internal/utils.go | 7 +++---- internal/utils_test.go | 7 ++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/utils.go b/internal/utils.go index ff552e95..05f63492 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -71,11 +71,10 @@ func NewSigningIdentity(cert *x509.Certificate) SigningIdentity { } } -// String formats the signing identity as guidance for the verify command, -// pointing the user at the exact flag values to pass. +// String reports the identity and issuer the signature was made with, so the +// user can see who they signed as. func (s SigningIdentity) String() string { - return fmt.Sprintf("gitsign: signed with identity %q (issuer %q)\ngitsign: to verify, run: gitsign verify --certificate-identity %q --certificate-oidc-issuer %q", - s.Identity, s.Issuer, s.Identity, s.Issuer) + return fmt.Sprintf("gitsign: signed with identity %q (issuer %q)", s.Identity, s.Issuer) } // StripURL returns the baseHost with the basePath given a full endpoint diff --git a/internal/utils_test.go b/internal/utils_test.go index 8cb65062..6d3d8f3b 100644 --- a/internal/utils_test.go +++ b/internal/utils_test.go @@ -108,14 +108,11 @@ func TestSigningIdentityString(t *testing.T) { } out := s.String() - // The formatted guidance must surface both values and point at the verify - // flags so a user can copy them directly. + // The output should surface both the identity and the issuer. for _, want := range []string{ "foo@example.com", "https://github.com/login/oauth", - "--certificate-identity", - "--certificate-oidc-issuer", - "gitsign verify", + "signed with identity", } { if !strings.Contains(out, want) { t.Errorf("String() output missing %q\ngot: %s", want, out)