From 22f001a735dc9c6e71bff8fcb489c80eb9daea65 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 3 Feb 2026 19:25:09 +0700 Subject: [PATCH] feat: add SFTP host key management and security improvements - Add HTTPS URL validation for extension store registry and downloads - Add Reset SFTP Host Key button (per-server) - Add Reset All SFTP Host Keys button - Add SFTP host key verification with TOFU (Trust On First Use) - Update cloud upload service with host key storage - Add flutter_secure_storage dependency for secure password storage --- android/app/src/main/AndroidManifest.xml | 3 +- go_backend/extension_runtime_http.go | 7 + go_backend/extension_store.go | 23 +++ ios/Runner/Info.plist | 2 +- lib/providers/upload_queue_provider.dart | 9 +- lib/screens/settings/cloud_settings_page.dart | 133 +++++++++++++++ lib/services/cloud_upload_service.dart | 160 +++++++++++++++++- pubspec.lock | 52 +++++- pubspec.yaml | 1 + 9 files changed, 380 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bd483d13..ac696ec9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -20,8 +20,7 @@ android:label="SpotiFLAC" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:requestLegacyExternalStorage="true" - android:usesCleartextTraffic="true" + android:usesCleartextTraffic="false" android:enableOnBackInvokedCallback="true"> NSAppTransportSecurity NSAllowsArbitraryLoads - + diff --git a/lib/providers/upload_queue_provider.dart b/lib/providers/upload_queue_provider.dart index 6608e039..c9b6500f 100644 --- a/lib/providers/upload_queue_provider.dart +++ b/lib/providers/upload_queue_provider.dart @@ -142,9 +142,14 @@ class UploadQueueNotifier extends Notifier { _isProcessing = true; state = state.copyWith(isProcessing: true); - final settings = ref.read(settingsProvider); - while (true) { + final settings = ref.read(settingsProvider); + + // Stop processing if cloud upload is disabled or provider is unset + if (!settings.cloudUploadEnabled || settings.cloudProvider == 'none') { + break; + } + // Find next pending item final pendingIndex = state.items.indexWhere( (i) => i.status == UploadStatus.pending, diff --git a/lib/screens/settings/cloud_settings_page.dart b/lib/screens/settings/cloud_settings_page.dart index 1b07d42d..910c258e 100644 --- a/lib/screens/settings/cloud_settings_page.dart +++ b/lib/screens/settings/cloud_settings_page.dart @@ -19,6 +19,8 @@ class _CloudSettingsPageState extends ConsumerState { late TextEditingController _passwordController; late TextEditingController _remotePathController; bool _isTestingConnection = false; + bool _isResettingHostKey = false; + bool _isResettingAllHostKeys = false; String? _connectionTestResult; @override @@ -220,6 +222,42 @@ class _CloudSettingsPageState extends ConsumerState { ), ), ), + if (settings.cloudProvider == 'sftp') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: OutlinedButton.icon( + onPressed: (_isResettingHostKey || settings.cloudServerUrl.isEmpty) + ? null + : _resetSftpHostKey, + icon: _isResettingHostKey + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.security), + label: Text(context.l10n.cloudSettingsResetSftpHostKey), + ), + ), + ), + if (settings.cloudProvider == 'sftp') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: OutlinedButton.icon( + onPressed: _isResettingAllHostKeys ? null : _resetAllSftpHostKeys, + icon: _isResettingAllHostKeys + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_sweep), + label: Text(context.l10n.cloudSettingsResetAllSftpHostKeys), + ), + ), + ), // Connection Test Result if (_connectionTestResult != null) @@ -433,6 +471,101 @@ class _CloudSettingsPageState extends ConsumerState { } } + Future _resetSftpHostKey() async { + final settings = ref.read(settingsProvider); + if (settings.cloudServerUrl.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.cloudSettingsServerUrlRequired)), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(context.l10n.cloudSettingsResetSftpHostKey), + content: Text(context.l10n.cloudSettingsResetSftpHostKeyMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.cloudSettingsResetConfirm), + ), + ], + ); + }, + ); + + if (confirmed != true) return; + + setState(() { + _isResettingHostKey = true; + }); + + final cleared = await CloudUploadService.instance.clearSftpHostKey( + serverUrl: settings.cloudServerUrl, + ); + + if (!mounted) return; + setState(() { + _isResettingHostKey = false; + }); + + final message = cleared + ? context.l10n.cloudSettingsResetSftpHostKeySuccess + : context.l10n.cloudSettingsResetSftpHostKeyNotFound; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + Future _resetAllSftpHostKeys() async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(context.l10n.cloudSettingsResetAllSftpHostKeys), + content: Text(context.l10n.cloudSettingsResetAllSftpHostKeysMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.cloudSettingsResetAllConfirm), + ), + ], + ); + }, + ); + + if (confirmed != true) return; + + setState(() { + _isResettingAllHostKeys = true; + }); + + final clearedCount = await CloudUploadService.instance.clearAllSftpHostKeys(); + + if (!mounted) return; + setState(() { + _isResettingAllHostKeys = false; + }); + + final message = clearedCount > 0 + ? context.l10n.cloudSettingsResetAllSftpHostKeysCleared(clearedCount) + : context.l10n.cloudSettingsResetAllSftpHostKeysNone; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + Widget _buildUploadQueueSection(BuildContext context) { final uploadState = ref.watch(uploadQueueProvider); final colorScheme = Theme.of(context).colorScheme; diff --git a/lib/services/cloud_upload_service.dart b/lib/services/cloud_upload_service.dart index b8ad996a..f4658455 100644 --- a/lib/services/cloud_upload_service.dart +++ b/lib/services/cloud_upload_service.dart @@ -1,7 +1,9 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:webdav_client/webdav_client.dart' as webdav; import 'package:dartssh2/dartssh2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/utils/logger.dart'; /// Result of a cloud upload operation @@ -43,10 +45,15 @@ class CloudUploadService { CloudUploadService._(); final LogBuffer _log = LogBuffer(); + final Future _prefs = SharedPreferences.getInstance(); webdav.Client? _webdavClient; String? _currentServerUrl; String? _currentUsername; + String? _currentPassword; + + static const _sftpHostKeysKey = 'sftp_known_host_keys'; + Map>? _knownHostKeys; void _logInfo(String tag, String message) { _log.add(LogEntry( @@ -71,16 +78,26 @@ class CloudUploadService { // WebDAV Methods // ============================================================ + bool _isHttpsUrl(String url) { + final uri = Uri.tryParse(url); + return uri != null && uri.scheme == 'https'; + } + /// Initialize WebDAV client with server credentials Future initializeWebDAV({ required String serverUrl, required String username, required String password, }) async { + if (!_isHttpsUrl(serverUrl)) { + throw ArgumentError('WebDAV URL must use https'); + } + // Reuse existing client if credentials haven't changed if (_webdavClient != null && _currentServerUrl == serverUrl && - _currentUsername == username) { + _currentUsername == username && + _currentPassword == password) { return; } @@ -93,6 +110,7 @@ class CloudUploadService { _currentServerUrl = serverUrl; _currentUsername = username; + _currentPassword = password; _logInfo('CloudUpload', 'WebDAV client initialized for $serverUrl'); } @@ -103,6 +121,9 @@ class CloudUploadService { required String username, required String password, }) async { + if (!_isHttpsUrl(serverUrl)) { + return CloudUploadResult.failure('WebDAV URL must use https.'); + } try { final client = webdav.newClient( serverUrl, @@ -131,6 +152,9 @@ class CloudUploadService { required String password, void Function(int sent, int total)? onProgress, }) async { + if (!_isHttpsUrl(serverUrl)) { + return CloudUploadResult.failure('WebDAV URL must use https.'); + } try { // Initialize client if needed await initializeWebDAV( @@ -266,6 +290,12 @@ class CloudUploadService { socket, username: username, onPasswordRequest: () => password, + onVerifyHostKey: (type, fingerprint) => _verifySftpHostKey( + host: serverInfo.host, + port: serverInfo.port, + type: type, + fingerprint: fingerprint, + ), ); // Wait for authentication @@ -273,7 +303,7 @@ class CloudUploadService { // Test SFTP subsystem final sftp = await client.sftp(); - await sftp.listdir('/'); + await sftp.listdir('.'); sftp.close(); _logInfo('CloudUpload', 'SFTP connection test successful: ${serverInfo.host}'); @@ -318,6 +348,12 @@ class CloudUploadService { socket, username: username, onPasswordRequest: () => password, + onVerifyHostKey: (type, fingerprint) => _verifySftpHostKey( + host: serverInfo.host, + port: serverInfo.port, + type: type, + fingerprint: fingerprint, + ), ); // Wait for authentication @@ -368,10 +404,16 @@ class CloudUploadService { /// Ensure a directory exists on the SFTP server, creating it if necessary Future _ensureSFTPDirectoryExists(SftpClient sftp, String path) async { final parts = path.split('/').where((p) => p.isNotEmpty).toList(); + if (parts.isEmpty) return; + final isAbsolute = path.startsWith('/'); var currentPath = ''; for (final part in parts) { - currentPath += '/$part'; + if (currentPath.isEmpty) { + currentPath = isAbsolute ? '/$part' : part; + } else { + currentPath += '/$part'; + } try { await sftp.mkdir(currentPath); } catch (e) { @@ -465,5 +507,117 @@ class CloudUploadService { _webdavClient = null; _currentServerUrl = null; _currentUsername = null; + _currentPassword = null; + } + + Future clearSftpHostKey({required String serverUrl}) async { + final serverInfo = _parseSftpUrl(serverUrl); + final knownHostKeys = await _loadKnownHostKeys(); + final keyId = '${serverInfo.host}:${serverInfo.port}'; + + final removed = knownHostKeys.remove(keyId) != null; + if (removed) { + await _saveKnownHostKeys(); + _logInfo('CloudUpload', 'Cleared SFTP host key for $keyId'); + } + return removed; + } + + Future clearAllSftpHostKeys() async { + final knownHostKeys = await _loadKnownHostKeys(); + final count = knownHostKeys.length; + if (count == 0) { + return 0; + } + + knownHostKeys.clear(); + await _saveKnownHostKeys(); + _logInfo('CloudUpload', 'Cleared all SFTP host keys'); + return count; + } + + Future>> _loadKnownHostKeys() async { + if (_knownHostKeys != null) { + return _knownHostKeys!; + } + + final prefs = await _prefs; + final raw = prefs.getString(_sftpHostKeysKey); + if (raw == null || raw.isEmpty) { + _knownHostKeys = >{}; + return _knownHostKeys!; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + final map = >{}; + decoded.forEach((key, value) { + if (value is Map) { + final type = value['type']; + final fingerprint = value['fingerprint']; + if (type is String && fingerprint is String) { + map[key] = {'type': type, 'fingerprint': fingerprint}; + } + } + }); + _knownHostKeys = map; + return map; + } + } catch (e) { + _logError('CloudUpload', 'Failed to parse known host keys', e.toString()); + } + + _knownHostKeys = >{}; + return _knownHostKeys!; + } + + Future _saveKnownHostKeys() async { + if (_knownHostKeys == null) return; + final prefs = await _prefs; + await prefs.setString(_sftpHostKeysKey, jsonEncode(_knownHostKeys)); + } + + String _formatFingerprint(Uint8List fingerprint) { + final buffer = StringBuffer(); + for (var i = 0; i < fingerprint.length; i++) { + if (i > 0) buffer.write(':'); + buffer.write(fingerprint[i].toRadixString(16).padLeft(2, '0')); + } + return buffer.toString(); + } + + Future _verifySftpHostKey({ + required String host, + required int port, + required String type, + required Uint8List fingerprint, + }) async { + final knownHostKeys = await _loadKnownHostKeys(); + final keyId = '$host:$port'; + final fingerprintHex = _formatFingerprint(fingerprint); + final existing = knownHostKeys[keyId]; + + if (existing == null) { + knownHostKeys[keyId] = { + 'type': type, + 'fingerprint': fingerprintHex, + }; + await _saveKnownHostKeys(); + _logInfo('CloudUpload', 'Saved new SFTP host key for $keyId'); + return true; + } + + final existingFingerprint = existing['fingerprint']; + if (existingFingerprint == fingerprintHex) { + return true; + } + + _logError( + 'CloudUpload', + 'SFTP host key mismatch for $keyId', + 'expected=$existingFingerprint got=$fingerprintHex', + ); + return false; } } diff --git a/pubspec.lock b/pubspec.lock index 4645d151..ca5ccd97 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -451,6 +451,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_svg: dependency: "direct main" description: @@ -561,10 +609,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6def1b10..58ea5053 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: # Storage & Persistence shared_preferences: ^2.5.3 + flutter_secure_storage: ^9.2.2 path_provider: ^2.1.5 path: ^1.9.0 sqflite: ^2.4.1