fix(dev): make key creation atomic

This commit is contained in:
Thomas Camlong committed 2026-07-31 15:28:00 +02:00
1 parent ae3e9f7793
commit b733ed6a79
2 files changed
+71 -14

No files matched your search

+44 -14
View File
@@ -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 {
@@ -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")
}
}
}