import 'dart:async'; import 'dart:convert'; import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/app_navigator.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/res/store.dart'; /// Must put this func out of any Class. /// /// Because of this function is called by [compute]. /// /// https://stackoverflow.com/questions/51998995/invalid-arguments-illegal-argument-in-isolate-message-object-is-a-closure List loadIndentity(String key) { return SSHKeyPair.fromPem(key); } /// [args] : [key, pwd] String decyptPem(List args) { /// skip when the key is not encrypted, or will throw exception if (!SSHKeyPair.isEncryptedPem(args[0])) return args[0]; final sshKey = SSHKeyPair.fromPem(args[0], args[1]); return sshKey.first.toPem(); } enum GenSSHClientStatus { socket, key, pwd } String getPrivateKey(String id) { final pki = Stores.key.fetchOne(id); if (pki == null) { throw SSHErr( type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id), ); } return pki.key; } Future genClient( Spi spi, { void Function(GenSSHClientStatus)? onStatus, /// Only pass this param if using multi-threading and key login String? privateKey, /// Only pass this param if using multi-threading and key login String? jumpPrivateKey, /// Prefer this map in isolate mode, fallback to [Stores.key] otherwise. Map? privateKeysByKeyId, /// Prefer this map in isolate mode, fallback to [Stores.server] otherwise. Map? jumpSpisById, Duration timeout = const Duration(seconds: 5), /// [Spi] of the jump server /// /// Must pass this param if using multi-threading and key login Spi? jumpSpi, /// Handle keyboard-interactive authentication SSHUserInfoRequestHandler? onKeyboardInteractive, Map? knownHostFingerprints, void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted, Future Function(HostKeyPromptInfo info)? onHostKeyPrompt, Set? visitedServerIds, }) async { final chainVisitedServerIds = visitedServerIds ?? {}; final currentServerId = _hostIdentifier(spi); if (!chainVisitedServerIds.add(currentServerId)) { throw SSHErr( type: SSHErrType.connect, message: 'Invalid jump chain: cycle detected at ${spi.name} ($currentServerId)', ); } onStatus?.call(GenSSHClientStatus.socket); final hostKeyCache = Map.from( knownHostFingerprints ?? _loadKnownHostFingerprints(), ); final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint; final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt; String? alterUser; final socket = await () async { // Proxy final jumpSpi_ = () { // Multi-thread or key login if (jumpSpi != null) return jumpSpi; // Main thread final jumpId = spi.jumpId; if (jumpId != null) { return jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId); } }(); if (jumpSpi_ != null) { String? nextJumpPrivateKey; final jumpSpiKeyId = jumpSpi_.keyId; if (jumpSpi != null && jumpSpi.id == jumpSpi_.id && jumpPrivateKey != null) { // Isolate mode may preload first-hop key and pass it via [jumpPrivateKey]. nextJumpPrivateKey = jumpPrivateKey; } else if (jumpSpiKeyId != null) { nextJumpPrivateKey = privateKeysByKeyId?[jumpSpiKeyId]; } final jumpClient = await genClient( jumpSpi_, privateKey: nextJumpPrivateKey, privateKeysByKeyId: privateKeysByKeyId, jumpSpisById: jumpSpisById, timeout: timeout, knownHostFingerprints: hostKeyCache, onHostKeyAccepted: hostKeyPersist, onHostKeyPrompt: hostKeyPrompt, visitedServerIds: chainVisitedServerIds, ); return await jumpClient.forwardLocal(spi.ip, spi.port); } // Direct try { return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout); } catch (e) { Loggers.app.warning('genClient', e); if (spi.alterUrl == null) rethrow; try { final res = spi.parseAlterUrl(); alterUser = res.$2; return await SSHSocket.connect(res.$1, res.$3, timeout: timeout); } catch (e) { Loggers.app.warning('genClient alterUrl', e); rethrow; } } }(); final hostKeyVerifier = _HostKeyVerifier( spi: spi, cache: hostKeyCache, persistCallback: hostKeyPersist, prompt: hostKeyPrompt, ); final keyId = spi.keyId; if (keyId == null) { onStatus?.call(GenSSHClientStatus.pwd); return SSHClient( socket, username: alterUser ?? spi.user, onPasswordRequest: () => spi.pwd, onUserInfoRequest: onKeyboardInteractive, onVerifyHostKey: hostKeyVerifier.call, // printDebug: debugPrint, // printTrace: debugPrint, ); } privateKey ??= privateKeysByKeyId?[keyId] ?? getPrivateKey(keyId); onStatus?.call(GenSSHClientStatus.key); return SSHClient( socket, username: spi.user, // Must use [compute] here, instead of [Computer.shared.start] identities: await compute(loadIndentity, privateKey), onUserInfoRequest: onKeyboardInteractive, onVerifyHostKey: hostKeyVerifier.call, // printDebug: debugPrint, // printTrace: debugPrint, ); } typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex); class HostKeyPromptInfo { HostKeyPromptInfo({ required this.spi, required this.keyType, required this.fingerprintHex, required this.fingerprintBase64, required this.isMismatch, this.previousFingerprintHex, }); final Spi spi; final String keyType; final String fingerprintHex; final String fingerprintBase64; final bool isMismatch; final String? previousFingerprintHex; } class _HostKeyVerifier { _HostKeyVerifier({ required this.spi, required Map cache, required this.prompt, this.persistCallback, }) : _cache = cache; final Spi spi; final Map _cache; final _HostKeyPersistCallback? persistCallback; final Future Function(HostKeyPromptInfo info) prompt; Future call(String keyType, Uint8List fingerprintBytes) async { final storageKey = _hostKeyStorageKey(spi, keyType); final fingerprintHex = _fingerprintToHex(fingerprintBytes); final fingerprintBase64 = _fingerprintToBase64(fingerprintBytes); final existing = _cache[storageKey]; if (existing == null) { final accepted = await prompt( HostKeyPromptInfo( spi: spi, keyType: keyType, fingerprintHex: fingerprintHex, fingerprintBase64: fingerprintBase64, isMismatch: false, ), ); if (!accepted) { Loggers.app.warning( 'User rejected new SSH host key for ${spi.name} ($keyType).', ); return false; } _cache[storageKey] = fingerprintHex; persistCallback?.call(storageKey, fingerprintHex); Loggers.app.info('Trusted SSH host key for ${spi.name} ($keyType).'); return true; } if (existing == fingerprintHex) { return true; } final accepted = await prompt( HostKeyPromptInfo( spi: spi, keyType: keyType, fingerprintHex: fingerprintHex, fingerprintBase64: fingerprintBase64, isMismatch: true, previousFingerprintHex: existing, ), ); if (!accepted) { Loggers.app.warning( 'SSH host key mismatch for ${spi.name}', 'expected $existing but received $fingerprintHex ($keyType)', ); return false; } _cache[storageKey] = fingerprintHex; persistCallback?.call(storageKey, fingerprintHex); Loggers.app.warning( 'Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.', ); return true; } } Map _loadKnownHostFingerprints() { try { final prop = Stores.setting.sshKnownHostFingerprints; return Map.from(prop.get()); } catch (e, stack) { Loggers.app.warning('Load SSH host key fingerprints failed', e, stack); return {}; } } void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) { try { final prop = Stores.setting.sshKnownHostFingerprints; final updated = Map.from(prop.get()); if (updated[storageKey] == fingerprintHex) { return; } updated[storageKey] = fingerprintHex; prop.put(updated); Loggers.app.info('Stored SSH host key fingerprint for $storageKey'); } catch (e, stack) { Loggers.app.warning('Persist SSH host key fingerprint failed', e, stack); } } Future _defaultHostKeyPrompt(HostKeyPromptInfo info) async { final ctx = AppNavigator.context; if (ctx == null) { Loggers.app.warning( 'Host key prompt skipped: navigator context unavailable.', ); return false; } final hostLine = '${info.spi.user}@${info.spi.ip}:${info.spi.port}'; final description = info.isMismatch ? l10n.sshHostKeyChangedDesc(info.spi.name) : l10n.sshHostKeyNewDesc(info.spi.name); final result = await ctx.showRoundDialog( title: libL10n.attention, barrierDismiss: false, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(description), const SizedBox(height: 12), SelectableText('${libL10n.server}: ${info.spi.name}'), SelectableText('${libL10n.addr}: $hostLine'), SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'), SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)), SelectableText( l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64), ), if (info.previousFingerprintHex != null) ...[ const SizedBox(height: 12), SelectableText( l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!), ), ], ], ), actions: [ TextButton(onPressed: () => ctx.pop(false), child: Text(libL10n.cancel)), TextButton(onPressed: () => ctx.pop(true), child: Text(libL10n.ok)), ], ); return result ?? false; } Future ensureKnownHostKey( Spi spi, { Duration timeout = const Duration(seconds: 5), SSHUserInfoRequestHandler? onKeyboardInteractive, Map? jumpSpisById, Set? visitedServerIds, }) async { final chainVisitedServerIds = visitedServerIds ?? {}; final currentServerId = _hostIdentifier(spi); if (!chainVisitedServerIds.add(currentServerId)) { throw SSHErr( type: SSHErrType.connect, message: 'Invalid jump chain: cycle detected at ${spi.name} ($currentServerId)', ); } final cache = _loadKnownHostFingerprints(); if (_hasKnownHostFingerprintForSpi(spi, cache)) { return; } final jumpId = spi.jumpId; final jumpSpi = jumpId != null ? (jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId)) : null; if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) { await ensureKnownHostKey( jumpSpi, timeout: timeout, onKeyboardInteractive: onKeyboardInteractive, jumpSpisById: jumpSpisById, visitedServerIds: chainVisitedServerIds, ); cache.addAll(_loadKnownHostFingerprints()); if (_hasKnownHostFingerprintForSpi(spi, cache)) return; } final client = await genClient( spi, timeout: timeout, onKeyboardInteractive: onKeyboardInteractive, knownHostFingerprints: cache, ); try { await client.authenticated; } finally { client.close(); } } bool _hasKnownHostFingerprintForSpi(Spi spi, Map cache) { final prefix = '${_hostIdentifier(spi)}::'; return cache.keys.any((key) => key.startsWith(prefix)); } String _hostKeyStorageKey(Spi spi, String keyType) { final base = _hostIdentifier(spi); return '$base::$keyType'; } String _hostIdentifier(Spi spi) => spi.id.isNotEmpty ? spi.id : spi.oldId; String _fingerprintToHex(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(); } String _fingerprintToBase64(Uint8List fingerprint) => base64.encode(fingerprint);