mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-04 17:16:50 +02:00
fix(security): encrypt extension storage at rest
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user