From 5e8f8693976fa01dd4c280f5571c3af36a37bd62 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Tue, 20 Oct 2026 01:24:51 +0200 Subject: [PATCH 01/13] docs: regenerate the configuration reference The overrideSourceImageRegistry and skipVersionfilter options were added without rerunning the docs generator. Bring docs/configuration.md back in sync with options.go before adding new options on top. --- docs/configuration.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 8a56d2b4..65ff0807 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -605,6 +605,15 @@ Artifacts defines names and references for various images used by the factory. --- +### `artifacts.overrideSourceImageRegistry` + +- **Type:** `string` +- **Env:** `ARTIFACTS_OVERRIDESOURCEIMAGEREGISTRY` + +Replace the image registry defined in the extension/overlay image with the one defined here + +--- + ### `artifacts.core` Core contains configuration for core images used by the image factory. @@ -878,6 +887,15 @@ RefreshInterval specifies how often the image factory should refresh its connect --- +### `artifacts.skipVersionfilter` + +- **Type:** `bool` +- **Env:** `ARTIFACTS_SKIPVERSIONFILTER` + +Whether to skip beta/alpha versions and such when obtaining valid Talos versions + +--- + ### `authentication` Authentication settings. @@ -1532,12 +1550,14 @@ artifacts: namespace: siderolabs registry: ghcr.io repository: "" + overrideSourceImageRegistry: "" refreshInterval: 5m0s schematic: insecure: false namespace: siderolabs/image-factory registry: ghcr.io repository: schematics + skipVersionfilter: false talosVersionRecheckInterval: 15m0s audit: file: @@ -1699,11 +1719,13 @@ IF_ARTIFACTS_INSTALLER_INTERNAL_INSECURE=false IF_ARTIFACTS_INSTALLER_INTERNAL_NAMESPACE=siderolabs IF_ARTIFACTS_INSTALLER_INTERNAL_REGISTRY=ghcr.io IF_ARTIFACTS_INSTALLER_INTERNAL_REPOSITORY= +IF_ARTIFACTS_OVERRIDESOURCEIMAGEREGISTRY= IF_ARTIFACTS_REFRESHINTERVAL=5m0s IF_ARTIFACTS_SCHEMATIC_INSECURE=false IF_ARTIFACTS_SCHEMATIC_NAMESPACE=siderolabs/image-factory IF_ARTIFACTS_SCHEMATIC_REGISTRY=ghcr.io IF_ARTIFACTS_SCHEMATIC_REPOSITORY=schematics +IF_ARTIFACTS_SKIPVERSIONFILTER=false IF_ARTIFACTS_TALOSVERSIONRECHECKINTERVAL=15m0s IF_AUDIT_FILE_MAXAGE=30 IF_AUDIT_FILE_MAXBACKUPS=16 From e5b9d9a6f3c3d39e4c745b87c8c4e69cc9fc1307 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Tue, 20 Oct 2026 01:25:03 +0200 Subject: [PATCH 02/13] feat(secureboot): decrypt the file-based SecureBoot keys with STACKIT KMS STACKIT KMS offers no RSA signing, so it cannot act as the signer the way Azure Key Vault and AWS KMS do. Instead the file-based private keys are stored as ciphertext of a symmetric KMS key. At startup they are decrypted and served to the imager over its signer sockets, so no plaintext key ever touches the filesystem. In memory the keys live in memguard enclaves: encrypted, locked against swapping and excluded from core dumps. They are opened for the duration of a single signing operation only. Importing memguard disables core dumps for the whole process. The new secureBoot.stackitKMS options select the KMS key. They are wired in through a small hook in buildSecureBootService; everything else lives in new files so the upstream code stays easy to rebase. --- cmd/image-factory/cmd/options.go | 31 ++++ cmd/image-factory/cmd/secureboot_stackit.go | 34 ++++ cmd/image-factory/cmd/service.go | 2 +- deploy/helm/image-factory/README.md | 22 +++ deploy/helm/image-factory/README.md.gotmpl | 22 +++ deploy/helm/image-factory/values.yaml | 7 + docs/configuration.md | 62 +++++++ go.mod | 6 +- go.sum | 8 + internal/secureboot/signerd.go | 171 ++++++++++++++++++++ internal/secureboot/stackit.go | 144 +++++++++++++++++ internal/secureboot/stackit_test.go | 156 ++++++++++++++++++ 12 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 cmd/image-factory/cmd/secureboot_stackit.go create mode 100644 internal/secureboot/signerd.go create mode 100644 internal/secureboot/stackit.go create mode 100644 internal/secureboot/stackit_test.go diff --git a/cmd/image-factory/cmd/options.go b/cmd/image-factory/cmd/options.go index 85fcf4cc..c849b530 100644 --- a/cmd/image-factory/cmd/options.go +++ b/cmd/image-factory/cmd/options.go @@ -515,6 +515,9 @@ type SecureBootOptions struct { // AWSKMS configures SecureBoot using AWS KMS. AWSKMS AWSKMSProviderOptions `koanf:"awsKMS"` + // STACKITKMS decrypts the file-based SecureBoot keys with STACKIT KMS. + STACKITKMS STACKITKMSProviderOptions `koanf:"stackitKMS"` + // Enabled enables SecureBoot asset generation. Enabled bool `koanf:"enabled"` } @@ -561,6 +564,28 @@ type AWSKMSProviderOptions struct { Region string `koanf:"region"` } +// STACKITKMSProviderOptions names the STACKIT KMS symmetric key the file-based SecureBoot keys are encrypted with. +// +// When set, the private keys in secureBoot.file contain the base64 ciphertext returned by the KMS +// encrypt operation. They are decrypted at startup and kept in memory. Credentials are read from the +// STACKIT SDK environment, for example STACKIT_SERVICE_ACCOUNT_KEY_PATH. +type STACKITKMSProviderOptions struct { + // ProjectID is the STACKIT project UUID the key ring belongs to. + ProjectID string `koanf:"projectID"` + + // Region is the STACKIT region of the key ring, for example eu01. + Region string `koanf:"region"` + + // KeyRingID is the UUID of the key ring. + KeyRingID string `koanf:"keyRingID"` + + // KeyID is the UUID of the aes_256_gcm key the key files are encrypted with. + KeyID string `koanf:"keyID"` + + // KeyVersion is the version of the KMS key, defaults to 1. + KeyVersion int64 `koanf:"keyVersion"` +} + // ArtifactsOptions defines the names and references of images used by the image factory. type ArtifactsOptions struct { // Replace the image registry defined in the extension/overlay image with the one defined here @@ -1035,6 +1060,12 @@ var DefaultOptions = Options{ }, }, + SecureBoot: SecureBootOptions{ + STACKITKMS: STACKITKMSProviderOptions{ + KeyVersion: 1, + }, + }, + Enterprise: EnterpriseOptions{ VEX: VEXOptions{ Data: OCIRepositoryOptions{ diff --git a/cmd/image-factory/cmd/secureboot_stackit.go b/cmd/image-factory/cmd/secureboot_stackit.go new file mode 100644 index 00000000..c2269621 --- /dev/null +++ b/cmd/image-factory/cmd/secureboot_stackit.go @@ -0,0 +1,34 @@ +// 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 cmd + +import ( + "fmt" + + "go.uber.org/zap" + + "github.com/siderolabs/image-factory/internal/secureboot" +) + +// buildSecureBootServiceWithStackit prefers the STACKIT KMS backed SecureBoot service when it is +// configured and falls back to the upstream backends otherwise. +func buildSecureBootServiceWithStackit(logger *zap.Logger, opts Options) (*secureboot.Service, error) { + if !opts.SecureBoot.Enabled || opts.SecureBoot.STACKITKMS.KeyID == "" { + return buildSecureBootService(opts) + } + + svc, err := secureboot.NewStackitKMSService( + logger, + secureboot.StackitKMSOptions(opts.SecureBoot.STACKITKMS), + opts.SecureBoot.File.SigningKeyPath, + opts.SecureBoot.File.SigningCertPath, + opts.SecureBoot.File.PCRKeyPath, + ) + if err != nil { + return nil, fmt.Errorf("failed to initialize SecureBoot service: %w", err) + } + + return svc, nil +} diff --git a/cmd/image-factory/cmd/service.go b/cmd/image-factory/cmd/service.go index a830c307..8fcea699 100644 --- a/cmd/image-factory/cmd/service.go +++ b/cmd/image-factory/cmd/service.go @@ -107,7 +107,7 @@ func RunFactory(ctx context.Context, logger *zap.Logger, opts Options) error { return err } - secureBootService, err := buildSecureBootService(opts) + secureBootService, err := buildSecureBootServiceWithStackit(logger, opts) if err != nil { return err } diff --git a/deploy/helm/image-factory/README.md b/deploy/helm/image-factory/README.md index e4e3f2cd..2e3d48ec 100644 --- a/deploy/helm/image-factory/README.md +++ b/deploy/helm/image-factory/README.md @@ -254,6 +254,28 @@ config: certificateName: signing-cert ``` +### Using STACKIT KMS + +The local files approach with the private keys encrypted by a STACKIT KMS +`aes_256_gcm` key. The key files contain the base64 ciphertext returned by the +KMS encrypt operation and are decrypted at startup. Credentials come from the +STACKIT SDK environment, for example `STACKIT_SERVICE_ACCOUNT_KEY_PATH`. + +```yaml +config: + secureBoot: + enabled: true + file: + signingCertPath: /path/to/cert.pem + signingKeyPath: /path/to/key.pem.enc + pcrKeyPath: /path/to/pcr-key.pem.enc + stackitKMS: + projectID: 00000000-0000-0000-0000-000000000000 + region: eu01 + keyRingID: 00000000-0000-0000-0000-000000000000 + keyID: 00000000-0000-0000-0000-000000000000 +``` + ## Monitoring ### Prometheus ServiceMonitor diff --git a/deploy/helm/image-factory/README.md.gotmpl b/deploy/helm/image-factory/README.md.gotmpl index c204194a..fc973a9e 100644 --- a/deploy/helm/image-factory/README.md.gotmpl +++ b/deploy/helm/image-factory/README.md.gotmpl @@ -250,6 +250,28 @@ config: certificateName: signing-cert ``` +### Using STACKIT KMS + +The local files approach with the private keys encrypted by a STACKIT KMS +`aes_256_gcm` key. The key files contain the base64 ciphertext returned by the +KMS encrypt operation and are decrypted at startup. Credentials come from the +STACKIT SDK environment, for example `STACKIT_SERVICE_ACCOUNT_KEY_PATH`. + +```yaml +config: + secureBoot: + enabled: true + file: + signingCertPath: /path/to/cert.pem + signingKeyPath: /path/to/key.pem.enc + pcrKeyPath: /path/to/pcr-key.pem.enc + stackitKMS: + projectID: 00000000-0000-0000-0000-000000000000 + region: eu01 + keyRingID: 00000000-0000-0000-0000-000000000000 + keyID: 00000000-0000-0000-0000-000000000000 +``` + ## Monitoring ### Prometheus ServiceMonitor diff --git a/deploy/helm/image-factory/values.yaml b/deploy/helm/image-factory/values.yaml index 02efdc72..31ce41ee 100644 --- a/deploy/helm/image-factory/values.yaml +++ b/deploy/helm/image-factory/values.yaml @@ -192,6 +192,13 @@ config: # certificateName: "" # keyName: "" # url: "" + ## STACKIT KMS key the file-based private keys are encrypted with (decrypted at startup) + # stackitKMS: + # projectID: "" + # region: "" + # keyRingID: "" + # keyID: "" + # keyVersion: 1 ## Enable SecureBoot asset generation # enabled: false ## Local file-based keys for signing diff --git a/docs/configuration.md b/docs/configuration.md index 65ff0807..c581d390 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -590,6 +590,57 @@ Region is the AWS region containing the KMS keys. --- +### `secureBoot.stackitKMS` + +STACKITKMS decrypts the file-based SecureBoot keys with STACKIT KMS. + +--- + +### `secureBoot.stackitKMS.projectID` + +- **Type:** `string` +- **Env:** `SECUREBOOT_STACKITKMS_PROJECTID` + +ProjectID is the STACKIT project UUID the key ring belongs to. + +--- + +### `secureBoot.stackitKMS.region` + +- **Type:** `string` +- **Env:** `SECUREBOOT_STACKITKMS_REGION` + +Region is the STACKIT region of the key ring, for example eu01. + +--- + +### `secureBoot.stackitKMS.keyRingID` + +- **Type:** `string` +- **Env:** `SECUREBOOT_STACKITKMS_KEYRINGID` + +KeyRingID is the UUID of the key ring. + +--- + +### `secureBoot.stackitKMS.keyID` + +- **Type:** `string` +- **Env:** `SECUREBOOT_STACKITKMS_KEYID` + +KeyID is the UUID of the aes_256_gcm key the key files are encrypted with. + +--- + +### `secureBoot.stackitKMS.keyVersion` + +- **Type:** `int64` +- **Env:** `SECUREBOOT_STACKITKMS_KEYVERSION` + +KeyVersion is the version of the KMS key, defaults to 1. + +--- + ### `secureBoot.enabled` - **Type:** `bool` @@ -1696,6 +1747,12 @@ secureBoot: pcrKeyPath: "" signingCertPath: "" signingKeyPath: "" + stackitKMS: + keyID: "" + keyRingID: "" + keyVersion: 1 + projectID: "" + region: "" ``` ### Environment Variables @@ -1828,4 +1885,9 @@ IF_SECUREBOOT_ENABLED=false IF_SECUREBOOT_FILE_PCRKEYPATH= IF_SECUREBOOT_FILE_SIGNINGCERTPATH= IF_SECUREBOOT_FILE_SIGNINGKEYPATH= +IF_SECUREBOOT_STACKITKMS_KEYID= +IF_SECUREBOOT_STACKITKMS_KEYRINGID= +IF_SECUREBOOT_STACKITKMS_KEYVERSION=1 +IF_SECUREBOOT_STACKITKMS_PROJECTID= +IF_SECUREBOOT_STACKITKMS_REGION= ``` diff --git a/go.mod b/go.mod index c7da1457..f9f4962b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/anchore/go-logger v0.2.0 github.com/anchore/grype v0.120.1 github.com/anchore/syft v1.54.1 + github.com/awnumar/memguard v0.23.0 github.com/blang/semver/v4 v4.0.0 github.com/coreos/go-oidc/v3 v3.21.0 github.com/felixge/httpsnoop v1.1.0 @@ -54,6 +55,8 @@ require ( github.com/slok/go-http-metrics v0.13.0 github.com/spdx/tools-golang v0.6.0-rc4 github.com/spf13/pflag v1.0.10 + github.com/stackitcloud/stackit-sdk-go/core v0.27.0 + github.com/stackitcloud/stackit-sdk-go/services/kms v1.13.1 github.com/stretchr/testify v1.12.1 github.com/u-root/u-root v0.16.0 github.com/ulikunitz/xz v0.5.17 @@ -66,6 +69,7 @@ require ( golang.org/x/sys v0.49.0 golang.org/x/text v0.43.0 golang.org/x/time v0.16.0 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) @@ -131,6 +135,7 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/awnumar/memcall v0.4.0 // indirect github.com/aws/aws-sdk-go-v2 v1.44.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.40 // indirect @@ -491,7 +496,6 @@ require ( google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/grpc v1.83.2 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.3 // indirect diff --git a/go.sum b/go.sum index aa83b0fe..914e010f 100644 --- a/go.sum +++ b/go.sum @@ -224,6 +224,10 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= +github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= +github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= +github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M= github.com/aws/aws-sdk-go-v2 v1.44.0 h1:4IbaHhtzy+4h37z4JQyO9a2QsiCml3CNYHtq5hIHigo= github.com/aws/aws-sdk-go-v2 v1.44.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= @@ -1359,6 +1363,10 @@ github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcG github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= +github.com/stackitcloud/stackit-sdk-go/core v0.27.0 h1:7lc6qStcFDFf8zHP4ORPa9joybw+Sa2LtB6BVjKQ+ek= +github.com/stackitcloud/stackit-sdk-go/core v0.27.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.13.1 h1:iEPerniBpji6dGiB31p1WAUxgBf9VBIL9gKocfbBqW8= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.13.1/go.mod h1:aG3xzmeVCsxnLwb+TA4U5i79a4sPiDicWoWG7lyg4Zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/internal/secureboot/signerd.go b/internal/secureboot/signerd.go new file mode 100644 index 00000000..f1811597 --- /dev/null +++ b/internal/secureboot/signerd.go @@ -0,0 +1,171 @@ +// 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 secureboot + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "io/fs" + "net" + "os" + "path/filepath" + + "github.com/awnumar/memguard" + "github.com/siderolabs/talos/pkg/machinery/api/signer" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +// sealedKey keeps the PKCS#1 encoding of an RSA private key in a memguard enclave and +// decrypts it per signing operation. The parsed key is still an ordinary heap allocation. +type sealedKey struct { + enclave *memguard.Enclave + public *rsa.PublicKey +} + +// sealKey seals der and wipes it. +func sealKey(der []byte) (*sealedKey, error) { + key, err := x509.ParsePKCS1PrivateKey(der) + if err != nil { + memguard.WipeBytes(der) + + return nil, err + } + + return &sealedKey{ + enclave: memguard.NewEnclave(der), + public: &key.PublicKey, + }, nil +} + +// open decrypts the key; the caller destroys the returned buffer when done. +func (k *sealedKey) open() (*rsa.PrivateKey, *memguard.LockedBuffer, error) { + buf, err := k.enclave.Open() + if err != nil { + return nil, nil, err + } + + key, err := x509.ParsePKCS1PrivateKey(buf.Bytes()) + if err != nil { + buf.Destroy() + + return nil, nil, err + } + + return key, buf, nil +} + +// signerServer serves a sealed RSA key over the Talos SignerService API. +type signerServer struct { + signer.UnimplementedSignerServiceServer + + logger *zap.Logger + key *sealedKey + cert *x509.Certificate +} + +func (s *signerServer) GetCertificate(context.Context, *emptypb.Empty) (*signer.CertificateResponse, error) { + if s.cert == nil { + return nil, status.Error(codes.NotFound, "signer has no certificate") + } + + return &signer.CertificateResponse{ + CertPem: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.cert.Raw}), + }, nil +} + +func (s *signerServer) GetPublicKey(context.Context, *emptypb.Empty) (*signer.PublicKeyResponse, error) { + der, err := x509.MarshalPKIXPublicKey(s.key.public) + if err != nil { + return nil, status.Errorf(codes.Internal, "marshal public key: %v", err) + } + + return &signer.PublicKeyResponse{ + PubKeyPem: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}), + }, nil +} + +func (s *signerServer) Sign(_ context.Context, req *signer.SignRequest) (*signer.SignResponse, error) { + var hash crypto.Hash + + switch req.GetHash() { + case signer.Hash_HASH_SHA256: + hash = crypto.SHA256 + case signer.Hash_HASH_SHA384: + hash = crypto.SHA384 + case signer.Hash_HASH_SHA512: + hash = crypto.SHA512 + case signer.Hash_HASH_UNSPECIFIED: + fallthrough + default: + return nil, status.Errorf(codes.InvalidArgument, "unsupported hash %s", req.GetHash()) + } + + key, buf, err := s.key.open() + if err != nil { + s.logger.Warn("opening the sealed key failed", zap.Error(err)) + + return nil, status.Errorf(codes.Internal, "open key: %v", err) + } + + defer buf.Destroy() + + var signature []byte + + switch req.GetScheme() { + case signer.Scheme_SCHEME_RSA_PKCS1V15: + signature, err = rsa.SignPKCS1v15(rand.Reader, key, hash, req.GetDigest()) + case signer.Scheme_SCHEME_RSA_PSS: + signature, err = rsa.SignPSS(rand.Reader, key, hash, req.GetDigest(), &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) + case signer.Scheme_SCHEME_UNSPECIFIED: + fallthrough + default: + return nil, status.Errorf(codes.InvalidArgument, "unsupported scheme %s", req.GetScheme()) + } + + if err != nil { + s.logger.Warn("signing failed", zap.String("hash", req.GetHash().String()), zap.String("scheme", req.GetScheme().String()), zap.Error(err)) + + return nil, status.Errorf(codes.Internal, "sign: %v", err) + } + + return &signer.SignResponse{Signature: signature}, nil +} + +// serveSigner serves srv for the lifetime of the process on a unix socket +// below the temp dir and returns the address for the imager profile. +// +// The path is fixed so that it does not change the profile hash between restarts. +func serveSigner(name string, srv *signerServer) (string, error) { + path := filepath.Join(os.TempDir(), "image-factory-signer", name+".sock") + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", err + } + + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return "", err + } + + lis, err := net.Listen("unix", path) + if err != nil { + return "", err + } + + server := grpc.NewServer() + signer.RegisterSignerServiceServer(server, srv) + + go server.Serve(lis) //nolint:errcheck + + return "unix://" + path, nil +} diff --git a/internal/secureboot/stackit.go b/internal/secureboot/stackit.go new file mode 100644 index 00000000..3dd266ee --- /dev/null +++ b/internal/secureboot/stackit.go @@ -0,0 +1,144 @@ +// 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 secureboot + +import ( + "bytes" + "context" + "crypto/rsa" + "encoding/base64" + "encoding/pem" + "fmt" + "os" + "time" + + "github.com/awnumar/memguard" + sideroX509 "github.com/siderolabs/crypto/x509" + "github.com/siderolabs/talos/pkg/imager/profile" + "github.com/stackitcloud/stackit-sdk-go/core/config" + kms "github.com/stackitcloud/stackit-sdk-go/services/kms/v1api" + "go.uber.org/zap" +) + +// StackitKMSOptions names the STACKIT KMS symmetric key the SecureBoot key files are encrypted with. +// +// The key files contain the base64 ciphertext returned by the KMS encrypt operation. +// Credentials are taken from the STACKIT SDK environment. +type StackitKMSOptions struct { + ProjectID string + Region string + KeyRingID string + KeyID string + KeyVersion int64 +} + +// NewStackitKMSService is the file-based approach with the private keys encrypted by STACKIT KMS: +// the keys are decrypted at startup, sealed in memory and served to the imager over its signer sockets. +func NewStackitKMSService( + logger *zap.Logger, + opts StackitKMSOptions, + signingKeyPath, signingCertPath, pcrKeyPath string, + kmsOpts ...config.ConfigurationOption, +) (*Service, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + client, err := kms.NewAPIClient(kmsOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create STACKIT KMS client: %w", err) + } + + signingKey, err := decryptKey(ctx, client, opts, signingKeyPath) + if err != nil { + return nil, err + } + + pcrKey, err := decryptKey(ctx, client, opts, pcrKeyPath) + if err != nil { + return nil, err + } + + certPEM, err := os.ReadFile(signingCertPath) + if err != nil { + return nil, err + } + + cert, err := (&sideroX509.PEMEncodedCertificate{Crt: certPEM}).GetCert() + if err != nil { + return nil, fmt.Errorf("parse %s: %w", signingCertPath, err) + } + + if certKey, ok := cert.PublicKey.(*rsa.PublicKey); !ok || !certKey.Equal(signingKey.public) { + return nil, fmt.Errorf("%s does not match the signing key", signingCertPath) + } + + secureBootAddr, err := serveSigner("secureboot", &signerServer{ + logger: logger.With(zap.String("signer", "secureboot")), + key: signingKey, + cert: cert, + }) + if err != nil { + return nil, err + } + + pcrAddr, err := serveSigner("pcr", &signerServer{ + logger: logger.With(zap.String("signer", "pcr")), + key: pcrKey, + }) + if err != nil { + return nil, err + } + + logger.Info("SecureBoot keys decrypted with STACKIT KMS and sealed in memory", + zap.String("project_id", opts.ProjectID), + zap.String("region", opts.Region), + zap.String("key_ring_id", opts.KeyRingID), + zap.String("key_id", opts.KeyID), + zap.Int64("key_version", opts.KeyVersion), + zap.String("secureboot_signer", secureBootAddr), + zap.String("pcr_signer", pcrAddr), + zap.String("signing_cert_subject", cert.Subject.String()), + ) + + return &Service{ + in: &profile.SecureBootAssets{ + SecureBootSigner: profile.SigningKeyAndCertificate{SignerAddress: secureBootAddr}, + PCRSigner: profile.SigningKey{SignerAddress: pcrAddr}, + }, + }, nil +} + +// decryptKey decrypts the KMS ciphertext at path and seals the RSA private key it contains. +func decryptKey(ctx context.Context, client *kms.APIClient, opts StackitKMSOptions, path string) (*sealedKey, error) { + ciphertext, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + decrypted, err := client.DefaultAPI.Decrypt(ctx, opts.ProjectID, opts.Region, opts.KeyRingID, opts.KeyID, opts.KeyVersion). + DecryptPayload(kms.DecryptPayload{Data: string(bytes.TrimSpace(ciphertext))}).Execute() + if err != nil { + return nil, fmt.Errorf("STACKIT KMS decrypt of %s: %w", path, err) + } + + keyPEM, err := base64.StdEncoding.DecodeString(decrypted.Data) + if err != nil { + return nil, fmt.Errorf("decode decrypted %s: %w", path, err) + } + + defer memguard.WipeBytes(keyPEM) + + block, _ := pem.Decode(keyPEM) + if block == nil || block.Type != "RSA PRIVATE KEY" { + return nil, fmt.Errorf("decrypted %s: expected a PEM encoded RSA PRIVATE KEY", path) + } + + key, err := sealKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("decrypted %s: %w", path, err) + } + + return key, nil +} diff --git a/internal/secureboot/stackit_test.go b/internal/secureboot/stackit_test.go new file mode 100644 index 00000000..c390088e --- /dev/null +++ b/internal/secureboot/stackit_test.go @@ -0,0 +1,156 @@ +// 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 secureboot //nolint:testpackage + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +// fakeKMS answers decrypt requests for ciphertexts produced by encrypt. +type fakeKMS struct { + t *testing.T +} + +func (f *fakeKMS) encrypt(plaintext []byte) string { + return base64.StdEncoding.EncodeToString(append([]byte("encrypted:"), plaintext...)) +} + +func (f *fakeKMS) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/keyrings/ring/keys/key/versions/3/decrypt") { + http.Error(w, r.Method+" "+r.URL.Path, http.StatusNotFound) + + return + } + + var payload struct { + Data string `json:"data"` + } + + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&payload)) + + ciphertext, err := base64.StdEncoding.DecodeString(payload.Data) + require.NoError(f.t, err) + + plaintext, ok := strings.CutPrefix(string(ciphertext), "encrypted:") + if !ok { + http.Error(w, "bad ciphertext", http.StatusBadRequest) + + return + } + + w.Header().Set("Content-Type", "application/json") + require.NoError(f.t, json.NewEncoder(w).Encode(map[string]string{ + "data": base64.StdEncoding.EncodeToString([]byte(plaintext)), + })) +} + +func keyPEM(t *testing.T, key *rsa.PrivateKey) []byte { + t.Helper() + + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) +} + +func selfSigned(t *testing.T, key *rsa.PrivateKey) *x509.Certificate { + t.Helper() + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + return cert +} + +func TestStackitKMSService(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + signingKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pcrKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + cert := selfSigned(t, signingKey) + + kms := &fakeKMS{t: t} + server := httptest.NewServer(kms) + t.Cleanup(server.Close) + + dir := t.TempDir() + opts := StackitKMSOptions{ProjectID: "project", Region: "eu01", KeyRingID: "ring", KeyID: "key", KeyVersion: 3} + + signingKeyPath := filepath.Join(dir, "signing.key.enc") + signingCertPath := filepath.Join(dir, "signing.crt") + pcrKeyPath := filepath.Join(dir, "pcr.key.enc") + + require.NoError(t, os.WriteFile(signingKeyPath, []byte(kms.encrypt(keyPEM(t, signingKey))+"\n"), 0o600)) + require.NoError(t, os.WriteFile(pcrKeyPath, []byte(kms.encrypt(keyPEM(t, pcrKey))), 0o600)) + require.NoError(t, os.WriteFile(signingCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0o600)) + + kmsOpts := []config.ConfigurationOption{config.WithEndpoint(server.URL), config.WithoutAuthentication()} + + svc, err := NewStackitKMSService(zaptest.NewLogger(t), opts, signingKeyPath, signingCertPath, pcrKeyPath, kmsOpts...) + require.NoError(t, err) + + digest := sha256.Sum256([]byte("boot asset")) + + // the Talos signerd client talks to the in-memory signers + secureBootSigner, err := svc.in.SecureBootSigner.GetSigner(t.Context()) + require.NoError(t, err) + + assert.True(t, cert.Equal(secureBootSigner.Certificate())) + + signature, err := secureBootSigner.Signer().Sign(rand.Reader, digest[:], crypto.SHA256) + require.NoError(t, err) + require.NoError(t, rsa.VerifyPKCS1v15(&signingKey.PublicKey, crypto.SHA256, digest[:], signature)) + require.NoError(t, secureBootSigner.Close()) + + pcrSigner, err := svc.in.PCRSigner.GetSigner(t.Context()) + require.NoError(t, err) + + assert.True(t, pcrKey.PublicKey.Equal(pcrSigner.PublicRSAKey())) + + signature, err = pcrSigner.Sign(rand.Reader, digest[:], &rsa.PSSOptions{Hash: crypto.SHA256}) + require.NoError(t, err) + require.NoError(t, rsa.VerifyPSS(&pcrKey.PublicKey, crypto.SHA256, digest[:], signature, nil)) + require.NoError(t, pcrSigner.Close()) + + // the certificate must belong to the signing key + otherCertPath := filepath.Join(dir, "other.crt") + require.NoError(t, os.WriteFile(otherCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: selfSigned(t, pcrKey).Raw}), 0o600)) + + _, err = NewStackitKMSService(zaptest.NewLogger(t), opts, signingKeyPath, otherCertPath, pcrKeyPath, kmsOpts...) + require.ErrorContains(t, err, "does not match the signing key") +} From 30dc6aac1952ebcc8bc4e0eb09f84e170f7427a8 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Tue, 20 Oct 2026 01:25:20 +0200 Subject: [PATCH 03/13] hack/dev: point the external URL at localhost The dev config carried the hostname of one developer machine. Compose publishes the factory on localhost:8080, so use that for everyone. --- hack/dev/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hack/dev/config.yaml b/hack/dev/config.yaml index 62dce1c0..70af511f 100644 --- a/hack/dev/config.yaml +++ b/hack/dev/config.yaml @@ -36,7 +36,7 @@ cache: http: # external URL the Image Factory is available at - externalURL: http://builder.cat-gacrux.ts.net:8080 + externalURL: http://localhost:8080 authentication: enabled: true From 6a40c1462f3e07f55c599801a97df1b4554bc045 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Tue, 20 Oct 2026 01:25:20 +0200 Subject: [PATCH 04/13] hack/dev: add a STACKIT KMS SecureBoot setup Enable SecureBoot in the dev config with the key files decrypted by STACKIT KMS, and add a small script that exchanges a service account key for a short-lived access token. The token is for the curl calls around the setup, for example creating the key ring and encrypting the key files; the factory itself authenticates through the STACKIT SDK. --- hack/dev/config.yaml | 15 +++++++++ hack/dev/stackit-token.sh | 70 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100755 hack/dev/stackit-token.sh diff --git a/hack/dev/config.yaml b/hack/dev/config.yaml index 70af511f..c4f85aa7 100644 --- a/hack/dev/config.yaml +++ b/hack/dev/config.yaml @@ -110,3 +110,18 @@ enterprise: repository: test-vex-data insecure: true cacheTTL: 15s + +secureBoot: + enabled: true + # The two private keys are ciphertext produced by the STACKIT KMS encrypt operation and are + # decrypted at startup. See hack/dev/stackit-token.sh for how the files were encrypted. + file: + signingKeyPath: /etc/image-factory/secureboot/uki-signing-key.enc + signingCertPath: /etc/image-factory/secureboot/uki-signing-cert.pem + pcrKeyPath: /etc/image-factory/secureboot/pcr-signing-key.enc + stackitKMS: + projectID: 551c5326-daa2-4fd5-b614-5389dbb0190c + region: eu01 + keyRingID: fdaf7511-7973-42b5-9952-31b78bc055ba + keyID: a25bcd52-6151-4d5a-93ab-4d099505b794 + keyVersion: 1 diff --git a/hack/dev/stackit-token.sh b/hack/dev/stackit-token.sh new file mode 100755 index 00000000..00dd017f --- /dev/null +++ b/hack/dev/stackit-token.sh @@ -0,0 +1,70 @@ +#!/bin/sh + +### Print a short-lived STACKIT access token for a service account key. +### +### This is the key flow described in the STACKIT docs ("Acquire short lived access tokens"): +### a JWT signed with the key's private RSA key is exchanged at the token endpoint for a +### bearer token. The STACKIT SDK does this on its own, so the image factory does not need +### this script. It is for the curl calls around it, for example to create the KMS key ring +### and to encrypt the SecureBoot key files. +### +### Usage: hack/dev/stackit-token.sh [service-account-key.json] +### The path defaults to STACKIT_SERVICE_ACCOUNT_KEY_PATH. +### +### Needs jq, openssl, uuidgen and curl. The token is printed to stdout and nothing else. + +set -eu + +KEY_FILE="${1:-${STACKIT_SERVICE_ACCOUNT_KEY_PATH:-}}" + +die() { + echo "error: $*" >&2 + exit 1 +} + +if [ -z "${KEY_FILE}" ]; then + die "usage: $0 " +fi + +if [ ! -r "${KEY_FILE}" ]; then + die "cannot read ${KEY_FILE}" +fi + +base64url() { + openssl base64 -A | tr '+/' '-_' | tr -d '=' +} + +PRIVATE_KEY="$(mktemp)" +trap 'rm -f "${PRIVATE_KEY}"' EXIT INT TERM + +jq -r '.credentials.privateKey' "${KEY_FILE}" > "${PRIVATE_KEY}" + +ISS="$(jq -r '.credentials.iss' "${KEY_FILE}")" +SUB="$(jq -r '.credentials.sub' "${KEY_FILE}")" +AUD="$(jq -r '.credentials.aud' "${KEY_FILE}")" +KID="$(jq -r '.credentials.kid' "${KEY_FILE}")" +TOKEN_URL="$(jq -r '.credentials.tokenEndpoint' "${KEY_FILE}")" + +NOW="$(date +%s)" +EXP=$((NOW + 600)) +JTI="$(uuidgen)" + +HEADER="$(jq -cn --arg kid "${KID}" '{alg: "RS512", typ: "JWT", kid: $kid}' | base64url)" +PAYLOAD="$(jq -cn --arg iss "${ISS}" --arg sub "${SUB}" --arg aud "${AUD}" --arg jti "${JTI}" \ + --argjson iat "${NOW}" --argjson exp "${EXP}" \ + '{iss: $iss, sub: $sub, aud: $aud, jti: $jti, iat: $iat, exp: $exp}' | base64url)" +SIGNATURE="$(printf '%s.%s' "${HEADER}" "${PAYLOAD}" \ + | openssl dgst -sha512 -sign "${PRIVATE_KEY}" -binary | base64url)" + +RESPONSE="$(curl --fail -sS -X POST "${TOKEN_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \ + --data-urlencode "assertion=${HEADER}.${PAYLOAD}.${SIGNATURE}")" + +TOKEN="$(printf '%s' "${RESPONSE}" | jq -r '.access_token // empty')" + +if [ -z "${TOKEN}" ]; then + die "no access_token in response: ${RESPONSE}" +fi + +printf '%s\n' "${TOKEN}" From 146cc35b46102a16ed1fd2e3043a2d321910a331 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Tue, 20 Oct 2026 10:20:34 +0200 Subject: [PATCH 05/13] fix golint --- cmd/image-factory/cmd/secureboot_stackit.go | 4 +++- cmd/image-factory/cmd/service.go | 2 +- internal/secureboot/signerd.go | 4 ++-- internal/secureboot/stackit.go | 7 ++++--- internal/secureboot/stackit_test.go | 4 ++-- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/cmd/image-factory/cmd/secureboot_stackit.go b/cmd/image-factory/cmd/secureboot_stackit.go index c2269621..637590d5 100644 --- a/cmd/image-factory/cmd/secureboot_stackit.go +++ b/cmd/image-factory/cmd/secureboot_stackit.go @@ -5,6 +5,7 @@ package cmd import ( + "context" "fmt" "go.uber.org/zap" @@ -14,12 +15,13 @@ import ( // buildSecureBootServiceWithStackit prefers the STACKIT KMS backed SecureBoot service when it is // configured and falls back to the upstream backends otherwise. -func buildSecureBootServiceWithStackit(logger *zap.Logger, opts Options) (*secureboot.Service, error) { +func buildSecureBootServiceWithStackit(ctx context.Context, logger *zap.Logger, opts Options) (*secureboot.Service, error) { if !opts.SecureBoot.Enabled || opts.SecureBoot.STACKITKMS.KeyID == "" { return buildSecureBootService(opts) } svc, err := secureboot.NewStackitKMSService( + ctx, logger, secureboot.StackitKMSOptions(opts.SecureBoot.STACKITKMS), opts.SecureBoot.File.SigningKeyPath, diff --git a/cmd/image-factory/cmd/service.go b/cmd/image-factory/cmd/service.go index 8fcea699..8d07f3af 100644 --- a/cmd/image-factory/cmd/service.go +++ b/cmd/image-factory/cmd/service.go @@ -107,7 +107,7 @@ func RunFactory(ctx context.Context, logger *zap.Logger, opts Options) error { return err } - secureBootService, err := buildSecureBootServiceWithStackit(logger, opts) + secureBootService, err := buildSecureBootServiceWithStackit(ctx, logger, opts) if err != nil { return err } diff --git a/internal/secureboot/signerd.go b/internal/secureboot/signerd.go index f1811597..172f5141 100644 --- a/internal/secureboot/signerd.go +++ b/internal/secureboot/signerd.go @@ -146,7 +146,7 @@ func (s *signerServer) Sign(_ context.Context, req *signer.SignRequest) (*signer // below the temp dir and returns the address for the imager profile. // // The path is fixed so that it does not change the profile hash between restarts. -func serveSigner(name string, srv *signerServer) (string, error) { +func serveSigner(ctx context.Context, name string, srv *signerServer) (string, error) { path := filepath.Join(os.TempDir(), "image-factory-signer", name+".sock") if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { @@ -157,7 +157,7 @@ func serveSigner(name string, srv *signerServer) (string, error) { return "", err } - lis, err := net.Listen("unix", path) + lis, err := (&net.ListenConfig{}).Listen(ctx, "unix", path) if err != nil { return "", err } diff --git a/internal/secureboot/stackit.go b/internal/secureboot/stackit.go index 3dd266ee..81129bbe 100644 --- a/internal/secureboot/stackit.go +++ b/internal/secureboot/stackit.go @@ -37,12 +37,13 @@ type StackitKMSOptions struct { // NewStackitKMSService is the file-based approach with the private keys encrypted by STACKIT KMS: // the keys are decrypted at startup, sealed in memory and served to the imager over its signer sockets. func NewStackitKMSService( + ctx context.Context, logger *zap.Logger, opts StackitKMSOptions, signingKeyPath, signingCertPath, pcrKeyPath string, kmsOpts ...config.ConfigurationOption, ) (*Service, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + ctx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() client, err := kms.NewAPIClient(kmsOpts...) @@ -74,7 +75,7 @@ func NewStackitKMSService( return nil, fmt.Errorf("%s does not match the signing key", signingCertPath) } - secureBootAddr, err := serveSigner("secureboot", &signerServer{ + secureBootAddr, err := serveSigner(ctx, "secureboot", &signerServer{ logger: logger.With(zap.String("signer", "secureboot")), key: signingKey, cert: cert, @@ -83,7 +84,7 @@ func NewStackitKMSService( return nil, err } - pcrAddr, err := serveSigner("pcr", &signerServer{ + pcrAddr, err := serveSigner(ctx, "pcr", &signerServer{ logger: logger.With(zap.String("signer", "pcr")), key: pcrKey, }) diff --git a/internal/secureboot/stackit_test.go b/internal/secureboot/stackit_test.go index c390088e..0fb16f7f 100644 --- a/internal/secureboot/stackit_test.go +++ b/internal/secureboot/stackit_test.go @@ -121,7 +121,7 @@ func TestStackitKMSService(t *testing.T) { kmsOpts := []config.ConfigurationOption{config.WithEndpoint(server.URL), config.WithoutAuthentication()} - svc, err := NewStackitKMSService(zaptest.NewLogger(t), opts, signingKeyPath, signingCertPath, pcrKeyPath, kmsOpts...) + svc, err := NewStackitKMSService(t.Context(), zaptest.NewLogger(t), opts, signingKeyPath, signingCertPath, pcrKeyPath, kmsOpts...) require.NoError(t, err) digest := sha256.Sum256([]byte("boot asset")) @@ -151,6 +151,6 @@ func TestStackitKMSService(t *testing.T) { otherCertPath := filepath.Join(dir, "other.crt") require.NoError(t, os.WriteFile(otherCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: selfSigned(t, pcrKey).Raw}), 0o600)) - _, err = NewStackitKMSService(zaptest.NewLogger(t), opts, signingKeyPath, otherCertPath, pcrKeyPath, kmsOpts...) + _, err = NewStackitKMSService(t.Context(), zaptest.NewLogger(t), opts, signingKeyPath, otherCertPath, pcrKeyPath, kmsOpts...) require.ErrorContains(t, err, "does not match the signing key") } From 5c64c4bc488d22d467f54b04e048a847add24049 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Sun, 1 Nov 2026 20:55:22 +0100 Subject: [PATCH 06/13] secureboot: destroy the sealed key buffer inside open() --- internal/secureboot/signerd.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/internal/secureboot/signerd.go b/internal/secureboot/signerd.go index 172f5141..7e304c62 100644 --- a/internal/secureboot/signerd.go +++ b/internal/secureboot/signerd.go @@ -48,21 +48,16 @@ func sealKey(der []byte) (*sealedKey, error) { }, nil } -// open decrypts the key; the caller destroys the returned buffer when done. -func (k *sealedKey) open() (*rsa.PrivateKey, *memguard.LockedBuffer, error) { +// open decrypts and parses the key. +func (k *sealedKey) open() (*rsa.PrivateKey, error) { buf, err := k.enclave.Open() if err != nil { - return nil, nil, err + return nil, err } - key, err := x509.ParsePKCS1PrivateKey(buf.Bytes()) - if err != nil { - buf.Destroy() - - return nil, nil, err - } + defer buf.Destroy() - return key, buf, nil + return x509.ParsePKCS1PrivateKey(buf.Bytes()) } // signerServer serves a sealed RSA key over the Talos SignerService API. @@ -111,15 +106,13 @@ func (s *signerServer) Sign(_ context.Context, req *signer.SignRequest) (*signer return nil, status.Errorf(codes.InvalidArgument, "unsupported hash %s", req.GetHash()) } - key, buf, err := s.key.open() + key, err := s.key.open() if err != nil { s.logger.Warn("opening the sealed key failed", zap.Error(err)) return nil, status.Errorf(codes.Internal, "open key: %v", err) } - defer buf.Destroy() - var signature []byte switch req.GetScheme() { From 2a2ea0fb1cc26aa3e4faa33f57b497e13d1bcf81 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Sun, 1 Nov 2026 20:55:22 +0100 Subject: [PATCH 07/13] secureboot: name the signer socket directory with a const --- internal/secureboot/signerd.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/secureboot/signerd.go b/internal/secureboot/signerd.go index 7e304c62..6ca46c4b 100644 --- a/internal/secureboot/signerd.go +++ b/internal/secureboot/signerd.go @@ -135,12 +135,15 @@ func (s *signerServer) Sign(_ context.Context, req *signer.SignRequest) (*signer return &signer.SignResponse{Signature: signature}, nil } +// signerSocketDir is the directory below the temp dir holding the signer sockets. +const signerSocketDir = "image-factory-signer" + // serveSigner serves srv for the lifetime of the process on a unix socket // below the temp dir and returns the address for the imager profile. // // The path is fixed so that it does not change the profile hash between restarts. func serveSigner(ctx context.Context, name string, srv *signerServer) (string, error) { - path := filepath.Join(os.TempDir(), "image-factory-signer", name+".sock") + path := filepath.Join(os.TempDir(), signerSocketDir, name+".sock") if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return "", err From 42aff2398e6bcf8c100dd6cae6e006be0de4c2c5 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Sun, 1 Nov 2026 20:55:22 +0100 Subject: [PATCH 08/13] secureboot: log a failing signer server instead of ignoring the error --- internal/secureboot/signerd.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/secureboot/signerd.go b/internal/secureboot/signerd.go index 6ca46c4b..c799cb59 100644 --- a/internal/secureboot/signerd.go +++ b/internal/secureboot/signerd.go @@ -161,7 +161,11 @@ func serveSigner(ctx context.Context, name string, srv *signerServer) (string, e server := grpc.NewServer() signer.RegisterSignerServiceServer(server, srv) - go server.Serve(lis) //nolint:errcheck + go func() { + if err := server.Serve(lis); err != nil { + srv.logger.Error("signer server stopped", zap.Error(err)) + } + }() return "unix://" + path, nil } From d273a9f1cda65ef9d3e06063d7c744340e6754a2 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Sun, 1 Nov 2026 20:55:22 +0100 Subject: [PATCH 09/13] secureboot: name the KMS decrypt timeout with a const --- internal/secureboot/stackit.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/secureboot/stackit.go b/internal/secureboot/stackit.go index 81129bbe..b5e2fa03 100644 --- a/internal/secureboot/stackit.go +++ b/internal/secureboot/stackit.go @@ -34,6 +34,9 @@ type StackitKMSOptions struct { KeyVersion int64 } +// kmsDecryptTimeout bounds the KMS decrypt calls at startup. +const kmsDecryptTimeout = time.Minute + // NewStackitKMSService is the file-based approach with the private keys encrypted by STACKIT KMS: // the keys are decrypted at startup, sealed in memory and served to the imager over its signer sockets. func NewStackitKMSService( @@ -43,7 +46,7 @@ func NewStackitKMSService( signingKeyPath, signingCertPath, pcrKeyPath string, kmsOpts ...config.ConfigurationOption, ) (*Service, error) { - ctx, cancel := context.WithTimeout(ctx, time.Minute) + ctx, cancel := context.WithTimeout(ctx, kmsDecryptTimeout) defer cancel() client, err := kms.NewAPIClient(kmsOpts...) From 032b5679396f78b1fca93b7979eac9cd264a0626 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Sun, 1 Nov 2026 21:09:21 +0100 Subject: [PATCH 10/13] config: rename overrideSourceImageRegistry to source.registry --- cmd/image-factory/cmd/options.go | 10 ++++++++-- docs/configuration.md | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/cmd/image-factory/cmd/options.go b/cmd/image-factory/cmd/options.go index c849b530..7c3e5713 100644 --- a/cmd/image-factory/cmd/options.go +++ b/cmd/image-factory/cmd/options.go @@ -588,8 +588,8 @@ type STACKITKMSProviderOptions struct { // ArtifactsOptions defines the names and references of images used by the image factory. type ArtifactsOptions struct { - // Replace the image registry defined in the extension/overlay image with the one defined here - OverrideSourceImageRegistry string `koanf:"overrideSourceImageRegistry"` + // Source configures where extension and overlay images are pulled from. + Source SourceImagesOptions `koanf:"source"` // Core contains configuration for core images used by the image factory. Core CoreImagesOptions `koanf:"core"` @@ -610,6 +610,12 @@ type ArtifactsOptions struct { SkipVersionFilter bool `koanf:"skipVersionfilter"` } +// SourceImagesOptions configures where extension and overlay images are pulled from. +type SourceImagesOptions struct { + // Registry replaces the registry named in the extension and overlay manifests, e.g. a mirror. + Registry string `koanf:"registry"` +} + // CoreImagesOptions defines the configuration for core images used by the image factory. type CoreImagesOptions struct { // Registry specifies the OCI registry host for base images, extensions, and related artifacts. diff --git a/docs/configuration.md b/docs/configuration.md index c581d390..098719cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -656,12 +656,18 @@ Artifacts defines names and references for various images used by the factory. --- -### `artifacts.overrideSourceImageRegistry` +### `artifacts.source` + +Source configures where extension and overlay images are pulled from. + +--- + +### `artifacts.source.registry` - **Type:** `string` -- **Env:** `ARTIFACTS_OVERRIDESOURCEIMAGEREGISTRY` +- **Env:** `ARTIFACTS_SOURCE_REGISTRY` -Replace the image registry defined in the extension/overlay image with the one defined here +Registry replaces the registry named in the extension and overlay manifests, e.g. a mirror. --- @@ -1601,7 +1607,6 @@ artifacts: namespace: siderolabs registry: ghcr.io repository: "" - overrideSourceImageRegistry: "" refreshInterval: 5m0s schematic: insecure: false @@ -1609,6 +1614,8 @@ artifacts: registry: ghcr.io repository: schematics skipVersionfilter: false + source: + registry: "" talosVersionRecheckInterval: 15m0s audit: file: @@ -1776,13 +1783,13 @@ IF_ARTIFACTS_INSTALLER_INTERNAL_INSECURE=false IF_ARTIFACTS_INSTALLER_INTERNAL_NAMESPACE=siderolabs IF_ARTIFACTS_INSTALLER_INTERNAL_REGISTRY=ghcr.io IF_ARTIFACTS_INSTALLER_INTERNAL_REPOSITORY= -IF_ARTIFACTS_OVERRIDESOURCEIMAGEREGISTRY= IF_ARTIFACTS_REFRESHINTERVAL=5m0s IF_ARTIFACTS_SCHEMATIC_INSECURE=false IF_ARTIFACTS_SCHEMATIC_NAMESPACE=siderolabs/image-factory IF_ARTIFACTS_SCHEMATIC_REGISTRY=ghcr.io IF_ARTIFACTS_SCHEMATIC_REPOSITORY=schematics IF_ARTIFACTS_SKIPVERSIONFILTER=false +IF_ARTIFACTS_SOURCE_REGISTRY= IF_ARTIFACTS_TALOSVERSIONRECHECKINTERVAL=15m0s IF_AUDIT_FILE_MAXAGE=30 IF_AUDIT_FILE_MAXBACKUPS=16 From ca9aab5d2861d13874dc4f210f9c18a69e434557 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Sun, 1 Nov 2026 21:09:42 +0100 Subject: [PATCH 11/13] config: rename skipVersionfilter to prereleases.enabled --- cmd/image-factory/cmd/options.go | 10 ++++++++-- cmd/image-factory/cmd/service.go | 2 +- docs/configuration.md | 17 ++++++++++++----- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/cmd/image-factory/cmd/options.go b/cmd/image-factory/cmd/options.go index 7c3e5713..9a7a62ab 100644 --- a/cmd/image-factory/cmd/options.go +++ b/cmd/image-factory/cmd/options.go @@ -606,8 +606,14 @@ type ArtifactsOptions struct { // RefreshInterval specifies how often the image factory should refresh its connection to registries. RefreshInterval time.Duration `koanf:"refreshInterval"` - // Whether to skip beta/alpha versions and such when obtaining valid Talos versions - SkipVersionFilter bool `koanf:"skipVersionfilter"` + // Prereleases controls whether alpha, beta and rc Talos versions are offered. + Prereleases PrereleasesOptions `koanf:"prereleases"` +} + +// PrereleasesOptions controls whether alpha, beta and rc Talos versions are offered. +type PrereleasesOptions struct { + // Enabled offers prerelease Talos versions instead of filtering them out. + Enabled bool `koanf:"enabled"` } // SourceImagesOptions configures where extension and overlay images are pulled from. diff --git a/cmd/image-factory/cmd/service.go b/cmd/image-factory/cmd/service.go index 8d07f3af..08339dc2 100644 --- a/cmd/image-factory/cmd/service.go +++ b/cmd/image-factory/cmd/service.go @@ -583,7 +583,7 @@ func buildArtifactsManager(logger *zap.Logger, opts Options) (*artifacts.Manager artifactsManagerOpts := artifacts.Options{ MinVersion: minVersion, BrokenVersions: brokenVersions, - SkipVersionFilter: opts.Artifacts.SkipVersionFilter, + SkipVersionFilter: opts.Artifacts.Prereleases.Enabled, ImageRegistry: opts.Artifacts.Core.Registry, ImageRegistryNamespace: opts.Artifacts.Core.Namespace, InsecureImageRegistry: opts.Artifacts.Core.Insecure, diff --git a/docs/configuration.md b/docs/configuration.md index 098719cb..6f81804e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -944,12 +944,18 @@ RefreshInterval specifies how often the image factory should refresh its connect --- -### `artifacts.skipVersionfilter` +### `artifacts.prereleases` + +Prereleases controls whether alpha, beta and rc Talos versions are offered. + +--- + +### `artifacts.prereleases.enabled` - **Type:** `bool` -- **Env:** `ARTIFACTS_SKIPVERSIONFILTER` +- **Env:** `ARTIFACTS_PRERELEASES_ENABLED` -Whether to skip beta/alpha versions and such when obtaining valid Talos versions +Enabled offers prerelease Talos versions instead of filtering them out. --- @@ -1607,13 +1613,14 @@ artifacts: namespace: siderolabs registry: ghcr.io repository: "" + prereleases: + enabled: false refreshInterval: 5m0s schematic: insecure: false namespace: siderolabs/image-factory registry: ghcr.io repository: schematics - skipVersionfilter: false source: registry: "" talosVersionRecheckInterval: 15m0s @@ -1783,12 +1790,12 @@ IF_ARTIFACTS_INSTALLER_INTERNAL_INSECURE=false IF_ARTIFACTS_INSTALLER_INTERNAL_NAMESPACE=siderolabs IF_ARTIFACTS_INSTALLER_INTERNAL_REGISTRY=ghcr.io IF_ARTIFACTS_INSTALLER_INTERNAL_REPOSITORY= +IF_ARTIFACTS_PRERELEASES_ENABLED=false IF_ARTIFACTS_REFRESHINTERVAL=5m0s IF_ARTIFACTS_SCHEMATIC_INSECURE=false IF_ARTIFACTS_SCHEMATIC_NAMESPACE=siderolabs/image-factory IF_ARTIFACTS_SCHEMATIC_REGISTRY=ghcr.io IF_ARTIFACTS_SCHEMATIC_REPOSITORY=schematics -IF_ARTIFACTS_SKIPVERSIONFILTER=false IF_ARTIFACTS_SOURCE_REGISTRY= IF_ARTIFACTS_TALOSVERSIONRECHECKINTERVAL=15m0s IF_AUDIT_FILE_MAXAGE=30 From c4d48eaaef2d514fb08cf6d47f0070aa29a13bf3 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Sun, 1 Nov 2026 21:09:55 +0100 Subject: [PATCH 12/13] config: pass artifacts.source.registry to the artifacts manager --- cmd/image-factory/cmd/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/image-factory/cmd/service.go b/cmd/image-factory/cmd/service.go index 08339dc2..6332adf9 100644 --- a/cmd/image-factory/cmd/service.go +++ b/cmd/image-factory/cmd/service.go @@ -584,6 +584,7 @@ func buildArtifactsManager(logger *zap.Logger, opts Options) (*artifacts.Manager MinVersion: minVersion, BrokenVersions: brokenVersions, SkipVersionFilter: opts.Artifacts.Prereleases.Enabled, + OverrideSourceImageRegistry: opts.Artifacts.Source.Registry, ImageRegistry: opts.Artifacts.Core.Registry, ImageRegistryNamespace: opts.Artifacts.Core.Namespace, InsecureImageRegistry: opts.Artifacts.Core.Insecure, From f10ea82cae8ad101b7268ad1118afa20c09d9fa6 Mon Sep 17 00:00:00 2001 From: Enno Boland Date: Sun, 1 Nov 2026 21:36:29 +0100 Subject: [PATCH 13/13] secureboot: drop memguard and keep the decrypted keys as plain Go values --- go.mod | 2 -- go.sum | 8 ++--- internal/secureboot/signerd.go | 57 ++++++---------------------------- internal/secureboot/stackit.go | 16 +++++----- 4 files changed, 18 insertions(+), 65 deletions(-) diff --git a/go.mod b/go.mod index f9f4962b..2823bf39 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/anchore/go-logger v0.2.0 github.com/anchore/grype v0.120.1 github.com/anchore/syft v1.54.1 - github.com/awnumar/memguard v0.23.0 github.com/blang/semver/v4 v4.0.0 github.com/coreos/go-oidc/v3 v3.21.0 github.com/felixge/httpsnoop v1.1.0 @@ -135,7 +134,6 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/awnumar/memcall v0.4.0 // indirect github.com/aws/aws-sdk-go-v2 v1.44.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.40 // indirect diff --git a/go.sum b/go.sum index 914e010f..39939c7f 100644 --- a/go.sum +++ b/go.sum @@ -224,10 +224,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= -github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= -github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= -github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M= github.com/aws/aws-sdk-go-v2 v1.44.0 h1:4IbaHhtzy+4h37z4JQyO9a2QsiCml3CNYHtq5hIHigo= github.com/aws/aws-sdk-go-v2 v1.44.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= @@ -1361,12 +1357,12 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw= -github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= github.com/stackitcloud/stackit-sdk-go/core v0.27.0 h1:7lc6qStcFDFf8zHP4ORPa9joybw+Sa2LtB6BVjKQ+ek= github.com/stackitcloud/stackit-sdk-go/core v0.27.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= github.com/stackitcloud/stackit-sdk-go/services/kms v1.13.1 h1:iEPerniBpji6dGiB31p1WAUxgBf9VBIL9gKocfbBqW8= github.com/stackitcloud/stackit-sdk-go/services/kms v1.13.1/go.mod h1:aG3xzmeVCsxnLwb+TA4U5i79a4sPiDicWoWG7lyg4Zs= +github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw= +github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/internal/secureboot/signerd.go b/internal/secureboot/signerd.go index c799cb59..a37ab9f8 100644 --- a/internal/secureboot/signerd.go +++ b/internal/secureboot/signerd.go @@ -17,7 +17,6 @@ import ( "os" "path/filepath" - "github.com/awnumar/memguard" "github.com/siderolabs/talos/pkg/machinery/api/signer" "go.uber.org/zap" "google.golang.org/grpc" @@ -26,46 +25,12 @@ import ( "google.golang.org/protobuf/types/known/emptypb" ) -// sealedKey keeps the PKCS#1 encoding of an RSA private key in a memguard enclave and -// decrypts it per signing operation. The parsed key is still an ordinary heap allocation. -type sealedKey struct { - enclave *memguard.Enclave - public *rsa.PublicKey -} - -// sealKey seals der and wipes it. -func sealKey(der []byte) (*sealedKey, error) { - key, err := x509.ParsePKCS1PrivateKey(der) - if err != nil { - memguard.WipeBytes(der) - - return nil, err - } - - return &sealedKey{ - enclave: memguard.NewEnclave(der), - public: &key.PublicKey, - }, nil -} - -// open decrypts and parses the key. -func (k *sealedKey) open() (*rsa.PrivateKey, error) { - buf, err := k.enclave.Open() - if err != nil { - return nil, err - } - - defer buf.Destroy() - - return x509.ParsePKCS1PrivateKey(buf.Bytes()) -} - -// signerServer serves a sealed RSA key over the Talos SignerService API. +// signerServer serves an RSA key over the Talos SignerService API. type signerServer struct { signer.UnimplementedSignerServiceServer logger *zap.Logger - key *sealedKey + key *rsa.PrivateKey cert *x509.Certificate } @@ -80,7 +45,7 @@ func (s *signerServer) GetCertificate(context.Context, *emptypb.Empty) (*signer. } func (s *signerServer) GetPublicKey(context.Context, *emptypb.Empty) (*signer.PublicKeyResponse, error) { - der, err := x509.MarshalPKIXPublicKey(s.key.public) + der, err := x509.MarshalPKIXPublicKey(&s.key.PublicKey) if err != nil { return nil, status.Errorf(codes.Internal, "marshal public key: %v", err) } @@ -106,20 +71,16 @@ func (s *signerServer) Sign(_ context.Context, req *signer.SignRequest) (*signer return nil, status.Errorf(codes.InvalidArgument, "unsupported hash %s", req.GetHash()) } - key, err := s.key.open() - if err != nil { - s.logger.Warn("opening the sealed key failed", zap.Error(err)) - - return nil, status.Errorf(codes.Internal, "open key: %v", err) - } - - var signature []byte + var ( + signature []byte + err error + ) switch req.GetScheme() { case signer.Scheme_SCHEME_RSA_PKCS1V15: - signature, err = rsa.SignPKCS1v15(rand.Reader, key, hash, req.GetDigest()) + signature, err = rsa.SignPKCS1v15(rand.Reader, s.key, hash, req.GetDigest()) case signer.Scheme_SCHEME_RSA_PSS: - signature, err = rsa.SignPSS(rand.Reader, key, hash, req.GetDigest(), &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) + signature, err = rsa.SignPSS(rand.Reader, s.key, hash, req.GetDigest(), &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) case signer.Scheme_SCHEME_UNSPECIFIED: fallthrough default: diff --git a/internal/secureboot/stackit.go b/internal/secureboot/stackit.go index b5e2fa03..4bd49267 100644 --- a/internal/secureboot/stackit.go +++ b/internal/secureboot/stackit.go @@ -8,13 +8,13 @@ import ( "bytes" "context" "crypto/rsa" + "crypto/x509" "encoding/base64" "encoding/pem" "fmt" "os" "time" - "github.com/awnumar/memguard" sideroX509 "github.com/siderolabs/crypto/x509" "github.com/siderolabs/talos/pkg/imager/profile" "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -38,7 +38,7 @@ type StackitKMSOptions struct { const kmsDecryptTimeout = time.Minute // NewStackitKMSService is the file-based approach with the private keys encrypted by STACKIT KMS: -// the keys are decrypted at startup, sealed in memory and served to the imager over its signer sockets. +// the keys are decrypted at startup, kept in memory and served to the imager over its signer sockets. func NewStackitKMSService( ctx context.Context, logger *zap.Logger, @@ -74,7 +74,7 @@ func NewStackitKMSService( return nil, fmt.Errorf("parse %s: %w", signingCertPath, err) } - if certKey, ok := cert.PublicKey.(*rsa.PublicKey); !ok || !certKey.Equal(signingKey.public) { + if certKey, ok := cert.PublicKey.(*rsa.PublicKey); !ok || !certKey.Equal(&signingKey.PublicKey) { return nil, fmt.Errorf("%s does not match the signing key", signingCertPath) } @@ -95,7 +95,7 @@ func NewStackitKMSService( return nil, err } - logger.Info("SecureBoot keys decrypted with STACKIT KMS and sealed in memory", + logger.Info("SecureBoot keys decrypted with STACKIT KMS and kept in memory", zap.String("project_id", opts.ProjectID), zap.String("region", opts.Region), zap.String("key_ring_id", opts.KeyRingID), @@ -114,8 +114,8 @@ func NewStackitKMSService( }, nil } -// decryptKey decrypts the KMS ciphertext at path and seals the RSA private key it contains. -func decryptKey(ctx context.Context, client *kms.APIClient, opts StackitKMSOptions, path string) (*sealedKey, error) { +// decryptKey decrypts the KMS ciphertext at path and parses the RSA private key it contains. +func decryptKey(ctx context.Context, client *kms.APIClient, opts StackitKMSOptions, path string) (*rsa.PrivateKey, error) { ciphertext, err := os.ReadFile(path) if err != nil { return nil, err @@ -132,14 +132,12 @@ func decryptKey(ctx context.Context, client *kms.APIClient, opts StackitKMSOptio return nil, fmt.Errorf("decode decrypted %s: %w", path, err) } - defer memguard.WipeBytes(keyPEM) - block, _ := pem.Decode(keyPEM) if block == nil || block.Type != "RSA PRIVATE KEY" { return nil, fmt.Errorf("decrypted %s: expected a PEM encoded RSA PRIVATE KEY", path) } - key, err := sealKey(block.Bytes) + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("decrypted %s: %w", path, err) }