Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 37 additions & 16 deletions internal/server/cert_store_archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ type certStoreArchive struct {
accountKey []byte
dynamicDomains []byte

// extraAccountKeys holds the per-directory account files (--tls-staging
// identities), keyed by filename.
extraAccountKeys map[string][]byte

// certs is keyed by the certificate's directory name (the sanitized
// certificate identifier).
certs map[string]archiveCertPair
Expand Down Expand Up @@ -268,6 +272,14 @@ func (a *certStoreArchive) placeEntry(name string, data []byte, rawCerts map[str
}

if rest, ok := strings.CutPrefix(name, archiveCertsPrefix); ok {
if isExtraAccountKeyFile(rest) {
Comment thread
mhenrixon marked this conversation as resolved.
if a.extraAccountKeys == nil {
a.extraAccountKeys = map[string][]byte{}
}
a.extraAccountKeys[rest] = data
return nil
}

dir, base, found := strings.Cut(rest, "/")
// The directory must already be in the sanitized form the exporter
// writes: two spellings that sanitize to the same on-disk path would
Expand Down Expand Up @@ -316,7 +328,7 @@ func (a *certStoreArchive) assembleCertPairs(rawCerts map[string]map[string][]by
// validate cross-checks the state file against the archived certificates and
// discards an account key that could not carry the ACME identity forward.
func (a *certStoreArchive) validate() error {
a.checkAccountKey()
a.checkAccountKeys()

if !a.hasState {
if len(a.certs) > 0 {
Expand Down Expand Up @@ -364,41 +376,50 @@ func (a *certStoreArchive) validate() error {
return nil
}

// checkAccountKey drops an account key entry that does not hold usable key
// material, with a warning: restoring it would make the next boot silently
// checkAccountKeys drops account key entries that do not hold usable key
// material, with a warning: restoring one would make the next boot silently
// register a fresh ACME account while the operator believes the identity was
// preserved. The estate's certificates still restore.
func (a *certStoreArchive) checkAccountKey() {
if a.accountKey == nil {
return
func (a *certStoreArchive) checkAccountKeys() {
if a.accountKey != nil && !a.usableAccountKey(a.accountKey, "ACME account key") {
a.accountKey = nil
}

for _, name := range slices.Sorted(maps.Keys(a.extraAccountKeys)) {
if !a.usableAccountKey(a.extraAccountKeys[name], "ACME account key "+name) {
delete(a.extraAccountKeys, name)
}
}
}

// usableAccountKey reports whether data parses as an account holding an ECDSA
// private key, warning under the given label otherwise. It mirrors
// loadOrCreateUser exactly: that only accepts an ECDSA key, so any other key
// type would be silently discarded at boot and a fresh account registered --
// the very outcome this check exists to make loud.
func (a *certStoreArchive) usableAccountKey(data []byte, label string) bool {
var user acmeUser
if err := json.Unmarshal(a.accountKey, &user); err != nil {
if err := json.Unmarshal(data, &user); err != nil {
a.warnings = append(a.warnings, certArchiveWarning{
kind: warnAccountKey,
text: fmt.Sprintf("the archived ACME account key does not parse and will not be restored; the next boot will register a fresh account: %v", err),
text: fmt.Sprintf("the archived %s does not parse and will not be restored; the next boot will register a fresh account: %v", label, err),
})
a.accountKey = nil
return
return false
}

// Mirror loadOrCreateUser exactly: it only accepts an ECDSA key, so any
// other key type would be silently discarded at boot and a fresh account
// registered -- the very outcome this check exists to make loud.
key, err := certcrypto.ParsePEMPrivateKey(user.KeyPEM)
if err == nil {
if _, ok := key.(*ecdsa.PrivateKey); ok {
return
return true
}
err = errors.New("the key is not an ECDSA key")
}

a.warnings = append(a.warnings, certArchiveWarning{
kind: warnAccountKey,
text: fmt.Sprintf("the archived ACME account key holds no usable private key and will not be restored; the next boot will register a fresh account: %v", err),
text: fmt.Sprintf("the archived %s holds no usable private key and will not be restored; the next boot will register a fresh account: %v", label, err),
})
a.accountKey = nil
return false
}

// validateManagerState checks the invariants a healthy manager always
Expand Down
40 changes: 39 additions & 1 deletion internal/server/cert_store_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@ const (
acmeUserFile = "acme_user.json"
)

// isExtraAccountKeyFile matches the per-directory ACME account files that sit
// beside the primary acme_user.json when services use their own ACME
// directory (--tls-staging). The set is closed to exactly the names
// accountFileForDirectory generates — acme_user_staging.json or an
// 8-character lowercase hex hash — so the archive's entry set stays
// enumerable and arbitrary acme_user_*.json files are not silently adopted
// as ACME identities.
func isExtraAccountKeyFile(name string) bool {
if name == "acme_user_staging.json" {
return true
}

hash, ok := strings.CutPrefix(name, "acme_user_")
if !ok {
return false
}
hash, ok = strings.CutSuffix(hash, ".json")
if !ok || len(hash) != 8 {
return false
}
for _, c := range hash {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}

// ErrCertStoreEmpty reports an export attempt against a store with nothing in
// it. Failing loudly beats a cron job faithfully archiving nothing.
var ErrCertStoreEmpty = errors.New("certificate store is empty; nothing to export")
Expand Down Expand Up @@ -108,7 +136,13 @@ func ExportCertificateStore(paths CertStorePaths, outputPath string) (CertsExpor
// The account key alone is not a certificate: a fresh estate that has only
// registered an account still gets its backup.
certsWithoutState := slices.ContainsFunc(certFiles, func(file archiveFile) bool {
return file.name != archiveAccountKeyEntry
if file.name == archiveAccountKeyEntry {
return false
}
// Per-directory account keys are estate metadata like the primary
// one: an account-only store (registered but nothing issued yet)
// still gets its backup.
return !isExtraAccountKeyFile(strings.TrimPrefix(file.name, archiveCertsPrefix))
})
if !hasState && certsWithoutState {
return summary, fmt.Errorf("the certificate store has certificates but no state file at %s; refusing to export an unrestorable archive", paths.ACMEStatePath)
Expand Down Expand Up @@ -200,6 +234,10 @@ func collectCertsEntries(certsPath string, state managerState, summary *CertsExp
if file, ok := collectOptionalJSON(filepath.Join(certsPath, name), archiveAccountKeyEntry, summary); ok {
files = append(files, file)
}
case !entry.IsDir() && isExtraAccountKeyFile(name):
Comment thread
mhenrixon marked this conversation as resolved.
if file, ok := collectOptionalJSON(filepath.Join(certsPath, name), archiveCertsPrefix+name, summary); ok {
files = append(files, file)
}
case entry.IsDir() && name == legacyHTTP01CacheDir:
summary.Warnings = append(summary.Warnings,
fmt.Sprintf("legacy %s cache is not exported: start the proxy once so it is adopted into the store first", legacyHTTP01CacheDir))
Expand Down
35 changes: 35 additions & 0 deletions internal/server/cert_store_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -472,3 +473,37 @@ func TestDirInsidePinnedTree(t *testing.T) {
require.NoError(t, err)
assert.False(t, inside)
}

func TestExportCertificateStore_AccountKeysOnlyStoreExports(t *testing.T) {
Comment thread
mhenrixon marked this conversation as resolved.
// A fresh estate that has registered accounts but issued nothing yet
// still gets its backup — whichever identities it holds.
tests := []struct {
name string
files []string
}{
{"primary and staging keys", []string{"acme_user.json", "acme_user_staging.json"}},
{"a lone staging key", []string{"acme_user_staging.json"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths := testCertStorePaths(t)
require.NoError(t, os.MkdirAll(paths.CertsPath, 0700))
for _, name := range tt.files {
require.NoError(t, os.WriteFile(filepath.Join(paths.CertsPath, name),
testAccountKeyJSON(t), 0600))
}

archivePath := filepath.Join(t.TempDir(), "backup.tar.gz")
summary, err := ExportCertificateStore(paths, archivePath)
require.NoError(t, err)
assert.Empty(t, summary.Warnings)
assert.Zero(t, summary.Certificates)

report, err := VerifyCertificateArchive(archivePath)
require.NoError(t, err)
assert.Empty(t, report.Certificates)
assert.Equal(t, slices.Contains(tt.files, "acme_user.json"), report.HasAccountKey)
})
}
}
9 changes: 9 additions & 0 deletions internal/server/cert_store_restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ func RestoreCertificateStore(opts CertStoreRestoreOptions) (CertsRestoreSummary,
summary.AccountKeyRestored = true
}

for _, name := range slices.Sorted(maps.Keys(archive.extraAccountKeys)) {
if err := os.MkdirAll(opts.Paths.CertsPath, 0700); err != nil {
return summary, fmt.Errorf("failed to create the certificate directory: %w", err)
}
if err := writeFileStaged(filepath.Join(opts.Paths.CertsPath, name), archive.extraAccountKeys[name]); err != nil {
return summary, fmt.Errorf("failed to restore the ACME account key %s: %w", name, err)
}
}

if archive.dynamicDomains != nil {
if err := writeFileStaged(opts.Paths.DynamicDomainsStatePath, archive.dynamicDomains); err != nil {
return summary, fmt.Errorf("failed to restore the dynamic domains state: %w", err)
Expand Down
36 changes: 36 additions & 0 deletions internal/server/cert_store_restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -656,3 +656,39 @@ func TestRestoreCertificateStore_DegenerateArchiveIntoFreshStore(t *testing.T) {
state := readImportedState(t, paths.ACMEStatePath)
assert.Contains(t, state.Certificates, "san:gone")
}

func TestRestoreCertificateStore_RoundTripsExtraAccountKeysAndDirectories(t *testing.T) {
paths := testCertStorePaths(t)
state := populateCertStore(t, paths, []string{"staging.example.com"})

// A per-service directory estate: the certificate records its issuing
// directory, and the staging identity's account key sits beside the
// primary one.
for _, cert := range state.Certificates {
cert.Directory = LetsEncryptStaging
}
require.NoError(t, writeManagerStateFile(paths.ACMEStatePath, state))
stagingKey := testAccountKeyJSON(t)
require.NoError(t, os.WriteFile(filepath.Join(paths.CertsPath, "acme_user_staging.json"), stagingKey, 0600))

archivePath := filepath.Join(t.TempDir(), "backup.tar.gz")
summary, err := ExportCertificateStore(paths, archivePath)
require.NoError(t, err)
assert.Empty(t, summary.Warnings, "a per-directory account key is part of the estate, not an unexpected file")

target := testCertStorePaths(t)
_, err = RestoreCertificateStore(CertStoreRestoreOptions{ArchivePath: archivePath, Paths: target})
require.NoError(t, err)

restoredKey, err := os.ReadFile(filepath.Join(target.CertsPath, "acme_user_staging.json"))
require.NoError(t, err)
assert.Equal(t, stagingKey, restoredKey)

var restored managerState
data, err := os.ReadFile(target.ACMEStatePath)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(data, &restored))
for _, cert := range restored.Certificates {
assert.Equal(t, LetsEncryptStaging, cert.Directory, "the issuing directory must survive export and restore")
}
}
Loading
Loading