diff --git a/cmd/image-factory/cmd/options.go b/cmd/image-factory/cmd/options.go index 85fcf4cc..9a7a62ab 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,10 +564,32 @@ 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 - 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"` @@ -581,8 +606,20 @@ 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. +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. @@ -1035,6 +1072,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..637590d5 --- /dev/null +++ b/cmd/image-factory/cmd/secureboot_stackit.go @@ -0,0 +1,36 @@ +// 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 ( + "context" + "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(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, + 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..6332adf9 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(ctx, logger, opts) if err != nil { return err } @@ -583,7 +583,8 @@ 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, + OverrideSourceImageRegistry: opts.Artifacts.Source.Registry, ImageRegistry: opts.Artifacts.Core.Registry, ImageRegistryNamespace: opts.Artifacts.Core.Namespace, InsecureImageRegistry: opts.Artifacts.Core.Insecure, 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 8a56d2b4..6f81804e 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` @@ -605,6 +656,21 @@ Artifacts defines names and references for various images used by the factory. --- +### `artifacts.source` + +Source configures where extension and overlay images are pulled from. + +--- + +### `artifacts.source.registry` + +- **Type:** `string` +- **Env:** `ARTIFACTS_SOURCE_REGISTRY` + +Registry replaces the registry named in the extension and overlay manifests, e.g. a mirror. + +--- + ### `artifacts.core` Core contains configuration for core images used by the image factory. @@ -878,6 +944,21 @@ RefreshInterval specifies how often the image factory should refresh its connect --- +### `artifacts.prereleases` + +Prereleases controls whether alpha, beta and rc Talos versions are offered. + +--- + +### `artifacts.prereleases.enabled` + +- **Type:** `bool` +- **Env:** `ARTIFACTS_PRERELEASES_ENABLED` + +Enabled offers prerelease Talos versions instead of filtering them out. + +--- + ### `authentication` Authentication settings. @@ -1532,12 +1613,16 @@ artifacts: namespace: siderolabs registry: ghcr.io repository: "" + prereleases: + enabled: false refreshInterval: 5m0s schematic: insecure: false namespace: siderolabs/image-factory registry: ghcr.io repository: schematics + source: + registry: "" talosVersionRecheckInterval: 15m0s audit: file: @@ -1676,6 +1761,12 @@ secureBoot: pcrKeyPath: "" signingCertPath: "" signingKeyPath: "" + stackitKMS: + keyID: "" + keyRingID: "" + keyVersion: 1 + projectID: "" + region: "" ``` ### Environment Variables @@ -1699,11 +1790,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_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_SOURCE_REGISTRY= IF_ARTIFACTS_TALOSVERSIONRECHECKINTERVAL=15m0s IF_AUDIT_FILE_MAXAGE=30 IF_AUDIT_FILE_MAXBACKUPS=16 @@ -1806,4 +1899,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..2823bf39 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,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 +68,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 ) @@ -491,7 +494,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..39939c7f 100644 --- a/go.sum +++ b/go.sum @@ -1357,6 +1357,10 @@ 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/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= diff --git a/hack/dev/config.yaml b/hack/dev/config.yaml index 62dce1c0..c4f85aa7 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 @@ -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}" diff --git a/internal/secureboot/signerd.go b/internal/secureboot/signerd.go new file mode 100644 index 00000000..a37ab9f8 --- /dev/null +++ b/internal/secureboot/signerd.go @@ -0,0 +1,132 @@ +// 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/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" +) + +// signerServer serves an RSA key over the Talos SignerService API. +type signerServer struct { + signer.UnimplementedSignerServiceServer + + logger *zap.Logger + key *rsa.PrivateKey + 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.PublicKey) + 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()) + } + + var ( + signature []byte + err error + ) + + switch req.GetScheme() { + case signer.Scheme_SCHEME_RSA_PKCS1V15: + signature, err = rsa.SignPKCS1v15(rand.Reader, s.key, hash, req.GetDigest()) + case signer.Scheme_SCHEME_RSA_PSS: + signature, err = rsa.SignPSS(rand.Reader, s.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 +} + +// 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(), signerSocketDir, 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.ListenConfig{}).Listen(ctx, "unix", path) + if err != nil { + return "", err + } + + server := grpc.NewServer() + signer.RegisterSignerServiceServer(server, srv) + + go func() { + if err := server.Serve(lis); err != nil { + srv.logger.Error("signer server stopped", zap.Error(err)) + } + }() + + return "unix://" + path, nil +} diff --git a/internal/secureboot/stackit.go b/internal/secureboot/stackit.go new file mode 100644 index 00000000..4bd49267 --- /dev/null +++ b/internal/secureboot/stackit.go @@ -0,0 +1,146 @@ +// 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" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "os" + "time" + + 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 +} + +// 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, kept 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(ctx, kmsDecryptTimeout) + 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.PublicKey) { + return nil, fmt.Errorf("%s does not match the signing key", signingCertPath) + } + + secureBootAddr, err := serveSigner(ctx, "secureboot", &signerServer{ + logger: logger.With(zap.String("signer", "secureboot")), + key: signingKey, + cert: cert, + }) + if err != nil { + return nil, err + } + + pcrAddr, err := serveSigner(ctx, "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 kept 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 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 + } + + 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) + } + + 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 := x509.ParsePKCS1PrivateKey(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..0fb16f7f --- /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(t.Context(), 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(t.Context(), zaptest.NewLogger(t), opts, signingKeyPath, otherCertPath, pcrKeyPath, kmsOpts...) + require.ErrorContains(t, err, "does not match the signing key") +}