mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
fix(security): encrypt extension storage at rest
This commit is contained in:
1 parent
cab1af1828
commit
29db940d63
15 files changed
+363
-34
No files matched your search
@@ -1931,7 +1931,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"initExtensionSystem" -> {
|
||||
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
||||
val dataDir = call.argument<String>("data_dir") ?: ""
|
||||
val masterKey = call.argument<String>("master_key") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setExtensionStorageMasterKey(masterKey)
|
||||
Gobackend.initExtensionSystem(extensionsDir, dataDir)
|
||||
}
|
||||
result.success(null)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -711,6 +711,9 @@ import Gobackend
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionsDir = args["extensions_dir"] as! String
|
||||
let dataDir = args["data_dir"] as! String
|
||||
let masterKey = args["master_key"] as! String
|
||||
GobackendSetExtensionStorageMasterKey(masterKey, &error)
|
||||
if let error = error { throw error }
|
||||
GobackendInitExtensionSystem(extensionsDir, dataDir, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
+7
-8
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
@@ -18,6 +17,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
import 'package:spotiflac_android/services/extension_storage_service.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -395,16 +395,15 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
final storage = await ExtensionStorageService.prepare();
|
||||
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.initialize(extensionsDir, dataDir);
|
||||
.initialize(
|
||||
storage.extensionsDir,
|
||||
storage.dataDir,
|
||||
masterKey: storage.masterKey,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize extensions: $e');
|
||||
}
|
||||
|
||||
@@ -67,7 +67,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||
Future<void> initialize(
|
||||
String extensionsDir,
|
||||
String dataDir, {
|
||||
required String masterKey,
|
||||
}) async {
|
||||
if (state.isInitialized) return;
|
||||
if (_initializationCompleter != null) {
|
||||
await _initializationCompleter!.future;
|
||||
@@ -100,7 +104,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
_log.w('Runtime state restore unavailable: $e');
|
||||
}
|
||||
}
|
||||
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
||||
await PlatformBridge.initExtensionSystem(
|
||||
extensionsDir,
|
||||
dataDir,
|
||||
masterKey: masterKey,
|
||||
);
|
||||
await loadExtensions(extensionsDir);
|
||||
await loadProviderPriority();
|
||||
await loadMetadataProviderPriority();
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/repo_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/extension_storage_service.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
|
||||
@@ -531,12 +532,11 @@ class _ExtensionDetailsScreenState
|
||||
|
||||
Future<void> _installExtension(RepoExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final storage = await ExtensionStorageService.prepare();
|
||||
|
||||
final success = await ref
|
||||
.read(repoProvider.notifier)
|
||||
.installExtension(ext.id, tempDir.path, extensionsDir);
|
||||
.installExtension(ext.id, tempDir.path, storage.extensionsDir);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/repo_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/screens/repo/extension_details_screen.dart';
|
||||
import 'package:spotiflac_android/services/extension_storage_service.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
|
||||
class RepoTab extends ConsumerStatefulWidget {
|
||||
@@ -532,12 +533,11 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
|
||||
Future<void> _installExtension(RepoExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final storage = await ExtensionStorageService.prepare();
|
||||
|
||||
final success = await ref
|
||||
.read(repoProvider.notifier)
|
||||
.installExtension(ext.id, tempDir.path, extensionsDir);
|
||||
.installExtension(ext.id, tempDir.path, storage.extensionsDir);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:spotiflac_android/widgets/extension_row.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
@@ -14,6 +13,7 @@ import 'package:spotiflac_android/screens/settings/download_fallback_extensions_
|
||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/services/extension_storage_service.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
|
||||
@@ -34,16 +34,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
Future<void> _initializeExtensions() async {
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
final storage = await ExtensionStorageService.prepare();
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.initialize(extensionsDir, dataDir);
|
||||
.initialize(
|
||||
storage.extensionsDir,
|
||||
storage.dataDir,
|
||||
masterKey: storage.masterKey,
|
||||
);
|
||||
} else {
|
||||
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
const _extensionMasterKeyName = 'extension_storage_master_key_v2';
|
||||
|
||||
class ExtensionStoragePaths {
|
||||
const ExtensionStoragePaths({
|
||||
required this.extensionsDir,
|
||||
required this.dataDir,
|
||||
required this.masterKey,
|
||||
});
|
||||
|
||||
final String extensionsDir;
|
||||
final String dataDir;
|
||||
final String masterKey;
|
||||
}
|
||||
|
||||
class ExtensionStorageService {
|
||||
ExtensionStorageService._();
|
||||
|
||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||
static Future<ExtensionStoragePaths>? _preparing;
|
||||
|
||||
static Future<ExtensionStoragePaths> prepare() {
|
||||
return _preparing ??= _prepare();
|
||||
}
|
||||
|
||||
static Future<ExtensionStoragePaths> _prepare() async {
|
||||
final supportDir = await getApplicationSupportDirectory();
|
||||
final documentsDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = Directory(p.join(supportDir.path, 'extensions'));
|
||||
final dataDir = Directory(p.join(supportDir.path, 'extension_data'));
|
||||
|
||||
await _migrateDirectory(
|
||||
Directory(p.join(documentsDir.path, 'extensions')),
|
||||
extensionsDir,
|
||||
);
|
||||
await _migrateDirectory(
|
||||
Directory(p.join(documentsDir.path, 'extension_data')),
|
||||
dataDir,
|
||||
);
|
||||
await extensionsDir.create(recursive: true);
|
||||
await dataDir.create(recursive: true);
|
||||
|
||||
return ExtensionStoragePaths(
|
||||
extensionsDir: extensionsDir.path,
|
||||
dataDir: dataDir.path,
|
||||
masterKey: await _loadOrCreateMasterKey(),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String> _loadOrCreateMasterKey() async {
|
||||
final existing = await _secureStorage.read(key: _extensionMasterKeyName);
|
||||
if (existing != null) {
|
||||
try {
|
||||
if (base64Decode(existing).length == 32) return existing;
|
||||
} on FormatException {
|
||||
// Replace malformed legacy data with a fresh keystore-backed key.
|
||||
}
|
||||
}
|
||||
|
||||
final random = Random.secure();
|
||||
final key = List<int>.generate(32, (_) => random.nextInt(256));
|
||||
final encoded = base64Encode(key);
|
||||
await _secureStorage.write(key: _extensionMasterKeyName, value: encoded);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
static Future<void> _migrateDirectory(
|
||||
Directory source,
|
||||
Directory destination,
|
||||
) async {
|
||||
if (p.equals(source.path, destination.path)) {
|
||||
return;
|
||||
}
|
||||
final sourceType = await FileSystemEntity.type(
|
||||
source.path,
|
||||
followLinks: false,
|
||||
);
|
||||
if (sourceType == FileSystemEntityType.notFound) return;
|
||||
if (sourceType == FileSystemEntityType.link) {
|
||||
await Link(source.path).delete();
|
||||
return;
|
||||
}
|
||||
if (sourceType != FileSystemEntityType.directory) {
|
||||
throw FileSystemException(
|
||||
'Extension storage path is not a directory',
|
||||
source.path,
|
||||
);
|
||||
}
|
||||
if (await FileSystemEntity.type(destination.path, followLinks: false) ==
|
||||
FileSystemEntityType.link) {
|
||||
throw FileSystemException(
|
||||
'Refusing extension storage symlink',
|
||||
destination.path,
|
||||
);
|
||||
}
|
||||
|
||||
await destination.create(recursive: true);
|
||||
await for (final entity in source.list(followLinks: false)) {
|
||||
final targetPath = p.join(destination.path, p.basename(entity.path));
|
||||
if (entity is Directory) {
|
||||
await _migrateDirectory(entity, Directory(targetPath));
|
||||
} else if (entity is File) {
|
||||
final target = File(targetPath);
|
||||
await target.parent.create(recursive: true);
|
||||
if (await target.exists()) {
|
||||
final sourceModified = await entity.lastModified();
|
||||
final targetModified = await target.lastModified();
|
||||
if (!sourceModified.isAfter(targetModified)) {
|
||||
await entity.delete();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await _copyFileAtomically(entity, target);
|
||||
await entity.delete();
|
||||
} else if (entity is Link) {
|
||||
// Extension storage must never preserve links into another sandbox.
|
||||
await entity.delete();
|
||||
}
|
||||
}
|
||||
if (await source.exists() && await source.list().isEmpty) {
|
||||
await source.delete();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _copyFileAtomically(File source, File target) async {
|
||||
final nonce = Random.secure().nextInt(0x7fffffff).toRadixString(16);
|
||||
final temporary = File('${target.path}.migration-$nonce.tmp');
|
||||
try {
|
||||
await source.openRead().pipe(temporary.openWrite());
|
||||
final sourceLength = await source.length();
|
||||
if (await temporary.length() != sourceLength) {
|
||||
throw const FileSystemException('Incomplete extension storage copy');
|
||||
}
|
||||
try {
|
||||
await temporary.rename(target.path);
|
||||
} on FileSystemException {
|
||||
// Windows cannot atomically replace an existing file. Mobile platforms
|
||||
// take the first branch; this fallback keeps development migrations
|
||||
// functional while retaining the source until replacement succeeds.
|
||||
if (!await target.exists()) rethrow;
|
||||
await target.delete();
|
||||
await temporary.rename(target.path);
|
||||
}
|
||||
} finally {
|
||||
if (await temporary.exists()) await temporary.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1446,12 +1446,14 @@ class PlatformBridge {
|
||||
|
||||
static Future<void> initExtensionSystem(
|
||||
String extensionsDir,
|
||||
String dataDir,
|
||||
) async {
|
||||
String dataDir, {
|
||||
required String masterKey,
|
||||
}) async {
|
||||
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
||||
await _channel.invokeMethod('initExtensionSystem', {
|
||||
'extensions_dir': extensionsDir,
|
||||
'data_dir': dataDir,
|
||||
'master_key': masterKey,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user