Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions internal/commands/root/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
Expand Down
40 changes: 40 additions & 0 deletions internal/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions internal/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
package internal

import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"net/url"
"strings"
"testing"
)

Expand All @@ -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)
}
}
}