From b733ed6a7907790edf0776d04f30ca0e8dcf5030 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 31 Jul 2026 15:28:00 +0200 Subject: [PATCH] fix(dev): make key creation atomic --- tools/homarr-dev/internal/docker/docker.go | 58 ++++++++++++++----- .../homarr-dev/internal/docker/docker_test.go | 27 +++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/tools/homarr-dev/internal/docker/docker.go b/tools/homarr-dev/internal/docker/docker.go index 8fcf1a9e9..da5f2fa92 100644 --- a/tools/homarr-dev/internal/docker/docker.go +++ b/tools/homarr-dev/internal/docker/docker.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) const developmentEncryptionKeyEnv = "HOMARR_DEV_SECRET_ENCRYPTION_KEY" @@ -236,43 +237,72 @@ func developmentEncryptionKey() (string, error) { func persistedDevelopmentEncryptionKey(configDir string) (string, error) { dir := filepath.Join(configDir, "homarr-dev") path := filepath.Join(dir, "secret-encryption-key") - if data, err := os.ReadFile(path); err == nil { - return validateDevelopmentEncryptionKey(strings.TrimSpace(string(data))) - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("read development encryption key: %w", err) + if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { + return key, err } if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("create development config directory: %w", err) } + lockPath := path + ".lock" + for attempts := 0; ; attempts++ { + if err := os.Mkdir(lockPath, 0o700); err == nil { + break + } else if !os.IsExist(err) { + return "", fmt.Errorf("lock development encryption key: %w", err) + } + if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { + return key, err + } + if attempts == 99 { + return "", fmt.Errorf("timed out waiting for development encryption key lock %q", lockPath) + } + time.Sleep(10 * time.Millisecond) + } + defer func() { _ = os.Remove(lockPath) }() + + if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { + return key, err + } keyBytes := make([]byte, 32) if _, err := rand.Read(keyBytes); err != nil { return "", fmt.Errorf("generate development encryption key: %w", err) } key := hex.EncodeToString(keyBytes) - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if os.IsExist(err) { - data, readErr := os.ReadFile(path) - if readErr != nil { - return "", fmt.Errorf("read development encryption key: %w", readErr) - } - return validateDevelopmentEncryptionKey(strings.TrimSpace(string(data))) - } + file, err := os.CreateTemp(dir, "secret-encryption-key-*.tmp") if err != nil { return "", fmt.Errorf("create development encryption key: %w", err) } + tempPath := file.Name() + defer func() { _ = os.Remove(tempPath) }() if _, err := file.WriteString(key + "\n"); err != nil { _ = file.Close() - _ = os.Remove(path) return "", fmt.Errorf("write development encryption key: %w", err) } if err := file.Close(); err != nil { - _ = os.Remove(path) return "", fmt.Errorf("close development encryption key: %w", err) } + if err := os.Chmod(tempPath, 0o600); err != nil { + return "", fmt.Errorf("secure development encryption key: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return "", fmt.Errorf("persist development encryption key: %w", err) + } return key, nil } +func readDevelopmentEncryptionKey(path string) (string, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("read development encryption key: %w", err) + } + key, err := validateDevelopmentEncryptionKey(strings.TrimSpace(string(data))) + return key, true, err +} + func validateDevelopmentEncryptionKey(key string) (string, error) { decoded, err := hex.DecodeString(key) if err != nil || len(decoded) != 32 { diff --git a/tools/homarr-dev/internal/docker/docker_test.go b/tools/homarr-dev/internal/docker/docker_test.go index 6d0ca844c..3422c703d 100644 --- a/tools/homarr-dev/internal/docker/docker_test.go +++ b/tools/homarr-dev/internal/docker/docker_test.go @@ -96,3 +96,30 @@ func TestPersistedDevelopmentEncryptionKeyRejectsInvalidValue(t *testing.T) { t.Fatal("expected invalid persisted key to fail") } } + +func TestPersistedDevelopmentEncryptionKeyConcurrentCreation(t *testing.T) { + configDir := t.TempDir() + type result struct { + key string + err error + } + results := make(chan result, 20) + for range 20 { + go func() { + key, err := persistedDevelopmentEncryptionKey(configDir) + results <- result{key: key, err: err} + }() + } + var first string + for range 20 { + result := <-results + if result.err != nil { + t.Fatal(result.err) + } + if first == "" { + first = result.key + } else if result.key != first { + t.Fatal("concurrent callers received different keys") + } + } +}