fix(settings): 原子发布首次创建的存储加密密钥 (#688)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
155
backend/internal/app/settings_key_concurrency_test.go
Normal file
155
backend/internal/app/settings_key_concurrency_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user