mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 16:37:17 +02:00
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
This commit is contained in:
@@ -142,9 +142,14 @@ class UploadQueueNotifier extends Notifier<UploadQueueState> {
|
||||
_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,
|
||||
|
||||
@@ -19,6 +19,8 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
|
||||
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<CloudSettingsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
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<CloudSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<bool>(
|
||||
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<void> _resetAllSftpHostKeys() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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;
|
||||
|
||||
@@ -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<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
webdav.Client? _webdavClient;
|
||||
String? _currentServerUrl;
|
||||
String? _currentUsername;
|
||||
String? _currentPassword;
|
||||
|
||||
static const _sftpHostKeysKey = 'sftp_known_host_keys';
|
||||
Map<String, Map<String, String>>? _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<void> 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<void> _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<bool> 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<int> 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<Map<String, Map<String, String>>> _loadKnownHostKeys() async {
|
||||
if (_knownHostKeys != null) {
|
||||
return _knownHostKeys!;
|
||||
}
|
||||
|
||||
final prefs = await _prefs;
|
||||
final raw = prefs.getString(_sftpHostKeysKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
_knownHostKeys = <String, Map<String, String>>{};
|
||||
return _knownHostKeys!;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final map = <String, Map<String, String>>{};
|
||||
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 = <String, Map<String, String>>{};
|
||||
return _knownHostKeys!;
|
||||
}
|
||||
|
||||
Future<void> _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<bool> _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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user