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
+7 -8
View File
@@ -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');
}
+10 -2
View File
@@ -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(
+3 -3
View File
@@ -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(
+7 -9
View File
@@ -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();
}
+155
View File
@@ -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();
}
}
}
+4 -2
View File
@@ -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,
});
}