Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions .github/workflows/analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,6 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
cache: true
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'

- name: Cache pub dependencies
uses: actions/cache@v4
with:
path: |
${{ env.PUB_CACHE }}
~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}-${{ hashFiles('**/pubspec.yaml') }}
restore-keys: |
${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}-
${{ runner.os }}-pub-

- name: Install dependencies
run: flutter pub get
Expand Down
2 changes: 2 additions & 0 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:fl_lib/generated/l10n/lib_l10n.dart';
import 'package:flutter/material.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/app_navigator.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
Expand Down Expand Up @@ -87,6 +88,7 @@ class _MyAppState extends State<MyApp> {

return MaterialApp(
key: ValueKey(locale),
navigatorKey: AppNavigator.key,
builder: ResponsivePoints.builder,
locale: locale,
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
Expand Down
8 changes: 8 additions & 0 deletions lib/core/app_navigator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';

/// Global navigator access used for cross-cutting flows (e.g. dialogs).
abstract final class AppNavigator {
static final key = GlobalKey<NavigatorState>();

static BuildContext? get context => key.currentContext;
}
26 changes: 26 additions & 0 deletions lib/core/utils/host_key_helper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/res/store.dart';

Future<bool> ensureHostKeyAcceptedForSftp(BuildContext context, Spi spi) async {
final known = Stores.setting.sshKnownHostFingerprints.get();
final hostId = spi.id.isNotEmpty ? spi.id : spi.oldId;
final prefix = '$hostId::';
if (known.keys.any((key) => key.startsWith(prefix))) {
return true;
}

final (result, error) = await context.showLoadingDialog<bool>(
fn: () async {
await ensureKnownHostKey(
spi,
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi, ctx: context),
);
return true;
},
);
return error == null && result == true;
}
249 changes: 241 additions & 8 deletions lib/core/utils/server.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
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';
Expand All @@ -29,7 +33,7 @@ enum GenSSHClientStatus { socket, key, pwd }
String getPrivateKey(String id) {
final pki = Stores.key.fetchOne(id);
if (pki == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: 'key [$id] not found');
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id));
}
return pki.key;
}
Expand All @@ -52,9 +56,16 @@ Future<SSHClient> genClient(

/// Handle keyboard-interactive authentication
SSHUserInfoRequestHandler? onKeyboardInteractive,
Map<String, String>? knownHostFingerprints,
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
}) async {
onStatus?.call(GenSSHClientStatus.socket);

final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;

String? alterUser;

final socket = await () async {
Expand All @@ -66,7 +77,14 @@ Future<SSHClient> genClient(
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
}();
if (jumpSpi_ != null) {
final jumpClient = await genClient(jumpSpi_, privateKey: jumpPrivateKey, timeout: timeout);
final jumpClient = await genClient(
jumpSpi_,
privateKey: jumpPrivateKey,
timeout: timeout,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: onHostKeyPrompt,
);

return await jumpClient.forwardLocal(spi.ip, spi.port);
}
Expand All @@ -88,6 +106,13 @@ Future<SSHClient> genClient(
}
}();

final hostKeyVerifier = _HostKeyVerifier(
spi: spi,
cache: hostKeyCache,
persistCallback: hostKeyPersist,
prompt: hostKeyPrompt,
);

final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
Expand All @@ -96,9 +121,7 @@ Future<SSHClient> genClient(
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,

/// TODO: verify host key
onVerifyHostKey: (type, fingerprint) => true,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
Expand All @@ -112,10 +135,220 @@ Future<SSHClient> genClient(
// Must use [compute] here, instead of [Computer.shared.start]
identities: await compute(loadIndentity, privateKey),
onUserInfoRequest: onKeyboardInteractive,

/// TODO: verify host key
onVerifyHostKey: (type, fingerprint) => true,
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<String, String> cache,
required this.prompt,
this.persistCallback,
}) : _cache = cache;

final Spi spi;
final Map<String, String> _cache;
final _HostKeyPersistCallback? persistCallback;
final Future<bool> Function(HostKeyPromptInfo info) prompt;

Future<bool> 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<String, String> _loadKnownHostFingerprints() {
try {
final prop = Stores.setting.sshKnownHostFingerprints;
return Map<String, String>.from(prop.get());
} catch (e, stack) {
Loggers.app.warning('Load SSH host key fingerprints failed', e, stack);
return <String, String>{};
}
}

void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) {
try {
final prop = Stores.setting.sshKnownHostFingerprints;
final updated = Map<String, String>.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<bool> _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<bool>(
title: libL10n.attention,
barrierDismiss: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(description),
const SizedBox(height: 12),
SelectableText('${l10n.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<void> ensureKnownHostKey(
Spi spi, {
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
}) async {
final cache = _loadKnownHostFingerprints();
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
return;
}

final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
await ensureKnownHostKey(
jumpSpi,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
);
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<String, String> 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);
12 changes: 12 additions & 0 deletions lib/data/store/setting.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ class SettingStore extends HiveStore {

late final editorFontSize = propertyDefault('editorFontSize', 12.5);

/// Trusted SSH host key fingerprints keyed by `serverId::keyType`.
late final sshKnownHostFingerprints = propertyDefault<Map<String, String>>(
'sshKnownHostFingerprints',
const {},
fromObj: (raw) {
if (raw is Map) {
return raw.map((key, value) => MapEntry(key.toString(), value.toString()));
}
return <String, String>{};
},
);

// Editor theme
late final editorTheme = propertyDefault('editorTheme', Defaults.editorTheme);

Expand Down
Loading