Skip to content

Commit

Permalink
feat: sign generated installer image
Browse files Browse the repository at this point in the history
Image Factory now signs the generated asset using cosign flow with a
fixed key. Image Factory also verifies the signature before redirecting
to the image. This way we ensure the consistency of the cache.

The signing ECDSA private key (PEM-encoded) should be supplied as
`--signing-key-path` flag.

Fixes #29

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
  • Loading branch information
smira committed Oct 24, 2023
1 parent c43564f commit 3dcb29d
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 23 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,37 @@ 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:

```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)
```
5 changes: 5 additions & 0 deletions cmd/image-factory/cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions cmd/image-factory/cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ package cmd

import (
"context"
"crypto"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"time"

"github.com/blang/semver/v4"
Expand All @@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
2 changes: 2 additions & 0 deletions cmd/image-factory/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/frontend/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package http

import (
"context"
"crypto"
"errors"
"fmt"
"io/fs"
Expand All @@ -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"
Expand All @@ -38,6 +40,7 @@ type Frontend struct {
logger *zap.Logger
puller *remote.Puller
pusher *remote.Pusher
imageSigner *signer.Signer
sf singleflight.Group
options Options
}
Expand All @@ -49,6 +52,8 @@ type Options struct {
InstallerInternalRepository name.Repository
InstallerExternalRepository name.Repository

CacheSigningKey crypto.PrivateKey

RemoteOptions []remote.Option
}

Expand All @@ -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))
Expand All @@ -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))
Expand Down
87 changes: 68 additions & 19 deletions internal/frontend/http/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
106 changes: 106 additions & 0 deletions internal/image/signer/signer.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 3dcb29d

Please sign in to comment.