fix(security): encrypt extension storage at rest

This commit is contained in:
zarzet
2026-08-29 18:52:16 +07:00
parent cab1af1828
commit 29db940d63
15 changed files with 363 additions and 34 deletions
+3
View File
@@ -282,6 +282,9 @@ func getEnabledExtensionProviderMetadataResponse(providerID, resourceType, resou
}
func InitExtensionSystem(extensionsDir, dataDir string) error {
if !extensionStorageKeyConfigured() {
return fmt.Errorf("extension storage master key is not configured")
}
manager := getExtensionManager()
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
return err
+21 -1
View File
@@ -316,6 +316,10 @@ func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
}
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
return deriveExtensionStorageKey(r.extensionID, "credentials")
}
func (r *extensionRuntime) getLegacyEncryptionKey() ([]byte, error) {
salt, err := r.getOrCreateSalt()
if err != nil {
return nil, err
@@ -340,7 +344,23 @@ func (r *extensionRuntime) readCredentialsFileLocked() (map[string]any, error) {
}
decrypted, err := decryptAES(data, key)
if err != nil {
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
legacyKey, legacyKeyErr := r.getLegacyEncryptionKey()
if legacyKeyErr != nil {
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
}
decrypted, legacyKeyErr = decryptAES(data, legacyKey)
if legacyKeyErr != nil {
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
}
// Transparently replace the legacy extension-id-derived ciphertext while
// the caller holds the per-file lock.
migrated, migrateErr := encryptAES(decrypted, key)
if migrateErr != nil {
return nil, fmt.Errorf("failed to migrate credentials: %w", migrateErr)
}
if migrateErr = writeExtensionFileLocked(r.getCredentialsPath(), migrated); migrateErr != nil {
return nil, fmt.Errorf("failed to migrate credentials: %w", migrateErr)
}
}
creds := make(map[string]any)
if err := json.Unmarshal(decrypted, &creds); err != nil {
@@ -1,6 +1,7 @@
package gobackend
import (
"bytes"
"encoding/json"
"fmt"
"io"
@@ -661,6 +662,23 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
if all := settingsStore.GetAll("ext"); all["a"] != float64(1) {
t.Fatalf("settings all = %#v", all)
}
settingsCiphertext, err := os.ReadFile(settingsStore.getSettingsPath("ext"))
if err != nil {
t.Fatalf("read encrypted settings: %v", err)
}
if bytes.Contains(settingsCiphertext, []byte("hidden")) || bytes.Contains(settingsCiphertext, []byte("quality")) {
t.Fatal("extension settings were stored as plaintext")
}
if _, err := os.Stat(settingsStore.getLegacySettingsPath("ext")); !os.IsNotExist(err) {
t.Fatalf("plaintext settings file still exists: %v", err)
}
reloadedWithData := &ExtensionSettingsStore{settings: map[string]map[string]any{}}
if err := reloadedWithData.SetDataDir(settingsStore.dataDir); err != nil {
t.Fatalf("reload encrypted settings: %v", err)
}
if reloadedWithData.GetAll("ext")["_secret"] != "hidden" {
t.Fatal("encrypted settings did not round-trip")
}
if err := settingsStore.Remove("ext", "a"); err != nil {
t.Fatalf("settings Remove: %v", err)
}
+54
View File
@@ -0,0 +1,54 @@
package gobackend
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"sync"
)
const extensionStorageMasterKeyBytes = 32
var extensionStorageKeyState struct {
sync.RWMutex
key []byte
}
// SetExtensionStorageMasterKey installs the platform-keystore-backed key used
// to encrypt extension settings and credentials. The key itself is never
// persisted by Go; Flutter stores it in Keychain/Android Keystore.
func SetExtensionStorageMasterKey(encodedKey string) error {
key, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil || len(key) != extensionStorageMasterKeyBytes {
return fmt.Errorf("extension storage master key must be 32 base64-encoded bytes")
}
extensionStorageKeyState.Lock()
extensionStorageKeyState.key = append(extensionStorageKeyState.key[:0], key...)
extensionStorageKeyState.Unlock()
return nil
}
func extensionStorageKeyConfigured() bool {
extensionStorageKeyState.RLock()
configured := len(extensionStorageKeyState.key) == extensionStorageMasterKeyBytes
extensionStorageKeyState.RUnlock()
return configured
}
func deriveExtensionStorageKey(extensionID, purpose string) ([]byte, error) {
extensionStorageKeyState.RLock()
if len(extensionStorageKeyState.key) != extensionStorageMasterKeyBytes {
extensionStorageKeyState.RUnlock()
return nil, fmt.Errorf("extension storage master key is not configured")
}
masterKey := append([]byte(nil), extensionStorageKeyState.key...)
extensionStorageKeyState.RUnlock()
mac := hmac.New(sha256.New, masterKey)
_, _ = mac.Write([]byte("SpotiFLAC Mobile extension storage v2\x00"))
_, _ = mac.Write([]byte(purpose))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(extensionID))
return mac.Sum(nil), nil
}
+58 -6
View File
@@ -31,9 +31,12 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !extensionStorageKeyConfigured() {
return fmt.Errorf("extension storage master key is not configured")
}
s.dataDir = dataDir
if err := os.MkdirAll(dataDir, 0755); err != nil {
if err := os.MkdirAll(dataDir, 0700); err != nil {
return fmt.Errorf("failed to create settings directory: %w", err)
}
@@ -41,6 +44,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
}
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
return filepath.Join(s.dataDir, extensionID, "settings.enc")
}
func (s *ExtensionSettingsStore) getLegacySettingsPath(extensionID string) string {
return filepath.Join(s.dataDir, extensionID, "settings.json")
}
@@ -73,10 +80,18 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]an
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]any), nil
return s.migrateLegacySettings(extensionID)
}
return nil, err
}
key, err := deriveExtensionStorageKey(extensionID, "settings")
if err != nil {
return nil, err
}
data, err = decryptAES(data, key)
if err != nil {
return nil, fmt.Errorf("failed to decrypt settings: %w", err)
}
var settings map[string]any
if err := json.Unmarshal(data, &settings); err != nil {
@@ -86,20 +101,53 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]an
return settings, nil
}
func (s *ExtensionSettingsStore) migrateLegacySettings(extensionID string) (map[string]any, error) {
legacyPath := s.getLegacySettingsPath(extensionID)
data, err := os.ReadFile(legacyPath)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]any), nil
}
return nil, err
}
settings := make(map[string]any)
if err := json.Unmarshal(data, &settings); err != nil {
return nil, fmt.Errorf("failed to read legacy settings: %w", err)
}
if err := s.saveSettings(extensionID, settings); err != nil {
return nil, fmt.Errorf("failed to encrypt legacy settings: %w", err)
}
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to remove legacy settings: %w", err)
}
return settings, nil
}
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]any) error {
settingsPath := s.getSettingsPath(extensionID)
dir := filepath.Dir(settingsPath)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.MarshalIndent(settings, "", " ")
data, err := json.Marshal(settings)
if err != nil {
return err
}
return os.WriteFile(settingsPath, data, 0644)
key, err := deriveExtensionStorageKey(extensionID, "settings")
if err != nil {
return err
}
data, err = encryptAES(data, key)
if err != nil {
return err
}
fileMu := extensionFileMu(settingsPath)
fileMu.Lock()
err = writeExtensionFileLocked(settingsPath, data)
fileMu.Unlock()
return err
}
func (s *ExtensionSettingsStore) Get(extensionID, key string) (any, error) {
@@ -180,6 +228,10 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
return err
}
legacyPath := s.getLegacySettingsPath(extensionID)
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove legacy extension settings: %w", err)
}
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package gobackend
import "encoding/base64"
func init() {
// Production receives this key from platform secure storage before the
// extension system starts. Tests install a deterministic process-local key.
key := make([]byte, extensionStorageMasterKeyBytes)
for index := range key {
key[index] = byte(index + 1)
}
if err := SetExtensionStorageMasterKey(base64.StdEncoding.EncodeToString(key)); err != nil {
panic(err)
}
}