diff --git a/README.md b/README.md index d117ac0..f533357 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,16 @@ Example: `docker pull factory.talos.dev/installer/376567988ad370138ad8b269821236 Pulls the Talos Linux `installer` image with the specified schematic and Talos Linux version. The image platform (architecture) will be determined by the architecture of the Talos Linux Linux machine. +### `GET /oci/cosign/signing-key.pub` + +Returns PEM-encoded public key used to sign the Talos Linux `installer` images. + +The key can be used to verify the installer images with `cosign`: + +```shell +cosign verify --offline --insecure-ignore-tlog --insecure-ignore-sct --key signing-key.pub factory.talos.dev/... +``` + ## Development Run integration tests in local mode, with registry mirrors: @@ -160,3 +170,20 @@ Run integration tests in local mode, with registry mirrors: ```bash make integration TEST_FLAGS="-test.image-registry=127.0.0.1:5004 -test.schematic-service-repository=127.0.0.1:5005/image-factory/schematic -test.installer-external-repository=127.0.0.1:5005/test -test.installer-internal-repository=127.0.0.1:5005/test" REGISTRY=127.0.0.1:5005 ``` + +In order to run the Image Factory, generate a ECDSA key pair: + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out cache-signing-key.key +``` + +Run the Image Factory passing the flags: + +```text +-image-registry 127.0.0.1:5004 # registry mirror for ghcr.io +-external-url https://example.com/ # external URL the Image Factory is available at +-schematic-service-repository 127.0.0.1:5005/image-factory/schematic # private registry for schematics +-installer-internal-repository 127.0.0.1:5005/siderolabs # internal registry to push installer images to +-installer-external-repository 127.0.0.1:5005/siderolabs # external registry to redirect users to pull installer +-cache-signing-key-path ./cache-signing-key.key # path to the ECDSA private key (to sign cached assets) +``` diff --git a/cmd/image-factory/cmd/options.go b/cmd/image-factory/cmd/options.go index 5842319..827fb9a 100644 --- a/cmd/image-factory/cmd/options.go +++ b/cmd/image-factory/cmd/options.go @@ -42,6 +42,11 @@ type Options struct { //nolint:govet // TalosVersionRecheckInterval is the interval for rechecking Talos versions. TalosVersionRecheckInterval time.Duration + + // CacheSigningKeyPath is the path to the signing key for the cache. + // + // Best choice is to use ECDSA key. + CacheSigningKeyPath string } // DefaultOptions are the default options. diff --git a/cmd/image-factory/cmd/service.go b/cmd/image-factory/cmd/service.go index 87c2692..9bf20bf 100644 --- a/cmd/image-factory/cmd/service.go +++ b/cmd/image-factory/cmd/service.go @@ -7,10 +7,12 @@ package cmd import ( "context" + "crypto" "errors" "fmt" "net/http" "net/url" + "os" "time" "github.com/blang/semver/v4" @@ -20,6 +22,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/sigstore/cosign/v2/cmd/cosign/cli/fulcio" "github.com/sigstore/cosign/v2/pkg/cosign" + "github.com/sigstore/sigstore/pkg/cryptoutils" "go.uber.org/zap" "golang.org/x/sync/errgroup" @@ -53,6 +56,13 @@ func RunFactory(ctx context.Context, logger *zap.Logger, opts Options) error { var frontendOptions frontendhttp.Options + cacheSigningKey, err := loadPrivateKey(opts.CacheSigningKeyPath) + if err != nil { + return fmt.Errorf("failed to load cache signing key: %w", err) + } + + frontendOptions.CacheSigningKey = cacheSigningKey + frontendOptions.ExternalURL, err = url.Parse(opts.ExternalURL) if err != nil { return fmt.Errorf("failed to parse self URL: %w", err) @@ -189,3 +199,12 @@ func remoteOptions() []remote.Option { ), } } + +func loadPrivateKey(keyPath string) (crypto.PrivateKey, error) { + fileBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, err + } + + return cryptoutils.UnmarshalPEMToPrivateKey(fileBytes, cryptoutils.SkipPassword) +} diff --git a/cmd/image-factory/flags.go b/cmd/image-factory/flags.go index 35aa2ec..b98cad0 100644 --- a/cmd/image-factory/flags.go +++ b/cmd/image-factory/flags.go @@ -40,6 +40,8 @@ func initFlags() cmd.Options { flag.DurationVar(&opts.TalosVersionRecheckInterval, "talos-versions-recheck-interval", cmd.DefaultOptions.TalosVersionRecheckInterval, "interval to recheck Talos versions") + flag.StringVar(&opts.CacheSigningKeyPath, "cache-signing-key-path", cmd.DefaultOptions.CacheSigningKeyPath, "path to the default cache signing key (PEM-encoded, ECDSA private key)") + flag.Parse() return opts diff --git a/internal/frontend/http/http.go b/internal/frontend/http/http.go index 60fcb09..f2ba943 100644 --- a/internal/frontend/http/http.go +++ b/internal/frontend/http/http.go @@ -7,6 +7,7 @@ package http import ( "context" + "crypto" "errors" "fmt" "io/fs" @@ -23,6 +24,7 @@ import ( "github.com/siderolabs/image-factory/internal/artifacts" "github.com/siderolabs/image-factory/internal/asset" + "github.com/siderolabs/image-factory/internal/image/signer" "github.com/siderolabs/image-factory/internal/profile" "github.com/siderolabs/image-factory/internal/schematic" "github.com/siderolabs/image-factory/internal/schematic/storage" @@ -38,6 +40,7 @@ type Frontend struct { logger *zap.Logger puller *remote.Puller pusher *remote.Pusher + imageSigner *signer.Signer sf singleflight.Group options Options } @@ -49,6 +52,8 @@ type Options struct { InstallerInternalRepository name.Repository InstallerExternalRepository name.Repository + CacheSigningKey crypto.PrivateKey + RemoteOptions []remote.Option } @@ -75,6 +80,11 @@ func NewFrontend(logger *zap.Logger, schematicFactory *schematic.Factory, assetB return nil, fmt.Errorf("failed to create pusher: %w", err) } + frontend.imageSigner, err = signer.NewSigner(opts.CacheSigningKey) + if err != nil { + return nil, fmt.Errorf("failed to create image signer: %w", err) + } + // images frontend.router.GET("/image/:schematic/:version/:path", frontend.wrapper(frontend.handleImage)) frontend.router.HEAD("/image/:schematic/:version/:path", frontend.wrapper(frontend.handleImage)) @@ -91,6 +101,7 @@ func NewFrontend(logger *zap.Logger, schematicFactory *schematic.Factory, assetB frontend.router.HEAD("/v2/:image/:schematic/blobs/:digest", frontend.wrapper(frontend.handleBlob)) frontend.router.GET("/v2/:image/:schematic/manifests/:tag", frontend.wrapper(frontend.handleManifest)) frontend.router.HEAD("/v2/:image/:schematic/manifests/:tag", frontend.wrapper(frontend.handleManifest)) + frontend.router.GET("/oci/cosign/signing-key.pub", frontend.wrapper(frontend.handleCosignSigningKeyPub)) // schematic frontend.router.POST("/schematics", frontend.wrapper(frontend.handleSchematicCreate)) diff --git a/internal/frontend/http/registry.go b/internal/frontend/http/registry.go index c367e15..e615c2d 100644 --- a/internal/frontend/http/registry.go +++ b/internal/frontend/http/registry.go @@ -20,6 +20,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/tarball" "github.com/google/go-containerregistry/pkg/v1/validate" "github.com/julienschmidt/httprouter" + "github.com/sigstore/cosign/v2/pkg/cosign" "go.uber.org/zap" "golang.org/x/sync/singleflight" @@ -132,29 +133,50 @@ func (f *Frontend) handleManifest(ctx context.Context, w http.ResponseWriter, _ return err } - // if the tag is the digest, we just redirect to the external registry - if strings.HasPrefix(versionTag, "sha256:") { + // if the tag is the digest, or it doesn't look like the version, we just redirect to the external registry + if strings.HasPrefix(versionTag, "sha256:") || !strings.HasPrefix(versionTag, "v") { return f.redirectToExternalRegistry(w, img.Name(), schematicID, versionTag) } - if !strings.HasPrefix(versionTag, "v") { - versionTag = "v" + versionTag - } + imageRepository := f.options.InstallerInternalRepository.Repo( + f.options.InstallerInternalRepository.RepositoryStr(), + img.Name(), + schematicID, + ) // check if the asset has already been built - f.logger.Info("heading installer image", zap.String("image", img.Name()), zap.String("schematic", schematicID), zap.String("version", versionTag)) + f.logger.Info("heading installer image", + zap.String("image", img.Name()), + zap.String("schematic", schematicID), + zap.String("version", versionTag), + zap.Stringer("ref", imageRepository.Tag(versionTag)), + ) extDesc, err := f.puller.Head( ctx, - f.options.InstallerInternalRepository.Repo( - f.options.InstallerInternalRepository.RepositoryStr(), - img.Name(), - schematicID, - ).Tag(versionTag), + imageRepository.Tag(versionTag), ) if err == nil { - // the asset has already been built, redirect to the external registry, but use the digest directly to avoid tag changes - return f.redirectToExternalRegistry(w, img.Name(), schematicID, extDesc.Digest.String()) + // the asset has already been built, so check the signature + f.logger.Info("verifying cached installer image signature", + zap.String("image", img.Name()), + zap.String("schematic", schematicID), + zap.String("version", versionTag), + zap.Stringer("ref", imageRepository.Digest(extDesc.Digest.String())), + ) + + _, _, signatureErr := cosign.VerifyImageSignatures( + ctx, + imageRepository.Digest(extDesc.Digest.String()), + f.imageSigner.GetCheckOpts(), + ) + if signatureErr == nil { + // redirect to the external registry, but use the digest directly to avoid tag changes + return f.redirectToExternalRegistry(w, img.Name(), schematicID, extDesc.Digest.String()) + } + + // log the signature verification error, but continue to build the image + f.logger.Error("error verifying cached image signature", zap.String("image", img.Name()), zap.String("schematic", schematicID), zap.String("version", versionTag), zap.Error(signatureErr)) } var transportError *transport.Error @@ -255,17 +277,44 @@ func (f *Frontend) buildInstallImage(ctx context.Context, img requestedImage, sc f.logger.Info("pushing installer image", zap.String("image", img.Name()), zap.String("schematic", schematicID), zap.String("version", versionTag)) + installerRepo := f.options.InstallerInternalRepository.Repo( + f.options.InstallerInternalRepository.RepositoryStr(), + img.Name(), + schematicID, + ) + if err := f.pusher.Push( ctx, - f.options.InstallerInternalRepository.Repo( - f.options.InstallerInternalRepository.RepositoryStr(), - img.Name(), - schematicID, - ).Tag(versionTag), + installerRepo.Tag(versionTag), imageIndex, ); err != nil { return v1.Hash{}, fmt.Errorf("error pushing index: %w", err) } - return imageIndex.Digest() + digest, err := imageIndex.Digest() + if err != nil { + return v1.Hash{}, fmt.Errorf("error getting index digest: %w", err) + } + + f.logger.Info("signing installer image", zap.String("image", img.Name()), zap.String("schematic", schematicID), zap.String("version", versionTag), zap.Stringer("digest", digest)) + + if err := f.imageSigner.SignImage( + ctx, + installerRepo.Digest(digest.String()), + f.pusher, + ); err != nil { + return v1.Hash{}, fmt.Errorf("error signing image: %w", err) + } + + return digest, nil +} + +// handleCosignSigningKeyPub returns cosign public key in PEM format. +func (f *Frontend) handleCosignSigningKeyPub(_ context.Context, w http.ResponseWriter, _ *http.Request, _ httprouter.Params) error { + w.Header().Set("Content-Type", "application/x-pem-file") + w.WriteHeader(http.StatusOK) + + _, err := w.Write(f.imageSigner.GetPublicKeyPEM()) + + return err } diff --git a/internal/image/signer/signer.go b/internal/image/signer/signer.go new file mode 100644 index 0000000..ebc67af --- /dev/null +++ b/internal/image/signer/signer.go @@ -0,0 +1,106 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package signer implements simplified cosign-compatible OCI image signer. +package signer + +import ( + "context" + "crypto" + "encoding/base64" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sigstore/cosign/v2/pkg/cosign" + "github.com/sigstore/cosign/v2/pkg/oci/empty" + "github.com/sigstore/cosign/v2/pkg/oci/mutate" + cosignremote "github.com/sigstore/cosign/v2/pkg/oci/remote" + "github.com/sigstore/cosign/v2/pkg/oci/static" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/sigstore/sigstore/pkg/signature" +) + +// Signer holds a key used to sign the images. +// +// We are not using directly 'cosign' implementation here, as it's behind +// series of internal/ packages. +type Signer struct { + sv signature.SignerVerifier + publicKeyPEM []byte +} + +// NewSigner creates a new signer. +func NewSigner(key crypto.PrivateKey) (*Signer, error) { + sv, err := signature.LoadSignerVerifier(key, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("failed to create signer: %w", err) + } + + pubKey, err := sv.PublicKey() + if err != nil { + return nil, fmt.Errorf("failed to retrieve public key: %w", err) + } + + pubKeyPEM, err := cryptoutils.MarshalPublicKeyToPEM(pubKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key to PEM: %w", err) + } + + return &Signer{ + sv: sv, + publicKeyPEM: pubKeyPEM, + }, nil +} + +// GetVerifier returns the verifier for the signature. +func (s *Signer) GetVerifier() signature.Verifier { + return s.sv +} + +// GetCheckOpts returns cosign compatible image signature verification options. +func (s *Signer) GetCheckOpts() *cosign.CheckOpts { + return &cosign.CheckOpts{ + SigVerifier: s.GetVerifier(), + IgnoreSCT: true, + IgnoreTlog: true, + Offline: true, + } +} + +// GetPublicKeyPEM returns the public key in PEM format. +func (s *Signer) GetPublicKeyPEM() []byte { + return s.publicKeyPEM +} + +// SignImage signs the image in the OCI repository. +func (s *Signer) SignImage(ctx context.Context, imageRef name.Digest, pusher *remote.Pusher) error { + payload, signature, err := signature.SignImage(s.sv, imageRef, nil) + if err != nil { + return fmt.Errorf("error generating signature: %w", err) + } + + b64Signature := base64.StdEncoding.EncodeToString(signature) + + signatureTag, err := cosignremote.SignatureTag(imageRef) + if err != nil { + return fmt.Errorf("error generating signature tag: %w", err) + } + + signatureLayer, err := static.NewSignature(payload, b64Signature) + if err != nil { + return fmt.Errorf("error generating signature layer: %w", err) + } + + signatures, err := mutate.AppendSignatures(empty.Signatures(), signatureLayer) + if err != nil { + return fmt.Errorf("error appending signatures: %w", err) + } + + if err = pusher.Push(ctx, signatureTag, signatures); err != nil { + return fmt.Errorf("error pushing signature: %w", err) + } + + return nil +} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 8064895..8b1afdd 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -8,12 +8,15 @@ package integration_test import ( "context" + "crypto/elliptic" "flag" "net" "net/http" + "os" "testing" "time" + "github.com/sigstore/sigstore/pkg/cryptoutils" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" "golang.org/x/sync/errgroup" @@ -36,6 +39,8 @@ func setupFactory(t *testing.T) (context.Context, string) { options.InstallerExternalRepository = installerExternalRepository options.InstallerInternalRepository = installerInternalRepository + setupCacheSigningKey(t, &options) + eg, ctx := errgroup.WithContext(ctx) eg.Go(func() error { @@ -61,6 +66,20 @@ func setupFactory(t *testing.T) (context.Context, string) { return ctx, options.HTTPListenAddr } +func setupCacheSigningKey(t *testing.T, options *cmd.Options) { + t.Helper() + + optionsDir := t.TempDir() + + // we use a new key each time in the tests, so cached assets will never be used, as the signature won't match + priv, _, err := cryptoutils.GeneratePEMEncodedECDSAKeyPair(elliptic.P256(), cryptoutils.SkipPassword) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(optionsDir+"/cache-signing-key.pem", priv, 0o600)) + + options.CacheSigningKeyPath = optionsDir + "/cache-signing-key.pem" +} + func findListenAddr(t *testing.T) string { t.Helper() @@ -98,7 +117,7 @@ func TestIntegration(t *testing.T) { t.Run("TestRegistryFrontend", func(t *testing.T) { t.Parallel() - testRegistryFrontend(ctx, t, listenAddr) + testRegistryFrontend(ctx, t, listenAddr, baseURL) }) t.Run("TestMetaFrontend", func(t *testing.T) { diff --git a/internal/integration/registry_test.go b/internal/integration/registry_test.go index de8a453..cb1a6db 100644 --- a/internal/integration/registry_test.go +++ b/internal/integration/registry_test.go @@ -9,18 +9,24 @@ package integration_test import ( "archive/tar" "context" + "crypto" "encoding/hex" "errors" "fmt" "io" + "net/http" "sort" "testing" + "time" "github.com/google/go-containerregistry/pkg/crane" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/siderolabs/gen/xslices" + "github.com/sigstore/cosign/v2/pkg/cosign" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/sigstore/sigstore/pkg/signature" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" @@ -28,7 +34,7 @@ import ( "github.com/siderolabs/image-factory/pkg/schematic" ) -func testInstallerImage(ctx context.Context, t *testing.T, registry name.Registry, talosVersion, schematic string, secureboot bool, platform v1.Platform) { +func testInstallerImage(ctx context.Context, t *testing.T, registry name.Registry, talosVersion, schematic string, secureboot bool, platform v1.Platform, baseURL string) { imageName := "installer" if secureboot { imageName += "-secureboot" @@ -69,6 +75,17 @@ func testInstallerImage(ctx context.Context, t *testing.T, registry name.Registr fmt.Sprintf("usr/install/%s/vmlinuz", platform.Architecture): {}, fmt.Sprintf("usr/install/%s/initramfs.xz", platform.Architecture): {}, }) + + // verify the image signature + assertImageSignature(ctx, t, ref, baseURL) + + // try to get the image once again, it should be fast now, as the image got cached & signed + start := time.Now() + + _, err = remote.Get(ref, remote.WithPlatform(platform)) + require.NoError(t, err) + + assert.Less(t, time.Since(start), 1*time.Second) } func assertImageContainsFiles(t *testing.T, img v1.Image, files map[string]struct{}) { @@ -105,7 +122,43 @@ func assertImageContainsFiles(t *testing.T, img v1.Image, files map[string]struc assert.Empty(t, files) } -func testRegistryFrontend(ctx context.Context, t *testing.T, registryAddr string) { +func assertImageSignature(ctx context.Context, t *testing.T, ref name.Reference, baseURL string) { + t.Helper() + + // download public key + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/oci/cosign/signing-key.pub", nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + t.Cleanup(func() { + resp.Body.Close() + }) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + pub, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + pubKey, err := cryptoutils.UnmarshalPEMToPublicKey(pub) + require.NoError(t, err) + + verifier, err := signature.LoadVerifier(pubKey, crypto.SHA256) + require.NoError(t, err) + + checkOpts := &cosign.CheckOpts{ + SigVerifier: verifier, + IgnoreSCT: true, + IgnoreTlog: true, + Offline: true, + } + + _, _, err = cosign.VerifyImageSignatures(ctx, ref, checkOpts) + assert.NoError(t, err) +} + +func testRegistryFrontend(ctx context.Context, t *testing.T, registryAddr string, baseURL string) { talosVersions := []string{ "v1.5.0", "v1.5.1", @@ -158,7 +211,7 @@ func testRegistryFrontend(ctx context.Context, t *testing.T, registryAddr string t.Run(platform.String(), func(t *testing.T) { t.Parallel() - testInstallerImage(ctx, t, registry, talosVersion, schematicID, secureboot, platform) + testInstallerImage(ctx, t, registry, talosVersion, schematicID, secureboot, platform, baseURL) }) } })