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..05f63492 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,41 @@ 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 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)", 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..6d3d8f3b 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,92 @@ 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 output should surface both the identity and the issuer. + for _, want := range []string{ + "foo@example.com", + "https://github.com/login/oauth", + "signed with identity", + } { + if !strings.Contains(out, want) { + t.Errorf("String() output missing %q\ngot: %s", want, out) + } + } +}