Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fulcio signing implementation #1785

Merged
merged 3 commits into from
Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 2 additions & 4 deletions docker/docker_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ import (

"github.com/containers/image/v5/docker/reference"
"github.com/containers/image/v5/internal/iolimits"
"github.com/containers/image/v5/internal/useragent"
"github.com/containers/image/v5/manifest"
"github.com/containers/image/v5/pkg/docker/config"
"github.com/containers/image/v5/pkg/sysregistriesv2"
"github.com/containers/image/v5/pkg/tlsclientconfig"
"github.com/containers/image/v5/types"
"github.com/containers/image/v5/version"
"github.com/containers/storage/pkg/homedir"
"github.com/docker/distribution/registry/api/errcode"
v2 "github.com/docker/distribution/registry/api/v2"
Expand Down Expand Up @@ -68,8 +68,6 @@ var (
{path: etcDir + "/containers/certs.d", absolute: true},
{path: etcDir + "/docker/certs.d", absolute: true},
}

defaultUserAgent = "containers/" + version.Version + " (github.com/containers/image)"
)

// extensionSignature and extensionSignatureList come from github.com/openshift/origin/pkg/dockerregistry/server/signaturedispatcher.go:
Expand Down Expand Up @@ -284,7 +282,7 @@ func newDockerClient(sys *types.SystemContext, registry, reference string) (*doc
}
tlsClientConfig.InsecureSkipVerify = skipVerify

userAgent := defaultUserAgent
userAgent := useragent.DefaultUserAgent
if sys != nil && sys.DockerRegistryUserAgent != "" {
userAgent = sys.DockerRegistryUserAgent
}
Expand Down
3 changes: 2 additions & 1 deletion docker/docker_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"testing"
"time"

"github.com/containers/image/v5/internal/useragent"
"github.com/containers/image/v5/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -176,7 +177,7 @@ func TestUserAgent(t *testing.T) {
}{
// Can't both test nil and set DockerInsecureSkipTLSVerify :(
// {nil, defaultUA},
{&types.SystemContext{}, defaultUserAgent},
{&types.SystemContext{}, useragent.DefaultUserAgent},
{&types.SystemContext{DockerRegistryUserAgent: sentinelUA}, sentinelUA},
} {
// For this test against localhost, we don't care.
Expand Down
6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ require (
github.com/opencontainers/selinux v1.10.2
github.com/ostreedev/ostree-go v0.0.0-20210805093236-719684c64e4f
github.com/proglottis/gpgme v0.1.3
github.com/sigstore/fulcio v1.0.0
github.com/sigstore/rekor v1.0.1
github.com/sigstore/sigstore v1.5.0
github.com/sirupsen/logrus v1.9.0
Expand All @@ -38,6 +39,7 @@ require (
go.etcd.io/bbolt v1.3.6
golang.org/x/crypto v0.5.0
golang.org/x/net v0.5.0
golang.org/x/oauth2 v0.4.0
golang.org/x/sync v0.1.0
golang.org/x/term v0.4.0
)
Expand All @@ -52,6 +54,7 @@ require (
github.com/chzyer/readline v1.5.1 // indirect
github.com/containerd/cgroups v1.0.4 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.13.0 // indirect
github.com/coreos/go-oidc/v3 v3.4.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
Expand Down Expand Up @@ -98,6 +101,8 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.3 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980 // indirect
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
github.com/tchap/go-patricia v2.3.0+incompatible // indirect
Expand All @@ -111,6 +116,7 @@ require (
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
golang.org/x/tools v0.4.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37 // indirect
google.golang.org/grpc v1.51.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
Expand Down
247 changes: 247 additions & 0 deletions go.sum

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions internal/signature/sigstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const (
SigstoreSignatureAnnotationKey = "dev.cosignproject.cosign/signature"
// from sigstore/cosign/pkg/oci/static.BundleAnnotationKey
SigstoreSETAnnotationKey = "dev.sigstore.cosign/bundle"
// from sigstore/cosign/pkg/oci/static.CertificateAnnotationKey
SigstoreCertificateAnnotationKey = "dev.sigstore.cosign/certificate"
// from sigstore/cosign/pkg/oci/static.ChainAnnotationKey
SigstoreIntermediateCertificateChainAnnotationKey = "dev.sigstore.cosign/chain"
)

// Sigstore is a github.com/cosign/cosign signature.
Expand Down
6 changes: 6 additions & 0 deletions internal/useragent/useragent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package useragent

import "github.com/containers/image/v5/version"

// DefaultUserAgent is a value that should be used by User-Agent headers, unless the user specifically instructs us otherwise.
var DefaultUserAgent = "containers/" + version.Version + " (github.com/containers/image)"
155 changes: 155 additions & 0 deletions signature/sigstore/fulcio/fulcio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package fulcio

import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"fmt"
"io"
"net/url"

"github.com/containers/image/v5/internal/useragent"
"github.com/containers/image/v5/signature/sigstore/internal"
"github.com/sigstore/fulcio/pkg/api"
"github.com/sigstore/sigstore/pkg/oauth"
"github.com/sigstore/sigstore/pkg/oauthflow"
sigstoreSignature "github.com/sigstore/sigstore/pkg/signature"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)

// setupSignerWithFulcio updates s with a certificate generated by fulcioURL based on oidcIDToken
func setupSignerWithFulcio(s *internal.SigstoreSigner, fulcioURL *url.URL, oidcIDToken *oauthflow.OIDCIDToken) error {
// ECDSA-P256 is the only interoperable algorithm per
// https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md#signature-schemes .
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generating short-term private key: %w", err)
}
keyAlgorithm := "ecdsa"
// SHA-256 is opencontainers/go-digest.Canonical, thus the algorithm to use here as well per
// https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md#hashing-algorithms
signer, err := sigstoreSignature.LoadECDSASigner(privateKey, crypto.SHA256)
if err != nil {
return fmt.Errorf("initializing short-term private key: %w", err)
}
s.PrivateKey = signer

logrus.Debugf("Requesting a certificate from Fulcio at %s", fulcioURL.Redacted())
fulcioClient := api.NewClient(fulcioURL, api.WithUserAgent(useragent.DefaultUserAgent))
// Sign the email address as part of the request
h := sha256.Sum256([]byte(oidcIDToken.Subject))
keyOwnershipProof, err := ecdsa.SignASN1(rand.Reader, privateKey, h[:])
if err != nil {
return fmt.Errorf("Error signing key ownership proof: %w", err)
}
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return fmt.Errorf("converting public key to ASN.1: %w", err)
}
// Note that unlike most OAuth2 uses, this passes the ID token, not an access token.
// This is only secure if every Fulcio server has an individual client ID value
// = fulcioOIDCClientID, distinct from other Fulcio servers,
// that is embedded into the ID token’s "aud" field.
resp, err := fulcioClient.SigningCert(api.CertificateRequest{
PublicKey: api.Key{
Content: publicKeyBytes,
Algorithm: keyAlgorithm,
},
SignedEmailAddress: keyOwnershipProof,
}, oidcIDToken.RawString)
if err != nil {
return fmt.Errorf("obtaining certificate from Fulcio: %w", err)
}
s.FulcioGeneratedCertificate = resp.CertPEM
s.FulcioGeneratedCertificateChain = resp.ChainPEM
// Cosign goes through an unmarshal/marshal roundtrip for Fulcio-generated certificates, let’s not do that.
s.SigningKeyOrCert = resp.CertPEM
return nil
}

// WithFulcioAndPreexistingOIDCIDToken sets up signing to use a short-lived key and a Fulcio-issued certificate
// based on a caller-provided OIDC ID token.
func WithFulcioAndPreexistingOIDCIDToken(fulcioURL *url.URL, oidcIDToken string) internal.Option {
return func(s *internal.SigstoreSigner) error {
if s.PrivateKey != nil {
return fmt.Errorf("multiple private key sources specified when preparing to create sigstore signatures")
}

// This adds dependencies even just to parse the token. We could possibly reimplement that, and split this variant
// into a subpackage without the OIDC dependencies… but really, is this going to be used in significantly different situations
// than the two interactive OIDC authentication workflows?
//
// Are there any widely used tools to manually obtain an ID token? Why would there be?
// For long-term usage, users provisioning a static OIDC credential might just as well provision an already-generated certificate
// or something like that.
logrus.Debugf("Using a statically-provided OIDC token")
staticTokenGetter := oauthflow.StaticTokenGetter{RawToken: oidcIDToken}
oidcIDToken, err := staticTokenGetter.GetIDToken(nil, oauth2.Config{})
if err != nil {
return fmt.Errorf("parsing OIDC token: %w", err)
}

return setupSignerWithFulcio(s, fulcioURL, oidcIDToken)
}
}

// WithFulcioAndDeviceAuthorizationGrantOIDC sets up signing to use a short-lived key and a Fulcio-issued certificate
// based on an OIDC ID token obtained using a device authorization grant (RFC 8628).
//
// interactiveOutput must be directly accesible to a human user in real time (i.e. not be just a log file).
func WithFulcioAndDeviceAuthorizationGrantOIDC(fulcioURL *url.URL, oidcIssuerURL *url.URL, oidcClientID, oidcClientSecret string,
interactiveOutput io.Writer) internal.Option {
return func(s *internal.SigstoreSigner) error {
if s.PrivateKey != nil {
return fmt.Errorf("multiple private key sources specified when preparing to create sigstore signatures")
}

logrus.Debugf("Starting OIDC device flow for issuer %s", oidcIssuerURL.Redacted())
tokenGetter := oauthflow.NewDeviceFlowTokenGetterForIssuer(oidcIssuerURL.String())
tokenGetter.MessagePrinter = func(s string) {
fmt.Fprintln(interactiveOutput, s)
}
oidcIDToken, err := oauthflow.OIDConnect(oidcIssuerURL.String(), oidcClientID, oidcClientSecret, "", tokenGetter)
if err != nil {
return fmt.Errorf("Error authenticating with OIDC: %w", err)
}

return setupSignerWithFulcio(s, fulcioURL, oidcIDToken)
}
}

// WithFulcioAndInterativeOIDC sets up signing to use a short-lived key and a Fulcio-issued certificate
// based on an interactively-obtained OIDC ID token.
// The token is obtained
// - directly using a browser, listening on localhost, automatically opening a browser to the OIDC issuer,
// to be redirected on localhost. (I.e. the current environment must allow launching a browser that connect back to the current process;
// either or both may be impossible in a container or a remote VM).
// - or by instructing the user to manually open a browser, obtain the OIDC code, and interactively input it as text.
//
// interactiveInput and interactiveOutput must both be directly operable by a human user in real time (i.e. not be just a log file).
func WithFulcioAndInteractiveOIDC(fulcioURL *url.URL, oidcIssuerURL *url.URL, oidcClientID, oidcClientSecret string,
interactiveInput io.Reader, interactiveOutput io.Writer) internal.Option {
return func(s *internal.SigstoreSigner) error {
if s.PrivateKey != nil {
return fmt.Errorf("multiple private key sources specified when preparing to create sigstore signatures")
}

logrus.Debugf("Starting interactive OIDC authentication for issuer %s", oidcIssuerURL.Redacted())
// This is intended to match oauthflow.DefaultIDTokenGetter, overriding only input/output
tokenGetter := &oauthflow.InteractiveIDTokenGetter{
HTMLPage: oauth.InteractiveSuccessHTML,
Input: interactiveInput,
Output: interactiveOutput,
}
oidcIDToken, err := oauthflow.OIDConnect(oidcIssuerURL.String(), oidcClientID, oidcClientSecret, "", tokenGetter)
if err != nil {
return fmt.Errorf("Error authenticating with OIDC: %w", err)
}

return setupSignerWithFulcio(s, fulcioURL, oidcIDToken)
}
}
10 changes: 10 additions & 0 deletions signature/sigstore/internal/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ type SigstoreSigner struct {
PrivateKey sigstoreSignature.Signer // May be nil during initialization
SigningKeyOrCert []byte // For possible Rekor upload; always initialized together with PrivateKey

// Fulcio results to include
FulcioGeneratedCertificate []byte // Or nil
FulcioGeneratedCertificateChain []byte // Or nil

// Rekor state
RekorUploader func(ctx context.Context, keyOrCertBytes []byte, signatureBytes []byte, payloadBytes []byte) ([]byte, error) // Or nil
}
Expand Down Expand Up @@ -74,6 +78,12 @@ func (s *SigstoreSigner) SignImageManifest(ctx context.Context, m []byte, docker
annotations := map[string]string{
signature.SigstoreSignatureAnnotationKey: base64Signature,
}
if s.FulcioGeneratedCertificate != nil {
annotations[signature.SigstoreCertificateAnnotationKey] = string(s.FulcioGeneratedCertificate)
}
if s.FulcioGeneratedCertificateChain != nil {
annotations[signature.SigstoreIntermediateCertificateChainAnnotationKey] = string(s.FulcioGeneratedCertificateChain)
}
if rekorSETBytes != nil {
annotations[signature.SigstoreSETAnnotationKey] = string(rekorSETBytes)
}
Expand Down
2 changes: 1 addition & 1 deletion signature/sigstore/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func NewSigner(opts ...Option) (*signer.Signer, error) {
}
}
if s.PrivateKey == nil {
return nil, fmt.Errorf("preparing to create a sigstore signature: nothing to sign with provided")
return nil, errors.New("no private key source provided (neither a private key nor Fulcio) when preparing to create sigstore signatures")
}

return internalSigner.NewSigner(&s), nil
Expand Down