fix(settings): 原子发布首次创建的存储加密密钥 (#688)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Catlaxy AI
2026-10-06 09:23:56 +08:00
committed by GitHub
parent 04d5a9c4f0
commit c7aeba24d7
2 changed files with 214 additions and 10 deletions

View File

@@ -15,6 +15,7 @@ import (
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"infinite-canvas/backend/internal/model"
@@ -423,28 +424,76 @@ func (s *Service) settingsEncryptionKey() ([]byte, error) {
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
if err := publishSettingsKey(path, key, os.Link); errors.Is(err, os.ErrExist) {
return readExistingSettingsKey(path)
} else if err != nil {
return nil, err
}
return key, nil
}
func readExistingSettingsKey(path string) ([]byte, error) {
// An older process or the exclusive-create fallback may still be writing.
for attempt := 0; attempt < 11; attempt++ {
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil, fmt.Errorf("读取存储加密密钥失败:%w", readErr)
}
if len(data) != 32 {
return nil, errors.New("存储加密密钥长度无效")
if len(data) == 32 {
return data, nil
}
if attempt < 10 {
time.Sleep(5 * time.Millisecond)
}
return data, nil
}
return nil, errors.New("存储加密密钥长度无效")
}
func publishSettingsKey(path string, key []byte, link func(string, string) error) error {
file, err := os.CreateTemp(filepath.Dir(path), ".settings-key-*")
if err != nil {
return nil, err
return err
}
defer os.Remove(file.Name())
defer file.Close()
if _, err := file.Write(key); err != nil {
_ = file.Close()
return nil, err
return err
}
if err := file.Sync(); err != nil {
return err
}
if err := file.Close(); err != nil {
return nil, err
return err
}
return key, nil
// Link publishes complete bytes without replacing another process's key.
if err := link(file.Name(), path); err != nil {
if !errors.Is(err, syscall.ENOTSUP) && !errors.Is(err, syscall.EPERM) {
return err
}
// Some filesystems prohibit hard links. O_EXCL still prevents replacement,
// but readers may see an incomplete file and a crash may leave it invalid.
fallback, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer fallback.Close()
if _, err := fallback.Write(key); err != nil {
return err
}
if err := fallback.Sync(); err != nil {
return err
}
if err := fallback.Close(); err != nil {
return err
}
}
// Persist the published name, not only the inode's contents.
dir, err := os.Open(filepath.Dir(path))
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
func (s *Service) protectTaskSecrets(value interface{}) error {

View File

@@ -0,0 +1,155 @@
package app
import (
"bytes"
"errors"
"os"
"path/filepath"
"strconv"
"sync"
"syscall"
"testing"
"time"
)
func TestSettingsEncryptionKeyPublication(t *testing.T) {
path := filepath.Join(t.TempDir(), ".settings-key")
key := bytes.Repeat([]byte{5}, 32)
link := func(temp, target string) error {
data, err := os.ReadFile(temp)
if err != nil || !bytes.Equal(data, key) {
t.Fatalf("incomplete temporary key: %v", err)
}
info, err := os.Stat(temp)
if err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("temporary permissions: %v", err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("key visible before publication: %v", err)
}
return os.Link(temp, target)
}
if err := publishSettingsKey(path, key, link); err != nil {
t.Fatal(err)
}
if err := publishSettingsKey(path, bytes.Repeat([]byte{6}, 32), os.Link); !os.IsExist(err) {
t.Fatalf("publication replaced existing key: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || !bytes.Equal(data, key) {
t.Fatalf("existing key changed: %v", err)
}
other := filepath.Join(t.TempDir(), ".settings-key")
if err := publishSettingsKey(other, key, func(string, string) error { return syscall.EIO }); !errors.Is(err, syscall.EIO) {
t.Fatalf("unexpected link error hidden: %v", err)
}
if _, err := os.Stat(other); !os.IsNotExist(err) {
t.Fatalf("unexpected fallback: %v", err)
}
}
func TestSettingsEncryptionKeyLinkFallback(t *testing.T) {
for _, failure := range []error{syscall.ENOTSUP, syscall.EPERM} {
t.Run(failure.Error(), func(t *testing.T) {
path := filepath.Join(t.TempDir(), ".settings-key")
key := bytes.Repeat([]byte{3}, 32)
link := func(string, string) error { return failure }
if err := publishSettingsKey(path, key, link); err != nil {
t.Fatal(err)
}
if err := publishSettingsKey(path, bytes.Repeat([]byte{4}, 32), link); !os.IsExist(err) {
t.Fatalf("existing key: %v", err)
}
got, err := os.ReadFile(path)
if err != nil || !bytes.Equal(got, key) {
t.Fatalf("key changed: %v", err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("permissions: %v", err)
}
})
}
}
func TestSettingsEncryptionKeyConcurrentFirstUse(t *testing.T) {
dir := t.TempDir()
const workers = 64
keys := make([][]byte, workers)
errs := make([]error, workers)
start := make(chan struct{})
var wg sync.WaitGroup
for i := range keys {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
keys[i], errs[i] = (&Service{dataDir: dir}).settingsEncryptionKey()
}(i)
}
close(start)
wg.Wait()
for i := range keys {
if errs[i] != nil || len(keys[i]) != 32 || !bytes.Equal(keys[i], keys[0]) {
t.Fatalf("worker %d returned inconsistent key: %v", i, errs[i])
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != ".settings-key" {
t.Fatalf("temporary keys leaked: %v", entries)
}
info, err := entries[0].Info()
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("key permissions = %o", info.Mode().Perm())
}
}
func TestSettingsEncryptionKeyWaitsForLegacyWriter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".settings-key")
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
defer f.Close()
expected := bytes.Repeat([]byte{7}, 32)
done := make(chan error, 1)
go func() { time.Sleep(10 * time.Millisecond); _, err := f.Write(expected); done <- err }()
key, err := (&Service{dataDir: dir}).settingsEncryptionKey()
if writeErr := <-done; writeErr != nil {
t.Fatal(writeErr)
}
if err != nil || !bytes.Equal(key, expected) {
t.Fatalf("legacy writer not respected: %v", err)
}
}
func TestSettingsEncryptionKeyNeverReplacesExisting(t *testing.T) {
for _, size := range []int{0, 17, 32, 33} {
t.Run(strconv.Itoa(size), func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".settings-key")
expected := bytes.Repeat([]byte{9}, size)
if err := os.WriteFile(path, expected, 0600); err != nil {
t.Fatal(err)
}
key, err := (&Service{dataDir: dir}).settingsEncryptionKey()
if (err == nil) != (size == 32) {
t.Fatalf("length %d: %v", size, err)
}
actual, readErr := os.ReadFile(path)
if readErr != nil || !bytes.Equal(actual, expected) {
t.Fatal("existing key changed", readErr)
}
if size == 32 && !bytes.Equal(key, expected) {
t.Fatal("existing key not returned")
}
})
}
}