From 5e98db5ea27eea8dec5078077b164e6c7c6250c9 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Wed, 26 Nov 2025 15:58:14 +0100 Subject: [PATCH 01/23] Implement CertificateResourceStorage as an atomic certificate storage --- config_test.go | 124 +++++++++++++++++++++++++++++++++++++++++++++++++ crypto.go | 25 +++++++++- storage.go | 26 ++++++++++- 3 files changed, 172 insertions(+), 3 deletions(-) diff --git a/config_test.go b/config_test.go index 89a63224..6822ac6e 100644 --- a/config_test.go +++ b/config_test.go @@ -20,6 +20,7 @@ import ( "encoding/json" "os" "reflect" + "slices" "testing" "time" @@ -78,6 +79,68 @@ func TestSaveCertResource(t *testing.T) { } } +func TestSaveCertResourceWithCertificateResourceStorage(t *testing.T) { + ctx := context.Background() + + am := &ACMEIssuer{CA: "https://example.com/acme/directory"} + mockStore := &mockCertificateResourceStorage{ + Storage: &FileStorage{Path: "./_testdata_tmp"}, + } + + testConfig := &Config{ + Storage: mockStore, + Issuers: []Issuer{am}, + Logger: defaultTestLogger, + certCache: new(Cache), + } + am.config = testConfig + + domain := "example.com" + certContents := "certificate" + keyContents := "private key" + + cert := CertificateResource{ + SANs: []string{domain}, + PrivateKeyPEM: []byte(keyContents), + CertificatePEM: []byte(certContents), + IssuerData: mustJSON(acme.Certificate{ + URL: "https://example.com/cert", + }), + issuerKey: am.IssuerKey(), + } + + err := testConfig.saveCertResource(ctx, am, cert) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + siteData, err := testConfig.loadCertResource(ctx, am, domain) + if err != nil { + t.Fatalf("Expected no error reading site, got: %v", err) + } + + if !slices.Equal(cert.SANs, siteData.SANs) { + t.Fatalf("Expected SANs to be equal, want: %v, got: %v", cert.SANs, siteData.SANs) + } + if !bytes.Equal(cert.PrivateKeyPEM, siteData.PrivateKeyPEM) { + t.Fatalf("Expected PrivateKeyPEM to be equal, want: %v, got: %v", cert.SANs, siteData.SANs) + } + if !bytes.Equal(cert.CertificatePEM, siteData.CertificatePEM) { + t.Fatalf("Expected CertificatePEM to be equal, want: %v, got: %v", cert.SANs, siteData.SANs) + } + if !bytes.Equal(cert.IssuerData, siteData.IssuerData) { + t.Fatalf("Expected IssuerData to be equal, want: %v, got: %v", cert.SANs, siteData.SANs) + } + + // Asserting internals is not ideal, but somehow we have to ensure interface promotion works correctly. + if mockStore.StoreCertificateResourceCalls != 1 { + t.Fatalf("Expected StoreCertificateResource to be called") + } + if mockStore.LoadCertificateResourceCalls != 1 { + t.Fatalf("Expected LoadCertificateResource to be called") + } +} + type mockStorageWithLease struct { *FileStorage renewCalled bool @@ -93,6 +156,67 @@ func (m *mockStorageWithLease) RenewLockLease(ctx context.Context, lockKey strin return m.renewError } +type mockCertificateResourceStorage struct { + Storage + + StoreCertificateResourceCalls int + LoadCertificateResourceCalls int +} + +func (s *mockCertificateResourceStorage) StoreCertificateResource(ctx context.Context, key string, res CertificateResource) error { + s.StoreCertificateResourceCalls++ + + // Some attributes in CertificateResource have the `json:"-"` tag, + // which is why we have to create a new struct for encoding. + resource := struct { + SANs []string + CertificatePEM []byte + PrivateKeyPEM []byte + IssuerData json.RawMessage + }{ + SANs: res.SANs, + CertificatePEM: res.CertificatePEM, + PrivateKeyPEM: res.PrivateKeyPEM, + IssuerData: res.IssuerData, + } + + buf, err := json.Marshal(resource) + if err != nil { + return err + } + + return s.Storage.Store(ctx, key, buf) +} + +func (s *mockCertificateResourceStorage) LoadCertificateResource(ctx context.Context, key string) (CertificateResource, error) { + s.LoadCertificateResourceCalls++ + + buf, err := s.Storage.Load(ctx, key) + if err != nil { + return CertificateResource{}, err + } + + var resource struct { + SANs []string + CertificatePEM []byte + PrivateKeyPEM []byte + IssuerData json.RawMessage + } + + if err := json.Unmarshal(buf, &resource); err != nil { + return CertificateResource{}, err + } + + res := CertificateResource{ + SANs: resource.SANs, + CertificatePEM: resource.CertificatePEM, + PrivateKeyPEM: resource.PrivateKeyPEM, + IssuerData: resource.IssuerData, + } + + return res, nil +} + func TestRenewLockLeaseDuration(t *testing.T) { ctx := context.Background() tmpDir, err := os.MkdirTemp(os.TempDir(), "certmagic-test*") diff --git a/crypto.go b/crypto.go index 9cbbb213..76e0ef5a 100644 --- a/crypto.go +++ b/crypto.go @@ -167,7 +167,19 @@ func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert Cer }, } - return storeTx(ctx, cfg.Storage, all) + if err := storeTx(ctx, cfg.Storage, all); err != nil { + return err + } + + // Also store the certificate resource as a single entity. + // This duplication is needed during the transition phase. + // TODO: Remove the storeTx call after all certificates have been migrated. + if crs, ok := cfg.Storage.(CertificateResourceStorage); ok { + key := StorageKeys.CertificateResource(issuer.IssuerKey(), cert.NamesKey()) + return crs.StoreCertificateResource(ctx, key, cert) + } + + return nil } // loadCertResourceAnyIssuer loads and returns the certificate resource from any @@ -246,6 +258,17 @@ func (cfg *Config) loadCertResource(ctx context.Context, issuer Issuer, certName return CertificateResource{}, fmt.Errorf("converting '%s' to ASCII: %v", certNamesKey, err) } + // Try to load the certificate resource as a single entity. + // This duplication is needed during the transition phase. + // TODO: Remove the individual loads all certificates have been migrated. + if crs, ok := cfg.Storage.(CertificateResourceStorage); ok { + key := StorageKeys.CertificateResource(certRes.issuerKey, normalizedName) + if res, err := crs.LoadCertificateResource(ctx, key); err == nil { + return res, nil + } + // Fallthrough to load the certificate resource from individual keys. + } + keyBytes, err := cfg.Storage.Load(ctx, StorageKeys.SitePrivateKey(certRes.issuerKey, normalizedName)) if err != nil { return CertificateResource{}, err diff --git a/storage.go b/storage.go index a4ae1fc6..efb5db3d 100644 --- a/storage.go +++ b/storage.go @@ -104,6 +104,18 @@ type Storage interface { Stat(ctx context.Context, key string) (KeyInfo, error) } +// CertificateResourceStorage adds helpers for storing and loading +// CertificateResource values. Implementations should serialize the +// resource to a consistent format. +type CertificateResourceStorage interface { + // StoreCertificateResource stores the certificate resource at key, + // overwriting any existing value. + StoreCertificateResource(ctx context.Context, key string, res CertificateResource) error + + // LoadCertificateResource loads the certificate resource at key. + LoadCertificateResource(ctx context.Context, key string) (CertificateResource, error) +} + // Locker facilitates synchronization across machines and networks. // It essentially provides a distributed named-mutex service so // that multiple consumers can coordinate tasks and share resources. @@ -250,6 +262,14 @@ func (keys KeyBuilder) SiteMeta(issuerKey, domain string) string { return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".json") } +// CertificateResource returns the path to the resource file for domain that +// is associated with the certificate from the given issuer with +// the given issuerKey. +func (keys KeyBuilder) CertificateResource(issuerKey, domain string) string { + safeDomain := keys.Safe(domain) + return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".res") +} + // OCSPStaple returns a key for the OCSP staple associated // with the given certificate. If you have the PEM bundle // handy, pass that in to save an extra encoding step. @@ -342,8 +362,10 @@ func releaseLock(ctx context.Context, storage Storage, lockKey string) error { // locks stores a reference to all the current // locks obtained by this process. -var locks = make(map[string]Storage) -var locksMu sync.Mutex +var ( + locks = make(map[string]Storage) + locksMu sync.Mutex +) // StorageKeys provides methods for accessing // keys and key prefixes for items in a Storage. From b1efdb98192fc1bf29b6bc96a7f1967dfb4e6cef Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Tue, 2 Dec 2025 11:24:42 +0100 Subject: [PATCH 02/23] Simplify certificate resource storage --- crypto.go | 101 ++++++++++++++++++++++++++++++++++------------------- storage.go | 12 ------- 2 files changed, 66 insertions(+), 47 deletions(-) diff --git a/crypto.go b/crypto.go index 76e0ef5a..2be2b73e 100644 --- a/crypto.go +++ b/crypto.go @@ -144,42 +144,16 @@ func fastHash(input []byte) string { // includes the certificate file itself, the private key, and the // metadata file. func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert CertificateResource) error { - metaBytes, err := json.MarshalIndent(cert, "", "\t") + encoded, err := encodeCertResource(cert) if err != nil { - return fmt.Errorf("encoding certificate metadata: %v", err) + return fmt.Errorf("encoding certificate resource: %v", err) } issuerKey := issuer.IssuerKey() certKey := cert.NamesKey() - all := []keyValue{ - { - key: StorageKeys.SitePrivateKey(issuerKey, certKey), - value: cert.PrivateKeyPEM, - }, - { - key: StorageKeys.SiteCert(issuerKey, certKey), - value: cert.CertificatePEM, - }, - { - key: StorageKeys.SiteMeta(issuerKey, certKey), - value: metaBytes, - }, - } - - if err := storeTx(ctx, cfg.Storage, all); err != nil { - return err - } - - // Also store the certificate resource as a single entity. - // This duplication is needed during the transition phase. - // TODO: Remove the storeTx call after all certificates have been migrated. - if crs, ok := cfg.Storage.(CertificateResourceStorage); ok { - key := StorageKeys.CertificateResource(issuer.IssuerKey(), cert.NamesKey()) - return crs.StoreCertificateResource(ctx, key, cert) - } - - return nil + key := StorageKeys.CertificateResource(issuerKey, certKey) + return cfg.Storage.Store(ctx, key, encoded) } // loadCertResourceAnyIssuer loads and returns the certificate resource from any @@ -261,12 +235,15 @@ func (cfg *Config) loadCertResource(ctx context.Context, issuer Issuer, certName // Try to load the certificate resource as a single entity. // This duplication is needed during the transition phase. // TODO: Remove the individual loads all certificates have been migrated. - if crs, ok := cfg.Storage.(CertificateResourceStorage); ok { - key := StorageKeys.CertificateResource(certRes.issuerKey, normalizedName) - if res, err := crs.LoadCertificateResource(ctx, key); err == nil { - return res, nil + certResourceKey := StorageKeys.CertificateResource(issuer.IssuerKey(), normalizedName) + certResourceEncoded, err := cfg.Storage.Load(ctx, certResourceKey) + if err == nil { + cert, err := decodeCertResource(certResourceEncoded) + if err == nil { + cert.issuerKey = issuer.IssuerKey() + return cert, nil } - // Fallthrough to load the certificate resource from individual keys. + // Fall through to load cert from individual keys. } keyBytes, err := cfg.Storage.Load(ctx, StorageKeys.SitePrivateKey(certRes.issuerKey, normalizedName)) @@ -291,6 +268,60 @@ func (cfg *Config) loadCertResource(ctx context.Context, issuer Issuer, certName return certRes, nil } +// StoredCertificateResource associates a certificate with its private +// key and other useful information, for use in maintaining the +// certificate. +type StoredCertificateResource struct { + // The list of names on the certificate; + // for convenience only. + SANs []string `json:"sans,omitempty"` + + // The PEM-encoding of DER-encoded ASN.1 data + // for the cert or chain. + CertificatePEM []byte `json:"certificate_pem,omitempty"` + + // The PEM-encoding of the certificate's private key. + PrivateKeyPEM []byte `json:"private_key_pem,omitempty"` + + // Any extra information associated with the certificate, + // usually provided by the issuer implementation. + IssuerData json.RawMessage `json:"issuer_data,omitempty"` +} + +type storedCertificate struct { + SANs []string `json:"sans,omitempty"` + CertificatePEM []byte `json:"certificate_pem,omitempty"` + PrivateKeyPEM []byte `json:"private_key_pem,omitempty"` + IssuerData json.RawMessage `json:"issuer_data,omitempty"` +} + +func encodeCertResource(cert CertificateResource) ([]byte, error) { + storedCert := storedCertificate{ + SANs: cert.SANs, + CertificatePEM: cert.CertificatePEM, + PrivateKeyPEM: cert.PrivateKeyPEM, + IssuerData: cert.IssuerData, + } + encoded, err := json.Marshal(storedCert) + if err != nil { + return nil, err + } + return encoded, nil +} + +func decodeCertResource(b []byte) (CertificateResource, error) { + var storedCert storedCertificate + if err := json.Unmarshal(b, &storedCert); err != nil { + return CertificateResource{}, err + } + return CertificateResource{ + SANs: storedCert.SANs, + CertificatePEM: storedCert.CertificatePEM, + PrivateKeyPEM: storedCert.PrivateKeyPEM, + IssuerData: storedCert.IssuerData, + }, nil +} + // hashCertificateChain computes the unique hash of certChain, // which is the chain of DER-encoded bytes. It returns the // hex encoding of the hash. diff --git a/storage.go b/storage.go index efb5db3d..cc5e4df3 100644 --- a/storage.go +++ b/storage.go @@ -104,18 +104,6 @@ type Storage interface { Stat(ctx context.Context, key string) (KeyInfo, error) } -// CertificateResourceStorage adds helpers for storing and loading -// CertificateResource values. Implementations should serialize the -// resource to a consistent format. -type CertificateResourceStorage interface { - // StoreCertificateResource stores the certificate resource at key, - // overwriting any existing value. - StoreCertificateResource(ctx context.Context, key string, res CertificateResource) error - - // LoadCertificateResource loads the certificate resource at key. - LoadCertificateResource(ctx context.Context, key string) (CertificateResource, error) -} - // Locker facilitates synchronization across machines and networks. // It essentially provides a distributed named-mutex service so // that multiple consumers can coordinate tasks and share resources. From de8899a44accd2c16a7fa05bf57a2d92dbede37c Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Tue, 2 Dec 2025 12:41:41 +0100 Subject: [PATCH 03/23] Add storage modes --- config.go | 2 +- config_test.go | 124 ------------------------------------------- crypto.go | 139 +++++++++++++++++++++++++++++++++++++------------ storage.go | 2 +- 4 files changed, 109 insertions(+), 158 deletions(-) diff --git a/config.go b/config.go index 419b3dcb..4b9ee0e8 100644 --- a/config.go +++ b/config.go @@ -704,7 +704,7 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool } err = cfg.saveCertResource(ctx, issuerUsed, certRes) if err != nil { - return fmt.Errorf("[%s] Obtain: saving assets: %v", name, err) + return fmt.Errorf("[%s] Renew: saving assets: %v", name, err) } log.Info("certificate obtained successfully", diff --git a/config_test.go b/config_test.go index 6822ac6e..89a63224 100644 --- a/config_test.go +++ b/config_test.go @@ -20,7 +20,6 @@ import ( "encoding/json" "os" "reflect" - "slices" "testing" "time" @@ -79,68 +78,6 @@ func TestSaveCertResource(t *testing.T) { } } -func TestSaveCertResourceWithCertificateResourceStorage(t *testing.T) { - ctx := context.Background() - - am := &ACMEIssuer{CA: "https://example.com/acme/directory"} - mockStore := &mockCertificateResourceStorage{ - Storage: &FileStorage{Path: "./_testdata_tmp"}, - } - - testConfig := &Config{ - Storage: mockStore, - Issuers: []Issuer{am}, - Logger: defaultTestLogger, - certCache: new(Cache), - } - am.config = testConfig - - domain := "example.com" - certContents := "certificate" - keyContents := "private key" - - cert := CertificateResource{ - SANs: []string{domain}, - PrivateKeyPEM: []byte(keyContents), - CertificatePEM: []byte(certContents), - IssuerData: mustJSON(acme.Certificate{ - URL: "https://example.com/cert", - }), - issuerKey: am.IssuerKey(), - } - - err := testConfig.saveCertResource(ctx, am, cert) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - siteData, err := testConfig.loadCertResource(ctx, am, domain) - if err != nil { - t.Fatalf("Expected no error reading site, got: %v", err) - } - - if !slices.Equal(cert.SANs, siteData.SANs) { - t.Fatalf("Expected SANs to be equal, want: %v, got: %v", cert.SANs, siteData.SANs) - } - if !bytes.Equal(cert.PrivateKeyPEM, siteData.PrivateKeyPEM) { - t.Fatalf("Expected PrivateKeyPEM to be equal, want: %v, got: %v", cert.SANs, siteData.SANs) - } - if !bytes.Equal(cert.CertificatePEM, siteData.CertificatePEM) { - t.Fatalf("Expected CertificatePEM to be equal, want: %v, got: %v", cert.SANs, siteData.SANs) - } - if !bytes.Equal(cert.IssuerData, siteData.IssuerData) { - t.Fatalf("Expected IssuerData to be equal, want: %v, got: %v", cert.SANs, siteData.SANs) - } - - // Asserting internals is not ideal, but somehow we have to ensure interface promotion works correctly. - if mockStore.StoreCertificateResourceCalls != 1 { - t.Fatalf("Expected StoreCertificateResource to be called") - } - if mockStore.LoadCertificateResourceCalls != 1 { - t.Fatalf("Expected LoadCertificateResource to be called") - } -} - type mockStorageWithLease struct { *FileStorage renewCalled bool @@ -156,67 +93,6 @@ func (m *mockStorageWithLease) RenewLockLease(ctx context.Context, lockKey strin return m.renewError } -type mockCertificateResourceStorage struct { - Storage - - StoreCertificateResourceCalls int - LoadCertificateResourceCalls int -} - -func (s *mockCertificateResourceStorage) StoreCertificateResource(ctx context.Context, key string, res CertificateResource) error { - s.StoreCertificateResourceCalls++ - - // Some attributes in CertificateResource have the `json:"-"` tag, - // which is why we have to create a new struct for encoding. - resource := struct { - SANs []string - CertificatePEM []byte - PrivateKeyPEM []byte - IssuerData json.RawMessage - }{ - SANs: res.SANs, - CertificatePEM: res.CertificatePEM, - PrivateKeyPEM: res.PrivateKeyPEM, - IssuerData: res.IssuerData, - } - - buf, err := json.Marshal(resource) - if err != nil { - return err - } - - return s.Storage.Store(ctx, key, buf) -} - -func (s *mockCertificateResourceStorage) LoadCertificateResource(ctx context.Context, key string) (CertificateResource, error) { - s.LoadCertificateResourceCalls++ - - buf, err := s.Storage.Load(ctx, key) - if err != nil { - return CertificateResource{}, err - } - - var resource struct { - SANs []string - CertificatePEM []byte - PrivateKeyPEM []byte - IssuerData json.RawMessage - } - - if err := json.Unmarshal(buf, &resource); err != nil { - return CertificateResource{}, err - } - - res := CertificateResource{ - SANs: resource.SANs, - CertificatePEM: resource.CertificatePEM, - PrivateKeyPEM: resource.PrivateKeyPEM, - IssuerData: resource.IssuerData, - } - - return res, nil -} - func TestRenewLockLeaseDuration(t *testing.T) { ctx := context.Background() tmpDir, err := os.MkdirTemp(os.TempDir(), "certmagic-test*") diff --git a/crypto.go b/crypto.go index 2be2b73e..1114a5e4 100644 --- a/crypto.go +++ b/crypto.go @@ -30,9 +30,11 @@ import ( "fmt" "hash/fnv" "io/fs" + "os" "sort" "strings" + "github.com/caarlos0/log" "github.com/klauspost/cpuid/v2" "github.com/zeebo/blake3" "go.uber.org/zap" @@ -140,10 +142,73 @@ func fastHash(input []byte) string { return fmt.Sprintf("%x", h.Sum32()) } -// saveCertResource saves the certificate resource to disk. This +const ( + StorageModeEnv = "CERTMAGIC_STORAGE_MODE" + + StorageModeLegacy = "legacy" + StorageModeTransition = "transition" + StorageModeBundle = "bundle" +) + +// saveCertResource saves the certificate resource to disk. +// It switches storage modes between legacy and bundle mode based on the CERTMAGIC_STORAGE_MODE env. +func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert CertificateResource) error { + switch os.Getenv(StorageModeEnv) { + case StorageModeTransition: + // In transition storage mode, certificate resources will be stored as a bundle + // in addition to the legacy storage mechanism. + // Errors that occured while storing the bundle will only be logged as warning. + // The legacy storage mechanism still determines the final success or failure. + if err := cfg.saveCertResourceBundle(ctx, issuer, cert); err != nil { + log.Warn("unable to store certificate resource bundle", + zap.String("issuer", issuer.IssuerKey()), + zap.String("domain", cert.NamesKey()), + zap.Error(err)) + } + return cfg.saveCertResourceLegacy(ctx, issuer, cert) + case StorageModeBundle: + // In bundle storage mode, certifiate resources will be stored as a single ".bundle" entity. + return cfg.saveCertResourceBundle(ctx, issuer, cert) + default: + // In legacy storage mode, certifiate resources will be stored unmodified + // in their seperate ".key", ".cert", ".json" entities. + return cfg.saveCertResourceLegacy(ctx, issuer, cert) + } +} + +// saveCertResourceLegacy saves the certificate resource to disk. This // includes the certificate file itself, the private key, and the // metadata file. -func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert CertificateResource) error { +func (cfg *Config) saveCertResourceLegacy(ctx context.Context, issuer Issuer, cert CertificateResource) error { + metaBytes, err := json.MarshalIndent(cert, "", "\t") + if err != nil { + return fmt.Errorf("encoding certificate metadata: %v", err) + } + + issuerKey := issuer.IssuerKey() + certKey := cert.NamesKey() + + all := []keyValue{ + { + key: StorageKeys.SitePrivateKey(issuerKey, certKey), + value: cert.PrivateKeyPEM, + }, + { + key: StorageKeys.SiteCert(issuerKey, certKey), + value: cert.CertificatePEM, + }, + { + key: StorageKeys.SiteMeta(issuerKey, certKey), + value: metaBytes, + }, + } + + return storeTx(ctx, cfg.Storage, all) +} + +// saveCertResourceBundle saves the certificate resource as a bundle to disk. This +// includes the certificate, the private key, and the metadata. +func (cfg *Config) saveCertResourceBundle(ctx context.Context, issuer Issuer, cert CertificateResource) error { encoded, err := encodeCertResource(cert) if err != nil { return fmt.Errorf("encoding certificate resource: %v", err) @@ -222,8 +287,25 @@ func (cfg *Config) loadCertResourceAnyIssuer(ctx context.Context, certNamesKey s return certResources[0].CertificateResource, nil } -// loadCertResource loads a certificate resource from the given issuer's storage location. func (cfg *Config) loadCertResource(ctx context.Context, issuer Issuer, certNamesKey string) (CertificateResource, error) { + switch os.Getenv(StorageModeEnv) { + case StorageModeBundle: + return cfg.loadCertResourceBundle(ctx, issuer, certNamesKey) + case StorageModeTransition: + // Try loading the certificate from the bundle first. + // Fallback to legacy storage on failure. + certRes, err := cfg.loadCertResourceBundle(ctx, issuer, certNamesKey) + if err == nil { + return certRes, nil + } + return cfg.loadCertResourceLegacy(ctx, issuer, certNamesKey) + default: + return cfg.loadCertResourceLegacy(ctx, issuer, certNamesKey) + } +} + +// loadCertResourceLegacy loads a certificate resource from the given issuer's storage location. +func (cfg *Config) loadCertResourceLegacy(ctx context.Context, issuer Issuer, certNamesKey string) (CertificateResource, error) { certRes := CertificateResource{issuerKey: issuer.IssuerKey()} // don't use the Lookup profile because we might be loading a wildcard cert which is rejected by the Lookup profile @@ -232,20 +314,6 @@ func (cfg *Config) loadCertResource(ctx context.Context, issuer Issuer, certName return CertificateResource{}, fmt.Errorf("converting '%s' to ASCII: %v", certNamesKey, err) } - // Try to load the certificate resource as a single entity. - // This duplication is needed during the transition phase. - // TODO: Remove the individual loads all certificates have been migrated. - certResourceKey := StorageKeys.CertificateResource(issuer.IssuerKey(), normalizedName) - certResourceEncoded, err := cfg.Storage.Load(ctx, certResourceKey) - if err == nil { - cert, err := decodeCertResource(certResourceEncoded) - if err == nil { - cert.issuerKey = issuer.IssuerKey() - return cert, nil - } - // Fall through to load cert from individual keys. - } - keyBytes, err := cfg.Storage.Load(ctx, StorageKeys.SitePrivateKey(certRes.issuerKey, normalizedName)) if err != nil { return CertificateResource{}, err @@ -268,24 +336,31 @@ func (cfg *Config) loadCertResource(ctx context.Context, issuer Issuer, certName return certRes, nil } -// StoredCertificateResource associates a certificate with its private -// key and other useful information, for use in maintaining the -// certificate. -type StoredCertificateResource struct { - // The list of names on the certificate; - // for convenience only. - SANs []string `json:"sans,omitempty"` +// loadCertResourceBundle loads a certificate resource from the given issuer's storage location as bundle. +func (cfg *Config) loadCertResourceBundle(ctx context.Context, issuer Issuer, certNamesKey string) (CertificateResource, error) { + // don't use the Lookup profile because we might be loading a wildcard cert which is rejected by the Lookup profile + normalizedName, err := idna.ToASCII(certNamesKey) + if err != nil { + return CertificateResource{}, fmt.Errorf("converting '%s' to ASCII: %v", certNamesKey, err) + } + + key := StorageKeys.CertificateResource(issuer.IssuerKey(), normalizedName) + encoded, err := cfg.Storage.Load(ctx, key) + if err != nil { + return CertificateResource{}, err + } - // The PEM-encoding of DER-encoded ASN.1 data - // for the cert or chain. - CertificatePEM []byte `json:"certificate_pem,omitempty"` + certRes, err := decodeCertResource(encoded) + if err != nil { + return CertificateResource{}, fmt.Errorf("decoding certificate metadata: %v", err) + } + certRes.issuerKey = issuer.IssuerKey() - // The PEM-encoding of the certificate's private key. - PrivateKeyPEM []byte `json:"private_key_pem,omitempty"` + if _, err := tls.X509KeyPair(certRes.PrivateKeyPEM, certRes.CertificatePEM); err != nil { + return CertificateResource{}, fmt.Errorf("unable to validate integrity of certificate resource bundle for: %v", key) + } - // Any extra information associated with the certificate, - // usually provided by the issuer implementation. - IssuerData json.RawMessage `json:"issuer_data,omitempty"` + return certRes, nil } type storedCertificate struct { diff --git a/storage.go b/storage.go index cc5e4df3..4d192017 100644 --- a/storage.go +++ b/storage.go @@ -255,7 +255,7 @@ func (keys KeyBuilder) SiteMeta(issuerKey, domain string) string { // the given issuerKey. func (keys KeyBuilder) CertificateResource(issuerKey, domain string) string { safeDomain := keys.Safe(domain) - return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".res") + return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".bundle") } // OCSPStaple returns a key for the OCSP staple associated From 04f3b88ecc6346a74bcdd00d02fb967f530f8317 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Tue, 2 Dec 2025 12:58:43 +0100 Subject: [PATCH 04/23] Add tests for storage modes --- config_test.go | 323 +++++++++++++++++++++++++++++++++++++++++++++++++ crypto.go | 5 +- 2 files changed, 325 insertions(+), 3 deletions(-) diff --git a/config_test.go b/config_test.go index 89a63224..27d13d6d 100644 --- a/config_test.go +++ b/config_test.go @@ -154,3 +154,326 @@ func mustJSON(val any) []byte { } return result } + +// Test certificate and key for bundle mode tests +const testCertPEM = `-----BEGIN CERTIFICATE----- +MIIBgDCCASegAwIBAgIUZ8ef3RJ8VIYFnqsK11i74ms+T+8wCgYIKoZIzj0EAwIw +FjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wHhcNMjUxMjAyMTE1NjE4WhcNMjYxMjAy +MTE1NjE4WjAWMRQwEgYDVQQDDAtleGFtcGxlLmNvbTBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABEG5s2FbSkBKBImV4mv5k6iXX7bC23oVC/8pxuPCMCV/CpWpBbnB +CagGQ/xjeMsfdFLVMmYWhvvUtvwLC7dCr0mjUzBRMB0GA1UdDgQWBBSIa6X5luCf +7PXFyTJI1j7hNZD1wzAfBgNVHSMEGDAWgBSIa6X5luCf7PXFyTJI1j7hNZD1wzAP +BgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFFrJ+/KgnOAFr+/mgW0 +Aha54okhtZ2xfc/BmoxBrQ10AiAH/nAINmhmDbj+l5Q8g9wFbWz4tLHJmJwKVQBG +zywvYA== +-----END CERTIFICATE-----` + +const testKeyPEM = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIL+JOk55ogoK9AyCEep1ao1Rhbb1RCFma0kMzu3znvJ6oAoGCCqGSM49 +AwEHoUQDQgAEQbmzYVtKQEoEiZXia/mTqJdftsLbehUL/ynG48IwJX8KlakFucEJ +qAZD/GN4yx90UtUyZhaG+9S2/AsLt0KvSQ== +-----END EC PRIVATE KEY-----` + +func TestStorageModeLegacy(t *testing.T) { + ctx := context.Background() + + // Set legacy storage mode + originalEnv := os.Getenv(StorageModeEnv) + defer os.Setenv(StorageModeEnv, originalEnv) + os.Setenv(StorageModeEnv, StorageModeLegacy) + + am := &ACMEIssuer{CA: "https://example.com/acme/directory"} + testConfig := &Config{ + Issuers: []Issuer{am}, + Storage: &FileStorage{Path: "./_testdata_tmp_legacy"}, + Logger: defaultTestLogger, + certCache: new(Cache), + } + am.config = testConfig + + testStorageDir := testConfig.Storage.(*FileStorage).Path + defer func() { + err := os.RemoveAll(testStorageDir) + if err != nil { + t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) + } + }() + + domain := "example.com" + certContents := "certificate" + keyContents := "private key" + + cert := CertificateResource{ + SANs: []string{domain}, + PrivateKeyPEM: []byte(keyContents), + CertificatePEM: []byte(certContents), + IssuerData: mustJSON(acme.Certificate{ + URL: "https://example.com/cert", + }), + issuerKey: am.IssuerKey(), + } + + err := testConfig.saveCertResource(ctx, am, cert) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Verify legacy files exist (.key, .crt, .json) + issuerKey := am.IssuerKey() + keyPath := StorageKeys.SitePrivateKey(issuerKey, domain) + certPath := StorageKeys.SiteCert(issuerKey, domain) + metaPath := StorageKeys.SiteMeta(issuerKey, domain) + + if !testConfig.Storage.Exists(ctx, keyPath) { + t.Errorf("Expected private key file to exist at %s", keyPath) + } + if !testConfig.Storage.Exists(ctx, certPath) { + t.Errorf("Expected certificate file to exist at %s", certPath) + } + if !testConfig.Storage.Exists(ctx, metaPath) { + t.Errorf("Expected metadata file to exist at %s", metaPath) + } + + // Verify bundle file does NOT exist + bundlePath := StorageKeys.CertificateResource(issuerKey, domain) + if testConfig.Storage.Exists(ctx, bundlePath) { + t.Errorf("Expected bundle file NOT to exist at %s in legacy mode", bundlePath) + } + + // Verify we can load it back + siteData, err := testConfig.loadCertResource(ctx, am, domain) + if err != nil { + t.Fatalf("Expected no error reading site, got: %v", err) + } + if string(siteData.PrivateKeyPEM) != keyContents { + t.Errorf("Expected private key %q, got %q", keyContents, string(siteData.PrivateKeyPEM)) + } + if string(siteData.CertificatePEM) != certContents { + t.Errorf("Expected certificate %q, got %q", certContents, string(siteData.CertificatePEM)) + } +} + +func TestStorageModeBundle(t *testing.T) { + ctx := context.Background() + + // Set bundle storage mode + originalEnv := os.Getenv(StorageModeEnv) + defer os.Setenv(StorageModeEnv, originalEnv) + os.Setenv(StorageModeEnv, StorageModeBundle) + + am := &ACMEIssuer{CA: "https://example.com/acme/directory"} + testConfig := &Config{ + Issuers: []Issuer{am}, + Storage: &FileStorage{Path: "./_testdata_tmp_bundle"}, + Logger: defaultTestLogger, + certCache: new(Cache), + } + am.config = testConfig + + testStorageDir := testConfig.Storage.(*FileStorage).Path + defer func() { + err := os.RemoveAll(testStorageDir) + if err != nil { + t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) + } + }() + + domain := "example.com" + + cert := CertificateResource{ + SANs: []string{domain}, + PrivateKeyPEM: []byte(testKeyPEM), + CertificatePEM: []byte(testCertPEM), + IssuerData: mustJSON(acme.Certificate{ + URL: "https://example.com/cert", + }), + issuerKey: am.IssuerKey(), + } + + err := testConfig.saveCertResource(ctx, am, cert) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Verify bundle file exists + issuerKey := am.IssuerKey() + bundlePath := StorageKeys.CertificateResource(issuerKey, domain) + + if !testConfig.Storage.Exists(ctx, bundlePath) { + t.Errorf("Expected bundle file to exist at %s", bundlePath) + } + + // Verify legacy files do NOT exist + keyPath := StorageKeys.SitePrivateKey(issuerKey, domain) + certPath := StorageKeys.SiteCert(issuerKey, domain) + metaPath := StorageKeys.SiteMeta(issuerKey, domain) + + if testConfig.Storage.Exists(ctx, keyPath) { + t.Errorf("Expected private key file NOT to exist at %s in bundle mode", keyPath) + } + if testConfig.Storage.Exists(ctx, certPath) { + t.Errorf("Expected certificate file NOT to exist at %s in bundle mode", certPath) + } + if testConfig.Storage.Exists(ctx, metaPath) { + t.Errorf("Expected metadata file NOT to exist at %s in bundle mode", metaPath) + } + + // Verify we can load it back + siteData, err := testConfig.loadCertResource(ctx, am, domain) + if err != nil { + t.Fatalf("Expected no error reading site, got: %v", err) + } + if string(siteData.PrivateKeyPEM) != testKeyPEM { + t.Errorf("Private key mismatch") + } + if string(siteData.CertificatePEM) != testCertPEM { + t.Errorf("Certificate mismatch") + } +} + +func TestStorageModeTransition(t *testing.T) { + ctx := context.Background() + + // Set transition storage mode + originalEnv := os.Getenv(StorageModeEnv) + defer os.Setenv(StorageModeEnv, originalEnv) + os.Setenv(StorageModeEnv, StorageModeTransition) + + am := &ACMEIssuer{CA: "https://example.com/acme/directory"} + testConfig := &Config{ + Issuers: []Issuer{am}, + Storage: &FileStorage{Path: "./_testdata_tmp_transition"}, + Logger: defaultTestLogger, + certCache: new(Cache), + } + am.config = testConfig + + testStorageDir := testConfig.Storage.(*FileStorage).Path + defer func() { + err := os.RemoveAll(testStorageDir) + if err != nil { + t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) + } + }() + + domain := "example.com" + + cert := CertificateResource{ + SANs: []string{domain}, + PrivateKeyPEM: []byte(testKeyPEM), + CertificatePEM: []byte(testCertPEM), + IssuerData: mustJSON(acme.Certificate{ + URL: "https://example.com/cert", + }), + issuerKey: am.IssuerKey(), + } + + err := testConfig.saveCertResource(ctx, am, cert) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Verify BOTH legacy and bundle files exist in transition mode + issuerKey := am.IssuerKey() + keyPath := StorageKeys.SitePrivateKey(issuerKey, domain) + certPath := StorageKeys.SiteCert(issuerKey, domain) + metaPath := StorageKeys.SiteMeta(issuerKey, domain) + bundlePath := StorageKeys.CertificateResource(issuerKey, domain) + + if !testConfig.Storage.Exists(ctx, keyPath) { + t.Errorf("Expected private key file to exist at %s in transition mode", keyPath) + } + if !testConfig.Storage.Exists(ctx, certPath) { + t.Errorf("Expected certificate file to exist at %s in transition mode", certPath) + } + if !testConfig.Storage.Exists(ctx, metaPath) { + t.Errorf("Expected metadata file to exist at %s in transition mode", metaPath) + } + if !testConfig.Storage.Exists(ctx, bundlePath) { + t.Errorf("Expected bundle file to exist at %s in transition mode", bundlePath) + } + + // Verify we can load it back (should prefer bundle) + siteData, err := testConfig.loadCertResource(ctx, am, domain) + if err != nil { + t.Fatalf("Expected no error reading site, got: %v", err) + } + if string(siteData.PrivateKeyPEM) != testKeyPEM { + t.Errorf("Private key mismatch") + } + if string(siteData.CertificatePEM) != testCertPEM { + t.Errorf("Certificate mismatch") + } +} + +func TestStorageModeTransitionFallback(t *testing.T) { + ctx := context.Background() + + // Set transition storage mode + originalEnv := os.Getenv(StorageModeEnv) + defer os.Setenv(StorageModeEnv, originalEnv) + os.Setenv(StorageModeEnv, StorageModeTransition) + + am := &ACMEIssuer{CA: "https://example.com/acme/directory"} + testConfig := &Config{ + Issuers: []Issuer{am}, + Storage: &FileStorage{Path: "./_testdata_tmp_transition_fallback"}, + Logger: defaultTestLogger, + certCache: new(Cache), + } + am.config = testConfig + + testStorageDir := testConfig.Storage.(*FileStorage).Path + defer func() { + err := os.RemoveAll(testStorageDir) + if err != nil { + t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) + } + }() + + domain := "example.com" + certContents := "certificate" + keyContents := "private key" + + cert := CertificateResource{ + SANs: []string{domain}, + PrivateKeyPEM: []byte(keyContents), + CertificatePEM: []byte(certContents), + IssuerData: mustJSON(acme.Certificate{ + URL: "https://example.com/cert", + }), + issuerKey: am.IssuerKey(), + } + + // First, save using legacy mode to simulate old data + os.Setenv(StorageModeEnv, StorageModeLegacy) + err := testConfig.saveCertResource(ctx, am, cert) + if err != nil { + t.Fatalf("Expected no error saving in legacy mode, got: %v", err) + } + + // Verify only legacy files exist + issuerKey := am.IssuerKey() + keyPath := StorageKeys.SitePrivateKey(issuerKey, domain) + bundlePath := StorageKeys.CertificateResource(issuerKey, domain) + + if !testConfig.Storage.Exists(ctx, keyPath) { + t.Errorf("Expected private key file to exist at %s", keyPath) + } + if testConfig.Storage.Exists(ctx, bundlePath) { + t.Errorf("Expected bundle file NOT to exist at %s yet", bundlePath) + } + + // Now switch to transition mode and try to load - should fall back to legacy + os.Setenv(StorageModeEnv, StorageModeTransition) + siteData, err := testConfig.loadCertResource(ctx, am, domain) + if err != nil { + t.Fatalf("Expected no error reading site in transition mode with fallback, got: %v", err) + } + if string(siteData.PrivateKeyPEM) != keyContents { + t.Errorf("Expected private key %q, got %q", keyContents, string(siteData.PrivateKeyPEM)) + } + if string(siteData.CertificatePEM) != certContents { + t.Errorf("Expected certificate %q, got %q", certContents, string(siteData.CertificatePEM)) + } +} diff --git a/crypto.go b/crypto.go index 1114a5e4..18647350 100644 --- a/crypto.go +++ b/crypto.go @@ -34,7 +34,6 @@ import ( "sort" "strings" - "github.com/caarlos0/log" "github.com/klauspost/cpuid/v2" "github.com/zeebo/blake3" "go.uber.org/zap" @@ -160,7 +159,7 @@ func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert Cer // Errors that occured while storing the bundle will only be logged as warning. // The legacy storage mechanism still determines the final success or failure. if err := cfg.saveCertResourceBundle(ctx, issuer, cert); err != nil { - log.Warn("unable to store certificate resource bundle", + cfg.Logger.Warn("unable to store certificate resource bundle", zap.String("issuer", issuer.IssuerKey()), zap.String("domain", cert.NamesKey()), zap.Error(err)) @@ -356,7 +355,7 @@ func (cfg *Config) loadCertResourceBundle(ctx context.Context, issuer Issuer, ce } certRes.issuerKey = issuer.IssuerKey() - if _, err := tls.X509KeyPair(certRes.PrivateKeyPEM, certRes.CertificatePEM); err != nil { + if _, err := tls.X509KeyPair(certRes.CertificatePEM, certRes.PrivateKeyPEM); err != nil { return CertificateResource{}, fmt.Errorf("unable to validate integrity of certificate resource bundle for: %v", key) } From 85ab379b7f58d0cc1d4de1fe8b994e4f408c9daf Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Tue, 2 Dec 2025 13:04:52 +0100 Subject: [PATCH 05/23] Refactor tests --- config.go | 2 +- config_test.go | 340 +++++++++++++++---------------------------------- crypto.go | 2 +- 3 files changed, 103 insertions(+), 241 deletions(-) diff --git a/config.go b/config.go index 4b9ee0e8..419b3dcb 100644 --- a/config.go +++ b/config.go @@ -704,7 +704,7 @@ func (cfg *Config) obtainCert(ctx context.Context, name string, interactive bool } err = cfg.saveCertResource(ctx, issuerUsed, certRes) if err != nil { - return fmt.Errorf("[%s] Renew: saving assets: %v", name, err) + return fmt.Errorf("[%s] Obtain: saving assets: %v", name, err) } log.Info("certificate obtained successfully", diff --git a/config_test.go b/config_test.go index 27d13d6d..94c147e1 100644 --- a/config_test.go +++ b/config_test.go @@ -174,306 +174,168 @@ AwEHoUQDQgAEQbmzYVtKQEoEiZXia/mTqJdftsLbehUL/ynG48IwJX8KlakFucEJ qAZD/GN4yx90UtUyZhaG+9S2/AsLt0KvSQ== -----END EC PRIVATE KEY-----` -func TestStorageModeLegacy(t *testing.T) { - ctx := context.Background() - - // Set legacy storage mode +// testStorageModeSetup creates a test config with the specified storage mode +func testStorageModeSetup(t *testing.T, mode, storagePath string) (*Config, *ACMEIssuer, func()) { + t.Helper() originalEnv := os.Getenv(StorageModeEnv) - defer os.Setenv(StorageModeEnv, originalEnv) - os.Setenv(StorageModeEnv, StorageModeLegacy) + os.Setenv(StorageModeEnv, mode) am := &ACMEIssuer{CA: "https://example.com/acme/directory"} - testConfig := &Config{ + cfg := &Config{ Issuers: []Issuer{am}, - Storage: &FileStorage{Path: "./_testdata_tmp_legacy"}, + Storage: &FileStorage{Path: storagePath}, Logger: defaultTestLogger, certCache: new(Cache), } - am.config = testConfig - - testStorageDir := testConfig.Storage.(*FileStorage).Path - defer func() { - err := os.RemoveAll(testStorageDir) - if err != nil { - t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) - } - }() - - domain := "example.com" - certContents := "certificate" - keyContents := "private key" - - cert := CertificateResource{ - SANs: []string{domain}, - PrivateKeyPEM: []byte(keyContents), - CertificatePEM: []byte(certContents), - IssuerData: mustJSON(acme.Certificate{ - URL: "https://example.com/cert", - }), - issuerKey: am.IssuerKey(), - } + am.config = cfg - err := testConfig.saveCertResource(ctx, am, cert) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) + cleanup := func() { + os.Setenv(StorageModeEnv, originalEnv) + os.RemoveAll(storagePath) } - // Verify legacy files exist (.key, .crt, .json) - issuerKey := am.IssuerKey() - keyPath := StorageKeys.SitePrivateKey(issuerKey, domain) - certPath := StorageKeys.SiteCert(issuerKey, domain) - metaPath := StorageKeys.SiteMeta(issuerKey, domain) + return cfg, am, cleanup +} - if !testConfig.Storage.Exists(ctx, keyPath) { - t.Errorf("Expected private key file to exist at %s", keyPath) - } - if !testConfig.Storage.Exists(ctx, certPath) { - t.Errorf("Expected certificate file to exist at %s", certPath) - } - if !testConfig.Storage.Exists(ctx, metaPath) { - t.Errorf("Expected metadata file to exist at %s", metaPath) +// makeCertResource creates a test certificate resource +func makeCertResource(am *ACMEIssuer, domain string, useLegacyContent bool) CertificateResource { + var keyPEM, certPEM []byte + if useLegacyContent { + keyPEM, certPEM = []byte("private key"), []byte("certificate") + } else { + keyPEM, certPEM = []byte(testKeyPEM), []byte(testCertPEM) } - // Verify bundle file does NOT exist - bundlePath := StorageKeys.CertificateResource(issuerKey, domain) - if testConfig.Storage.Exists(ctx, bundlePath) { - t.Errorf("Expected bundle file NOT to exist at %s in legacy mode", bundlePath) + return CertificateResource{ + SANs: []string{domain}, + PrivateKeyPEM: keyPEM, + CertificatePEM: certPEM, + IssuerData: mustJSON(acme.Certificate{URL: "https://example.com/cert"}), + issuerKey: am.IssuerKey(), } +} - // Verify we can load it back - siteData, err := testConfig.loadCertResource(ctx, am, domain) - if err != nil { - t.Fatalf("Expected no error reading site, got: %v", err) +// assertFileExists checks if a file exists at the given path +func assertFileExists(t *testing.T, ctx context.Context, storage Storage, path string, shouldExist bool) { + t.Helper() + exists := storage.Exists(ctx, path) + if shouldExist && !exists { + t.Errorf("Expected file to exist at %s", path) + } else if !shouldExist && exists { + t.Errorf("Expected file NOT to exist at %s", path) } - if string(siteData.PrivateKeyPEM) != keyContents { - t.Errorf("Expected private key %q, got %q", keyContents, string(siteData.PrivateKeyPEM)) +} + +// assertCertResourceContent verifies the loaded certificate matches expected content +func assertCertResourceContent(t *testing.T, loaded CertificateResource, expectedKey, expectedCert string) { + t.Helper() + if string(loaded.PrivateKeyPEM) != expectedKey { + t.Errorf("Private key mismatch: expected %q, got %q", expectedKey, string(loaded.PrivateKeyPEM)) } - if string(siteData.CertificatePEM) != certContents { - t.Errorf("Expected certificate %q, got %q", certContents, string(siteData.CertificatePEM)) + if string(loaded.CertificatePEM) != expectedCert { + t.Errorf("Certificate mismatch: expected %q, got %q", expectedCert, string(loaded.CertificatePEM)) } } -func TestStorageModeBundle(t *testing.T) { +func TestStorageModeLegacy(t *testing.T) { ctx := context.Background() - - // Set bundle storage mode - originalEnv := os.Getenv(StorageModeEnv) - defer os.Setenv(StorageModeEnv, originalEnv) - os.Setenv(StorageModeEnv, StorageModeBundle) - - am := &ACMEIssuer{CA: "https://example.com/acme/directory"} - testConfig := &Config{ - Issuers: []Issuer{am}, - Storage: &FileStorage{Path: "./_testdata_tmp_bundle"}, - Logger: defaultTestLogger, - certCache: new(Cache), - } - am.config = testConfig - - testStorageDir := testConfig.Storage.(*FileStorage).Path - defer func() { - err := os.RemoveAll(testStorageDir) - if err != nil { - t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) - } - }() + cfg, am, cleanup := testStorageModeSetup(t, StorageModeLegacy, "./_testdata_tmp_legacy") + defer cleanup() domain := "example.com" + cert := makeCertResource(am, domain, true) - cert := CertificateResource{ - SANs: []string{domain}, - PrivateKeyPEM: []byte(testKeyPEM), - CertificatePEM: []byte(testCertPEM), - IssuerData: mustJSON(acme.Certificate{ - URL: "https://example.com/cert", - }), - issuerKey: am.IssuerKey(), - } - - err := testConfig.saveCertResource(ctx, am, cert) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) + if err := cfg.saveCertResource(ctx, am, cert); err != nil { + t.Fatalf("Failed to save cert resource: %v", err) } - // Verify bundle file exists issuerKey := am.IssuerKey() - bundlePath := StorageKeys.CertificateResource(issuerKey, domain) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.CertificateResource(issuerKey, domain), false) - if !testConfig.Storage.Exists(ctx, bundlePath) { - t.Errorf("Expected bundle file to exist at %s", bundlePath) + loaded, err := cfg.loadCertResource(ctx, am, domain) + if err != nil { + t.Fatalf("Failed to load cert resource: %v", err) } + assertCertResourceContent(t, loaded, "private key", "certificate") +} - // Verify legacy files do NOT exist - keyPath := StorageKeys.SitePrivateKey(issuerKey, domain) - certPath := StorageKeys.SiteCert(issuerKey, domain) - metaPath := StorageKeys.SiteMeta(issuerKey, domain) +func TestStorageModeBundle(t *testing.T) { + ctx := context.Background() + cfg, am, cleanup := testStorageModeSetup(t, StorageModeBundle, "./_testdata_tmp_bundle") + defer cleanup() - if testConfig.Storage.Exists(ctx, keyPath) { - t.Errorf("Expected private key file NOT to exist at %s in bundle mode", keyPath) - } - if testConfig.Storage.Exists(ctx, certPath) { - t.Errorf("Expected certificate file NOT to exist at %s in bundle mode", certPath) - } - if testConfig.Storage.Exists(ctx, metaPath) { - t.Errorf("Expected metadata file NOT to exist at %s in bundle mode", metaPath) + domain := "example.com" + cert := makeCertResource(am, domain, false) + + if err := cfg.saveCertResource(ctx, am, cert); err != nil { + t.Fatalf("Failed to save cert resource: %v", err) } - // Verify we can load it back - siteData, err := testConfig.loadCertResource(ctx, am, domain) + issuerKey := am.IssuerKey() + assertFileExists(t, ctx, cfg.Storage, StorageKeys.CertificateResource(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), false) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), false) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), false) + + loaded, err := cfg.loadCertResource(ctx, am, domain) if err != nil { - t.Fatalf("Expected no error reading site, got: %v", err) - } - if string(siteData.PrivateKeyPEM) != testKeyPEM { - t.Errorf("Private key mismatch") - } - if string(siteData.CertificatePEM) != testCertPEM { - t.Errorf("Certificate mismatch") + t.Fatalf("Failed to load cert resource: %v", err) } + assertCertResourceContent(t, loaded, testKeyPEM, testCertPEM) } func TestStorageModeTransition(t *testing.T) { ctx := context.Background() - - // Set transition storage mode - originalEnv := os.Getenv(StorageModeEnv) - defer os.Setenv(StorageModeEnv, originalEnv) - os.Setenv(StorageModeEnv, StorageModeTransition) - - am := &ACMEIssuer{CA: "https://example.com/acme/directory"} - testConfig := &Config{ - Issuers: []Issuer{am}, - Storage: &FileStorage{Path: "./_testdata_tmp_transition"}, - Logger: defaultTestLogger, - certCache: new(Cache), - } - am.config = testConfig - - testStorageDir := testConfig.Storage.(*FileStorage).Path - defer func() { - err := os.RemoveAll(testStorageDir) - if err != nil { - t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) - } - }() + cfg, am, cleanup := testStorageModeSetup(t, StorageModeTransition, "./_testdata_tmp_transition") + defer cleanup() domain := "example.com" + cert := makeCertResource(am, domain, false) - cert := CertificateResource{ - SANs: []string{domain}, - PrivateKeyPEM: []byte(testKeyPEM), - CertificatePEM: []byte(testCertPEM), - IssuerData: mustJSON(acme.Certificate{ - URL: "https://example.com/cert", - }), - issuerKey: am.IssuerKey(), - } - - err := testConfig.saveCertResource(ctx, am, cert) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) + if err := cfg.saveCertResource(ctx, am, cert); err != nil { + t.Fatalf("Failed to save cert resource: %v", err) } - // Verify BOTH legacy and bundle files exist in transition mode + // Verify BOTH legacy and bundle files exist issuerKey := am.IssuerKey() - keyPath := StorageKeys.SitePrivateKey(issuerKey, domain) - certPath := StorageKeys.SiteCert(issuerKey, domain) - metaPath := StorageKeys.SiteMeta(issuerKey, domain) - bundlePath := StorageKeys.CertificateResource(issuerKey, domain) - - if !testConfig.Storage.Exists(ctx, keyPath) { - t.Errorf("Expected private key file to exist at %s in transition mode", keyPath) - } - if !testConfig.Storage.Exists(ctx, certPath) { - t.Errorf("Expected certificate file to exist at %s in transition mode", certPath) - } - if !testConfig.Storage.Exists(ctx, metaPath) { - t.Errorf("Expected metadata file to exist at %s in transition mode", metaPath) - } - if !testConfig.Storage.Exists(ctx, bundlePath) { - t.Errorf("Expected bundle file to exist at %s in transition mode", bundlePath) - } + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.CertificateResource(issuerKey, domain), true) - // Verify we can load it back (should prefer bundle) - siteData, err := testConfig.loadCertResource(ctx, am, domain) + loaded, err := cfg.loadCertResource(ctx, am, domain) if err != nil { - t.Fatalf("Expected no error reading site, got: %v", err) - } - if string(siteData.PrivateKeyPEM) != testKeyPEM { - t.Errorf("Private key mismatch") - } - if string(siteData.CertificatePEM) != testCertPEM { - t.Errorf("Certificate mismatch") + t.Fatalf("Failed to load cert resource: %v", err) } + assertCertResourceContent(t, loaded, testKeyPEM, testCertPEM) } func TestStorageModeTransitionFallback(t *testing.T) { ctx := context.Background() - - // Set transition storage mode - originalEnv := os.Getenv(StorageModeEnv) - defer os.Setenv(StorageModeEnv, originalEnv) - os.Setenv(StorageModeEnv, StorageModeTransition) - - am := &ACMEIssuer{CA: "https://example.com/acme/directory"} - testConfig := &Config{ - Issuers: []Issuer{am}, - Storage: &FileStorage{Path: "./_testdata_tmp_transition_fallback"}, - Logger: defaultTestLogger, - certCache: new(Cache), - } - am.config = testConfig - - testStorageDir := testConfig.Storage.(*FileStorage).Path - defer func() { - err := os.RemoveAll(testStorageDir) - if err != nil { - t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) - } - }() + cfg, am, cleanup := testStorageModeSetup(t, StorageModeTransition, "./_testdata_tmp_transition_fallback") + defer cleanup() domain := "example.com" - certContents := "certificate" - keyContents := "private key" - - cert := CertificateResource{ - SANs: []string{domain}, - PrivateKeyPEM: []byte(keyContents), - CertificatePEM: []byte(certContents), - IssuerData: mustJSON(acme.Certificate{ - URL: "https://example.com/cert", - }), - issuerKey: am.IssuerKey(), - } + cert := makeCertResource(am, domain, true) - // First, save using legacy mode to simulate old data + // Save in legacy mode to simulate existing data os.Setenv(StorageModeEnv, StorageModeLegacy) - err := testConfig.saveCertResource(ctx, am, cert) - if err != nil { - t.Fatalf("Expected no error saving in legacy mode, got: %v", err) + if err := cfg.saveCertResource(ctx, am, cert); err != nil { + t.Fatalf("Failed to save cert in legacy mode: %v", err) } - // Verify only legacy files exist issuerKey := am.IssuerKey() - keyPath := StorageKeys.SitePrivateKey(issuerKey, domain) - bundlePath := StorageKeys.CertificateResource(issuerKey, domain) - - if !testConfig.Storage.Exists(ctx, keyPath) { - t.Errorf("Expected private key file to exist at %s", keyPath) - } - if testConfig.Storage.Exists(ctx, bundlePath) { - t.Errorf("Expected bundle file NOT to exist at %s yet", bundlePath) - } + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.CertificateResource(issuerKey, domain), false) - // Now switch to transition mode and try to load - should fall back to legacy + // Switch to transition mode and verify fallback to legacy works os.Setenv(StorageModeEnv, StorageModeTransition) - siteData, err := testConfig.loadCertResource(ctx, am, domain) + loaded, err := cfg.loadCertResource(ctx, am, domain) if err != nil { - t.Fatalf("Expected no error reading site in transition mode with fallback, got: %v", err) - } - if string(siteData.PrivateKeyPEM) != keyContents { - t.Errorf("Expected private key %q, got %q", keyContents, string(siteData.PrivateKeyPEM)) - } - if string(siteData.CertificatePEM) != certContents { - t.Errorf("Expected certificate %q, got %q", certContents, string(siteData.CertificatePEM)) + t.Fatalf("Failed to load cert in transition mode with fallback: %v", err) } + assertCertResourceContent(t, loaded, "private key", "certificate") } diff --git a/crypto.go b/crypto.go index 18647350..eac3589f 100644 --- a/crypto.go +++ b/crypto.go @@ -356,7 +356,7 @@ func (cfg *Config) loadCertResourceBundle(ctx context.Context, issuer Issuer, ce certRes.issuerKey = issuer.IssuerKey() if _, err := tls.X509KeyPair(certRes.CertificatePEM, certRes.PrivateKeyPEM); err != nil { - return CertificateResource{}, fmt.Errorf("unable to validate integrity of certificate resource bundle for: %v", key) + return CertificateResource{}, fmt.Errorf("unable to validate integrity of certificate resource bundle for %q: %w", key, err) } return certRes, nil From 2c061609bc5b49dfb8e06d5a5f14f9bdd8b1fc2f Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Fri, 5 Dec 2025 10:59:34 +0100 Subject: [PATCH 06/23] Remove obsolete integrity check --- crypto.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crypto.go b/crypto.go index eac3589f..26b2e25b 100644 --- a/crypto.go +++ b/crypto.go @@ -355,10 +355,6 @@ func (cfg *Config) loadCertResourceBundle(ctx context.Context, issuer Issuer, ce } certRes.issuerKey = issuer.IssuerKey() - if _, err := tls.X509KeyPair(certRes.CertificatePEM, certRes.PrivateKeyPEM); err != nil { - return CertificateResource{}, fmt.Errorf("unable to validate integrity of certificate resource bundle for %q: %w", key, err) - } - return certRes, nil } From f3ca8400aa0c23abd092170d8a309bb353d8c477 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Mon, 8 Dec 2025 14:07:19 +0100 Subject: [PATCH 07/23] Apply storage mode to storageHasCertResources --- config.go | 32 +++++++++++++++++++++++++++++++- config_test.go | 8 ++++---- crypto.go | 16 +++++++++++++++- storage.go | 4 ++-- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/config.go b/config.go index 419b3dcb..d263e6ab 100644 --- a/config.go +++ b/config.go @@ -32,6 +32,7 @@ import ( "net" "net/http" "net/url" + "os" "strings" "time" @@ -1268,9 +1269,29 @@ func (cfg *Config) checkStorage(ctx context.Context) error { // storageHasCertResources returns true if the storage // associated with cfg's certificate cache has all the +// resources related to the certificate for domain. +func (cfg *Config) storageHasCertResources(ctx context.Context, issuer Issuer, domain string) bool { + switch os.Getenv(StorageModeEnv) { + case StorageModeTransition: + if cfg.storageHasCertResourcesBundle(ctx, issuer, domain) { + return true + } + if cfg.storageHasCertResourcesLegacy(ctx, issuer, domain) { + return true + } + return false + case StorageModeBundle: + return cfg.storageHasCertResourcesBundle(ctx, issuer, domain) + default: + return cfg.storageHasCertResourcesLegacy(ctx, issuer, domain) + } +} + +// storageHasCertResourcesLegacy returns true if the storage +// associated with cfg's certificate cache has all the // resources related to the certificate for domain: the // certificate, the private key, and the metadata. -func (cfg *Config) storageHasCertResources(ctx context.Context, issuer Issuer, domain string) bool { +func (cfg *Config) storageHasCertResourcesLegacy(ctx context.Context, issuer Issuer, domain string) bool { issuerKey := issuer.IssuerKey() certKey := StorageKeys.SiteCert(issuerKey, domain) keyKey := StorageKeys.SitePrivateKey(issuerKey, domain) @@ -1280,6 +1301,15 @@ func (cfg *Config) storageHasCertResources(ctx context.Context, issuer Issuer, d cfg.Storage.Exists(ctx, metaKey) } +// storageHasCertResourcesBundle returns true if the storage +// associated with cfg's certificate cache has the +// certificate resource bundle for domain. +func (cfg *Config) storageHasCertResourcesBundle(ctx context.Context, issuer Issuer, domain string) bool { + issuerKey := issuer.IssuerKey() + certBundle := StorageKeys.SiteBundle(issuerKey, domain) + return cfg.Storage.Exists(ctx, certBundle) +} + // deleteSiteAssets deletes the folder in storage containing the // certificate, private key, and metadata file for domain from the // issuer with the given issuer key. diff --git a/config_test.go b/config_test.go index 94c147e1..36fd9b24 100644 --- a/config_test.go +++ b/config_test.go @@ -253,7 +253,7 @@ func TestStorageModeLegacy(t *testing.T) { assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), true) assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.CertificateResource(issuerKey, domain), false) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain), false) loaded, err := cfg.loadCertResource(ctx, am, domain) if err != nil { @@ -275,7 +275,7 @@ func TestStorageModeBundle(t *testing.T) { } issuerKey := am.IssuerKey() - assertFileExists(t, ctx, cfg.Storage, StorageKeys.CertificateResource(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain), true) assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), false) assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), false) assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), false) @@ -304,7 +304,7 @@ func TestStorageModeTransition(t *testing.T) { assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), true) assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.CertificateResource(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain), true) loaded, err := cfg.loadCertResource(ctx, am, domain) if err != nil { @@ -329,7 +329,7 @@ func TestStorageModeTransitionFallback(t *testing.T) { issuerKey := am.IssuerKey() assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.CertificateResource(issuerKey, domain), false) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain), false) // Switch to transition mode and verify fallback to legacy works os.Setenv(StorageModeEnv, StorageModeTransition) diff --git a/crypto.go b/crypto.go index 26b2e25b..33a0416e 100644 --- a/crypto.go +++ b/crypto.go @@ -142,6 +142,20 @@ func fastHash(input []byte) string { } const ( + // Storage mode controls the format in which certificates are stored in `Storage`. + // + // Formats: + // - legacy: Store cert, privkey and meta as three separate storage items (.cert, .key, .json). + // - bundle: Store cert, privkey and meta as a single, bundled storage item (.bundle). + // + // Modes: + // - legacy: Store and load certificates in legacy format. + // - transition: Store and load certificates in legacy and bundle format. + // - bundle: Store and load certificates in bundle format. + // + // In the transition mode, failures around reads and writes of the bundle are soft. + // They should only log errors and try to work with the legacy format as fallback. + // Operations on the legacy format are hard-failures, implying that errors should be propagated up. StorageModeEnv = "CERTMAGIC_STORAGE_MODE" StorageModeLegacy = "legacy" @@ -216,7 +230,7 @@ func (cfg *Config) saveCertResourceBundle(ctx context.Context, issuer Issuer, ce issuerKey := issuer.IssuerKey() certKey := cert.NamesKey() - key := StorageKeys.CertificateResource(issuerKey, certKey) + key := StorageKeys.SiteBundle(issuerKey, certKey) return cfg.Storage.Store(ctx, key, encoded) } diff --git a/storage.go b/storage.go index 4d192017..03400d0a 100644 --- a/storage.go +++ b/storage.go @@ -250,10 +250,10 @@ func (keys KeyBuilder) SiteMeta(issuerKey, domain string) string { return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".json") } -// CertificateResource returns the path to the resource file for domain that +// SiteBundle returns the path to the resource file for domain that // is associated with the certificate from the given issuer with // the given issuerKey. -func (keys KeyBuilder) CertificateResource(issuerKey, domain string) string { +func (keys KeyBuilder) SiteBundle(issuerKey, domain string) string { safeDomain := keys.Safe(domain) return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".bundle") } From 9e99fd5bd3d00e5992df6f9ef53eefbaedebd19c Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Mon, 8 Dec 2025 15:26:45 +0100 Subject: [PATCH 08/23] Apply storage mode to deleteSiteAssets --- config.go | 30 ++++++++++++++++++++++++++++++ crypto.go | 15 +++------------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/config.go b/config.go index d263e6ab..bd8b5a85 100644 --- a/config.go +++ b/config.go @@ -1314,6 +1314,26 @@ func (cfg *Config) storageHasCertResourcesBundle(ctx context.Context, issuer Iss // certificate, private key, and metadata file for domain from the // issuer with the given issuer key. func (cfg *Config) deleteSiteAssets(ctx context.Context, issuerKey, domain string) error { + switch os.Getenv(StorageModeEnv) { + case StorageModeTransition: + if err := cfg.deleteSiteAssetsBundle(ctx, issuerKey, domain); err != nil { + cfg.Logger.Warn("unable to delete certificate resource bundle", + zap.String("issuer", issuerKey), + zap.String("domain", domain), + zap.Error(err)) + } + return cfg.deleteSiteAssetsLegacy(ctx, issuerKey, domain) + case StorageModeBundle: + return cfg.deleteSiteAssetsBundle(ctx, issuerKey, domain) + default: + return cfg.deleteSiteAssetsLegacy(ctx, issuerKey, domain) + } +} + +// deleteSiteAssetsLegacy deletes the folder in storage containing the +// certificate, private key, and metadata file for domain from the +// issuer with the given issuer key. +func (cfg *Config) deleteSiteAssetsLegacy(ctx context.Context, issuerKey, domain string) error { err := cfg.Storage.Delete(ctx, StorageKeys.SiteCert(issuerKey, domain)) if err != nil { return fmt.Errorf("deleting certificate file: %v", err) @@ -1333,6 +1353,16 @@ func (cfg *Config) deleteSiteAssets(ctx context.Context, issuerKey, domain strin return nil } +// deleteSiteAssetsBundle deletes the folder in storage containing the +// certificate bundle for domain from the issuer with the given issuer key. +func (cfg *Config) deleteSiteAssetsBundle(ctx context.Context, issuerKey, domain string) error { + err := cfg.Storage.Delete(ctx, StorageKeys.SiteBundle(issuerKey, domain)) + if err != nil { + return fmt.Errorf("deleting certificate bundle: %v", err) + } + return nil +} + // lockKey returns a key for a lock that is specific to the operation // named op being performed related to domainName and this config's CA. func (cfg *Config) lockKey(op, domainName string) string { diff --git a/crypto.go b/crypto.go index 33a0416e..ec5d2b38 100644 --- a/crypto.go +++ b/crypto.go @@ -168,10 +168,6 @@ const ( func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert CertificateResource) error { switch os.Getenv(StorageModeEnv) { case StorageModeTransition: - // In transition storage mode, certificate resources will be stored as a bundle - // in addition to the legacy storage mechanism. - // Errors that occured while storing the bundle will only be logged as warning. - // The legacy storage mechanism still determines the final success or failure. if err := cfg.saveCertResourceBundle(ctx, issuer, cert); err != nil { cfg.Logger.Warn("unable to store certificate resource bundle", zap.String("issuer", issuer.IssuerKey()), @@ -180,11 +176,8 @@ func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert Cer } return cfg.saveCertResourceLegacy(ctx, issuer, cert) case StorageModeBundle: - // In bundle storage mode, certifiate resources will be stored as a single ".bundle" entity. return cfg.saveCertResourceBundle(ctx, issuer, cert) default: - // In legacy storage mode, certifiate resources will be stored unmodified - // in their seperate ".key", ".cert", ".json" entities. return cfg.saveCertResourceLegacy(ctx, issuer, cert) } } @@ -302,16 +295,14 @@ func (cfg *Config) loadCertResourceAnyIssuer(ctx context.Context, certNamesKey s func (cfg *Config) loadCertResource(ctx context.Context, issuer Issuer, certNamesKey string) (CertificateResource, error) { switch os.Getenv(StorageModeEnv) { - case StorageModeBundle: - return cfg.loadCertResourceBundle(ctx, issuer, certNamesKey) case StorageModeTransition: - // Try loading the certificate from the bundle first. - // Fallback to legacy storage on failure. certRes, err := cfg.loadCertResourceBundle(ctx, issuer, certNamesKey) if err == nil { return certRes, nil } return cfg.loadCertResourceLegacy(ctx, issuer, certNamesKey) + case StorageModeBundle: + return cfg.loadCertResourceBundle(ctx, issuer, certNamesKey) default: return cfg.loadCertResourceLegacy(ctx, issuer, certNamesKey) } @@ -357,7 +348,7 @@ func (cfg *Config) loadCertResourceBundle(ctx context.Context, issuer Issuer, ce return CertificateResource{}, fmt.Errorf("converting '%s' to ASCII: %v", certNamesKey, err) } - key := StorageKeys.CertificateResource(issuer.IssuerKey(), normalizedName) + key := StorageKeys.SiteBundle(issuer.IssuerKey(), normalizedName) encoded, err := cfg.Storage.Load(ctx, key) if err != nil { return CertificateResource{}, err From c7ecb0ae3401fa9ccf61fe4c6dc6d1f0cc046ebf Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Mon, 8 Dec 2025 15:26:55 +0100 Subject: [PATCH 09/23] Apply storage mode to loadStoredACMECertificateMetadata --- maintain.go | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/maintain.go b/maintain.go index bda4a93f..dbcdc316 100644 --- a/maintain.go +++ b/maintain.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "io/fs" + "os" "path" "runtime" "strings" @@ -427,9 +428,25 @@ func (cfg *Config) storageHasNewerARI(ctx context.Context, cert Certificate) (bo return false, acme.RenewalInfo{}, nil } -// loadStoredACMECertificateMetadata loads the stored ACME certificate data -// from the cert's sidecar JSON file. +// loadStoredACMECertificateMetadata loads the stored ACME certificate data. func (cfg *Config) loadStoredACMECertificateMetadata(ctx context.Context, cert Certificate) (acme.Certificate, error) { + switch os.Getenv(StorageModeEnv) { + case StorageModeTransition: + acmecert, err := cfg.loadStoredACMECertificateMetadataBundle(ctx, cert) + if err == nil { + return acmecert, nil + } + return cfg.loadStoredACMECertificateMetadataLegacy(ctx, cert) + case StorageModeBundle: + return cfg.loadStoredACMECertificateMetadataBundle(ctx, cert) + default: + return cfg.loadStoredACMECertificateMetadataLegacy(ctx, cert) + } +} + +// loadStoredACMECertificateMetadataLegacy loads the stored ACME certificate data +// from the cert's sidecar JSON file. +func (cfg *Config) loadStoredACMECertificateMetadataLegacy(ctx context.Context, cert Certificate) (acme.Certificate, error) { metaBytes, err := cfg.Storage.Load(ctx, StorageKeys.SiteMeta(cert.issuerKey, cert.Names[0])) if err != nil { return acme.Certificate{}, fmt.Errorf("loading cert metadata: %w", err) @@ -448,6 +465,26 @@ func (cfg *Config) loadStoredACMECertificateMetadata(ctx context.Context, cert C return acmeCert, nil } +// loadStoredACMECertificateMetadataBundle loads the stored ACME certificate data from the cert bundle. +func (cfg *Config) loadStoredACMECertificateMetadataBundle(ctx context.Context, cert Certificate) (acme.Certificate, error) { + bundleBytes, err := cfg.Storage.Load(ctx, StorageKeys.SiteBundle(cert.issuerKey, cert.Names[0])) + if err != nil { + return acme.Certificate{}, fmt.Errorf("loading cert metadata: %w", err) + } + + certRes, err := decodeCertResource(bundleBytes) + if err != nil { + return acme.Certificate{}, fmt.Errorf("unmarshaling cert metadata: %w", err) + } + + var acmeCert acme.Certificate + if err = json.Unmarshal(certRes.IssuerData, &acmeCert); err != nil { + return acme.Certificate{}, fmt.Errorf("unmarshaling potential ACME issuer metadata: %v", err) + } + + return acmeCert, nil +} + // updateARI updates the cert's ACME renewal info, first by checking storage for a newer // one, or getting it from the CA if needed. The updated info is stored in storage and // updated in the cache. The certificate with the updated ARI is returned. If true is From f7ee18a99b3f78fa6287bfbd1949dad432588448 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Mon, 8 Dec 2025 15:29:29 +0100 Subject: [PATCH 10/23] Move StorageMode config to certmagic.go --- certmagic.go | 22 ++++++++++++++++++++++ crypto.go | 22 ---------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/certmagic.go b/certmagic.go index 322a0f1b..3861138f 100644 --- a/certmagic.go +++ b/certmagic.go @@ -499,3 +499,25 @@ var ( // Maximum size for the stack trace when recovering from panics. const stackTraceBufferSize = 1024 * 128 + +const ( + // Storage mode controls the format in which certificates are stored in `Storage`. + // + // Formats: + // - legacy: Store cert, privkey and meta as three separate storage items (.cert, .key, .json). + // - bundle: Store cert, privkey and meta as a single, bundled storage item (.bundle). + // + // Modes: + // - legacy: Store and load certificates in legacy format. + // - transition: Store and load certificates in legacy and bundle format. + // - bundle: Store and load certificates in bundle format. + // + // In the transition mode, failures around reads and writes of the bundle are soft. + // They should only log errors and try to work with the legacy format as fallback. + // Operations on the legacy format are hard-failures, implying that errors should be propagated up. + StorageModeEnv = "CERTMAGIC_STORAGE_MODE" + + StorageModeLegacy = "legacy" + StorageModeTransition = "transition" + StorageModeBundle = "bundle" +) diff --git a/crypto.go b/crypto.go index ec5d2b38..be08124f 100644 --- a/crypto.go +++ b/crypto.go @@ -141,28 +141,6 @@ func fastHash(input []byte) string { return fmt.Sprintf("%x", h.Sum32()) } -const ( - // Storage mode controls the format in which certificates are stored in `Storage`. - // - // Formats: - // - legacy: Store cert, privkey and meta as three separate storage items (.cert, .key, .json). - // - bundle: Store cert, privkey and meta as a single, bundled storage item (.bundle). - // - // Modes: - // - legacy: Store and load certificates in legacy format. - // - transition: Store and load certificates in legacy and bundle format. - // - bundle: Store and load certificates in bundle format. - // - // In the transition mode, failures around reads and writes of the bundle are soft. - // They should only log errors and try to work with the legacy format as fallback. - // Operations on the legacy format are hard-failures, implying that errors should be propagated up. - StorageModeEnv = "CERTMAGIC_STORAGE_MODE" - - StorageModeLegacy = "legacy" - StorageModeTransition = "transition" - StorageModeBundle = "bundle" -) - // saveCertResource saves the certificate resource to disk. // It switches storage modes between legacy and bundle mode based on the CERTMAGIC_STORAGE_MODE env. func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert CertificateResource) error { From bb5cf2dd5f4e25b4b923dd25025384ae997e7a8d Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Tue, 9 Dec 2025 09:28:23 +0100 Subject: [PATCH 11/23] Clean up tests --- certmagic.go | 2 ++ config_test.go | 77 +++++++++++++++++--------------------------------- 2 files changed, 28 insertions(+), 51 deletions(-) diff --git a/certmagic.go b/certmagic.go index 3861138f..b8ed0160 100644 --- a/certmagic.go +++ b/certmagic.go @@ -515,6 +515,8 @@ const ( // In the transition mode, failures around reads and writes of the bundle are soft. // They should only log errors and try to work with the legacy format as fallback. // Operations on the legacy format are hard-failures, implying that errors should be propagated up. + // + // The storage mode is controlled via the CERTMAGIC_STORAGE_MODE environment variable StorageModeEnv = "CERTMAGIC_STORAGE_MODE" StorageModeLegacy = "legacy" diff --git a/config_test.go b/config_test.go index 36fd9b24..b62ff48d 100644 --- a/config_test.go +++ b/config_test.go @@ -155,25 +155,6 @@ func mustJSON(val any) []byte { return result } -// Test certificate and key for bundle mode tests -const testCertPEM = `-----BEGIN CERTIFICATE----- -MIIBgDCCASegAwIBAgIUZ8ef3RJ8VIYFnqsK11i74ms+T+8wCgYIKoZIzj0EAwIw -FjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wHhcNMjUxMjAyMTE1NjE4WhcNMjYxMjAy -MTE1NjE4WjAWMRQwEgYDVQQDDAtleGFtcGxlLmNvbTBZMBMGByqGSM49AgEGCCqG -SM49AwEHA0IABEG5s2FbSkBKBImV4mv5k6iXX7bC23oVC/8pxuPCMCV/CpWpBbnB -CagGQ/xjeMsfdFLVMmYWhvvUtvwLC7dCr0mjUzBRMB0GA1UdDgQWBBSIa6X5luCf -7PXFyTJI1j7hNZD1wzAfBgNVHSMEGDAWgBSIa6X5luCf7PXFyTJI1j7hNZD1wzAP -BgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFFrJ+/KgnOAFr+/mgW0 -Aha54okhtZ2xfc/BmoxBrQ10AiAH/nAINmhmDbj+l5Q8g9wFbWz4tLHJmJwKVQBG -zywvYA== ------END CERTIFICATE-----` - -const testKeyPEM = `-----BEGIN EC PRIVATE KEY----- -MHcCAQEEIL+JOk55ogoK9AyCEep1ao1Rhbb1RCFma0kMzu3znvJ6oAoGCCqGSM49 -AwEHoUQDQgAEQbmzYVtKQEoEiZXia/mTqJdftsLbehUL/ynG48IwJX8KlakFucEJ -qAZD/GN4yx90UtUyZhaG+9S2/AsLt0KvSQ== ------END EC PRIVATE KEY-----` - // testStorageModeSetup creates a test config with the specified storage mode func testStorageModeSetup(t *testing.T, mode, storagePath string) (*Config, *ACMEIssuer, func()) { t.Helper() @@ -197,36 +178,30 @@ func testStorageModeSetup(t *testing.T, mode, storagePath string) (*Config, *ACM return cfg, am, cleanup } -// makeCertResource creates a test certificate resource func makeCertResource(am *ACMEIssuer, domain string, useLegacyContent bool) CertificateResource { - var keyPEM, certPEM []byte - if useLegacyContent { - keyPEM, certPEM = []byte("private key"), []byte("certificate") - } else { - keyPEM, certPEM = []byte(testKeyPEM), []byte(testCertPEM) - } - return CertificateResource{ SANs: []string{domain}, - PrivateKeyPEM: keyPEM, - CertificatePEM: certPEM, + PrivateKeyPEM: []byte("private key"), + CertificatePEM: []byte("certificate"), IssuerData: mustJSON(acme.Certificate{URL: "https://example.com/cert"}), issuerKey: am.IssuerKey(), } } -// assertFileExists checks if a file exists at the given path -func assertFileExists(t *testing.T, ctx context.Context, storage Storage, path string, shouldExist bool) { +func assertFileExists(t *testing.T, ctx context.Context, storage Storage, path string) { t.Helper() - exists := storage.Exists(ctx, path) - if shouldExist && !exists { + if !storage.Exists(ctx, path) { t.Errorf("Expected file to exist at %s", path) - } else if !shouldExist && exists { + } +} + +func assertFileNotExists(t *testing.T, ctx context.Context, storage Storage, path string) { + t.Helper() + if storage.Exists(ctx, path) { t.Errorf("Expected file NOT to exist at %s", path) } } -// assertCertResourceContent verifies the loaded certificate matches expected content func assertCertResourceContent(t *testing.T, loaded CertificateResource, expectedKey, expectedCert string) { t.Helper() if string(loaded.PrivateKeyPEM) != expectedKey { @@ -250,10 +225,10 @@ func TestStorageModeLegacy(t *testing.T) { } issuerKey := am.IssuerKey() - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain), false) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain)) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain)) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain)) + assertFileNotExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain)) loaded, err := cfg.loadCertResource(ctx, am, domain) if err != nil { @@ -275,16 +250,16 @@ func TestStorageModeBundle(t *testing.T) { } issuerKey := am.IssuerKey() - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), false) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), false) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), false) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain)) + assertFileNotExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain)) + assertFileNotExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain)) + assertFileNotExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain)) loaded, err := cfg.loadCertResource(ctx, am, domain) if err != nil { t.Fatalf("Failed to load cert resource: %v", err) } - assertCertResourceContent(t, loaded, testKeyPEM, testCertPEM) + assertCertResourceContent(t, loaded, "private key", "certificate") } func TestStorageModeTransition(t *testing.T) { @@ -301,16 +276,16 @@ func TestStorageModeTransition(t *testing.T) { // Verify BOTH legacy and bundle files exist issuerKey := am.IssuerKey() - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain), true) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain)) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteCert(issuerKey, domain)) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteMeta(issuerKey, domain)) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain)) loaded, err := cfg.loadCertResource(ctx, am, domain) if err != nil { t.Fatalf("Failed to load cert resource: %v", err) } - assertCertResourceContent(t, loaded, testKeyPEM, testCertPEM) + assertCertResourceContent(t, loaded, "private key", "certificate") } func TestStorageModeTransitionFallback(t *testing.T) { @@ -328,8 +303,8 @@ func TestStorageModeTransitionFallback(t *testing.T) { } issuerKey := am.IssuerKey() - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain), true) - assertFileExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain), false) + assertFileExists(t, ctx, cfg.Storage, StorageKeys.SitePrivateKey(issuerKey, domain)) + assertFileNotExists(t, ctx, cfg.Storage, StorageKeys.SiteBundle(issuerKey, domain)) // Switch to transition mode and verify fallback to legacy works os.Setenv(StorageModeEnv, StorageModeTransition) From 70470a43f2761353485c25a6de4d265b2b49241e Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Tue, 9 Dec 2025 12:38:12 +0100 Subject: [PATCH 12/23] Update certmagic.go Co-authored-by: ErikBooijFR --- certmagic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certmagic.go b/certmagic.go index b8ed0160..2ff53235 100644 --- a/certmagic.go +++ b/certmagic.go @@ -509,7 +509,7 @@ const ( // // Modes: // - legacy: Store and load certificates in legacy format. - // - transition: Store and load certificates in legacy and bundle format. + // - transition: Store in legacy and bundle format, load as bundle with fallback to legacy format. // - bundle: Store and load certificates in bundle format. // // In the transition mode, failures around reads and writes of the bundle are soft. From c1ec40a9a2a46f33308299d1c9bde376b0d939c3 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Tue, 9 Dec 2025 12:38:22 +0100 Subject: [PATCH 13/23] Update config.go Co-authored-by: ErikBooijFR --- config.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/config.go b/config.go index bd8b5a85..e71c7eab 100644 --- a/config.go +++ b/config.go @@ -1276,10 +1276,7 @@ func (cfg *Config) storageHasCertResources(ctx context.Context, issuer Issuer, d if cfg.storageHasCertResourcesBundle(ctx, issuer, domain) { return true } - if cfg.storageHasCertResourcesLegacy(ctx, issuer, domain) { - return true - } - return false + return cfg.storageHasCertResourcesLegacy(ctx, issuer, domain) case StorageModeBundle: return cfg.storageHasCertResourcesBundle(ctx, issuer, domain) default: From c4a7cbbd9ef22be1d22729647b6165c11aa3ca3b Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Tue, 9 Dec 2025 14:23:21 +0100 Subject: [PATCH 14/23] Simplify test setup --- config_test.go | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/config_test.go b/config_test.go index b62ff48d..9f0ca98d 100644 --- a/config_test.go +++ b/config_test.go @@ -156,10 +156,9 @@ func mustJSON(val any) []byte { } // testStorageModeSetup creates a test config with the specified storage mode -func testStorageModeSetup(t *testing.T, mode, storagePath string) (*Config, *ACMEIssuer, func()) { +func testStorageModeSetup(t *testing.T, mode, storagePath string) (*Config, *ACMEIssuer) { t.Helper() - originalEnv := os.Getenv(StorageModeEnv) - os.Setenv(StorageModeEnv, mode) + t.Setenv(StorageModeEnv, mode) am := &ACMEIssuer{CA: "https://example.com/acme/directory"} cfg := &Config{ @@ -170,12 +169,11 @@ func testStorageModeSetup(t *testing.T, mode, storagePath string) (*Config, *ACM } am.config = cfg - cleanup := func() { - os.Setenv(StorageModeEnv, originalEnv) + t.Cleanup(func() { os.RemoveAll(storagePath) - } + }) - return cfg, am, cleanup + return cfg, am } func makeCertResource(am *ACMEIssuer, domain string, useLegacyContent bool) CertificateResource { @@ -214,8 +212,7 @@ func assertCertResourceContent(t *testing.T, loaded CertificateResource, expecte func TestStorageModeLegacy(t *testing.T) { ctx := context.Background() - cfg, am, cleanup := testStorageModeSetup(t, StorageModeLegacy, "./_testdata_tmp_legacy") - defer cleanup() + cfg, am := testStorageModeSetup(t, StorageModeLegacy, "./_testdata_tmp_legacy") domain := "example.com" cert := makeCertResource(am, domain, true) @@ -239,8 +236,7 @@ func TestStorageModeLegacy(t *testing.T) { func TestStorageModeBundle(t *testing.T) { ctx := context.Background() - cfg, am, cleanup := testStorageModeSetup(t, StorageModeBundle, "./_testdata_tmp_bundle") - defer cleanup() + cfg, am := testStorageModeSetup(t, StorageModeBundle, "./_testdata_tmp_bundle") domain := "example.com" cert := makeCertResource(am, domain, false) @@ -264,8 +260,7 @@ func TestStorageModeBundle(t *testing.T) { func TestStorageModeTransition(t *testing.T) { ctx := context.Background() - cfg, am, cleanup := testStorageModeSetup(t, StorageModeTransition, "./_testdata_tmp_transition") - defer cleanup() + cfg, am := testStorageModeSetup(t, StorageModeTransition, "./_testdata_tmp_transition") domain := "example.com" cert := makeCertResource(am, domain, false) @@ -290,8 +285,7 @@ func TestStorageModeTransition(t *testing.T) { func TestStorageModeTransitionFallback(t *testing.T) { ctx := context.Background() - cfg, am, cleanup := testStorageModeSetup(t, StorageModeTransition, "./_testdata_tmp_transition_fallback") - defer cleanup() + cfg, am := testStorageModeSetup(t, StorageModeTransition, "./_testdata_tmp_transition_fallback") domain := "example.com" cert := makeCertResource(am, domain, true) From fb0c0a838d0e57e2f4ad3e9e9535325fba1e0da4 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Wed, 10 Dec 2025 14:13:05 +0100 Subject: [PATCH 15/23] Add storage mode support for update ARI --- maintain.go | 198 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 1 deletion(-) diff --git a/maintain.go b/maintain.go index dbcdc316..2b95d3de 100644 --- a/maintain.go +++ b/maintain.go @@ -494,6 +494,24 @@ func (cfg *Config) loadStoredACMECertificateMetadataBundle(ctx context.Context, // This will always try to ARI without checking if it needs to be refreshed. Call // NeedsRefresh() on the RenewalInfo first, and only call this if that returns true. func (cfg *Config) updateARI(ctx context.Context, cert Certificate, logger *zap.Logger) (updatedCert Certificate, changed bool, err error) { + switch os.Getenv(StorageModeEnv) { + case StorageModeTransition: + if updatedCert, changed, err = cfg.updateARIBundle(ctx, cert, logger); err != nil { + cfg.Logger.Warn("unable to update ARI in bundle", + zap.Strings("identifiers", cert.Names), + zap.String("issuer", cert.issuerKey), + zap.Error(err)) + } + return cfg.updateARILegacy(ctx, cert, logger) + case StorageModeBundle: + return cfg.updateARIBundle(ctx, cert, logger) + default: + return cfg.updateARILegacy(ctx, cert, logger) + } +} + +// updateARILegacy updates the cert's ACME renewal info using the legacy storage format. +func (cfg *Config) updateARILegacy(ctx context.Context, cert Certificate, logger *zap.Logger) (updatedCert Certificate, changed bool, err error) { logger = logger.With( zap.Strings("identifiers", cert.Names), zap.String("cert_hash", cert.hash), @@ -622,7 +640,7 @@ func (cfg *Config) updateARI(ctx context.Context, cert Certificate, logger *zap. // update the ARI value in storage var certData acme.Certificate - certData, err = cfg.loadStoredACMECertificateMetadata(ctx, cert) + certData, err = cfg.loadStoredACMECertificateMetadataLegacy(ctx, cert) if err != nil { err = fmt.Errorf("got new ARI from %s, but failed loading stored certificate metadata: %v", iss.IssuerKey(), err) return @@ -660,6 +678,184 @@ func (cfg *Config) updateARI(ctx context.Context, cert Certificate, logger *zap. return } +// updateARIBundle updates the cert's ACME renewal info using the bundle storage format. +func (cfg *Config) updateARIBundle(ctx context.Context, cert Certificate, logger *zap.Logger) (updatedCert Certificate, changed bool, err error) { + logger = logger.With( + zap.Strings("identifiers", cert.Names), + zap.String("cert_hash", cert.hash), + zap.String("ari_unique_id", cert.ari.UniqueIdentifier), + zap.Time("cert_expiry", cert.Leaf.NotAfter)) + + updatedCert = cert + oldARI := cert.ari + + // synchronize ARI fetching; see #297 + lockName := "ari_" + cert.ari.UniqueIdentifier + if _, ok := cfg.Storage.(TryLocker); ok { + ok, err := tryAcquireLock(ctx, cfg.Storage, lockName) + if err != nil { + return cert, false, fmt.Errorf("unable to obtain ARI lock: %v", err) + } + if !ok { + logger.Debug("attempted to obtain ARI lock but it was already taken") + return cert, false, nil + } + } else if err := acquireLock(ctx, cfg.Storage, lockName); err != nil { + return cert, false, fmt.Errorf("unable to obtain ARI lock: %v", err) + } + defer func() { + if err := releaseLock(ctx, cfg.Storage, lockName); err != nil { + logger.Error("unable to release ARI lock", zap.Error(err)) + } + }() + + // see if the stored value has been refreshed already by another instance + gotNewARI, newARI, err := cfg.storageHasNewerARI(ctx, cert) + + // when we're all done, log if something about the schedule is different + // ("WARN" level because ARI window changing may be a sign of external trouble + // and we want to draw their attention to a potential explanation URL) + defer func() { + changed = !newARI.SameWindow(oldARI) + + if changed { + logger.Warn("ARI window or selected renewal time changed", + zap.Time("prev_start", oldARI.SuggestedWindow.Start), + zap.Time("next_start", newARI.SuggestedWindow.Start), + zap.Time("prev_end", oldARI.SuggestedWindow.End), + zap.Time("next_end", newARI.SuggestedWindow.End), + zap.Time("prev_selected_time", oldARI.SelectedTime), + zap.Time("next_selected_time", newARI.SelectedTime), + zap.String("explanation_url", newARI.ExplanationURL)) + } + }() + + if err == nil && gotNewARI { + // great, storage has a newer one we can use + cfg.certCache.mu.Lock() + var ok bool + updatedCert, ok = cfg.certCache.cache[cert.hash] + if !ok { + // cert is no longer in the cache... why? what's the right thing to do here? + cfg.certCache.mu.Unlock() + updatedCert = cert // return input cert, not an empty one + updatedCert.ari = newARI // might as well give it the new ARI for the benefit of our caller, but it won't be updated in the cache or in storage + logger.Warn("loaded newer ARI from storage, but certificate is no longer in cache; newer ARI will be returned to caller, but not persisted in the cache", + zap.Time("selected_time", newARI.SelectedTime), + zap.Timep("next_update", newARI.RetryAfter), + zap.String("explanation_url", newARI.ExplanationURL)) + return + } + updatedCert.ari = newARI + cfg.certCache.cache[cert.hash] = updatedCert + cfg.certCache.mu.Unlock() + logger.Info("reloaded ARI with newer one in storage", + zap.Timep("next_refresh", newARI.RetryAfter), + zap.Time("renewal_time", newARI.SelectedTime)) + return + } + + if err != nil { + logger.Error("error while checking storage for updated ARI; updating ARI now", zap.Error(err)) + } + + // of the issuers configured, hopefully one of them is the ACME CA we got the cert from + for _, iss := range cfg.Issuers { + if ariGetter, ok := iss.(RenewalInfoGetter); ok && iss.IssuerKey() == cert.issuerKey { + newARI, err = ariGetter.GetRenewalInfo(ctx, cert) // be sure to use existing newARI variable so we can compare against old value in the defer + if err != nil { + // could be anything, but a common error might simply be the "wrong" ACME CA + // (meaning, different from the one that issued the cert, thus the only one + // that would have any ARI for it) if multiple ACME CAs are configured + logger.Error("failed updating renewal info from ACME CA", + zap.String("issuer", iss.IssuerKey()), + zap.Error(err)) + continue + } + + // when we get the latest ARI, the acme package will select a time within the window + // for us; of course, since it's random, it's likely different from the previously- + // selected time; but if the window doesn't change, there's no need to change the + // selected time (the acme package doesn't know the previous window to know better) + // ... so if the window hasn't changed we'll just put back the selected time + if newARI.SameWindow(oldARI) && !oldARI.SelectedTime.IsZero() { + newARI.SelectedTime = oldARI.SelectedTime + } + + // then store the updated ARI (even if the window didn't change, the Retry-After + // likely did) in cache and storage + + // be sure we get the cert from the cache while inside a lock to avoid logical races + cfg.certCache.mu.Lock() + updatedCert, ok = cfg.certCache.cache[cert.hash] + if !ok { + // cert is no longer in the cache; this can happen for several reasons (past expiration, + // rejected by on-demand permission module, random eviction due to full cache, etc), but + // it probably means we don't have use of this ARI update now, so while we can return it + // to the caller, we don't persist it anywhere beyond that... + cfg.certCache.mu.Unlock() + updatedCert = cert // return input cert, not an empty one + updatedCert.ari = newARI // might as well give it the new ARI for the benefit of our caller, but it won't be updated in the cache or in storage + logger.Warn("obtained ARI update, but certificate no longer in cache; ARI update will be returned to caller, but not stored", + zap.Time("selected_time", newARI.SelectedTime), + zap.Timep("next_update", newARI.RetryAfter), + zap.String("explanation_url", newARI.ExplanationURL)) + return + } + updatedCert.ari = newARI + cfg.certCache.cache[cert.hash] = updatedCert + cfg.certCache.mu.Unlock() + + // update the ARI value in storage + var bundleBytes []byte + bundleBytes, err = cfg.Storage.Load(ctx, StorageKeys.SiteBundle(cert.issuerKey, cert.Names[0])) + if err != nil { + err = fmt.Errorf("got new ARI from %s, but failed loading certificate bundle: %v", iss.IssuerKey(), err) + return + } + var certRes CertificateResource + certRes, err = decodeCertResource(bundleBytes) + if err != nil { + err = fmt.Errorf("got new ARI from %s, but failed decoding certificate bundle: %v", iss.IssuerKey(), err) + return + } + var certData acme.Certificate + if err = json.Unmarshal(certRes.IssuerData, &certData); err != nil { + err = fmt.Errorf("got new ARI from %s, but failed unmarshaling ACME issuer metadata: %v", iss.IssuerKey(), err) + return + } + certData.RenewalInfo = &newARI + var certDataBytes []byte + certDataBytes, err = json.Marshal(certData) + if err != nil { + err = fmt.Errorf("got new ARI from %s, but failed marshaling certificate ACME metadata: %v", iss.IssuerKey(), err) + return + } + certRes.IssuerData = certDataBytes + var encoded []byte + encoded, err = encodeCertResource(certRes) + if err != nil { + err = fmt.Errorf("got new ARI from %s, but could not re-encode certificate bundle: %v", iss.IssuerKey(), err) + return + } + if err = cfg.Storage.Store(ctx, StorageKeys.SiteBundle(cert.issuerKey, cert.Names[0]), encoded); err != nil { + err = fmt.Errorf("got new ARI from %s, but could not store it with certificate bundle: %v", iss.IssuerKey(), err) + return + } + + logger.Info("updated and stored ACME renewal information", + zap.Time("selected_time", newARI.SelectedTime), + zap.Timep("next_update", newARI.RetryAfter), + zap.String("explanation_url", newARI.ExplanationURL)) + + return + } + } + + err = fmt.Errorf("could not fully update ACME renewal info: either no issuer supporting ARI is configured for certificate, or all such failed (make sure the ACME CA that issued the certificate is configured)") + return +} + // CleanStorageOptions specifies how to clean up a storage unit. type CleanStorageOptions struct { // Optional custom logger. From 0ea7665b2cc136947402310c84d8ff687a2b2101 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Wed, 10 Dec 2025 14:28:29 +0100 Subject: [PATCH 16/23] Add storage mode support for deleteExpiredCerts --- maintain.go | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/maintain.go b/maintain.go index 2b95d3de..7abf19d8 100644 --- a/maintain.go +++ b/maintain.go @@ -1007,6 +1007,21 @@ func deleteOldOCSPStaples(ctx context.Context, storage Storage, logger *zap.Logg } func deleteExpiredCerts(ctx context.Context, storage Storage, logger *zap.Logger, gracePeriod time.Duration) error { + switch os.Getenv(StorageModeEnv) { + case StorageModeTransition: + if err := deleteExpiredCertsBundle(ctx, storage, logger, gracePeriod); err != nil { + logger.Warn("unable to delete expired certs from bundle", + zap.Error(err)) + } + return deleteExpiredCertsLegacy(ctx, storage, logger, gracePeriod) + case StorageModeBundle: + return deleteExpiredCertsBundle(ctx, storage, logger, gracePeriod) + default: + return deleteExpiredCertsLegacy(ctx, storage, logger, gracePeriod) + } +} + +func deleteExpiredCertsLegacy(ctx context.Context, storage Storage, logger *zap.Logger, gracePeriod time.Duration) error { issuerKeys, err := storage.List(ctx, prefixCerts, false) if err != nil { // maybe just hasn't been created yet; no big deal @@ -1092,6 +1107,88 @@ func deleteExpiredCerts(ctx context.Context, storage Storage, logger *zap.Logger return nil } +func deleteExpiredCertsBundle(ctx context.Context, storage Storage, logger *zap.Logger, gracePeriod time.Duration) error { + issuerKeys, err := storage.List(ctx, prefixCerts, false) + if err != nil { + // maybe just hasn't been created yet; no big deal + return nil + } + + for _, issuerKey := range issuerKeys { + siteKeys, err := storage.List(ctx, issuerKey, false) + if err != nil { + logger.Error("listing contents", zap.String("issuer_key", issuerKey), zap.Error(err)) + continue + } + + for _, siteKey := range siteKeys { + // if context was cancelled, quit early; otherwise proceed + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + siteAssets, err := storage.List(ctx, siteKey, false) + if err != nil { + logger.Error("listing site contents", zap.String("site_key", siteKey), zap.Error(err)) + continue + } + + for _, assetKey := range siteAssets { + if path.Ext(assetKey) != ".bundle" { + continue + } + + bundleFile, err := storage.Load(ctx, assetKey) + if err != nil { + return fmt.Errorf("loading certificate bundle %s: %v", assetKey, err) + } + certRes, err := decodeCertResource(bundleFile) + if err != nil { + return fmt.Errorf("decoding certificate bundle %s: %v", assetKey, err) + } + block, _ := pem.Decode(certRes.CertificatePEM) + if block == nil || block.Type != "CERTIFICATE" { + return fmt.Errorf("certificate bundle %s does not contain PEM-encoded certificate", assetKey) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("certificate bundle %s is malformed; error parsing PEM: %v", assetKey, err) + } + + if expiredTime := time.Since(expiresAt(cert)); expiredTime >= gracePeriod { + logger.Info("certificate expired beyond grace period; cleaning up", + zap.String("asset_key", assetKey), + zap.Duration("expired_for", expiredTime), + zap.Duration("grace_period", gracePeriod)) + logger.Info("deleting asset because resource expired", zap.String("asset_key", assetKey)) + err := storage.Delete(ctx, assetKey) + if err != nil { + logger.Error("could not clean up expired certificate bundle", + zap.String("asset_key", assetKey), + zap.Error(err)) + } + } + } + + // update listing; if folder is empty, delete it + siteAssets, err = storage.List(ctx, siteKey, false) + if err != nil { + continue + } + if len(siteAssets) == 0 { + logger.Info("deleting site folder because key is empty", zap.String("site_key", siteKey)) + err := storage.Delete(ctx, siteKey) + if err != nil { + return fmt.Errorf("deleting empty site folder %s: %v", siteKey, err) + } + } + } + } + return nil +} + // forceRenew forcefully renews cert and replaces it in the cache, and returns the new certificate. It is intended // for use primarily in the case of cert revocation. This MUST NOT be called within a lock on cfg.certCacheMu. func (cfg *Config) forceRenew(ctx context.Context, logger *zap.Logger, cert Certificate) (Certificate, error) { From 44fe9b96c81468d43af0c6826b548e84d3cbdc65 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Wed, 10 Dec 2025 14:35:52 +0100 Subject: [PATCH 17/23] Add bundle support for RevokeCert --- config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config.go b/config.go index e71c7eab..b3e10b08 100644 --- a/config.go +++ b/config.go @@ -1102,7 +1102,8 @@ func (cfg *Config) RevokeCert(ctx context.Context, domain string, reason int, in return err } - if !cfg.Storage.Exists(ctx, StorageKeys.SitePrivateKey(issuerKey, domain)) { + // loadCertResource should already fail if private key is missing. + if len(certRes.PrivateKeyPEM) == 0 { return fmt.Errorf("private key not found for %s", certRes.SANs) } From 5d82d073b8e311cae72173497afa019b101bcac2 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Wed, 10 Dec 2025 14:50:45 +0100 Subject: [PATCH 18/23] Add storage mode support for reusePrivateKey --- config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index b3e10b08..3bd09ac1 100644 --- a/config.go +++ b/config.go @@ -753,8 +753,7 @@ func (cfg *Config) reusePrivateKey(ctx context.Context, domain string) (privKey for i, issuer := range issuers { // see if this issuer location in storage has a private key for the domain - privateKeyStorageKey := StorageKeys.SitePrivateKey(issuer.IssuerKey(), domain) - privKeyPEM, err = cfg.Storage.Load(ctx, privateKeyStorageKey) + certRes, err := cfg.loadCertResource(ctx, issuer, domain) if errors.Is(err, fs.ErrNotExist) { err = nil // obviously, it's OK to not have a private key; so don't prevent obtaining a cert continue @@ -762,6 +761,7 @@ func (cfg *Config) reusePrivateKey(ctx context.Context, domain string) (privKey if err != nil { return nil, nil, nil, fmt.Errorf("loading existing private key for reuse with issuer %s: %v", issuer.IssuerKey(), err) } + privKeyPEM = certRes.PrivateKeyPEM // we loaded a private key; try decoding it so we can use it privKey, err = PEMDecodePrivateKey(privKeyPEM) From 95988354cdb0cc4d919a3e209d19fa68e44b780b Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Wed, 10 Dec 2025 14:55:53 +0100 Subject: [PATCH 19/23] Add storage mode support for moveCompromisedPrivateKey --- maintain.go | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/maintain.go b/maintain.go index 7abf19d8..9f963200 100644 --- a/maintain.go +++ b/maintain.go @@ -1251,24 +1251,39 @@ func (cfg *Config) forceRenew(ctx context.Context, logger *zap.Logger, cert Cert // moveCompromisedPrivateKey moves the private key for cert to a ".compromised" file // by copying the data to the new file, then deleting the old one. func (cfg *Config) moveCompromisedPrivateKey(ctx context.Context, cert Certificate, logger *zap.Logger) error { - privKeyStorageKey := StorageKeys.SitePrivateKey(cert.issuerKey, cert.Names[0]) + // find the issuer that matches the cert's issuer key + var issuer Issuer + for _, iss := range cfg.Issuers { + if iss.IssuerKey() == cert.issuerKey { + issuer = iss + break + } + } + if issuer == nil { + return fmt.Errorf("no configured issuer matches certificate's issuer key: %s", cert.issuerKey) + } - privKeyPEM, err := cfg.Storage.Load(ctx, privKeyStorageKey) + // load cert resource to get private key (handles both legacy and bundle storage modes) + certRes, err := cfg.loadCertResource(ctx, issuer, cert.Names[0]) if err != nil { return err } - compromisedPrivKeyStorageKey := privKeyStorageKey + ".compromised" - err = cfg.Storage.Store(ctx, compromisedPrivKeyStorageKey, privKeyPEM) + // store the compromised key for audit purposes + compromisedPrivKeyStorageKey := StorageKeys.SitePrivateKey(cert.issuerKey, cert.Names[0]) + ".compromised" + err = cfg.Storage.Store(ctx, compromisedPrivKeyStorageKey, certRes.PrivateKeyPEM) if err != nil { - // better safe than sorry: as a last resort, try deleting the key so it won't be reused - cfg.Storage.Delete(ctx, privKeyStorageKey) return err } - err = cfg.Storage.Delete(ctx, privKeyStorageKey) - if err != nil { - return err + // in legacy/transition mode, delete the separate private key file + // in bundle mode, there's no separate file (key is inside bundle which will be replaced) + privKeyStorageKey := StorageKeys.SitePrivateKey(cert.issuerKey, cert.Names[0]) + if cfg.Storage.Exists(ctx, privKeyStorageKey) { + err = cfg.Storage.Delete(ctx, privKeyStorageKey) + if err != nil { + return err + } } logger.Info("removed certificate's compromised private key from use", From 8c0d8a93696009acc4a38407f867b480181d29da Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Thu, 11 Dec 2025 13:06:27 +0100 Subject: [PATCH 20/23] Fix compromised key not deleted in bundle storage mode --- maintain.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/maintain.go b/maintain.go index 9f963200..94599dbf 100644 --- a/maintain.go +++ b/maintain.go @@ -1276,14 +1276,20 @@ func (cfg *Config) moveCompromisedPrivateKey(ctx context.Context, cert Certifica return err } - // in legacy/transition mode, delete the separate private key file - // in bundle mode, there's no separate file (key is inside bundle which will be replaced) privKeyStorageKey := StorageKeys.SitePrivateKey(cert.issuerKey, cert.Names[0]) - if cfg.Storage.Exists(ctx, privKeyStorageKey) { - err = cfg.Storage.Delete(ctx, privKeyStorageKey) - if err != nil { - return err - } + bundleKey := StorageKeys.SiteBundle(cert.issuerKey, cert.Names[0]) + + // Delete the storage containing the compromised key based on storage mode. + // We intentionally ignore delete errors since the file might not exist, + // and we avoid calling .Exists() before .Delete() to minimize storage roundtrips. + switch os.Getenv(StorageModeEnv) { + case StorageModeTransition: + cfg.Storage.Delete(ctx, bundleKey) + cfg.Storage.Delete(ctx, privKeyStorageKey) + case StorageModeBundle: + cfg.Storage.Delete(ctx, bundleKey) + default: + cfg.Storage.Delete(ctx, privKeyStorageKey) } logger.Info("removed certificate's compromised private key from use", From c5060837232efe62e2522d2092cef85d0145649d Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Thu, 11 Dec 2025 13:15:55 +0100 Subject: [PATCH 21/23] Fix duplicate CA requests for ARI in transition mode --- maintain.go | 49 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/maintain.go b/maintain.go index 94599dbf..148a7f63 100644 --- a/maintain.go +++ b/maintain.go @@ -496,13 +496,17 @@ func (cfg *Config) loadStoredACMECertificateMetadataBundle(ctx context.Context, func (cfg *Config) updateARI(ctx context.Context, cert Certificate, logger *zap.Logger) (updatedCert Certificate, changed bool, err error) { switch os.Getenv(StorageModeEnv) { case StorageModeTransition: - if updatedCert, changed, err = cfg.updateARIBundle(ctx, cert, logger); err != nil { - cfg.Logger.Warn("unable to update ARI in bundle", - zap.Strings("identifiers", cert.Names), - zap.String("issuer", cert.issuerKey), - zap.Error(err)) + updatedCert, changed, err = cfg.updateARILegacy(ctx, cert, logger) + if err == nil { + // Also update bundle storage with the new ARI + if bundleErr := cfg.storeARIToBundle(ctx, updatedCert); bundleErr != nil { + cfg.Logger.Warn("unable to update ARI in bundle", + zap.Strings("identifiers", cert.Names), + zap.String("issuer", cert.issuerKey), + zap.Error(bundleErr)) + } } - return cfg.updateARILegacy(ctx, cert, logger) + return updatedCert, changed, err case StorageModeBundle: return cfg.updateARIBundle(ctx, cert, logger) default: @@ -510,6 +514,39 @@ func (cfg *Config) updateARI(ctx context.Context, cert Certificate, logger *zap. } } +// storeARIToBundle updates the ARI in the bundle storage without fetching from CA. +// Note: This function only exists for transition mode to minimize CA requests. +// In transition mode, we use updateARILegacy as the source of truth (which fetches +// from CA if needed), then call this function to also update the bundle storage. +func (cfg *Config) storeARIToBundle(ctx context.Context, cert Certificate) error { + bundleBytes, err := cfg.Storage.Load(ctx, StorageKeys.SiteBundle(cert.issuerKey, cert.Names[0])) + if err != nil { + return fmt.Errorf("loading certificate bundle: %v", err) + } + certRes, err := decodeCertResource(bundleBytes) + if err != nil { + return fmt.Errorf("decoding certificate bundle: %v", err) + } + var certData acme.Certificate + if err = json.Unmarshal(certRes.IssuerData, &certData); err != nil { + return fmt.Errorf("unmarshaling ACME issuer metadata: %v", err) + } + certData.RenewalInfo = &cert.ari + certDataBytes, err := json.Marshal(certData) + if err != nil { + return fmt.Errorf("marshaling certificate ACME metadata: %v", err) + } + certRes.IssuerData = certDataBytes + encoded, err := encodeCertResource(certRes) + if err != nil { + return fmt.Errorf("encoding certificate bundle: %v", err) + } + if err = cfg.Storage.Store(ctx, StorageKeys.SiteBundle(cert.issuerKey, cert.Names[0]), encoded); err != nil { + return fmt.Errorf("storing certificate bundle: %v", err) + } + return nil +} + // updateARILegacy updates the cert's ACME renewal info using the legacy storage format. func (cfg *Config) updateARILegacy(ctx context.Context, cert Certificate, logger *zap.Logger) (updatedCert Certificate, changed bool, err error) { logger = logger.With( From 0544895fb26fc5bfc894638cce52b356d4e1a272 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Thu, 18 Dec 2025 14:11:46 +0100 Subject: [PATCH 22/23] Add more doc to the function comments --- config.go | 5 +++-- crypto.go | 2 ++ maintain.go | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index 3bd09ac1..8d9c5d65 100644 --- a/config.go +++ b/config.go @@ -1268,9 +1268,9 @@ func (cfg *Config) checkStorage(ctx context.Context) error { return nil } -// storageHasCertResources returns true if the storage -// associated with cfg's certificate cache has all the +// storageHasCertResources returns true if the storage associated with cfg's certificate cache has all the // resources related to the certificate for domain. +// It switches storage modes between legacy and bundle mode based on the CERTMAGIC_STORAGE_MODE env. func (cfg *Config) storageHasCertResources(ctx context.Context, issuer Issuer, domain string) bool { switch os.Getenv(StorageModeEnv) { case StorageModeTransition: @@ -1311,6 +1311,7 @@ func (cfg *Config) storageHasCertResourcesBundle(ctx context.Context, issuer Iss // deleteSiteAssets deletes the folder in storage containing the // certificate, private key, and metadata file for domain from the // issuer with the given issuer key. +// It switches storage modes between legacy and bundle mode based on the CERTMAGIC_STORAGE_MODE env. func (cfg *Config) deleteSiteAssets(ctx context.Context, issuerKey, domain string) error { switch os.Getenv(StorageModeEnv) { case StorageModeTransition: diff --git a/crypto.go b/crypto.go index be08124f..73cbc632 100644 --- a/crypto.go +++ b/crypto.go @@ -271,6 +271,8 @@ func (cfg *Config) loadCertResourceAnyIssuer(ctx context.Context, certNamesKey s return certResources[0].CertificateResource, nil } +// loadCertResource loads a certificate resource from the given issuer's storage location. +// It switches storage modes between legacy and bundle mode based on the CERTMAGIC_STORAGE_MODE env. func (cfg *Config) loadCertResource(ctx context.Context, issuer Issuer, certNamesKey string) (CertificateResource, error) { switch os.Getenv(StorageModeEnv) { case StorageModeTransition: diff --git a/maintain.go b/maintain.go index 148a7f63..0bd4c6f1 100644 --- a/maintain.go +++ b/maintain.go @@ -429,6 +429,7 @@ func (cfg *Config) storageHasNewerARI(ctx context.Context, cert Certificate) (bo } // loadStoredACMECertificateMetadata loads the stored ACME certificate data. +// It switches storage modes between legacy and bundle mode based on the CERTMAGIC_STORAGE_MODE env. func (cfg *Config) loadStoredACMECertificateMetadata(ctx context.Context, cert Certificate) (acme.Certificate, error) { switch os.Getenv(StorageModeEnv) { case StorageModeTransition: @@ -493,6 +494,7 @@ func (cfg *Config) loadStoredACMECertificateMetadataBundle(ctx context.Context, // // This will always try to ARI without checking if it needs to be refreshed. Call // NeedsRefresh() on the RenewalInfo first, and only call this if that returns true. +// It switches storage modes between legacy and bundle mode based on the CERTMAGIC_STORAGE_MODE env. func (cfg *Config) updateARI(ctx context.Context, cert Certificate, logger *zap.Logger) (updatedCert Certificate, changed bool, err error) { switch os.Getenv(StorageModeEnv) { case StorageModeTransition: From b090dfe2b8dfafeb021f70561d4324d3e35da01e Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Thu, 18 Dec 2025 14:11:59 +0100 Subject: [PATCH 23/23] Use t.Context() in tests --- config_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config_test.go b/config_test.go index 9f0ca98d..0db73428 100644 --- a/config_test.go +++ b/config_test.go @@ -28,7 +28,7 @@ import ( ) func TestSaveCertResource(t *testing.T) { - ctx := context.Background() + ctx := t.Context() am := &ACMEIssuer{CA: "https://example.com/acme/directory"} testConfig := &Config{ @@ -211,7 +211,7 @@ func assertCertResourceContent(t *testing.T, loaded CertificateResource, expecte } func TestStorageModeLegacy(t *testing.T) { - ctx := context.Background() + ctx := t.Context() cfg, am := testStorageModeSetup(t, StorageModeLegacy, "./_testdata_tmp_legacy") domain := "example.com" @@ -235,7 +235,7 @@ func TestStorageModeLegacy(t *testing.T) { } func TestStorageModeBundle(t *testing.T) { - ctx := context.Background() + ctx := t.Context() cfg, am := testStorageModeSetup(t, StorageModeBundle, "./_testdata_tmp_bundle") domain := "example.com" @@ -259,7 +259,7 @@ func TestStorageModeBundle(t *testing.T) { } func TestStorageModeTransition(t *testing.T) { - ctx := context.Background() + ctx := t.Context() cfg, am := testStorageModeSetup(t, StorageModeTransition, "./_testdata_tmp_transition") domain := "example.com" @@ -284,7 +284,7 @@ func TestStorageModeTransition(t *testing.T) { } func TestStorageModeTransitionFallback(t *testing.T) { - ctx := context.Background() + ctx := t.Context() cfg, am := testStorageModeSetup(t, StorageModeTransition, "./_testdata_tmp_transition_fallback") domain := "example.com"