From 8ec09830e958acb91645023a5c2b61c087afbdb0 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Mon, 20 Oct 2025 16:54:21 +0200 Subject: [PATCH 1/3] config: allow arbitrary custom clevis pin In order to support new clevis pin, either they need to be added each time in the hardcoded list of pins or ignition can allow any name for the pin. This is required in order to enable the clevis trustee pin used for confidential clusters. Signed-off-by: Alice Frosi --- config/v3_6_experimental/types/clevis.go | 8 +------- config/v3_6_experimental/types/clevis_test.go | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/config/v3_6_experimental/types/clevis.go b/config/v3_6_experimental/types/clevis.go index 68887d434..3742633dd 100644 --- a/config/v3_6_experimental/types/clevis.go +++ b/config/v3_6_experimental/types/clevis.go @@ -33,13 +33,7 @@ func (cu ClevisCustom) Validate(c path.ContextPath) (r report.Report) { if util.NilOrEmpty(cu.Pin) && util.NilOrEmpty(cu.Config) && !util.IsTrue(cu.NeedsNetwork) { return } - if util.NotEmpty(cu.Pin) { - switch *cu.Pin { - case "tpm2", "tang", "sss": - default: - r.AddOnError(c.Append("pin"), errors.ErrUnknownClevisPin) - } - } else { + if util.NilOrEmpty(cu.Pin) { r.AddOnError(c.Append("pin"), errors.ErrClevisPinRequired) } if util.NilOrEmpty(cu.Config) { diff --git a/config/v3_6_experimental/types/clevis_test.go b/config/v3_6_experimental/types/clevis_test.go index f616868cd..81fb3b26e 100644 --- a/config/v3_6_experimental/types/clevis_test.go +++ b/config/v3_6_experimental/types/clevis_test.go @@ -56,7 +56,7 @@ func TestClevisCustomValidate(t *testing.T) { Pin: util.StrToPtr("z"), }, at: path.New("", "pin"), - out: errors.ErrUnknownClevisPin, + out: nil, }, { in: ClevisCustom{ From 33693349bea8f899e0312c69f9e4c94861048040 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 22 Oct 2025 10:29:03 +0200 Subject: [PATCH 2/3] config: add the attestation The attestation includes the fields necessary to attest the machine. For example, in confidential clusters. The registration of the attestation key enables to extract the AK of the TPM and register it before using it to sign the TPM quote. Signed-off-by: Alice Frosi --- config/v3_6_experimental/translate/translate.go | 6 +++++- config/v3_6_experimental/types/schema.go | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/config/v3_6_experimental/translate/translate.go b/config/v3_6_experimental/translate/translate.go index 8fa256740..15bb350b8 100644 --- a/config/v3_6_experimental/translate/translate.go +++ b/config/v3_6_experimental/translate/translate.go @@ -63,6 +63,10 @@ func Translate(old old_types.Config) (ret types.Config) { tr.AddCustomTranslator(translateIgnition) tr.AddCustomTranslator(translateDirectoryEmbedded1) tr.AddCustomTranslator(translateFileEmbedded1) - tr.Translate(&old, &ret) + tr.Translate(&old.Ignition, &ret.Ignition) + tr.Translate(&old.KernelArguments, &ret.KernelArguments) + tr.Translate(&old.Passwd, &ret.Passwd) + tr.Translate(&old.Storage, &ret.Storage) + tr.Translate(&old.Systemd, &ret.Systemd) return } diff --git a/config/v3_6_experimental/types/schema.go b/config/v3_6_experimental/types/schema.go index c652d666d..95130d39c 100644 --- a/config/v3_6_experimental/types/schema.go +++ b/config/v3_6_experimental/types/schema.go @@ -25,6 +25,7 @@ type Config struct { Passwd Passwd `json:"passwd,omitempty"` Storage Storage `json:"storage,omitempty"` Systemd Systemd `json:"systemd,omitempty"` + Attestation Attestation `json:"attestation,omitempty"` } type Device string @@ -262,3 +263,16 @@ type Unit struct { type Verification struct { Hash *string `json:"hash,omitempty"` } + +type Attestation struct { + AttestationKey AttestationKey `json:"attestation_key,omitempty"` +} + +type AttestationKey struct { + Registration Registration `json:"registration,omitempty"` +} + +type Registration struct { + Url *string `json:"url,omitempty"` + Certificate *string `json:"certificat,omitempty"` +} From 74c1254399e13a0f5751aba92b318096a8993f69 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 22 Oct 2025 15:58:06 +0200 Subject: [PATCH 3/3] Add generation and registration of the AK Generate and persistent the AK in the TPM if the key registration is request in the config. The public AK is then registered at the url specified in the config. The AK is generate only once and if the /var/tpm/ak.pub exists then it means that this step has already been performed and it is skipped of the next stages. The registration of the AK required networking hence, during the fetch-offline stage it signals that the networking is necessary. The retries mechanism ensures that the registration is tried multiple times for allowing the parallel network configuration in the fetch phase. Signed-off-by: Alice Frosi --- internal/attestation/attestation.go | 234 ++++++++++++++++++++++++++++ internal/exec/engine.go | 20 ++- 2 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 internal/attestation/attestation.go diff --git a/internal/attestation/attestation.go b/internal/attestation/attestation.go new file mode 100644 index 000000000..c0eb3ec38 --- /dev/null +++ b/internal/attestation/attestation.go @@ -0,0 +1,234 @@ +// Copyright 2025 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package attestation + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "syscall" + "time" + + "github.com/coreos/ignition/v2/config/util" + "github.com/coreos/ignition/v2/config/v3_6_experimental/types" + "github.com/coreos/ignition/v2/internal/log" + "github.com/coreos/ignition/v2/internal/resource" +) + +const ( + TPMDir = "/var/tpm" + AKPath = "/var/tpm/ak.pub" + AKCtxPath = "/var/tpm/ak.ctx" + AKRegisterd = "/var/tpm/ak.registerd" + AKHandle = "0x81010002" + EKHandle = "0x81010001" +) + +func HandleAttestation(logger *log.Logger, cfg *types.Config, platformName string, needNetPath string) error { + if !util.NilOrEmpty(cfg.Attestation.AttestationKey.Registration.Url) { + // Generate and persist the AK + if err := GenerateAndPersistAK(logger); err != nil { + return err + } + + attestationKeyBytes, err := os.ReadFile(AKPath) + if err != nil { + return err + } + attestationKey := string(attestationKeyBytes) + + // Check if the neednet file exists to determine our retry behavior + _, needNetErr := os.Stat(needNetPath) + needNetExists := (needNetErr == nil) + if needNetExists { + logger.Info("neednet file exists, network should be available for attestation") + } else { + logger.Info("neednet file does not exist, will return ErrNeedNet if network is unavailable") + } + + err = AttestationKeyRegistration(logger, cfg.Attestation.AttestationKey.Registration, + attestationKey, platformName) + if err != nil { + // If we got ErrNeedNet and the neednet file doesn't exist, propagate it + // (we're in fetch-offline and need to signal for network) + if err == resource.ErrNeedNet && !needNetExists { + return err + } + // If we got ErrNeedNet but neednet file exists, we're in fetch stage + // Retry the registration with delays to allow network to come up + if err == resource.ErrNeedNet && needNetExists { + logger.Info("Network not ready yet in fetch stage, retrying with delays...") + // Retry up to 10 times with increasing delays + maxRetries := 20 + for attempt := 2; attempt <= maxRetries; attempt++ { + delay := time.Duration(min(attempt*2, 10)) * time.Second + logger.Info("Waiting %v before retry attempt %d/%d", delay, attempt, maxRetries) + time.Sleep(delay) + + err = AttestationKeyRegistration(logger, cfg.Attestation.AttestationKey.Registration, + attestationKey, platformName) + if err == nil { + break + } + logger.Info("Attestation registration attempt %d/%d failed: %v", attempt, maxRetries, err) + } + if err != nil { + return fmt.Errorf("failed to register attestation key after retries: %w", err) + } + } else { + return err + } + } + } + return nil +} + +// GenerateAndPersistAK creates and persists the Attestation Key in the TPM +func GenerateAndPersistAK(logger *log.Logger) error { + if err := os.MkdirAll(TPMDir, 0755); err != nil { + return fmt.Errorf("couldn't create %s directory: %w", TPMDir, err) + } + + if _, err := os.Stat(AKPath); err == nil { + logger.Info("Attestation Key already exists, skipping generation") + return nil + } + + logger.Info("Generating Attestation Key") + cmd := exec.Command("tpm2_createak", "-C", EKHandle, + "-c", AKCtxPath, "-G", "rsa", "-g", "sha256", + "-s", "rsassa", "-u", AKPath, "-f", "pem") + if _, err := logger.LogCmd(cmd, "creating attestation key"); err != nil { + return fmt.Errorf("failed to create attestation key: %w", err) + } + + cmd = exec.Command("tpm2_evictcontrol", "-c", AKCtxPath, AKHandle) + if _, err := logger.LogCmd(cmd, "persisting attestation key"); err != nil { + return fmt.Errorf("failed to persist attestation key: %w", err) + } + + return nil +} + +// AttestationKeyRegistration sends a request to register an attestation key +func AttestationKeyRegistration(logger *log.Logger, registration types.Registration, attestationKey string, platform string) error { + if registration.Url == nil || *registration.Url == "" { + return fmt.Errorf("registration URL is required") + } + // Check if AK was already generated + if _, err := os.Stat(AKRegisterd); err == nil { + return nil + } + + requestBody := map[string]string{ + "attestation_key": attestationKey, + "platform": platform, + } + + jsonBody, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + client := &http.Client{} + + if !util.NilOrEmpty(registration.Certificate) { + tlsConfig, err := createTLSConfig(*registration.Certificate) + if err != nil { + return fmt.Errorf("failed to create TLS config: %w", err) + } + + client.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + } + + // Single attempt - caller (HandleAttestation) handles retries + req, err := http.NewRequest(http.MethodPut, *registration.Url, bytes.NewBuffer(jsonBody)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + // Return network errors as ErrNeedNet for caller to handle + if isNetworkUnreachable(err) { + return resource.ErrNeedNet + } + return fmt.Errorf("failed to register attestation key: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("registration failed with status code: %d", resp.StatusCode) + } + + // Registration successful + if err := os.WriteFile(AKRegisterd, []byte{}, 0644); err != nil { + return fmt.Errorf("failed to create AK registered file: %w", err) + } + logger.Info("Register successfully the AK") + return nil +} + +// isNetworkUnreachable checks if the error indicates network is unreachable +func isNetworkUnreachable(err error) bool { + var opErr *net.OpError + if errors.As(err, &opErr) { + // Check for ENETUNREACH (network unreachable) + if errors.Is(opErr.Err, syscall.ENETUNREACH) { + return true + } + // Check for EHOSTUNREACH (host unreachable) + if errors.Is(opErr.Err, syscall.EHOSTUNREACH) { + return true + } + // Check for "connect: network is unreachable" string + if opErr.Err != nil && opErr.Err.Error() == "network is unreachable" { + return true + } + } + return false +} + +func createTLSConfig(certPEM string) (*tls.Config, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM certificate") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + certPool := x509.NewCertPool() + certPool.AddCert(cert) + + return &tls.Config{ + RootCAs: certPool, + }, nil +} diff --git a/internal/exec/engine.go b/internal/exec/engine.go index c2e61f7eb..c63c9f86b 100644 --- a/internal/exec/engine.go +++ b/internal/exec/engine.go @@ -26,6 +26,7 @@ import ( "github.com/coreos/ignition/v2/config/shared/errors" latest "github.com/coreos/ignition/v2/config/v3_6_experimental" "github.com/coreos/ignition/v2/config/v3_6_experimental/types" + "github.com/coreos/ignition/v2/internal/attestation" "github.com/coreos/ignition/v2/internal/exec/stages" executil "github.com/coreos/ignition/v2/internal/exec/util" "github.com/coreos/ignition/v2/internal/log" @@ -176,7 +177,7 @@ func logStructuredJournalEntry(cfgInfo state.FetchedConfig) error { func (e *Engine) acquireConfig(stageName string) (cfg types.Config, err error) { switch { case strings.HasPrefix(stageName, "fetch"): - cfg, err = e.acquireProviderConfig() + cfg, err = e.acquireProviderConfig(stageName) // if we've successfully fetched and cached the configs, log about them if err == nil && journal.Enabled() { @@ -216,7 +217,7 @@ func (e *Engine) acquireCachedConfig() (cfg types.Config, err error) { // acquireProviderConfig attempts to fetch the configuration from the // provider. -func (e *Engine) acquireProviderConfig() (cfg types.Config, err error) { +func (e *Engine) acquireProviderConfig(stageName string) (cfg types.Config, err error) { // Create a new http client and fetcher with the timeouts set via the flags, // since we don't have a config with timeout values we can use timeout := int(e.FetchTimeout.Seconds()) @@ -228,7 +229,7 @@ func (e *Engine) acquireProviderConfig() (cfg types.Config, err error) { } // (Re)Fetch the config if the cache is unreadable. - cfg, err = e.fetchProviderConfig() + cfg, err = e.fetchProviderConfig(stageName) if err == errors.ErrEmpty { // Continue if the provider config was empty as we want to write an empty // cache config for use by other stages. @@ -285,7 +286,7 @@ func (e *Engine) acquireProviderConfig() (cfg types.Config, err error) { // it checks the config engine's provider. An error is returned if the provider // is unavailable. This will also render the config (see renderConfig) before // returning. -func (e *Engine) fetchProviderConfig() (types.Config, error) { +func (e *Engine) fetchProviderConfig(stageName string) (types.Config, error) { platformConfigs := []platform.Config{ cmdline.Config, system.Config, @@ -315,6 +316,17 @@ func (e *Engine) fetchProviderConfig() (types.Config, error) { Referenced: false, }) + if err := attestation.HandleAttestation(e.Logger, &cfg, e.PlatformConfig.Name(), e.NeedNet); err != nil { + if err == resource.ErrNeedNet && stageName == "fetch-offline" { + err = e.signalNeedNet() + if err != nil { + e.Logger.Crit("failed to signal neednet: %v", err) + } + return cfg, resource.ErrNeedNet + } + return types.Config{}, err + } + // Replace the HTTP client in the fetcher to be configured with the // timeouts of the config err = e.Fetcher.UpdateHttpTimeoutsAndCAs(cfg.Ignition.Timeouts, cfg.Ignition.Security.TLS.CertificateAuthorities, cfg.Ignition.Proxy)