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:
zarzet
2026-02-03 19:25:09 +07:00
parent 26d464d3c7
commit 22f001a735
9 changed files with 380 additions and 10 deletions
+1 -2
View File
@@ -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">
<activity
+7
View File
@@ -26,6 +26,13 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
return fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme == "" {
return fmt.Errorf("invalid URL: scheme is required")
}
if parsed.Scheme != "https" {
return fmt.Errorf("network access denied: only https is allowed")
}
domain := parsed.Hostname()
// Block private/local network access (SSRF protection)
+23
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
@@ -214,6 +215,10 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return s.cache, nil
}
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
return nil, err
}
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := &http.Client{Timeout: 30 * time.Second}
@@ -303,6 +308,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return fmt.Errorf("extension %s not found in store", extensionID)
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
return err
}
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := &http.Client{Timeout: 5 * time.Minute}
@@ -332,6 +341,20 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil
}
func requireHTTPSURL(rawURL string, context string) error {
if rawURL == "" {
return fmt.Errorf("%s URL is empty", context)
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
}
if parsed.Scheme != "https" {
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
}
return nil
}
func (s *ExtensionStore) GetCategories() []string {
return []string{
CategoryMetadata,
+1 -1
View File
@@ -67,7 +67,7 @@
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<false/>
</dict>
<!-- File Sharing - Allow access via Files app -->
+7 -2
View File
@@ -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;
+157 -3
View File
@@ -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;
}
}
+50 -2
View File
@@ -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:
+1
View File
@@ -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