Add support for Fulcio and Rekor, and --sign-by-sigstore=param-file

(skopeo copy) and (skopeo sync) now support --sign-by-sigstore=param-file,
using the containers-sigstore-signing-params.yaml(5) file format.

That notably adds support for Fulcio and Rekor signing.

Signed-off-by: Miloslav Trmač <mitr@redhat.com>
This commit is contained in:
Miloslav Trmač
2023-01-11 21:42:03 +01:00
parent 03b5bdec24
commit bb1ac89327
620 changed files with 127508 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
package params
import (
"bytes"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// SigningParameterFile collects parameters used for creating sigstore signatures.
//
// To consume such a file, most callers should use c/image/pkg/cli/sigstore instead
// of dealing with this type explicitly using ParseFile.
//
// This type is exported primarily to allow creating parameter files programmatically
// (and eventually this subpackage should provide an API to convert this type into
// the appropriate file contents, so that callers dont need to do that manually).
type SigningParameterFile struct {
// Keep this in sync with docs/containers-sigstore-signing-params.yaml.5.md !
PrivateKeyFile string `yaml:"privateKeyFile,omitempty"` // If set, sign using a private key stored in this file.
PrivateKeyPassphraseFile string `yaml:"privateKeyPassphraseFile,omitempty"` // A file that contains the passprase required for PrivateKeyFile.
Fulcio *SigningParameterFileFulcio `yaml:"fulcio,omitempty"` // If set, sign using a short-lived key and a Fulcio-issued certificate.
RekorURL string `yaml:"rekorURL,omitempty"` // If set, upload the signature to the specified Rekor server, and include a log inclusion proof in the signature.
}
// SigningParameterFileFulcio is a subset of SigningParameterFile dedicated to Fulcio parameters.
type SigningParameterFileFulcio struct {
// Keep this in sync with docs/containers-sigstore-signing-params.yaml.5.md !
FulcioURL string `yaml:"fulcioURL,omitempty"` // URL of the Fulcio server. Required.
// How to obtain the OIDC ID token required by Fulcio. Required.
OIDCMode OIDCMode `yaml:"oidcMode,omitempty"`
// oidcMode = staticToken
OIDCIDToken string `yaml:"oidcIDToken,omitempty"`
// oidcMode = deviceGrant || interactive
OIDCIssuerURL string `yaml:"oidcIssuerURL,omitempty"` //
OIDCClientID string `yaml:"oidcClientID,omitempty"`
OIDCClientSecret string `yaml:"oidcClientSecret,omitempty"`
}
type OIDCMode string
const (
// OIDCModeStaticToken means the parameter file contains an user-provided OIDC ID token value.
OIDCModeStaticToken OIDCMode = "staticToken"
// OIDCModeDeviceGrant specifies the OIDC ID token should be obtained using a device authorization grant (RFC 8628).
OIDCModeDeviceGrant OIDCMode = "deviceGrant"
// OIDCModeInteractive specifies the OIDC ID token should be obtained interactively (automatically opening a browser,
// or interactively prompting the user.)
OIDCModeInteractive OIDCMode = "interactive"
)
// ParseFile parses a SigningParameterFile at the specified path.
//
// Most consumers of the parameter file should use c/image/pkg/cli/sigstore to obtain a *signer.Signer instead.
func ParseFile(path string) (*SigningParameterFile, error) {
var res SigningParameterFile
source, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %q: %w", path, err)
}
dec := yaml.NewDecoder(bytes.NewReader(source))
dec.KnownFields(true)
if err = dec.Decode(&res); err != nil {
return nil, fmt.Errorf("parsing %q: %w", path, err)
}
return &res, nil
}

View File

@@ -0,0 +1,117 @@
package sigstore
import (
"errors"
"fmt"
"io"
"net/url"
"github.com/containers/image/v5/pkg/cli"
"github.com/containers/image/v5/pkg/cli/sigstore/params"
"github.com/containers/image/v5/signature/signer"
"github.com/containers/image/v5/signature/sigstore"
"github.com/containers/image/v5/signature/sigstore/fulcio"
"github.com/containers/image/v5/signature/sigstore/rekor"
)
// Options collects data that the caller should provide to NewSignerFromParameterFile.
// The caller should set all fields unless documented otherwise.
type Options struct {
PrivateKeyPassphrasePrompt func(keyFile string) (string, error) // A function to call to interactively prompt for a passphrase
Stdin io.Reader
Stdout io.Writer
}
// NewSignerFromParameterFile returns a signature.Signer which creates sigstore signatures based a parameter file at the specified path.
//
// The caller must call Close() on the returned Signer.
func NewSignerFromParameterFile(path string, options *Options) (*signer.Signer, error) {
params, err := params.ParseFile(path)
if err != nil {
return nil, fmt.Errorf("setting up signing using parameter file %q: %w", path, err)
}
return newSignerFromParameterData(params, options)
}
// newSignerFromParameterData returns a signature.Signer which creates sigstore signatures based on parameter file contents.
//
// The caller must call Close() on the returned Signer.
func newSignerFromParameterData(params *params.SigningParameterFile, options *Options) (*signer.Signer, error) {
opts := []sigstore.Option{}
if params.PrivateKeyFile != "" {
var getPassphrase func(keyFile string) (string, error)
switch {
case params.PrivateKeyPassphraseFile != "":
getPassphrase = func(_ string) (string, error) {
return cli.ReadPassphraseFile(params.PrivateKeyPassphraseFile)
}
case options.PrivateKeyPassphrasePrompt != nil:
getPassphrase = options.PrivateKeyPassphrasePrompt
default: // This shouldnt happen, the caller is expected to set options.PrivateKeyPassphrasePrompt
return nil, fmt.Errorf("private key %s specified, but no way to get a passphrase", params.PrivateKeyFile)
}
passphrase, err := getPassphrase(params.PrivateKeyFile)
if err != nil {
return nil, err
}
opts = append(opts, sigstore.WithPrivateKeyFile(params.PrivateKeyFile, []byte(passphrase)))
}
if params.Fulcio != nil {
fulcioOpt, err := fulcioOption(params.Fulcio, options)
if err != nil {
return nil, err
}
opts = append(opts, fulcioOpt)
}
if params.RekorURL != "" {
rekorURL, err := url.Parse(params.RekorURL)
if err != nil {
return nil, fmt.Errorf("parsing rekorURL %q: %w", params.RekorURL, err)
}
opts = append(opts, rekor.WithRekor(rekorURL))
}
return sigstore.NewSigner(opts...)
}
// fulcioOption returns a sigstore.Option for Fulcio use based on f.
func fulcioOption(f *params.SigningParameterFileFulcio, options *Options) (sigstore.Option, error) {
if f.FulcioURL == "" {
return nil, errors.New("missing fulcioURL")
}
fulcioURL, err := url.Parse(f.FulcioURL)
if err != nil {
return nil, fmt.Errorf("parsing fulcioURL %q: %w", f.FulcioURL, err)
}
if f.OIDCMode == params.OIDCModeStaticToken {
if f.OIDCIDToken == "" {
return nil, errors.New("missing oidcToken")
}
return fulcio.WithFulcioAndPreexistingOIDCIDToken(fulcioURL, f.OIDCIDToken), nil
}
if f.OIDCIssuerURL == "" {
return nil, errors.New("missing oidcIssuerURL")
}
oidcIssuerURL, err := url.Parse(f.OIDCIssuerURL)
if err != nil {
return nil, fmt.Errorf("parsing oidcIssuerURL %q: %w", f.OIDCIssuerURL, err)
}
switch f.OIDCMode {
case params.OIDCModeDeviceGrant:
return fulcio.WithFulcioAndDeviceAuthorizationGrantOIDC(fulcioURL, oidcIssuerURL, f.OIDCClientID, f.OIDCClientSecret,
options.Stdout), nil
case params.OIDCModeInteractive:
return fulcio.WithFulcioAndInteractiveOIDC(fulcioURL, oidcIssuerURL, f.OIDCClientID, f.OIDCClientSecret,
options.Stdin, options.Stdout), nil
case "":
return nil, errors.New("missing oidcMode")
case params.OIDCModeStaticToken:
return nil, errors.New("internal inconsistency: SigningParameterFileOIDCModeStaticToken was supposed to already be handled")
default:
return nil, fmt.Errorf("unknown oidcMode value %q", f.OIDCMode)
}
}

View File

@@ -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 tokens "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, lets 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)
}
}

View File

@@ -0,0 +1,52 @@
package rekor
import (
"fmt"
"github.com/hashicorp/go-retryablehttp"
"github.com/sirupsen/logrus"
)
// leveledLogger adapts our use of logrus to the expected go-retryablehttp.LeveledLogger interface.
type leveledLogger struct {
logger *logrus.Logger
}
func leveledLoggerForLogrus(logger *logrus.Logger) retryablehttp.LeveledLogger {
return &leveledLogger{logger: logger}
}
// log is the actual conversion implementation
func (l *leveledLogger) log(level logrus.Level, msg string, keysAndValues []interface{}) {
fields := logrus.Fields{}
for i := 0; i < len(keysAndValues)-1; i += 2 {
key := keysAndValues[i]
keyString, isString := key.(string)
if !isString {
// It seems attractive to panic() here, but we might already be in a failure state, so lets not make it worse
keyString = fmt.Sprintf("[Invalid LeveledLogger key %#v]", key)
}
fields[keyString] = keysAndValues[i+1]
}
l.logger.WithFields(fields).Log(level, msg)
}
// Debug implements retryablehttp.LeveledLogger
func (l *leveledLogger) Debug(msg string, keysAndValues ...interface{}) {
l.log(logrus.DebugLevel, msg, keysAndValues)
}
// Error implements retryablehttp.LeveledLogger
func (l *leveledLogger) Error(msg string, keysAndValues ...interface{}) {
l.log(logrus.ErrorLevel, msg, keysAndValues)
}
// Info implements retryablehttp.LeveledLogger
func (l *leveledLogger) Info(msg string, keysAndValues ...interface{}) {
l.log(logrus.InfoLevel, msg, keysAndValues)
}
// Warn implements retryablehttp.LeveledLogger
func (l *leveledLogger) Warn(msg string, keysAndValues ...interface{}) {
l.log(logrus.WarnLevel, msg, keysAndValues)
}

View File

@@ -0,0 +1,160 @@
package rekor
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"github.com/containers/image/v5/signature/internal"
signerInternal "github.com/containers/image/v5/signature/sigstore/internal"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/swag"
rekor "github.com/sigstore/rekor/pkg/client"
"github.com/sigstore/rekor/pkg/generated/client"
"github.com/sigstore/rekor/pkg/generated/client/entries"
"github.com/sigstore/rekor/pkg/generated/models"
"github.com/sirupsen/logrus"
)
// WithRekor asks the generated signature to be uploaded to the specified Rekor server,
// and to include a log inclusion proof in the signature.
func WithRekor(rekorURL *url.URL) signerInternal.Option {
return func(s *signerInternal.SigstoreSigner) error {
logrus.Debugf("Using Rekor server at %s", rekorURL.Redacted())
client, err := rekor.GetRekorClient(rekorURL.String(),
rekor.WithLogger(leveledLoggerForLogrus(logrus.StandardLogger())))
if err != nil {
return fmt.Errorf("creating Rekor client: %w", err)
}
u := uploader{
client: client,
}
s.RekorUploader = u.uploadKeyOrCert
return nil
}
}
// uploader wraps a Rekor client, basically so that we can set RekorUploader to a method instead of an one-off closure.
type uploader struct {
client *client.Rekor
}
// rekorEntryToSET converts a Rekor log entry into a sigstore “signed entry timestamp”.
func rekorEntryToSET(entry *models.LogEntryAnon) (internal.UntrustedRekorSET, error) {
// We could plausibly call entry.Validate() here; that mostly just uses unnecessary reflection instead of direct == nil checks.
// Right now the only extra validation .Validate() does is *entry.LogIndex >= 0 and a regex check on *entry.LogID;
// we dont particularly care about either of these (notably signature verification only uses the Body value).
if entry.Verification == nil || entry.IntegratedTime == nil || entry.LogIndex == nil || entry.LogID == nil {
return internal.UntrustedRekorSET{}, fmt.Errorf("invalid Rekor entry (missing data): %#v", *entry)
}
bodyBase64, ok := entry.Body.(string)
if !ok {
return internal.UntrustedRekorSET{}, fmt.Errorf("unexpected Rekor entry body type: %#v", entry.Body)
}
body, err := base64.StdEncoding.DecodeString(bodyBase64)
if err != nil {
return internal.UntrustedRekorSET{}, fmt.Errorf("error parsing Rekor entry body: %w", err)
}
payloadJSON, err := internal.UntrustedRekorPayload{
Body: body,
IntegratedTime: *entry.IntegratedTime,
LogIndex: *entry.LogIndex,
LogID: *entry.LogID,
}.MarshalJSON()
if err != nil {
return internal.UntrustedRekorSET{}, err
}
return internal.UntrustedRekorSET{
UntrustedSignedEntryTimestamp: entry.Verification.SignedEntryTimestamp,
UntrustedPayload: payloadJSON,
}, nil
}
// uploadEntry ensures proposedEntry exists in Rekor (usually uploading it), and returns the resulting log entry.
func (u *uploader) uploadEntry(ctx context.Context, proposedEntry models.ProposedEntry) (models.LogEntry, error) {
params := entries.NewCreateLogEntryParamsWithContext(ctx)
params.SetProposedEntry(proposedEntry)
logrus.Debugf("Calling Rekor's CreateLogEntry")
resp, err := u.client.Entries.CreateLogEntry(params)
if err != nil {
// In ordinary operation, we should not get duplicate entries, because our payload contains a timestamp,
// so it is supposed to be unique; and the default key format, ECDSA p256, also contains a nonce.
// But conflicts can fairly easily happen during debugging and experimentation, so it pays to handle this.
var conflictErr *entries.CreateLogEntryConflict
if errors.As(err, &conflictErr) && conflictErr.Location != "" {
location := conflictErr.Location.String()
logrus.Debugf("CreateLogEntry reported a conflict, location = %s", location)
// We might be able to just GET the returned Location, but lets use the generated API client.
// OTOH that requires us to hard-code the URI structure…
uuidDelimiter := strings.LastIndexByte(location, '/')
if uuidDelimiter != -1 { // Otherwise the URI is unexpected, and fall through to the bottom
uuid := location[uuidDelimiter+1:]
logrus.Debugf("Calling Rekor's NewGetLogEntryByUUIDParamsWithContext")
params2 := entries.NewGetLogEntryByUUIDParamsWithContext(ctx)
params2.SetEntryUUID(uuid)
resp2, err := u.client.Entries.GetLogEntryByUUID(params2)
if err != nil {
return nil, fmt.Errorf("Error re-loading previously-created log entry with UUID %s: %w", uuid, err)
}
return resp2.GetPayload(), nil
}
}
return nil, fmt.Errorf("Error uploading a log entry: %w", err)
}
return resp.GetPayload(), nil
}
// uploadKeyOrCert integrates this code into sigstore/internal.Signer.
// Given components of the created signature, it returns a SET that should be added to the signature.
func (u *uploader) uploadKeyOrCert(ctx context.Context, keyOrCertBytes []byte, signatureBytes []byte, payloadBytes []byte) ([]byte, error) {
payloadHash := sha256.Sum256(payloadBytes) // HashedRecord only accepts SHA-256
proposedEntry := models.Hashedrekord{
APIVersion: swag.String(internal.HashedRekordV001APIVersion),
Spec: models.HashedrekordV001Schema{
Data: &models.HashedrekordV001SchemaData{
Hash: &models.HashedrekordV001SchemaDataHash{
Algorithm: swag.String(models.HashedrekordV001SchemaDataHashAlgorithmSha256),
Value: swag.String(hex.EncodeToString(payloadHash[:])),
},
},
Signature: &models.HashedrekordV001SchemaSignature{
Content: strfmt.Base64(signatureBytes),
PublicKey: &models.HashedrekordV001SchemaSignaturePublicKey{
Content: strfmt.Base64(keyOrCertBytes),
},
},
},
}
uploadedPayload, err := u.uploadEntry(ctx, &proposedEntry)
if err != nil {
return nil, err
}
if len(uploadedPayload) != 1 {
return nil, fmt.Errorf("expected 1 Rekor entry, got %d", len(uploadedPayload))
}
var storedEntry *models.LogEntryAnon
// This “loop” extracts the single value from the uploadedPayload map.
for _, p := range uploadedPayload {
storedEntry = &p
break
}
rekorBundle, err := rekorEntryToSET(storedEntry)
if err != nil {
return nil, err
}
rekorSETBytes, err := json.Marshal(rekorBundle)
if err != nil {
return nil, err
}
return rekorSETBytes, nil
}