feat: native 端支持自签证书

This commit is contained in:
chaoszhu
2026-08-29 13:45:55 +08:00
parent df9c896a4a
commit 309f7c57e3
27 changed files with 726 additions and 35 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
PODFILE CHECKSUM: 288656d4464589360115d6f81dd5d8f559352730
+10
View File
@@ -14,6 +14,7 @@ import 'core/api/cookie_store.dart';
import 'core/api/api_result.dart';
import 'core/storage/app_storage.dart';
import 'core/storage/secure_storage.dart';
import 'core/security/server_certificate_trust.dart';
import 'features/auth/auth_session.dart';
import 'features/auth/login_controller.dart';
import 'features/auth/login_page.dart';
@@ -42,6 +43,7 @@ class _Bootstrap {
required this.initialPassword,
required this.initialAuthState,
required this.initialIpAccessDenied,
required this.certificateTrust,
});
final AppStorage appStorage;
@@ -52,6 +54,7 @@ class _Bootstrap {
final String initialPassword;
final AuthState initialAuthState;
final bool initialIpAccessDenied;
final ServerCertificateTrustStore certificateTrust;
}
class EasyNodeApp extends StatelessWidget {
@@ -65,6 +68,7 @@ class EasyNodeApp extends StatelessWidget {
final secureWrapper = SecureAppStorage(secure);
final appStorage = AppStorage(prefs);
final cookieStore = SessionCookieStore(secureWrapper);
final certificateTrust = ServerCertificateTrustStore(secureWrapper);
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.buildNumber.isEmpty
? packageInfo.version
@@ -96,11 +100,13 @@ class EasyNodeApp extends StatelessWidget {
deviceId.isNotEmpty;
if (hasStoredLogin) {
await certificateTrust.prepare(appStorage.serverAddress);
final api = ApiClient(
serverAddress: appStorage.serverAddress,
cookieStore: cookieStore,
token: token,
appVersion: appVersion,
certificateTrust: certificateTrust,
);
try {
final pubKey = await api.getPublicKey();
@@ -150,6 +156,7 @@ class EasyNodeApp extends StatelessWidget {
initialPassword: initialPassword,
initialAuthState: initialAuthState,
initialIpAccessDenied: initialIpAccessDenied,
certificateTrust: certificateTrust,
),
);
}
@@ -161,6 +168,7 @@ class EasyNodeApp extends StatelessWidget {
appStorageProvider.overrideWithValue(_b.appStorage),
secureStorageProvider.overrideWithValue(_b.secureStorage),
cookieStoreProvider.overrideWithValue(_b.cookieStore),
certificateTrustProvider.overrideWithValue(_b.certificateTrust),
authProvider.overrideWith(
(ref) => AuthNotifier(ref, _b.initialAuthState),
),
@@ -210,6 +218,7 @@ class _AppRootState extends ConsumerState<_AppRoot> {
super.initState();
_loginController = LoginController(
apiClientFactory: _buildAnonymousApiClient,
certificateTrust: ref.read(certificateTrustProvider),
)..onLoginSuccess(_onLoginSuccess);
// The restored client is constructed before ProviderScope exists. Bind
// its session-failure handler once the global coordinator is available.
@@ -239,6 +248,7 @@ class _AppRootState extends ConsumerState<_AppRoot> {
cookieStore: ref.read(cookieStoreProvider),
token: token,
appVersion: widget.appVersion,
certificateTrust: ref.read(certificateTrustProvider),
);
}
+19 -2
View File
@@ -1,10 +1,11 @@
import 'dart:io' show Platform;
import 'dart:io' show HandshakeException, Platform;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'api_result.dart';
import 'cookie_store.dart';
import '../security/server_certificate_trust.dart';
const String _fallbackNativeAppVersion = 'unknown';
const String ipAccessDeniedCode = 'IP_ACCESS_DENIED';
@@ -125,7 +126,10 @@ class ApiClient {
SessionFailureHandler? onSessionFailure,
String? appVersion,
Dio? dio,
}) : _cookieStore = cookieStore,
ServerCertificateTrustStore? certificateTrust,
}) : _serverAddress = serverAddress,
_certificateTrust = certificateTrust,
_cookieStore = cookieStore,
_token = token,
_onSessionFailure = onSessionFailure,
_dio =
@@ -140,6 +144,9 @@ class ApiClient {
},
),
) {
if (dio == null && certificateTrust != null) {
_dio.httpClientAdapter = certificateTrust.createDioAdapter();
}
if (kDebugMode) {
_dio.interceptors.add(
InterceptorsWrapper(
@@ -195,6 +202,8 @@ class ApiClient {
}
final Dio _dio;
final String _serverAddress;
final ServerCertificateTrustStore? _certificateTrust;
final SessionCookieStore _cookieStore;
SessionFailureHandler? _onSessionFailure;
String? _token;
@@ -255,6 +264,14 @@ class ApiClient {
try {
return _asJson(await send());
} on DioException catch (error) {
final certificate = _certificateTrust?.pendingCertificateFor(
_serverAddress,
);
if (certificate != null &&
(error.type == DioExceptionType.badCertificate ||
error.error is HandshakeException)) {
throw UntrustedServerCertificateException(certificate);
}
final failure = apiFailureFromDioException(error);
if (failure is ApiSessionFailure) {
final handler = _onSessionFailure;
@@ -0,0 +1,192 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/io.dart';
import 'package:web_socket/io_web_socket.dart';
import 'package:web_socket/web_socket.dart' as ws;
import '../storage/secure_storage.dart';
class PresentedServerCertificate {
const PresentedServerCertificate({
required this.origin,
required this.fingerprint,
this.previousFingerprint,
});
final String origin;
final String fingerprint;
final String? previousFingerprint;
String get displayFingerprint => formatCertificateFingerprint(fingerprint);
bool get replacesTrustedCertificate => previousFingerprint != null;
}
class UntrustedServerCertificateException implements Exception {
const UntrustedServerCertificateException(this.certificate);
final PresentedServerCertificate certificate;
@override
String toString() => 'Untrusted certificate for ${certificate.origin}';
}
String canonicalServerOrigin(String address) {
final uri = Uri.parse(address);
final scheme = switch (uri.scheme.toLowerCase()) {
'wss' => 'https',
'ws' => 'http',
final value => value,
};
return Uri(
scheme: scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
).origin;
}
String formatCertificateFingerprint(String fingerprint) {
final normalized = fingerprint.replaceAll(':', '').toUpperCase();
final pairs = <String>[];
for (var index = 0; index < normalized.length; index += 2) {
pairs.add(normalized.substring(index, index + 2));
}
return pairs.join(':');
}
class ServerCertificateTrustStore {
ServerCertificateTrustStore(this._storage);
final SecureAppStorage _storage;
final Map<String, String> _trustedFingerprints = {};
final Map<String, PresentedServerCertificate> _pendingCertificates = {};
final Set<String> _loadedOrigins = {};
HttpClient? _systemWebSocketClient;
HttpClient? _pinnedWebSocketClient;
Future<void> prepare(String serverAddress) async {
final origin = canonicalServerOrigin(serverAddress);
if (_loadedOrigins.contains(origin)) return;
final stored = await _storage.readServerCertificateFingerprint(origin);
if (stored != null && stored.isNotEmpty) {
final normalized = _normalizeFingerprint(stored);
if (_isValidFingerprint(normalized)) {
_trustedFingerprints[origin] = normalized;
}
}
_loadedOrigins.add(origin);
}
PresentedServerCertificate? pendingCertificateFor(String serverAddress) {
return _pendingCertificates[canonicalServerOrigin(serverAddress)];
}
Future<void> trust(PresentedServerCertificate certificate) async {
await _storage.writeServerCertificateFingerprint(
certificate.origin,
certificate.fingerprint,
);
_trustedFingerprints[certificate.origin] = certificate.fingerprint;
_loadedOrigins.add(certificate.origin);
_pendingCertificates.remove(certificate.origin);
}
IOHttpClientAdapter createDioAdapter() {
return IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.badCertificateCallback = _acceptBadCertificate;
return client;
},
validateCertificate: _validateCertificate,
);
}
Future<ws.WebSocket> connectWebSocket(
Uri uri, {
Iterable<String>? protocols,
Map<String, String>? headers,
}) async {
final origin = canonicalServerOrigin(uri.toString());
final hasPinnedCertificate = _trustedFingerprints.containsKey(origin);
final client = _webSocketClient(pinned: hasPinnedCertificate);
final rawSocket = await WebSocket.connect(
uri.toString(),
protocols: protocols,
headers: headers,
customClient: client,
);
return IOWebSocket.fromWebSocket(rawSocket);
}
HttpClient _webSocketClient({required bool pinned}) {
if (pinned) {
return _pinnedWebSocketClient ??= HttpClient(
context: SecurityContext(withTrustedRoots: false),
)..badCertificateCallback = _acceptBadCertificate;
}
return _systemWebSocketClient ??= HttpClient()
..badCertificateCallback = _acceptBadCertificate;
}
bool _acceptBadCertificate(
X509Certificate certificate,
String host,
int port,
) {
final presented = _presentedCertificate(certificate, host, port);
final trusted = _trustedFingerprints[presented.origin];
if (trusted == presented.fingerprint) {
_pendingCertificates.remove(presented.origin);
return true;
}
_pendingCertificates[presented.origin] = presented;
return false;
}
bool _validateCertificate(
X509Certificate? certificate,
String host,
int port,
) {
// Plain HTTP responses do not have a peer certificate. The TLS trust
// policy must not change the app's existing HTTP behaviour.
if (certificate == null) return true;
final presented = _presentedCertificate(certificate, host, port);
final trusted = _trustedFingerprints[presented.origin];
if (trusted == null) {
_pendingCertificates.remove(presented.origin);
return true;
}
if (trusted == presented.fingerprint) {
_pendingCertificates.remove(presented.origin);
return true;
}
_pendingCertificates[presented.origin] = presented;
return false;
}
PresentedServerCertificate _presentedCertificate(
X509Certificate certificate,
String host,
int port,
) {
final origin = Uri(
scheme: 'https',
host: host,
port: port == 443 ? null : port,
).origin;
final fingerprint = sha256.convert(certificate.der).toString();
return PresentedServerCertificate(
origin: origin,
fingerprint: fingerprint,
previousFingerprint: _trustedFingerprints[origin],
);
}
String _normalizeFingerprint(String value) =>
value.replaceAll(':', '').trim().toLowerCase();
bool _isValidFingerprint(String value) =>
RegExp(r'^[0-9a-f]{64}$').hasMatch(value);
}
@@ -37,4 +37,21 @@ class SecureAppStorage {
Future<void> writeDeviceId(String value) =>
_storage.write(key: 'loginDeviceId', value: value);
Future<void> deleteDeviceId() => _storage.delete(key: 'loginDeviceId');
String _certificateFingerprintKey(String serverOrigin) =>
'serverCertificateFingerprint:$serverOrigin';
Future<String?> readServerCertificateFingerprint(String serverOrigin) {
return _storage.read(key: _certificateFingerprintKey(serverOrigin));
}
Future<void> writeServerCertificateFingerprint(
String serverOrigin,
String fingerprint,
) {
return _storage.write(
key: _certificateFingerprintKey(serverOrigin),
value: fingerprint,
);
}
}
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:socket_io_client/socket_io_client.dart' as sio;
import '../../core/api/cookie_store.dart';
import '../../core/security/server_certificate_trust.dart';
import '../auth/auth_session.dart';
import 'agent_models.dart';
@@ -38,11 +39,14 @@ class AgentSocketClient {
AgentSocketClient({
required AuthSession authSession,
required SessionCookieStore cookieStore,
required ServerCertificateTrustStore certificateTrust,
}) : _authSession = authSession,
_cookieStore = cookieStore;
_cookieStore = cookieStore,
_certificateTrust = certificateTrust;
final AuthSession _authSession;
final SessionCookieStore _cookieStore;
final ServerCertificateTrustStore _certificateTrust;
final _events = StreamController<Map<String, dynamic>>.broadcast();
final _connections = StreamController<AgentConnectionStatus>.broadcast();
final _errors = StreamController<String>.broadcast();
@@ -92,6 +96,7 @@ class AgentSocketClient {
'Cookie': cookie,
'Origin': _authSession.serverAddress,
})
.setWebSocketConnector(_certificateTrust.connectWebSocket)
.disableAutoConnect()
.disableReconnection()
.build();
+26 -4
View File
@@ -1,6 +1,7 @@
import '../../core/api/api_client.dart';
import '../../core/api/api_result.dart';
import '../../core/crypto/rsa_crypto.dart';
import '../../core/security/server_certificate_trust.dart';
import '../../core/utils/jwt_expiry.dart';
import '../../core/utils/validators.dart';
import 'auth_session.dart';
@@ -13,6 +14,7 @@ class LoginResult {
this.message = '',
this.messageKey,
this.session,
this.certificate,
});
/// `true` only when login fully succeeded.
@@ -32,6 +34,10 @@ class LoginResult {
final String? messageKey;
final AuthSession? session;
final PresentedServerCertificate? certificate;
bool get requiresCertificateConfirmation => certificate != null;
}
/// Builds an [ApiClient] for a given server address. Allows tests to inject
@@ -44,9 +50,13 @@ typedef LoginSuccessHandler =
/// Orchestrates the native client login flow.
class LoginController {
LoginController({required ApiClientFactory apiClientFactory, RsaCrypto? rsa})
: _apiClientFactory = apiClientFactory,
_rsa = rsa ?? RsaCrypto();
LoginController({
required ApiClientFactory apiClientFactory,
RsaCrypto? rsa,
ServerCertificateTrustStore? certificateTrust,
}) : _apiClientFactory = apiClientFactory,
_rsa = rsa ?? RsaCrypto(),
_certificateTrust = certificateTrust;
/// Test factory that returns a controller without a real HTTP client. Real
/// network calls will fail because the factory throws when invoked.
@@ -57,6 +67,15 @@ class LoginController {
final ApiClientFactory _apiClientFactory;
final RsaCrypto _rsa;
final ServerCertificateTrustStore? _certificateTrust;
Future<void> trustCertificate(PresentedServerCertificate certificate) async {
final trust = _certificateTrust;
if (trust == null) {
throw StateError('Certificate trust is not configured');
}
await trust.trust(certificate);
}
Future<LoginResult> login({
required String serverAddress,
@@ -93,8 +112,9 @@ class LoginController {
);
}
final api = _apiClientFactory(normalized);
try {
await _certificateTrust?.prepare(normalized);
final api = _apiClientFactory(normalized);
final publicKey = await api.getPublicKey();
final ciphertext = _rsa.encryptPassword(publicKey, password);
final response = await api.postJson('/login', {
@@ -130,6 +150,8 @@ class LoginController {
await onLoginSuccess(session, savePassword ? password : null);
}
return LoginResult(success: true, session: session);
} on UntrustedServerCertificateException catch (error) {
return LoginResult(certificate: error.certificate);
} on ApiFailure catch (error) {
return LoginResult(message: error.message);
} catch (error) {
+78
View File
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../core/ui/app_color_theme.dart';
import '../../core/utils/jwt_expiry.dart';
import '../../core/utils/validators.dart';
import '../../core/security/server_certificate_trust.dart';
import '../../l10n/app_localizations.dart';
import '../../state/package_info_provider.dart';
import 'auth_session.dart';
@@ -115,6 +116,20 @@ class _LoginPageState extends State<LoginPage> {
await _submit();
return;
}
if (result.requiresCertificateConfirmation) {
setState(() => _submitting = false);
final certificate = result.certificate!;
final accepted = await _confirmCertificate(certificate);
if (!mounted || accepted != true) return;
try {
await widget.controller.trustCertificate(certificate);
} catch (error) {
if (mounted) setState(() => _errorMessage = error.toString());
return;
}
await _submit();
return;
}
if (!result.success || result.session == null) {
setState(() => _errorMessage = _resolveErrorMessage(result, l));
return;
@@ -146,6 +161,69 @@ class _LoginPageState extends State<LoginPage> {
);
}
Future<bool?> _confirmCertificate(PresentedServerCertificate certificate) {
final l = AppLocalizations.of(context);
final previous = certificate.previousFingerprint;
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertDialog(
title: Text(
l.tr(
certificate.replacesTrustedCertificate
? 'login.certificateChangedTitle'
: 'login.certificateTitle',
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.tr(
certificate.replacesTrustedCertificate
? 'login.certificateChangedBody'
: 'login.certificateBody',
),
),
const SizedBox(height: 16),
Text(l.tr('login.certificateServer')),
const SizedBox(height: 4),
SelectableText(certificate.origin),
const SizedBox(height: 12),
Text(l.tr('login.certificateFingerprint')),
const SizedBox(height: 4),
SelectableText(
certificate.displayFingerprint,
style: const TextStyle(fontFamily: 'monospace'),
),
if (previous != null) ...[
const SizedBox(height: 12),
Text(l.tr('login.certificatePreviousFingerprint')),
const SizedBox(height: 4),
SelectableText(
formatCertificateFingerprint(previous),
style: const TextStyle(fontFamily: 'monospace'),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l.tr('login.trustCertificate')),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart' show ChangeNotifier;
import '../../core/api/cookie_store.dart';
import '../../core/security/server_certificate_trust.dart';
import '../auth/auth_session.dart';
import '../servers/server_model.dart';
import 'docker_container.dart';
@@ -61,6 +62,7 @@ class DockerSessionManager extends ChangeNotifier {
required ServerModel server,
required AuthSession authSession,
required SessionCookieStore cookieStore,
required ServerCertificateTrustStore certificateTrust,
}) async {
final existing = _sessions[server.id];
if (existing != null) {
@@ -78,6 +80,7 @@ class DockerSessionManager extends ChangeNotifier {
final client = DockerSocketClient(
authSession: authSession,
cookieStore: cookieStore,
certificateTrust: certificateTrust,
hostId: server.id,
);
final session = DockerSessionState(server: server, client: client);
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:socket_io_client/socket_io_client.dart' as sio;
import '../../core/api/cookie_store.dart';
import '../../core/security/server_certificate_trust.dart';
import '../auth/auth_session.dart';
import 'docker_container.dart';
@@ -12,12 +13,15 @@ class DockerSocketClient {
DockerSocketClient({
required AuthSession authSession,
required SessionCookieStore cookieStore,
required ServerCertificateTrustStore certificateTrust,
required this.hostId,
}) : _authSession = authSession,
_cookieStore = cookieStore;
_cookieStore = cookieStore,
_certificateTrust = certificateTrust;
final AuthSession _authSession;
final SessionCookieStore _cookieStore;
final ServerCertificateTrustStore _certificateTrust;
final String hostId;
final StreamController<List<DockerContainer>> _containersController =
@@ -72,6 +76,7 @@ class DockerSocketClient {
'Cookie': cookie,
'Origin': _authSession.serverAddress,
})
.setWebSocketConnector(_certificateTrust.connectWebSocket)
.disableAutoConnect()
.disableReconnection()
.build();
@@ -113,6 +113,7 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
server: server,
authSession: authSession,
cookieStore: ref.read(cookieStoreProvider),
certificateTrust: ref.read(certificateTrustProvider),
);
} catch (error) {
if (!mounted) return;
+11
View File
@@ -44,6 +44,17 @@ const Map<String, String> stringsEn = {
'login.httpRiskTitle': 'HTTP is not encrypted',
'login.httpRiskBody':
'All your data may be stolen. It is recommended to use HTTPS(only in intranet environments).',
'login.certificateTitle': 'Cannot verify server certificate',
'login.certificateBody':
'This certificate is not trusted by the system. Verify the server and fingerprint before permanently trusting it.',
'login.certificateChangedTitle': 'Server certificate changed',
'login.certificateChangedBody':
'The server certificate differs from the certificate trusted previously. Cancel unless you know the server certificate was replaced.',
'login.certificateServer': 'Server',
'login.certificateFingerprint': 'New SHA-256 fingerprint',
'login.certificatePreviousFingerprint':
'Previously trusted SHA-256 fingerprint',
'login.trustCertificate': 'Trust this certificate',
'login.errEmptyUsername': 'Please enter a username',
'login.errEmptyPassword': 'Please enter a password',
'login.errInvalidServer': 'Please enter a valid server address',
+8
View File
@@ -43,6 +43,14 @@ const Map<String, String> stringsZh = {
'login.failed': '登录失败',
'login.httpRiskTitle': 'HTTP 连接未加密',
'login.httpRiskBody': 'http协议下存在数据泄露风险,公网使用请配置https协议。是否继续?',
'login.certificateTitle': '无法验证服务器证书',
'login.certificateBody': '该证书不是系统信任的证书。确认服务器和指纹无误后,可以永久信任此证书。',
'login.certificateChangedTitle': '服务器证书已发生变化',
'login.certificateChangedBody': '服务器返回的证书与此前信任的证书不同。除非你确认服务器已更换证书,否则请取消连接。',
'login.certificateServer': '服务器',
'login.certificateFingerprint': '新证书 SHA-256 指纹',
'login.certificatePreviousFingerprint': '此前信任的 SHA-256 指纹',
'login.trustCertificate': '信任此证书',
'login.errEmptyUsername': '请输入用户名',
'login.errEmptyPassword': '请输入密码',
'login.errInvalidServer': '请输入有效的服务端地址',
+1
View File
@@ -191,6 +191,7 @@ final agentControllerProvider =
final socket = AgentSocketClient(
authSession: session,
cookieStore: ref.watch(cookieStoreProvider),
certificateTrust: ref.watch(certificateTrustProvider),
);
return AgentStateNotifier(
repository: ref.watch(agentRepositoryProvider),
+5
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/cookie_store.dart';
import '../core/storage/app_storage.dart';
import '../core/storage/secure_storage.dart';
import '../core/security/server_certificate_trust.dart';
/// All four storage providers are bootstrap-only — `EasyNodeApp.bootstrap`
/// constructs the concrete instances once and overrides them on the root
@@ -18,3 +19,7 @@ final secureStorageProvider = Provider<SecureAppStorage>((ref) {
final cookieStoreProvider = Provider<SessionCookieStore>((ref) {
throw UnimplementedError('cookieStoreProvider must be overridden');
});
final certificateTrustProvider = Provider<ServerCertificateTrustStore>((ref) {
throw UnimplementedError('certificateTrustProvider must be overridden');
});
+2 -2
View File
@@ -122,7 +122,7 @@ packages:
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -1037,7 +1037,7 @@ packages:
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
dependency: "direct main"
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
+2
View File
@@ -13,6 +13,7 @@ dependencies:
sdk: flutter
intl: any
dio: ^5.7.0
crypto: ^3.0.7
cookie_jar: ^4.0.8
dio_cookie_manager: ^3.1.1
flutter_secure_storage: ^9.2.2
@@ -41,6 +42,7 @@ dependencies:
url_launcher: ^6.3.1
package_info_plus: ^8.1.0
socket_io_client: ^3.1.6
web_socket: ^1.0.1
flutter_markdown_plus: ^1.0.12
dev_dependencies:
@@ -0,0 +1,85 @@
import 'dart:convert';
import 'dart:io';
import 'package:easynode_native/core/security/server_certificate_trust.dart';
import 'package:easynode_native/core/storage/secure_storage.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:web_socket/web_socket.dart' as ws;
void main() {
test('canonicalizes HTTP and WebSocket origins consistently', () {
expect(
canonicalServerOrigin('https://100.74.175.1:8092/path'),
'https://100.74.175.1:8092',
);
expect(
canonicalServerOrigin('wss://100.74.175.1:8092/docker/'),
'https://100.74.175.1:8092',
);
expect(
canonicalServerOrigin('https://example.com:443'),
'https://example.com',
);
});
test('formats a SHA-256 fingerprint for display', () {
expect(formatCertificateFingerprint('aabbccdd'), 'AA:BB:CC:DD');
expect(formatCertificateFingerprint('AA:BB:CC:DD'), 'AA:BB:CC:DD');
});
test('keeps plain HTTP requests working', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final subscription = server.listen((request) async {
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(jsonEncode({'status': 200}));
await request.response.close();
});
final trust = ServerCertificateTrustStore(
SecureAppStorage(const FlutterSecureStorage()),
);
final dio = Dio()..httpClientAdapter = trust.createDioAdapter();
try {
final response = await dio.get(
'http://${server.address.address}:${server.port}/health',
);
expect(response.statusCode, HttpStatus.ok);
expect(response.data, {'status': 200});
} finally {
dio.close(force: true);
await server.close(force: true);
await subscription.cancel();
}
});
test('keeps plain WebSocket connections working', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final subscription = server.transform(WebSocketTransformer()).listen((
socket,
) {
socket.listen(socket.add);
});
final trust = ServerCertificateTrustStore(
SecureAppStorage(const FlutterSecureStorage()),
);
final client = await trust.connectWebSocket(
Uri.parse('ws://${server.address.address}:${server.port}/socket'),
);
try {
final event = client.events.first;
client.sendText('ping');
final received = await event;
expect(received, isA<ws.TextDataReceived>());
expect((received as ws.TextDataReceived).text, 'ping');
} finally {
await client.close();
await server.close(force: true);
await subscription.cancel();
}
});
}
@@ -3,6 +3,7 @@ import 'package:easynode_native/core/api/api_result.dart';
import 'package:easynode_native/core/api/cookie_store.dart';
import 'package:easynode_native/core/crypto/rsa_crypto.dart';
import 'package:easynode_native/core/storage/secure_storage.dart';
import 'package:easynode_native/core/security/server_certificate_trust.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:easynode_native/features/auth/login_controller.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -37,6 +38,47 @@ class _FakeRsaCrypto extends RsaCrypto {
'encrypted-password';
}
class _MemorySecureStorage extends SecureAppStorage {
_MemorySecureStorage() : super(const FlutterSecureStorage());
final Map<String, String> fingerprints = {};
@override
Future<String?> readServerCertificateFingerprint(String serverOrigin) async {
return fingerprints[serverOrigin];
}
@override
Future<void> writeServerCertificateFingerprint(
String serverOrigin,
String fingerprint,
) async {
fingerprints[serverOrigin] = fingerprint;
}
}
class _CertificateFailureApiClient extends ApiClient {
_CertificateFailureApiClient(this.certificate)
: super(
serverAddress: certificate.origin,
cookieStore: SessionCookieStore(
SecureAppStorage(const FlutterSecureStorage()),
),
);
final PresentedServerCertificate certificate;
@override
Future<String> getPublicKey() async {
throw UntrustedServerCertificateException(certificate);
}
}
PresentedServerCertificate _certificate() => PresentedServerCertificate(
origin: 'https://100.74.175.1:8092',
fingerprint: List.filled(32, 'ab').join(),
);
void main() {
test('blocks http login until user confirms risk', () async {
final controller = LoginController.fake();
@@ -123,4 +165,36 @@ void main() {
expect(result.success, isFalse);
expect(result.message, 'session initialization failed');
});
test(
'returns an untrusted certificate and persists explicit trust',
() async {
final secureStorage = _MemorySecureStorage();
final trust = ServerCertificateTrustStore(secureStorage);
final certificate = _certificate();
final controller = LoginController(
apiClientFactory: (_, {String? token}) =>
_CertificateFailureApiClient(certificate),
certificateTrust: trust,
);
final result = await controller.login(
serverAddress: certificate.origin,
username: 'root',
password: 'secret',
mfa2Token: '',
httpRiskAccepted: false,
savePassword: false,
);
expect(result.requiresCertificateConfirmation, isTrue);
expect(result.certificate, same(certificate));
await controller.trustCertificate(certificate);
expect(
secureStorage.fingerprints[certificate.origin],
certificate.fingerprint,
);
},
);
}
@@ -6,8 +6,36 @@ import 'package:easynode_native/features/auth/auth_session.dart';
import 'package:easynode_native/features/auth/login_controller.dart';
import 'package:easynode_native/features/auth/login_page.dart';
import 'package:easynode_native/core/ui/app_color_theme.dart';
import 'package:easynode_native/core/security/server_certificate_trust.dart';
import 'package:easynode_native/core/utils/jwt_expiry.dart';
import 'package:easynode_native/l10n/app_localizations.dart';
class _CertificateLoginController extends LoginController {
_CertificateLoginController(this.certificate)
: super(
apiClientFactory: (_, {String? token}) =>
throw StateError('API client is not used by this UI test'),
);
final PresentedServerCertificate certificate;
@override
Future<LoginResult> login({
required String serverAddress,
required String username,
required String password,
required String mfa2Token,
required bool httpRiskAccepted,
required bool savePassword,
LoginExpiry expiry = LoginExpiry.threeDays,
}) async => LoginResult(certificate: certificate);
}
PresentedServerCertificate _certificate() => PresentedServerCertificate(
origin: 'https://100.74.175.1:8092',
fingerprint: List.filled(32, 'ab').join(),
);
void main() {
Widget wrap(Widget child) => ProviderScope(
child: MaterialApp(
@@ -119,4 +147,32 @@ void main() {
expect(loginPageShouldWarnHttp('http://10.0.0.1'), isTrue);
expect(loginPageShouldWarnHttp('https://10.0.0.1'), isFalse);
});
testWidgets('offers cancel and permanent trust for an invalid certificate', (
tester,
) async {
final certificate = _certificate();
final controller = _CertificateLoginController(certificate);
await pumpLoginPage(
tester,
LoginPage(
controller: controller,
initialServerAddress: certificate.origin,
initialUsername: 'root',
initialSavePassword: false,
onLoginSuccess: (_) {},
),
);
await tester.ensureVisible(byKey(const Key('field-password')));
await tester.enterText(byKey(const Key('field-password')), 'secret');
await tester.tap(byKey(const Key('btn-login')));
await tester.pumpAndSettle();
expect(find.text('无法验证服务器证书'), findsOneWidget);
expect(find.text('取消'), findsOneWidget);
expect(find.text('信任此证书'), findsOneWidget);
expect(find.text('仅本次继续'), findsNothing);
expect(find.text(certificate.displayFingerprint), findsOneWidget);
});
}