From c0df423cb5d09f9b0dab7cc6dfeafc5623dc8ce1 Mon Sep 17 00:00:00 2001 From: Philipp Tanlak Date: Wed, 10 Dec 2025 12:05:00 +0100 Subject: [PATCH] yolo --- bundle.go | 588 +++++++++++++++++++++++++++++++++++++++++++++++++ bundle_test.go | 359 ++++++++++++++++++++++++++++++ config.go | 45 ++-- crypto.go | 69 +----- maintain.go | 141 ++++++------ storage.go | 8 + 6 files changed, 1052 insertions(+), 158 deletions(-) create mode 100644 bundle.go create mode 100644 bundle_test.go diff --git a/bundle.go b/bundle.go new file mode 100644 index 00000000..4996026a --- /dev/null +++ b/bundle.go @@ -0,0 +1,588 @@ +// Copyright 2015 Matthew Holt +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package certmagic + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "strings" + "time" + + "go.uber.org/zap" + "golang.org/x/net/idna" +) + +// StorageMode determines how certificates are stored and loaded. +type StorageMode string + +const ( + // StorageModeLegacy uses only the legacy 3-file format (.crt, .key, .json). + // This is the default mode for full backward compatibility. + StorageModeLegacy StorageMode = "legacy" + + // StorageModeTransition reads from bundle format first (with legacy fallback), + // and writes to BOTH formats. This allows safe rollback during migration. + StorageModeTransition StorageMode = "transition" + + // StorageModeBundle reads from bundle format first (with legacy fallback), + // and writes only to bundle format. Legacy files are cleaned up after writing. + StorageModeBundle StorageMode = "bundle" +) + +// StorageModeEnvVar is the environment variable name used to set the storage mode. +const StorageModeEnvVar = "CERTMAGIC_STORAGE_MODE" + +// GetStorageMode returns the current storage mode from the environment variable. +// If not set or invalid, it defaults to StorageModeLegacy. +func GetStorageMode() StorageMode { + mode := StorageMode(strings.ToLower(os.Getenv(StorageModeEnvVar))) + switch mode { + case StorageModeLegacy, StorageModeTransition, StorageModeBundle: + return mode + default: + return StorageModeLegacy + } +} + +// BundleVersion is the current certificate bundle format version. +// This allows for future format evolution while maintaining backward compatibility. +const BundleVersion = 1 + +// CertificateBundle is the unified storage format that combines certificate, +// private key, and metadata into a single file. This provides atomic writes +// and simplifies storage operations. +type CertificateBundle struct { + // Version of the bundle format (for future compatibility) + Version int `json:"version"` + + // SANs are the Subject Alternative Names on the certificate + SANs []string `json:"sans,omitempty"` + + // CertificatePEM is the PEM-encoded certificate chain + CertificatePEM []byte `json:"certificate_pem"` + + // PrivateKeyPEM is the PEM-encoded private key + PrivateKeyPEM []byte `json:"private_key_pem"` + + // IssuerData contains issuer-specific metadata (e.g., ACME cert info, ARI) + IssuerData json.RawMessage `json:"issuer_data,omitempty"` + + // CreatedAt is when this bundle was first created + CreatedAt time.Time `json:"created_at,omitempty"` + + // UpdatedAt is when this bundle was last updated + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// CertStore abstracts all certificate storage operations, providing a clean +// interface for saving, loading, and managing certificate bundles. It handles +// the transition from the legacy 3-file format to the new bundle format. +type CertStore struct { + storage Storage + logger *zap.Logger + mode StorageMode +} + +// NewCertStore creates a new CertStore with the given storage backend and logger. +// The storage mode is determined by the CERTMAGIC_STORAGE_MODE environment variable. +func NewCertStore(storage Storage, logger *zap.Logger) *CertStore { + if logger == nil { + logger = zap.NewNop() + } + return &CertStore{ + storage: storage, + logger: logger, + mode: GetStorageMode(), + } +} + +// NewCertStoreWithMode creates a new CertStore with an explicit storage mode. +// This is useful for testing or when the mode needs to be set programmatically. +func NewCertStoreWithMode(storage Storage, logger *zap.Logger, mode StorageMode) *CertStore { + if logger == nil { + logger = zap.NewNop() + } + return &CertStore{ + storage: storage, + logger: logger, + mode: mode, + } +} + +// Save writes a certificate resource to storage according to the configured storage mode: +// - legacy: writes only to 3-file format +// - transition: writes to both bundle and 3-file format (for safe rollback) +// - bundle: writes only to bundle format (and cleans up legacy files) +func (cs *CertStore) Save(ctx context.Context, issuerKey string, res CertificateResource) error { + certKey := res.NamesKey() + + switch cs.mode { + case StorageModeLegacy: + return cs.saveLegacy(ctx, issuerKey, certKey, res) + + case StorageModeTransition: + // Write to both formats for safe rollback + if err := cs.saveBundle(ctx, issuerKey, certKey, res); err != nil { + return err + } + return cs.saveLegacy(ctx, issuerKey, certKey, res) + + case StorageModeBundle: + if err := cs.saveBundle(ctx, issuerKey, certKey, res); err != nil { + return err + } + // Clean up legacy files if they exist + cs.deleteLegacyFiles(ctx, issuerKey, certKey) + return nil + + default: + return cs.saveLegacy(ctx, issuerKey, certKey, res) + } +} + +// saveBundle writes a certificate resource as a single bundle file. +func (cs *CertStore) saveBundle(ctx context.Context, issuerKey, certKey string, res CertificateResource) error { + bundle := CertificateBundle{ + Version: BundleVersion, + SANs: res.SANs, + CertificatePEM: res.CertificatePEM, + PrivateKeyPEM: res.PrivateKeyPEM, + IssuerData: res.IssuerData, + UpdatedAt: time.Now().UTC(), + } + + // Check if this is an update to an existing bundle + bundleKey := StorageKeys.SiteBundle(issuerKey, certKey) + if existingData, err := cs.storage.Load(ctx, bundleKey); err == nil { + var existing CertificateBundle + if json.Unmarshal(existingData, &existing) == nil { + bundle.CreatedAt = existing.CreatedAt + } + } + if bundle.CreatedAt.IsZero() { + bundle.CreatedAt = bundle.UpdatedAt + } + + bundleBytes, err := json.MarshalIndent(bundle, "", "\t") + if err != nil { + return fmt.Errorf("encoding certificate bundle: %v", err) + } + + if err := cs.storage.Store(ctx, bundleKey, bundleBytes); err != nil { + return fmt.Errorf("storing certificate bundle: %v", err) + } + + return nil +} + +// saveLegacy writes a certificate resource as 3 separate files (legacy format). +func (cs *CertStore) saveLegacy(ctx context.Context, issuerKey, certKey string, res CertificateResource) error { + metaBytes, err := json.MarshalIndent(CertificateResource{ + SANs: res.SANs, + IssuerData: res.IssuerData, + }, "", "\t") + if err != nil { + return fmt.Errorf("encoding certificate metadata: %v", err) + } + + all := []keyValue{ + { + key: StorageKeys.SitePrivateKey(issuerKey, certKey), + value: res.PrivateKeyPEM, + }, + { + key: StorageKeys.SiteCert(issuerKey, certKey), + value: res.CertificatePEM, + }, + { + key: StorageKeys.SiteMeta(issuerKey, certKey), + value: metaBytes, + }, + } + + return storeTx(ctx, cs.storage, all) +} + +// Load reads a certificate resource according to the configured storage mode: +// - legacy: reads only from 3-file format +// - transition/bundle: tries bundle format first, falls back to 3-file format +func (cs *CertStore) Load(ctx context.Context, issuerKey, certNamesKey string) (CertificateResource, error) { + // Normalize the name + normalizedName, err := idna.ToASCII(certNamesKey) + if err != nil { + return CertificateResource{}, fmt.Errorf("converting '%s' to ASCII: %v", certNamesKey, err) + } + + switch cs.mode { + case StorageModeLegacy: + // Only read from legacy format + return cs.loadLegacy(ctx, issuerKey, normalizedName) + + case StorageModeTransition, StorageModeBundle: + // Try new bundle format first + bundleKey := StorageKeys.SiteBundle(issuerKey, normalizedName) + if bundleData, err := cs.storage.Load(ctx, bundleKey); err == nil { + return cs.decodeBundle(bundleData, issuerKey) + } + // Fall back to legacy 3-file format + return cs.loadLegacy(ctx, issuerKey, normalizedName) + + default: + return cs.loadLegacy(ctx, issuerKey, normalizedName) + } +} + +// Exists checks if a certificate exists in storage according to the configured storage mode: +// - legacy: checks only 3-file format +// - transition/bundle: checks bundle format first, then 3-file format +func (cs *CertStore) Exists(ctx context.Context, issuerKey, domain string) bool { + normalizedName, err := idna.ToASCII(domain) + if err != nil { + return false + } + + // Check legacy format (all 3 files must exist) + legacyExists := func() bool { + certKey := StorageKeys.SiteCert(issuerKey, normalizedName) + keyKey := StorageKeys.SitePrivateKey(issuerKey, normalizedName) + metaKey := StorageKeys.SiteMeta(issuerKey, normalizedName) + return cs.storage.Exists(ctx, certKey) && + cs.storage.Exists(ctx, keyKey) && + cs.storage.Exists(ctx, metaKey) + } + + switch cs.mode { + case StorageModeLegacy: + return legacyExists() + + case StorageModeTransition, StorageModeBundle: + // Check bundle format first + bundleKey := StorageKeys.SiteBundle(issuerKey, normalizedName) + if cs.storage.Exists(ctx, bundleKey) { + return true + } + // Fall back to legacy format + return legacyExists() + + default: + return legacyExists() + } +} + +// Delete removes a certificate from storage according to the configured storage mode. +// In all modes, both bundle and legacy files are deleted to ensure complete cleanup. +func (cs *CertStore) Delete(ctx context.Context, issuerKey, domain string) error { + normalizedName, err := idna.ToASCII(domain) + if err != nil { + return fmt.Errorf("converting '%s' to ASCII: %v", domain, err) + } + + var errs []error + + // Always try to delete both formats to ensure complete cleanup + // (a certificate might have been created in a different mode) + + // Delete bundle format + bundleKey := StorageKeys.SiteBundle(issuerKey, normalizedName) + if cs.storage.Exists(ctx, bundleKey) { + if err := cs.storage.Delete(ctx, bundleKey); err != nil { + errs = append(errs, fmt.Errorf("deleting bundle: %v", err)) + } + } + + // Delete legacy files + cs.deleteLegacyFiles(ctx, issuerKey, normalizedName) + + // Delete the site folder if empty + sitePrefix := StorageKeys.CertsSitePrefix(issuerKey, normalizedName) + if cs.storage.Exists(ctx, sitePrefix) { + if err := cs.storage.Delete(ctx, sitePrefix); err != nil { + // Not a critical error - folder might not be empty + cs.logger.Debug("could not delete site folder", zap.String("path", sitePrefix), zap.Error(err)) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +// UpdateMetadata atomically updates only the metadata (IssuerData) portion of +// a certificate bundle. This is useful for ARI updates where only the metadata +// changes but the certificate and key remain the same. +func (cs *CertStore) UpdateMetadata(ctx context.Context, issuerKey, domain string, + updateFn func(current json.RawMessage) (json.RawMessage, error)) error { + + normalizedName, err := idna.ToASCII(domain) + if err != nil { + return fmt.Errorf("converting '%s' to ASCII: %v", domain, err) + } + + // Load the current bundle + certRes, err := cs.Load(ctx, issuerKey, normalizedName) + if err != nil { + return fmt.Errorf("loading certificate for metadata update: %v", err) + } + + // Apply the update function + newIssuerData, err := updateFn(certRes.IssuerData) + if err != nil { + return fmt.Errorf("updating metadata: %v", err) + } + certRes.IssuerData = newIssuerData + + // Save the updated bundle + return cs.Save(ctx, issuerKey, certRes) +} + +// LoadPrivateKey loads only the private key for a certificate. This is used +// when reusing private keys across certificate renewals. +func (cs *CertStore) LoadPrivateKey(ctx context.Context, issuerKey, domain string) ([]byte, error) { + normalizedName, err := idna.ToASCII(domain) + if err != nil { + return nil, fmt.Errorf("converting '%s' to ASCII: %v", domain, err) + } + + switch cs.mode { + case StorageModeLegacy: + keyKey := StorageKeys.SitePrivateKey(issuerKey, normalizedName) + return cs.storage.Load(ctx, keyKey) + + case StorageModeTransition, StorageModeBundle: + // Try bundle format first + bundleKey := StorageKeys.SiteBundle(issuerKey, normalizedName) + if bundleData, err := cs.storage.Load(ctx, bundleKey); err == nil { + var bundle CertificateBundle + if err := json.Unmarshal(bundleData, &bundle); err != nil { + return nil, fmt.Errorf("decoding bundle: %v", err) + } + return bundle.PrivateKeyPEM, nil + } + // Fall back to legacy format + keyKey := StorageKeys.SitePrivateKey(issuerKey, normalizedName) + return cs.storage.Load(ctx, keyKey) + + default: + keyKey := StorageKeys.SitePrivateKey(issuerKey, normalizedName) + return cs.storage.Load(ctx, keyKey) + } +} + +// MoveCompromisedKey moves a compromised private key to a ".compromised" location +// and removes it from the certificate storage. +func (cs *CertStore) MoveCompromisedKey(ctx context.Context, issuerKey, domain string) error { + normalizedName, err := idna.ToASCII(domain) + if err != nil { + return fmt.Errorf("converting '%s' to ASCII: %v", domain, err) + } + + // Load the private key + privKeyPEM, err := cs.LoadPrivateKey(ctx, issuerKey, normalizedName) + if err != nil { + return fmt.Errorf("loading private key: %v", err) + } + + // Save to compromised location (use appropriate path based on mode) + var compromisedKey string + switch cs.mode { + case StorageModeLegacy: + compromisedKey = StorageKeys.SitePrivateKey(issuerKey, normalizedName) + ".compromised" + default: + compromisedKey = StorageKeys.SiteBundle(issuerKey, normalizedName) + ".compromised" + } + + if err := cs.storage.Store(ctx, compromisedKey, privKeyPEM); err != nil { + return fmt.Errorf("storing compromised key: %v", err) + } + + // Delete the certificate entirely (forces re-obtain with new key) + if err := cs.Delete(ctx, issuerKey, normalizedName); err != nil { + return fmt.Errorf("deleting certificate with compromised key: %v", err) + } + + cs.logger.Info("moved compromised private key", + zap.String("domain", domain), + zap.String("issuer", issuerKey), + zap.String("compromised_path", compromisedKey)) + + return nil +} + +// decodeBundle decodes a bundle from JSON bytes into a CertificateResource. +func (cs *CertStore) decodeBundle(data []byte, issuerKey string) (CertificateResource, error) { + var bundle CertificateBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return CertificateResource{}, fmt.Errorf("decoding certificate bundle: %v", err) + } + + // Handle future version upgrades here if needed + if bundle.Version > BundleVersion { + cs.logger.Warn("bundle version is newer than supported", + zap.Int("bundle_version", bundle.Version), + zap.Int("supported_version", BundleVersion)) + } + + return CertificateResource{ + SANs: bundle.SANs, + CertificatePEM: bundle.CertificatePEM, + PrivateKeyPEM: bundle.PrivateKeyPEM, + IssuerData: bundle.IssuerData, + issuerKey: issuerKey, + }, nil +} + +// loadLegacy loads a certificate from the legacy 3-file format. +func (cs *CertStore) loadLegacy(ctx context.Context, issuerKey, normalizedName string) (CertificateResource, error) { + certRes := CertificateResource{issuerKey: issuerKey} + + keyBytes, err := cs.storage.Load(ctx, StorageKeys.SitePrivateKey(issuerKey, normalizedName)) + if err != nil { + return CertificateResource{}, err + } + certRes.PrivateKeyPEM = keyBytes + + certBytes, err := cs.storage.Load(ctx, StorageKeys.SiteCert(issuerKey, normalizedName)) + if err != nil { + return CertificateResource{}, err + } + certRes.CertificatePEM = certBytes + + metaBytes, err := cs.storage.Load(ctx, StorageKeys.SiteMeta(issuerKey, normalizedName)) + if err != nil { + return CertificateResource{}, err + } + + if err := json.Unmarshal(metaBytes, &certRes); err != nil { + return CertificateResource{}, fmt.Errorf("decoding certificate metadata: %v", err) + } + + return certRes, nil +} + +// deleteLegacyFiles removes the legacy 3-file format files if they exist. +// Errors are logged but not returned since this is a cleanup operation. +func (cs *CertStore) deleteLegacyFiles(ctx context.Context, issuerKey, certKey string) { + legacyFiles := []string{ + StorageKeys.SiteCert(issuerKey, certKey), + StorageKeys.SitePrivateKey(issuerKey, certKey), + StorageKeys.SiteMeta(issuerKey, certKey), + } + + for _, key := range legacyFiles { + if cs.storage.Exists(ctx, key) { + if err := cs.storage.Delete(ctx, key); err != nil { + cs.logger.Debug("could not delete legacy file", + zap.String("key", key), + zap.Error(err)) + } else { + cs.logger.Debug("deleted legacy file", zap.String("key", key)) + } + } + } +} + +// Migrate converts a certificate from the legacy 3-file format to the new +// bundle format. This is useful for batch migration of existing certificates. +// Note: This method ignores the storage mode and always writes to bundle format. +// Use this for explicit migration operations. +func (cs *CertStore) Migrate(ctx context.Context, issuerKey, domain string) error { + normalizedName, err := idna.ToASCII(domain) + if err != nil { + return fmt.Errorf("converting '%s' to ASCII: %v", domain, err) + } + + // Check if already migrated + bundleKey := StorageKeys.SiteBundle(issuerKey, normalizedName) + if cs.storage.Exists(ctx, bundleKey) { + return nil // Already migrated + } + + // Check if legacy exists + if !cs.storage.Exists(ctx, StorageKeys.SiteCert(issuerKey, normalizedName)) { + return fs.ErrNotExist + } + + // Load from legacy + certRes, err := cs.loadLegacy(ctx, issuerKey, normalizedName) + if err != nil { + return fmt.Errorf("loading legacy certificate: %v", err) + } + + // Save as bundle + if err := cs.saveBundle(ctx, issuerKey, normalizedName, certRes); err != nil { + return fmt.Errorf("saving as bundle: %v", err) + } + + // Clean up legacy files after successful migration + cs.deleteLegacyFiles(ctx, issuerKey, normalizedName) + + cs.logger.Info("migrated certificate to bundle format", + zap.String("domain", domain), + zap.String("issuer", issuerKey)) + + return nil +} + +// MigrateAll migrates all certificates for a given issuer to bundle format. +// This scans for legacy site folders and migrates each certificate found. +func (cs *CertStore) MigrateAll(ctx context.Context, issuerKey string) error { + certsPrefix := StorageKeys.CertsPrefix(issuerKey) + items, err := cs.storage.List(ctx, certsPrefix, false) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil // No certificates to migrate + } + return fmt.Errorf("listing certificates: %v", err) + } + + var migrated, skipped, failed int + for _, itemKey := range items { + // Skip if it's already a bundle file + if strings.HasSuffix(itemKey, ".bundle.json") { + skipped++ + continue + } + + // Extract domain from path (site folder name) + domain := itemKey[len(certsPrefix)+1:] // +1 for the slash + + err := cs.Migrate(ctx, issuerKey, domain) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + skipped++ + continue + } + cs.logger.Error("failed to migrate certificate", + zap.String("domain", domain), + zap.Error(err)) + failed++ + continue + } + migrated++ + } + + cs.logger.Info("migration complete", + zap.String("issuer", issuerKey), + zap.Int("migrated", migrated), + zap.Int("skipped", skipped), + zap.Int("failed", failed)) + + return nil +} diff --git a/bundle_test.go b/bundle_test.go new file mode 100644 index 00000000..73637efd --- /dev/null +++ b/bundle_test.go @@ -0,0 +1,359 @@ +// Copyright 2015 Matthew Holt +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package certmagic + +import ( + "context" + "encoding/json" + "os" + "testing" +) + +func TestGetStorageMode(t *testing.T) { + tests := []struct { + envValue string + expected StorageMode + }{ + {"", StorageModeLegacy}, + {"legacy", StorageModeLegacy}, + {"LEGACY", StorageModeLegacy}, + {"transition", StorageModeTransition}, + {"TRANSITION", StorageModeTransition}, + {"bundle", StorageModeBundle}, + {"BUNDLE", StorageModeBundle}, + {"invalid", StorageModeLegacy}, + {"unknown", StorageModeLegacy}, + } + + for _, tt := range tests { + t.Run(tt.envValue, func(t *testing.T) { + os.Setenv(StorageModeEnvVar, tt.envValue) + defer os.Unsetenv(StorageModeEnvVar) + + mode := GetStorageMode() + if mode != tt.expected { + t.Errorf("GetStorageMode() = %v, want %v", mode, tt.expected) + } + }) + } +} + +func TestCertStoreLegacyMode(t *testing.T) { + ctx := context.Background() + storage := &FileStorage{Path: t.TempDir()} + certStore := NewCertStoreWithMode(storage, nil, StorageModeLegacy) + + issuerKey := "test-issuer" + domain := "example.com" + certRes := CertificateResource{ + SANs: []string{domain}, + CertificatePEM: []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), + PrivateKeyPEM: []byte("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"), + IssuerData: json.RawMessage(`{"test": "data"}`), + } + + // Save should write to legacy format + err := certStore.Save(ctx, issuerKey, certRes) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Verify legacy files exist + if !storage.Exists(ctx, StorageKeys.SiteCert(issuerKey, domain)) { + t.Error("legacy .crt file should exist") + } + if !storage.Exists(ctx, StorageKeys.SitePrivateKey(issuerKey, domain)) { + t.Error("legacy .key file should exist") + } + if !storage.Exists(ctx, StorageKeys.SiteMeta(issuerKey, domain)) { + t.Error("legacy .json file should exist") + } + + // Verify bundle file does NOT exist + if storage.Exists(ctx, StorageKeys.SiteBundle(issuerKey, domain)) { + t.Error("bundle file should NOT exist in legacy mode") + } + + // Load should work + loaded, err := certStore.Load(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if string(loaded.CertificatePEM) != string(certRes.CertificatePEM) { + t.Error("loaded certificate doesn't match") + } + + // Exists should return true + if !certStore.Exists(ctx, issuerKey, domain) { + t.Error("Exists() should return true") + } + + // Delete should remove legacy files + err = certStore.Delete(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + if storage.Exists(ctx, StorageKeys.SiteCert(issuerKey, domain)) { + t.Error("legacy .crt file should be deleted") + } +} + +func TestCertStoreBundleMode(t *testing.T) { + ctx := context.Background() + storage := &FileStorage{Path: t.TempDir()} + certStore := NewCertStoreWithMode(storage, nil, StorageModeBundle) + + issuerKey := "test-issuer" + domain := "example.com" + certRes := CertificateResource{ + SANs: []string{domain}, + CertificatePEM: []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), + PrivateKeyPEM: []byte("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"), + IssuerData: json.RawMessage(`{"test": "data"}`), + } + + // Save should write to bundle format + err := certStore.Save(ctx, issuerKey, certRes) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Verify bundle file exists + if !storage.Exists(ctx, StorageKeys.SiteBundle(issuerKey, domain)) { + t.Error("bundle file should exist") + } + + // Verify legacy files do NOT exist + if storage.Exists(ctx, StorageKeys.SiteCert(issuerKey, domain)) { + t.Error("legacy .crt file should NOT exist in bundle mode") + } + + // Load should work + loaded, err := certStore.Load(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if string(loaded.CertificatePEM) != string(certRes.CertificatePEM) { + t.Error("loaded certificate doesn't match") + } + + // Exists should return true + if !certStore.Exists(ctx, issuerKey, domain) { + t.Error("Exists() should return true") + } + + // Delete should remove bundle file + err = certStore.Delete(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + if storage.Exists(ctx, StorageKeys.SiteBundle(issuerKey, domain)) { + t.Error("bundle file should be deleted") + } +} + +func TestCertStoreTransitionMode(t *testing.T) { + ctx := context.Background() + storage := &FileStorage{Path: t.TempDir()} + certStore := NewCertStoreWithMode(storage, nil, StorageModeTransition) + + issuerKey := "test-issuer" + domain := "example.com" + certRes := CertificateResource{ + SANs: []string{domain}, + CertificatePEM: []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), + PrivateKeyPEM: []byte("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"), + IssuerData: json.RawMessage(`{"test": "data"}`), + } + + // Save should write to BOTH formats + err := certStore.Save(ctx, issuerKey, certRes) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Verify bundle file exists + if !storage.Exists(ctx, StorageKeys.SiteBundle(issuerKey, domain)) { + t.Error("bundle file should exist in transition mode") + } + + // Verify legacy files also exist + if !storage.Exists(ctx, StorageKeys.SiteCert(issuerKey, domain)) { + t.Error("legacy .crt file should exist in transition mode") + } + if !storage.Exists(ctx, StorageKeys.SitePrivateKey(issuerKey, domain)) { + t.Error("legacy .key file should exist in transition mode") + } + if !storage.Exists(ctx, StorageKeys.SiteMeta(issuerKey, domain)) { + t.Error("legacy .json file should exist in transition mode") + } + + // Load should prefer bundle format + loaded, err := certStore.Load(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if string(loaded.CertificatePEM) != string(certRes.CertificatePEM) { + t.Error("loaded certificate doesn't match") + } +} + +func TestCertStoreMigration(t *testing.T) { + ctx := context.Background() + storage := &FileStorage{Path: t.TempDir()} + + issuerKey := "test-issuer" + domain := "example.com" + certRes := CertificateResource{ + SANs: []string{domain}, + CertificatePEM: []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), + PrivateKeyPEM: []byte("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"), + IssuerData: json.RawMessage(`{"test": "data"}`), + } + + // First, save in legacy mode + legacyStore := NewCertStoreWithMode(storage, nil, StorageModeLegacy) + err := legacyStore.Save(ctx, issuerKey, certRes) + if err != nil { + t.Fatalf("Save() in legacy mode error = %v", err) + } + + // Verify only legacy files exist + if storage.Exists(ctx, StorageKeys.SiteBundle(issuerKey, domain)) { + t.Error("bundle file should NOT exist before migration") + } + if !storage.Exists(ctx, StorageKeys.SiteCert(issuerKey, domain)) { + t.Error("legacy .crt file should exist before migration") + } + + // Now migrate + bundleStore := NewCertStoreWithMode(storage, nil, StorageModeBundle) + err = bundleStore.Migrate(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Migrate() error = %v", err) + } + + // Verify bundle file exists after migration + if !storage.Exists(ctx, StorageKeys.SiteBundle(issuerKey, domain)) { + t.Error("bundle file should exist after migration") + } + + // Verify legacy files are cleaned up + if storage.Exists(ctx, StorageKeys.SiteCert(issuerKey, domain)) { + t.Error("legacy .crt file should be cleaned up after migration") + } + + // Verify data is preserved + loaded, err := bundleStore.Load(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Load() after migration error = %v", err) + } + if string(loaded.CertificatePEM) != string(certRes.CertificatePEM) { + t.Error("certificate data not preserved after migration") + } + if string(loaded.PrivateKeyPEM) != string(certRes.PrivateKeyPEM) { + t.Error("private key data not preserved after migration") + } +} + +func TestCertStoreBundleModeWithLegacyFallback(t *testing.T) { + ctx := context.Background() + storage := &FileStorage{Path: t.TempDir()} + + issuerKey := "test-issuer" + domain := "example.com" + certRes := CertificateResource{ + SANs: []string{domain}, + CertificatePEM: []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), + PrivateKeyPEM: []byte("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"), + IssuerData: json.RawMessage(`{"test": "data"}`), + } + + // Save in legacy mode (simulating pre-migration data) + legacyStore := NewCertStoreWithMode(storage, nil, StorageModeLegacy) + err := legacyStore.Save(ctx, issuerKey, certRes) + if err != nil { + t.Fatalf("Save() in legacy mode error = %v", err) + } + + // Now try to load in bundle mode (should fall back to legacy) + bundleStore := NewCertStoreWithMode(storage, nil, StorageModeBundle) + loaded, err := bundleStore.Load(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Load() in bundle mode with legacy fallback error = %v", err) + } + if string(loaded.CertificatePEM) != string(certRes.CertificatePEM) { + t.Error("loaded certificate doesn't match") + } + + // Exists should also work with fallback + if !bundleStore.Exists(ctx, issuerKey, domain) { + t.Error("Exists() should return true with legacy fallback") + } +} + +func TestCertStoreUpdateMetadata(t *testing.T) { + ctx := context.Background() + storage := &FileStorage{Path: t.TempDir()} + certStore := NewCertStoreWithMode(storage, nil, StorageModeBundle) + + issuerKey := "test-issuer" + domain := "example.com" + certRes := CertificateResource{ + SANs: []string{domain}, + CertificatePEM: []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), + PrivateKeyPEM: []byte("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"), + IssuerData: json.RawMessage(`{"original": "data"}`), + } + + // Save initial + err := certStore.Save(ctx, issuerKey, certRes) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Update metadata + err = certStore.UpdateMetadata(ctx, issuerKey, domain, func(current json.RawMessage) (json.RawMessage, error) { + return json.RawMessage(`{"updated": "metadata"}`), nil + }) + if err != nil { + t.Fatalf("UpdateMetadata() error = %v", err) + } + + // Load and verify metadata was updated + loaded, err := certStore.Load(ctx, issuerKey, domain) + if err != nil { + t.Fatalf("Load() after UpdateMetadata error = %v", err) + } + // Parse JSON for comparison to ignore formatting differences + var gotMeta, wantMeta map[string]string + if err := json.Unmarshal(loaded.IssuerData, &gotMeta); err != nil { + t.Fatalf("failed to parse loaded IssuerData: %v", err) + } + if err := json.Unmarshal([]byte(`{"updated": "metadata"}`), &wantMeta); err != nil { + t.Fatalf("failed to parse expected IssuerData: %v", err) + } + if gotMeta["updated"] != wantMeta["updated"] { + t.Errorf("IssuerData = %v, want %v", gotMeta, wantMeta) + } + + // Verify certificate and key are unchanged + if string(loaded.CertificatePEM) != string(certRes.CertificatePEM) { + t.Error("certificate should be unchanged after metadata update") + } + if string(loaded.PrivateKeyPEM) != string(certRes.PrivateKeyPEM) { + t.Error("private key should be unchanged after metadata update") + } +} diff --git a/config.go b/config.go index 419b3dcb..daf24993 100644 --- a/config.go +++ b/config.go @@ -750,10 +750,11 @@ func (cfg *Config) reusePrivateKey(ctx context.Context, domain string) (privKey issuers = make([]Issuer, len(cfg.Issuers)) copy(issuers, cfg.Issuers) + certStore := NewCertStore(cfg.Storage, cfg.Logger) + 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) + privKeyPEM, err = certStore.LoadPrivateKey(ctx, issuer.IssuerKey(), 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 @@ -1101,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)) { + // Ensure we have the private key (it's loaded as part of the bundle/resource) + if len(certRes.PrivateKeyPEM) == 0 { return fmt.Errorf("private key not found for %s", certRes.SANs) } @@ -1268,39 +1270,18 @@ 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: the -// certificate, the private key, and the metadata. +// resources related to the certificate for domain (either +// as a bundle or in the legacy 3-file format). func (cfg *Config) storageHasCertResources(ctx context.Context, issuer Issuer, domain string) bool { - issuerKey := issuer.IssuerKey() - certKey := StorageKeys.SiteCert(issuerKey, domain) - keyKey := StorageKeys.SitePrivateKey(issuerKey, domain) - metaKey := StorageKeys.SiteMeta(issuerKey, domain) - return cfg.Storage.Exists(ctx, certKey) && - cfg.Storage.Exists(ctx, keyKey) && - cfg.Storage.Exists(ctx, metaKey) + certStore := NewCertStore(cfg.Storage, cfg.Logger) + return certStore.Exists(ctx, issuer.IssuerKey(), domain) } -// deleteSiteAssets deletes the folder in storage containing the -// certificate, private key, and metadata file for domain from the -// issuer with the given issuer key. +// deleteSiteAssets deletes the certificate bundle (or legacy files) +// for domain from the issuer with the given issuer key. func (cfg *Config) deleteSiteAssets(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) - } - err = cfg.Storage.Delete(ctx, StorageKeys.SitePrivateKey(issuerKey, domain)) - if err != nil { - return fmt.Errorf("deleting private key: %v", err) - } - err = cfg.Storage.Delete(ctx, StorageKeys.SiteMeta(issuerKey, domain)) - if err != nil { - return fmt.Errorf("deleting metadata file: %v", err) - } - err = cfg.Storage.Delete(ctx, StorageKeys.CertsSitePrefix(issuerKey, domain)) - if err != nil { - return fmt.Errorf("deleting site asset folder: %v", err) - } - return nil + certStore := NewCertStore(cfg.Storage, cfg.Logger) + return certStore.Delete(ctx, issuerKey, domain) } // lockKey returns a key for a lock that is specific to the operation diff --git a/crypto.go b/crypto.go index 9cbbb213..d198b009 100644 --- a/crypto.go +++ b/crypto.go @@ -24,7 +24,6 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" - "encoding/json" "encoding/pem" "errors" "fmt" @@ -36,7 +35,6 @@ import ( "github.com/klauspost/cpuid/v2" "github.com/zeebo/blake3" "go.uber.org/zap" - "golang.org/x/net/idna" ) // PEMEncodePrivateKey marshals a private key into a PEM-encoded block. @@ -140,34 +138,11 @@ func fastHash(input []byte) string { return fmt.Sprintf("%x", h.Sum32()) } -// saveCertResource saves the certificate resource to disk. This -// includes the certificate file itself, the private key, and the -// metadata file. +// saveCertResource saves the certificate resource to disk as a single +// bundle file containing the certificate, private key, and metadata. func (cfg *Config) saveCertResource(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) + certStore := NewCertStore(cfg.Storage, cfg.Logger) + return certStore.Save(ctx, issuer.IssuerKey(), cert) } // loadCertResourceAnyIssuer loads and returns the certificate resource from any @@ -175,11 +150,13 @@ func (cfg *Config) saveCertResource(ctx context.Context, issuer Issuer, cert Cer // configured, and all 3 have a resource matching certNamesKey), then the newest // (latest NotBefore date) resource will be chosen. func (cfg *Config) loadCertResourceAnyIssuer(ctx context.Context, certNamesKey string) (CertificateResource, error) { + certStore := NewCertStore(cfg.Storage, cfg.Logger) + // we can save some extra decoding steps if there's only one issuer, since // we don't need to compare potentially multiple available resources to // select the best one, when there's only one choice anyway if len(cfg.Issuers) == 1 { - return cfg.loadCertResource(ctx, cfg.Issuers[0], certNamesKey) + return certStore.Load(ctx, cfg.Issuers[0].IssuerKey(), certNamesKey) } type decodedCertResource struct { @@ -193,7 +170,7 @@ func (cfg *Config) loadCertResourceAnyIssuer(ctx context.Context, certNamesKey s // load and decode all certificate resources found with the // configured issuers so we can sort by newest for _, issuer := range cfg.Issuers { - certRes, err := cfg.loadCertResource(ctx, issuer, certNamesKey) + certRes, err := certStore.Load(ctx, issuer.IssuerKey(), certNamesKey) if err != nil { if errors.Is(err, fs.ErrNotExist) { // not a problem, but we need to remember the error @@ -238,34 +215,8 @@ func (cfg *Config) loadCertResourceAnyIssuer(ctx context.Context, certNamesKey s // 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) { - 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 - normalizedName, err := idna.ToASCII(certNamesKey) - if err != nil { - return CertificateResource{}, fmt.Errorf("converting '%s' to ASCII: %v", certNamesKey, err) - } - - keyBytes, err := cfg.Storage.Load(ctx, StorageKeys.SitePrivateKey(certRes.issuerKey, normalizedName)) - if err != nil { - return CertificateResource{}, err - } - certRes.PrivateKeyPEM = keyBytes - certBytes, err := cfg.Storage.Load(ctx, StorageKeys.SiteCert(certRes.issuerKey, normalizedName)) - if err != nil { - return CertificateResource{}, err - } - certRes.CertificatePEM = certBytes - metaBytes, err := cfg.Storage.Load(ctx, StorageKeys.SiteMeta(certRes.issuerKey, normalizedName)) - if err != nil { - return CertificateResource{}, err - } - err = json.Unmarshal(metaBytes, &certRes) - if err != nil { - return CertificateResource{}, fmt.Errorf("decoding certificate metadata: %v", err) - } - - return certRes, nil + certStore := NewCertStore(cfg.Storage, cfg.Logger) + return certStore.Load(ctx, issuer.IssuerKey(), certNamesKey) } // hashCertificateChain computes the unique hash of certChain, diff --git a/maintain.go b/maintain.go index bda4a93f..6cb90dea 100644 --- a/maintain.go +++ b/maintain.go @@ -428,18 +428,14 @@ func (cfg *Config) storageHasNewerARI(ctx context.Context, cert Certificate) (bo } // loadStoredACMECertificateMetadata loads the stored ACME certificate data -// from the cert's sidecar JSON file. +// from the certificate bundle or legacy sidecar JSON file. func (cfg *Config) loadStoredACMECertificateMetadata(ctx context.Context, cert Certificate) (acme.Certificate, error) { - metaBytes, err := cfg.Storage.Load(ctx, StorageKeys.SiteMeta(cert.issuerKey, cert.Names[0])) + certStore := NewCertStore(cfg.Storage, cfg.Logger) + certRes, err := certStore.Load(ctx, cert.issuerKey, cert.Names[0]) if err != nil { return acme.Certificate{}, fmt.Errorf("loading cert metadata: %w", err) } - var certRes CertificateResource - if err = json.Unmarshal(metaBytes, &certRes); 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) @@ -583,29 +579,19 @@ func (cfg *Config) updateARI(ctx context.Context, cert Certificate, logger *zap. cfg.certCache.cache[cert.hash] = updatedCert cfg.certCache.mu.Unlock() - // update the ARI value in storage - var certData acme.Certificate - certData, err = cfg.loadStoredACMECertificateMetadata(ctx, cert) - if err != nil { - err = fmt.Errorf("got new ARI from %s, but failed loading stored certificate metadata: %v", iss.IssuerKey(), err) - return - } - certData.RenewalInfo = &newARI - var certDataBytes, certResBytes []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 - } - certResBytes, err = json.MarshalIndent(CertificateResource{ - SANs: cert.Names, - IssuerData: certDataBytes, - }, "", "\t") + // update the ARI value in storage using atomic metadata update + certStore := NewCertStore(cfg.Storage, cfg.Logger) + err = certStore.UpdateMetadata(ctx, cert.issuerKey, cert.Names[0], func(current json.RawMessage) (json.RawMessage, error) { + var certData acme.Certificate + if len(current) > 0 { + if jsonErr := json.Unmarshal(current, &certData); jsonErr != nil { + return nil, fmt.Errorf("unmarshaling current metadata: %v", jsonErr) + } + } + certData.RenewalInfo = &newARI + return json.Marshal(certData) + }) if err != nil { - err = fmt.Errorf("got new ARI from %s, but could not re-encode certificate metadata: %v", iss.IssuerKey(), err) - return - } - if err = cfg.Storage.Store(ctx, StorageKeys.SiteMeta(cert.issuerKey, cert.Names[0]), certResBytes); err != nil { err = fmt.Errorf("got new ARI from %s, but could not store it with certificate metadata: %v", iss.IssuerKey(), err) return } @@ -780,14 +766,19 @@ func deleteExpiredCerts(ctx context.Context, storage Storage, logger *zap.Logger return nil } + certStore := NewCertStore(storage, logger) + for _, issuerKey := range issuerKeys { - siteKeys, err := storage.List(ctx, issuerKey, false) + // Extract issuer name from path (e.g., "certificates/acme-v02.api.letsencrypt.org-directory" -> "acme-v02.api.letsencrypt.org-directory") + issuerName := path.Base(issuerKey) + + contents, 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 { + for _, itemKey := range contents { // if context was cancelled, quit early; otherwise proceed select { case <-ctx.Done(): @@ -795,9 +786,46 @@ func deleteExpiredCerts(ctx context.Context, storage Storage, logger *zap.Logger default: } - siteAssets, err := storage.List(ctx, siteKey, false) + // Handle new bundle format (.bundle.json files directly under issuer) + if strings.HasSuffix(itemKey, ".bundle.json") { + bundleData, err := storage.Load(ctx, itemKey) + if err != nil { + logger.Error("loading bundle file", zap.String("key", itemKey), zap.Error(err)) + continue + } + + var bundle CertificateBundle + if err := json.Unmarshal(bundleData, &bundle); err != nil { + logger.Error("parsing bundle file", zap.String("key", itemKey), zap.Error(err)) + continue + } + + certs, err := parseCertsFromPEMBundle(bundle.CertificatePEM) + if err != nil || len(certs) == 0 { + logger.Error("parsing certificate from bundle", zap.String("key", itemKey), zap.Error(err)) + continue + } + + if expiredTime := time.Since(expiresAt(certs[0])); expiredTime >= gracePeriod { + domain := strings.TrimSuffix(path.Base(itemKey), ".bundle.json") + logger.Info("certificate bundle expired beyond grace period; cleaning up", + zap.String("domain", domain), + zap.Duration("expired_for", expiredTime), + zap.Duration("grace_period", gracePeriod)) + + if err := certStore.Delete(ctx, issuerName, domain); err != nil { + logger.Error("could not delete expired certificate bundle", + zap.String("domain", domain), + zap.Error(err)) + } + } + continue + } + + // Handle legacy format (site folders containing .crt, .key, .json) + siteAssets, err := storage.List(ctx, itemKey, false) if err != nil { - logger.Error("listing site contents", zap.String("site_key", siteKey), zap.Error(err)) + // Not a directory, skip continue } @@ -808,15 +836,18 @@ func deleteExpiredCerts(ctx context.Context, storage Storage, logger *zap.Logger certFile, err := storage.Load(ctx, assetKey) if err != nil { - return fmt.Errorf("loading certificate file %s: %v", assetKey, err) + logger.Error("loading certificate file", zap.String("key", assetKey), zap.Error(err)) + continue } block, _ := pem.Decode(certFile) if block == nil || block.Type != "CERTIFICATE" { - return fmt.Errorf("certificate file %s does not contain PEM-encoded certificate", assetKey) + logger.Error("certificate file does not contain PEM-encoded certificate", zap.String("key", assetKey)) + continue } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { - return fmt.Errorf("certificate file %s is malformed; error parsing PEM: %v", assetKey, err) + logger.Error("certificate file is malformed", zap.String("key", assetKey), zap.Error(err)) + continue } if expiredTime := time.Since(expiresAt(cert)); expiredTime >= gracePeriod { @@ -843,15 +874,15 @@ func deleteExpiredCerts(ctx context.Context, storage Storage, logger *zap.Logger } // update listing; if folder is empty, delete it - siteAssets, err = storage.List(ctx, siteKey, false) + siteAssets, err = storage.List(ctx, itemKey, 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) + logger.Info("deleting site folder because key is empty", zap.String("site_key", itemKey)) + err := storage.Delete(ctx, itemKey) if err != nil { - return fmt.Errorf("deleting empty site folder %s: %v", siteKey, err) + logger.Error("deleting empty site folder", zap.String("key", itemKey), zap.Error(err)) } } } @@ -919,34 +950,10 @@ 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. +// by copying the data to the new file, then deleting the certificate. func (cfg *Config) moveCompromisedPrivateKey(ctx context.Context, cert Certificate, logger *zap.Logger) error { - privKeyStorageKey := StorageKeys.SitePrivateKey(cert.issuerKey, cert.Names[0]) - - privKeyPEM, err := cfg.Storage.Load(ctx, privKeyStorageKey) - if err != nil { - return err - } - - compromisedPrivKeyStorageKey := privKeyStorageKey + ".compromised" - err = cfg.Storage.Store(ctx, compromisedPrivKeyStorageKey, privKeyPEM) - 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 - } - - logger.Info("removed certificate's compromised private key from use", - zap.String("storage_path", compromisedPrivKeyStorageKey), - zap.Strings("identifiers", cert.Names), - zap.String("issuer", cert.issuerKey)) - - return nil + certStore := NewCertStore(cfg.Storage, cfg.Logger) + return certStore.MoveCompromisedKey(ctx, cert.issuerKey, cert.Names[0]) } // certShouldBeForceRenewed returns true if cert should be forcefully renewed diff --git a/storage.go b/storage.go index a4ae1fc6..1d97dc06 100644 --- a/storage.go +++ b/storage.go @@ -250,6 +250,14 @@ func (keys KeyBuilder) SiteMeta(issuerKey, domain string) string { return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".json") } +// SiteBundle returns the path to the certificate bundle file for domain +// that is associated with the issuer with the given issuerKey. The bundle +// format combines certificate, private key, and metadata into a single file. +func (keys KeyBuilder) SiteBundle(issuerKey, domain string) string { + safeDomain := keys.Safe(domain) + return path.Join(keys.CertsPrefix(issuerKey), safeDomain+".bundle.json") +} + // 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.