import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:web_socket_channel/io.dart'; import '../../utils/future_extensions.dart'; import '../../i18n/strings.g.dart'; import '../../models/companion_remote/remote_command.dart'; import '../../models/companion_remote/remote_session.dart'; import '../../utils/app_logger.dart'; import '../../utils/serial_future_queue.dart'; import '../base_peer_service.dart'; import 'remote_auth_context.dart'; import 'remote_auth_service.dart'; // Re-export so callers that import from here get the types. export '../base_peer_service.dart' show PeerError, PeerErrorType; /// Backward-compatible aliases so existing callers that reference the /// Remote-specific names keep compiling. typedef RemotePeerErrorType = PeerErrorType; typedef RemotePeerError = PeerError; typedef _SessionKeyDeriver = Future> Function(List homeSecret, List hostNonce, List clientNonce); typedef _RaceProbeConnection = ({Future Function() close, Future ready, Stream stream}); typedef _RaceProbeFactory = _RaceProbeConnection Function(Uri uri); class CompanionRemotePeerService with KeepaliveMixin { static const int _productionMaxTotalHostConnections = 100; static const int _productionMaxHostConnectionsPerSource = 5; static const int _productionMaxPreAuthMessageBytes = 65536; static const Duration _productionAuthTimeout = Duration(seconds: 10); static const int _productionMaxFailedAuthAttempts = 5; static const Duration _productionAuthLockoutDuration = Duration(seconds: 30); CompanionRemotePeerService() : this.forTesting( maxTotalHostConnections: _productionMaxTotalHostConnections, maxHostConnectionsPerSource: _productionMaxHostConnectionsPerSource, maxPreAuthMessageBytes: _productionMaxPreAuthMessageBytes, authTimeout: _productionAuthTimeout, maxFailedAuthAttempts: _productionMaxFailedAuthAttempts, authLockoutDuration: _productionAuthLockoutDuration, ); CompanionRemotePeerService.forTesting({ int maxTotalHostConnections = _productionMaxTotalHostConnections, int maxHostConnectionsPerSource = _productionMaxHostConnectionsPerSource, int maxPreAuthMessageBytes = _productionMaxPreAuthMessageBytes, Duration authTimeout = _productionAuthTimeout, int maxFailedAuthAttempts = _productionMaxFailedAuthAttempts, Duration authLockoutDuration = _productionAuthLockoutDuration, Future> Function(List homeSecret, List hostNonce, List clientNonce)? deriveSessionEncKey, ({Future Function() close, Future ready, Stream stream}) Function(Uri uri)? raceProbeFactory, this._afterHostUpgrade, }) : assert(maxTotalHostConnections > 0), assert(maxHostConnectionsPerSource > 0), assert(maxPreAuthMessageBytes > 0), assert(authTimeout > Duration.zero), assert(maxFailedAuthAttempts > 0), _maxTotalHostConnections = maxTotalHostConnections, _maxHostConnectionsPerSource = maxHostConnectionsPerSource, _maxPreAuthMessageBytes = maxPreAuthMessageBytes, _authTimeout = authTimeout, _maxFailedAuthAttempts = maxFailedAuthAttempts, _authLockoutDuration = authLockoutDuration, _deriveSessionEncKey = deriveSessionEncKey ?? ((homeSecret, hostNonce, clientNonce) { return RemoteAuthService.instance.deriveSessionEncKey(homeSecret, hostNonce, clientNonce); }), _raceProbeFactory = raceProbeFactory ?? _openRaceProbe; static _RaceProbeConnection _openRaceProbe(Uri uri) { final channel = IOWebSocketChannel.connect(uri, connectTimeout: const Duration(seconds: 5)); return ( close: () async { await channel.sink.close(); }, ready: channel.ready, stream: channel.stream, ); } final int _maxTotalHostConnections; final int _maxHostConnectionsPerSource; final int _maxPreAuthMessageBytes; final Duration _authTimeout; final int _maxFailedAuthAttempts; final Duration _authLockoutDuration; final _SessionKeyDeriver _deriveSessionEncKey; final _RaceProbeFactory _raceProbeFactory; final void Function()? _afterHostUpgrade; // Server-side (host) fields HttpServer? _server; WebSocket? _clientSocket; _HostAdmission? _currentHostAdmission; final Set<_HostAdmission> _hostAdmissions = {}; final Map _hostAdmissionsBySource = {}; int _hostAdmissionCount = 0; int _authenticationCommitGeneration = 0; Future _hostAuthenticationCommitTail = Future.value(); bool _acceptingHostConnections = false; bool _isDisconnecting = false; Future? _disconnectInProgress; Future? _disposeInProgress; bool _disposed = false; // Client-side (remote) fields IOWebSocketChannel? _channel; StreamSubscription? _channelSubscription; int _remoteConnectionGeneration = 0; String? _myPeerId; String? _hostAddress; // Format: "ip:port" RemoteSessionRole? _role; String? _selectedAuthContextId; String? _selectedHostClientId; // Encrypted channel state List? _sessionEncKey; int _sendCounter = 0; int _recvCounter = 0; bool _isAuthenticated = false; final _commandReceivedController = StreamController.broadcast(); final _deviceConnectedController = StreamController.broadcast(); final _deviceDisconnectedController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); final _connectionStateController = StreamController.broadcast(); // Keepalive (via KeepaliveMixin) @override Duration get pingInterval => const Duration(seconds: 5); @override Duration get pongTimeout => Duration.zero; // No pong timeout; host just replies inline // Auth rate limiting (per source IP) final Map _failedAuthAttempts = {}; final Map _authLockouts = {}; Stream get onCommandReceived => _commandReceivedController.stream; Stream get onDeviceConnected => _deviceConnectedController.stream; Stream get onDeviceDisconnected => _deviceDisconnectedController.stream; Stream get onError => _errorController.stream; Stream get onConnectionStateChanged => _connectionStateController.stream; String? get myPeerId => _myPeerId; String? get hostAddress => _hostAddress; RemoteSessionRole? get role => _role; String? get selectedAuthContextId => _selectedAuthContextId; String? get selectedHostClientId => _selectedHostClientId; bool get isHost => _role == RemoteSessionRole.host; bool get isConnected => _clientSocket != null || (_channel != null && _channel?.closeCode == null); Future> _getAllLocalIpAddresses() async { try { final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4); final preferred = []; final others = []; for (final interface in interfaces) { if (interface.name.toLowerCase().contains('lo')) continue; for (final addr in interface.addresses) { if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) { final name = interface.name.toLowerCase(); if (name.contains('en') || name.contains('wl') || name.contains('eth')) { preferred.add(addr.address); } else { others.add(addr.address); } } } } final all = [...preferred, ...others]; if (all.isEmpty) { throw RemotePeerError( type: RemotePeerErrorType.networkError, message: t.companionRemote.errors.noNetworkInterface, ); } return all; } catch (e) { appLogger.e('CompanionRemote: Failed to get local IPs', error: e); rethrow; } } /// Create a host session that accepts any of the provided remote identities. Future<({List addresses, int port})> createSessionForContexts( String deviceName, String platform, List authContexts, ) async { if (_disposed) { throw StateError('CompanionRemotePeerService is disposed'); } if (authContexts.isEmpty) { throw RemotePeerError( type: RemotePeerErrorType.authFailed, message: t.companionRemote.errors.authenticationFailed, ); } if (_server != null) { await disconnect(); } _role = RemoteSessionRole.host; _myPeerId = 'host'; try { const int preferredPort = 48632; late final HttpServer server; try { server = await HttpServer.bind(InternetAddress.anyIPv4, preferredPort); appLogger.d('CompanionRemote: Server bound to port $preferredPort'); } catch (e) { appLogger.w('CompanionRemote: Port $preferredPort occupied, using random port'); server = await HttpServer.bind(InternetAddress.anyIPv4, 0); } _server = server; _acceptingHostConnections = true; final localIps = await _getAllLocalIpAddresses(); final port = server.port; final addresses = localIps.map((ip) => '$ip:$port').toList(); _hostAddress = addresses.first; appLogger.d('CompanionRemote: Host server started, addresses: $addresses'); server.listen((request) { unawaited(_serveHostRequest(request, server, deviceName, platform, authContexts)); }); _connectionStateController.add(RemoteSessionStatus.connected); return (addresses: addresses, port: port); } catch (e) { _acceptingHostConnections = false; final failedServer = _server; _server = null; await _runDisconnectCleanup(failedServer?.close(force: true), 'failed server'); appLogger.e('CompanionRemote: Failed to create server', error: e); _errorController.add( RemotePeerError( type: RemotePeerErrorType.serverError, message: t.companionRemote.errors.serverStartFailed(error: e.toString()), originalError: e, ), ); rethrow; } } Future _serveHostRequest( HttpRequest request, HttpServer server, String hostDeviceName, String hostPlatform, List authContexts, ) async { if (request.uri.path != '/ws') { request.response.statusCode = HttpStatus.notFound; await request.response.close(); return; } final sourceIp = request.connectionInfo?.remoteAddress.address ?? 'unknown'; final admission = _tryReserveHostAdmission(sourceIp, server); if (admission == null) { request.response.statusCode = HttpStatus.tooManyRequests; request.response.headers.contentLength = 0; await request.response.close(); return; } try { final socket = await WebSocketTransformer.upgrade(request, compression: CompressionOptions.compressionOff); admission.socket = socket; admission.completeUpgrade(); _afterHostUpgrade?.call(); if (!_isHostAdmissionLive(admission, phase: _HostAdmissionPhase.upgrading)) { await _closeHostAdmissionSocket(admission); return; } _handleNewWebSocketConnection(admission, hostDeviceName, hostPlatform, authContexts); } catch (e) { admission.completeUpgrade(); await _closeHostAdmissionSocket(admission); try { request.response.statusCode = HttpStatus.badRequest; await request.response.close(); } catch (_) { // The upgrade path may already have committed and closed the response. } appLogger.d('CompanionRemote: WebSocket upgrade rejected', error: e); } } _HostAdmission? _tryReserveHostAdmission(String sourceIp, HttpServer server) { if (!_acceptingHostConnections || !identical(_server, server) || _isSourceLockedOut(sourceIp)) { return null; } final sourceCount = _hostAdmissionsBySource[sourceIp] ?? 0; if (_hostAdmissionCount >= _maxTotalHostConnections || sourceCount >= _maxHostConnectionsPerSource) { return null; } final admission = _HostAdmission(sourceIp: sourceIp, server: server); _hostAdmissions.add(admission); _hostAdmissionCount++; _hostAdmissionsBySource[sourceIp] = sourceCount + 1; return admission; } bool _isSourceLockedOut(String sourceIp) { final lockout = _authLockouts[sourceIp]; if (lockout == null) return false; if (DateTime.now().isBefore(lockout)) return true; _authLockouts.remove(sourceIp); _failedAuthAttempts.remove(sourceIp); return false; } bool _isHostAdmissionLive(_HostAdmission admission, {required _HostAdmissionPhase phase, int? commitGeneration}) { final socket = admission.socket; return !admission.released && admission.phase == phase && identical(_server, admission.server) && _acceptingHostConnections && socket != null && socket.readyState == WebSocket.open && (commitGeneration == null || commitGeneration == _authenticationCommitGeneration); } void _handleNewWebSocketConnection( _HostAdmission admission, String hostDeviceName, String hostPlatform, List authContexts, ) { final socket = admission.socket!; final auth = RemoteAuthService.instance; final hostNonce = auth.generateNonce(); final primaryContext = authContexts.first; appLogger.d('CompanionRemote: New WebSocket connection from ${admission.sourceIp}'); admission.phase = _HostAdmissionPhase.awaitingAuth; try { socket.add( jsonEncode({ 'type': 'challenge', 'nonce': base64Encode(hostNonce), 'hostClientId': primaryContext.clientIdentifier, 'authContexts': [ for (final context in authContexts) {'id': context.id, 'hostClientId': context.clientIdentifier}, ], }), ); } catch (e) { appLogger.d('CompanionRemote: Failed to send authentication challenge', error: e); unawaited(_closeHostAdmissionSocket(admission)); return; } admission.authTimer = Timer(_authTimeout, () { if (admission.phase == _HostAdmissionPhase.awaitingAuth || admission.phase == _HostAdmissionPhase.authenticating) { appLogger.w('CompanionRemote: Authentication timeout'); unawaited(_closeHostAdmissionSocket(admission, code: 4001, reason: 'Authentication timeout')); } }); late final StreamSubscription socketSubscription; socketSubscription = socket.listen( (data) { if (admission.phase == _HostAdmissionPhase.awaitingAuth) { // Claim the only pre-auth message before parsing or awaiting. admission.phase = _HostAdmissionPhase.authenticating; unawaited( _authenticateHostAdmission( admission, data, hostNonce, hostDeviceName, hostPlatform, authContexts, primaryContext, ), ); } else if (admission.phase == _HostAdmissionPhase.authenticated) { unawaited( _handleEncryptedCommand(data).catchError((Object error, StackTrace stackTrace) { appLogger.e('CompanionRemote: Failed to process encrypted message', error: error, stackTrace: stackTrace); }), ); } }, onDone: () { appLogger.d('CompanionRemote: WebSocket connection closed'); admission.phase = _HostAdmissionPhase.terminal; _releaseHostAdmission(admission); }, onError: (Object error, StackTrace stackTrace) { admission.phase = _HostAdmissionPhase.terminal; admission.authTimer?.cancel(); admission.authTimer = null; appLogger.e('CompanionRemote: WebSocket error', error: error, stackTrace: stackTrace); _errorController.add( RemotePeerError( type: RemotePeerErrorType.dataChannelError, message: t.companionRemote.pairing.failedToConnect(error: error.toString()), originalError: error, ), ); unawaited(_closeHostAdmissionSocket(admission)); }, cancelOnError: true, ); admission.subscription = socketSubscription; } Future _authenticateHostAdmission( _HostAdmission admission, dynamic data, List hostNonce, String hostDeviceName, String hostPlatform, List authContexts, RemoteAuthContext primaryContext, ) async { // dart:io delivers an assembled WebSocket message. This bounds application // parsing and allocation after delivery, not the runtime's frame buffer. if (data is! String || data.length > _maxPreAuthMessageBytes) { _rejectHostAuthentication(admission); return; } late final Map message; try { if (utf8.encode(data).length > _maxPreAuthMessageBytes) { _rejectHostAuthentication(admission); return; } final decoded = jsonDecode(data); if (decoded is! Map) { _rejectHostAuthentication(admission); return; } message = decoded; } catch (_) { _rejectHostAuthentication(admission); return; } if (message['type'] != 'auth') { _rejectHostAuthentication(admission, closeCode: 4002, closeReason: 'Authentication required'); return; } final authTag = message['authTag']; final clientNonceB64 = message['clientNonce']; final userUuid = message['userUUID']; final clientIdentifier = message['clientIdentifier']; final deviceName = message['deviceName']; final platform = message['platform']; final authContextIdValue = message['authContextId']; if (authTag is! String || clientNonceB64 is! String || userUuid is! String || clientIdentifier is! String || deviceName is! String || platform is! String || (authContextIdValue != null && authContextIdValue is! String)) { _rejectHostAuthentication(admission); return; } late final List clientNonce; try { clientNonce = base64Decode(clientNonceB64); } catch (_) { _rejectHostAuthentication(admission); return; } if (clientNonce.length != 32) { _rejectHostAuthentication(admission); return; } final authContextId = authContextIdValue as String?; RemoteAuthContext? selectedContext; if (authContextId != null && authContextId.isNotEmpty) { for (final context in authContexts) { if (context.id == authContextId) { selectedContext = context; break; } } } else if (authContexts.length == 1) { selectedContext = primaryContext; } if (selectedContext == null || (selectedContext.allowedUserUuids.isNotEmpty && !selectedContext.allowedUserUuids.contains(userUuid))) { _rejectHostAuthentication(admission); return; } final authenticatedContext = selectedContext; final valid = RemoteAuthService.instance.verifyAuthTag( authTag: authTag, homeSecret: authenticatedContext.homeSecret, hostNonce: hostNonce, clientNonce: clientNonce, hostClientId: authenticatedContext.clientIdentifier, userUUID: userUuid, clientIdentifier: clientIdentifier, deviceName: deviceName, platform: platform, ); if (!valid) { _rejectHostAuthentication(admission); return; } admission.authTimer?.cancel(); admission.authTimer = null; _failedAuthAttempts.remove(admission.sourceIp); late final List sessionEncKey; try { sessionEncKey = await _deriveSessionEncKey(authenticatedContext.homeSecret, hostNonce, clientNonce); } catch (e, stackTrace) { appLogger.e('CompanionRemote: Failed to derive session key', error: e, stackTrace: stackTrace); await _closeHostAdmissionSocket(admission, code: 4003, reason: 'Authentication failed'); return; } await _serializeHostAuthenticationCommit( () => _commitAuthenticatedHostAdmission( admission: admission, sessionEncKey: sessionEncKey, selectedContext: authenticatedContext, deviceName: deviceName, platform: platform, hostDeviceName: hostDeviceName, hostPlatform: hostPlatform, ), ); } Future _serializeHostAuthenticationCommit(Future Function() commit) { final operation = _hostAuthenticationCommitTail.then((_) => commit()); _hostAuthenticationCommitTail = operation.catchError((Object _, StackTrace _) {}); return operation; } Future _commitAuthenticatedHostAdmission({ required _HostAdmission admission, required List sessionEncKey, required RemoteAuthContext selectedContext, required String deviceName, required String platform, required String hostDeviceName, required String hostPlatform, }) async { if (!_isHostAdmissionLive(admission, phase: _HostAdmissionPhase.authenticating)) { await _closeHostAdmissionSocket(admission); return; } final previousAdmission = _currentHostAdmission; if (previousAdmission != null && !identical(previousAdmission, admission)) { appLogger.d('CompanionRemote: Replacing existing client connection'); await _retireReplacedHostAdmission(previousAdmission); } if (!_isHostAdmissionLive(admission, phase: _HostAdmissionPhase.authenticating)) { await _closeHostAdmissionSocket(admission); return; } final commitGeneration = ++_authenticationCommitGeneration; admission.commitGeneration = commitGeneration; admission.phase = _HostAdmissionPhase.authenticated; _currentHostAdmission = admission; _clientSocket = admission.socket; _sessionEncKey = sessionEncKey; _sendCounter = 0; _recvCounter = 0; _isAuthenticated = true; _selectedAuthContextId = selectedContext.id; _selectedHostClientId = selectedContext.clientIdentifier; appLogger.d('CompanionRemote: Client authenticated: $deviceName ($platform)'); try { await _sendEncryptedToSocket(admission.socket!, jsonEncode({'type': 'authSuccess'})); } catch (e, stackTrace) { appLogger.e('CompanionRemote: Failed to send authentication result', error: e, stackTrace: stackTrace); await _closeHostAdmissionSocket(admission); return; } if (!_isHostAdmissionLive( admission, phase: _HostAdmissionPhase.authenticated, commitGeneration: commitGeneration, )) { await _closeHostAdmissionSocket(admission); return; } final device = RemoteDevice(id: 'remote-client', name: deviceName, platform: platform, connectedAt: DateTime.now()); _deviceConnectedController.add(device); _connectionStateController.add(RemoteSessionStatus.connected); sendDeviceInfo(hostDeviceName, hostPlatform); } void _rejectHostAuthentication( _HostAdmission admission, { int closeCode = 4003, String closeReason = 'Authentication failed', }) { if (admission.phase != _HostAdmissionPhase.authenticating) return; admission.phase = _HostAdmissionPhase.terminal; admission.authTimer?.cancel(); admission.authTimer = null; _recordFailedAuth(admission.sourceIp); final socket = admission.socket; if (socket != null && socket.readyState == WebSocket.open) { try { socket.add(jsonEncode({'type': 'authFailed'})); } catch (_) { // The generic close below remains the terminal result. } } unawaited(_closeHostAdmissionSocket(admission, code: closeCode, reason: closeReason)); } Future _retireReplacedHostAdmission(_HostAdmission admission) async { admission.phase = _HostAdmissionPhase.terminal; admission.authTimer?.cancel(); admission.authTimer = null; _clearHostSessionIfOwned(admission, notify: false); final subscription = admission.subscription; admission.subscription = null; await _runDisconnectCleanup(subscription?.cancel(), 'replaced client listener'); unawaited(_closeHostAdmissionSocket(admission, code: 4004, reason: 'Replaced by new connection')); } Future _closeHostAdmissionSocket(_HostAdmission admission, {int? code, String? reason}) { final existing = admission.closeFuture; if (existing != null) return existing; final closeFuture = _closeHostAdmissionSocketOnce(admission, code: code, reason: reason); admission.closeFuture = closeFuture; return closeFuture; } Future _closeHostAdmissionSocketOnce(_HostAdmission admission, {int? code, String? reason}) async { admission.phase = _HostAdmissionPhase.terminal; admission.authTimer?.cancel(); admission.authTimer = null; final socket = admission.socket; if (socket != null) { await _runDisconnectCleanup(socket.close(code, reason), 'host socket'); } _releaseHostAdmission(admission); } void _releaseHostAdmission(_HostAdmission admission) { if (admission.released) return; admission.released = true; admission.phase = _HostAdmissionPhase.terminal; admission.authTimer?.cancel(); admission.authTimer = null; final subscription = admission.subscription; admission.subscription = null; if (subscription != null) { unawaited(_runDisconnectCleanup(subscription.cancel(), 'host listener')); } if (_hostAdmissions.remove(admission)) { _hostAdmissionCount--; final sourceCount = (_hostAdmissionsBySource[admission.sourceIp] ?? 1) - 1; if (sourceCount <= 0) { _hostAdmissionsBySource.remove(admission.sourceIp); } else { _hostAdmissionsBySource[admission.sourceIp] = sourceCount; } } _clearHostSessionIfOwned(admission, notify: !_isDisconnecting); admission.completeTerminal(); } void _clearHostSessionIfOwned(_HostAdmission admission, {required bool notify}) { if (!identical(_currentHostAdmission, admission)) return; _currentHostAdmission = null; _clientSocket = null; _sessionEncKey = null; _isAuthenticated = false; _selectedAuthContextId = null; _selectedHostClientId = null; stopKeepalive(); if (notify) { _deviceDisconnectedController.add(null); _connectionStateController.add(RemoteSessionStatus.disconnected); } } void _recordFailedAuth(String sourceIp) { final attempts = (_failedAuthAttempts[sourceIp] ?? 0) + 1; _failedAuthAttempts[sourceIp] = attempts; if (attempts >= _maxFailedAuthAttempts) { _authLockouts[sourceIp] = DateTime.now().add(_authLockoutDuration); appLogger.w('CompanionRemote: IP $sourceIp locked out for ${_authLockoutDuration.inSeconds}s'); } } bool _ownsRemoteChannel(IOWebSocketChannel channel, int generation) { return !_disposed && generation == _remoteConnectionGeneration && identical(_channel, channel); } /// Join a host session with any local auth context that the host also supports. Future joinSessionWithContexts( String deviceName, String platform, String hostAddress, List authContexts, { String? authContextId, String expectedHostClientId = '', }) async { if (authContexts.isEmpty) { throw RemotePeerError( type: RemotePeerErrorType.authFailed, message: t.companionRemote.errors.authenticationFailed, ); } if (_channel != null) { await disconnect(); } final connectionGeneration = ++_remoteConnectionGeneration; _role = RemoteSessionRole.remote; _hostAddress = hostAddress; _myPeerId = 'remote-${Random.secure().nextInt(99999)}'; final completer = Completer(); final auth = RemoteAuthService.instance; IOWebSocketChannel? attemptedChannel; try { final url = 'ws://$hostAddress/ws'; appLogger.d('CompanionRemote: Connecting to $url'); _connectionStateController.add(RemoteSessionStatus.connecting); final channel = IOWebSocketChannel.connect(Uri.parse(url)); attemptedChannel = channel; _channel = channel; await channel.ready; if (!_ownsRemoteChannel(channel, connectionGeneration)) { unawaited(channel.sink.close()); throw StateError('Companion Remote connection attempt became stale'); } List? hostNonce; List? clientNonce; String? receivedHostClientId; _channelSubscription = channel.stream.listen( (data) async { if (!_ownsRemoteChannel(channel, connectionGeneration)) return; try { if (_isAuthenticated) { await _handleEncryptedCommand(data); } else if (_sessionEncKey != null) { // Keys derived, waiting for encrypted authSuccess final decrypted = await _decryptIncoming(data); if (decrypted == null || !_ownsRemoteChannel(channel, connectionGeneration)) return; final json = jsonDecode(decrypted) as Map; if (json['type'] == 'authSuccess') { _isAuthenticated = true; appLogger.d('CompanionRemote: Authentication successful'); if (!completer.isCompleted) { completer.complete(); } final device = RemoteDevice( id: 'host', name: 'Desktop', platform: 'desktop', connectedAt: DateTime.now(), ); _deviceConnectedController.add(device); _connectionStateController.add(RemoteSessionStatus.connected); sendDeviceInfo(deviceName, platform); startKeepalive(); } else if (json['type'] == 'authFailed') { if (!completer.isCompleted) { completer.completeError( RemotePeerError( type: RemotePeerErrorType.authFailed, message: t.companionRemote.errors.authenticationFailed, ), ); } } } else { // Pre-auth: plaintext handshake final json = jsonDecode(data as String) as Map; final messageType = json['type'] as String?; if (messageType == 'challenge') { hostNonce = base64Decode(json['nonce'] as String); final legacyHostClientId = json['hostClientId'] as String? ?? ''; final challengeContextHostIds = {}; final challengeContexts = json['authContexts'] as List? ?? const []; for (final item in challengeContexts) { if (item is! Map) continue; final id = item['id'] as String?; final hostClientId = item['hostClientId'] as String?; if (id != null && id.isNotEmpty && hostClientId != null && hostClientId.isNotEmpty) { challengeContextHostIds[id] = hostClientId; } } RemoteAuthContext? selectedContext; if (authContextId != null && authContextId.isNotEmpty) { for (final context in authContexts) { if (context.id == authContextId) { selectedContext = context; break; } } } else if (challengeContextHostIds.isNotEmpty) { for (final context in authContexts) { if (challengeContextHostIds.containsKey(context.id)) { selectedContext = context; break; } } } else { selectedContext = authContexts.first; } if (selectedContext == null || (challengeContextHostIds.isNotEmpty && !challengeContextHostIds.containsKey(selectedContext.id))) { appLogger.w('CompanionRemote: No shared auth context with host'); if (!completer.isCompleted) { completer.completeError( RemotePeerError( type: RemotePeerErrorType.authFailed, message: t.companionRemote.errors.authenticationFailed, ), ); } unawaited(channel.sink.close(4003, 'Authentication failed')); return; } receivedHostClientId = challengeContextHostIds[selectedContext.id] ?? legacyHostClientId; clientNonce = auth.generateNonce(); if (expectedHostClientId.isNotEmpty && receivedHostClientId != expectedHostClientId) { appLogger.w('CompanionRemote: Host client ID mismatch'); if (!completer.isCompleted) { completer.completeError( RemotePeerError( type: RemotePeerErrorType.authFailed, message: t.companionRemote.errors.authenticationFailed, ), ); } unawaited(channel.sink.close(4003, 'Authentication failed')); return; } final authTag = auth.computeAuthTag( homeSecret: selectedContext.homeSecret, hostNonce: hostNonce!, clientNonce: clientNonce!, hostClientId: receivedHostClientId!, userUUID: selectedContext.userUuid, clientIdentifier: selectedContext.clientIdentifier, deviceName: deviceName, platform: platform, ); channel.sink.add( jsonEncode({ 'type': 'auth', 'authContextId': selectedContext.id, 'clientNonce': base64Encode(clientNonce!), 'userUUID': selectedContext.userUuid, 'clientIdentifier': selectedContext.clientIdentifier, 'deviceName': deviceName, 'platform': platform, 'authTag': authTag, }), ); final sessionEncKey = await auth.deriveSessionEncKey( selectedContext.homeSecret, hostNonce!, clientNonce!, ); if (!_ownsRemoteChannel(channel, connectionGeneration)) return; _sessionEncKey = sessionEncKey; _sendCounter = 0; _recvCounter = 0; _selectedAuthContextId = selectedContext.id; _selectedHostClientId = receivedHostClientId; } else if (messageType == 'authFailed') { appLogger.w('CompanionRemote: Authentication failed'); if (!completer.isCompleted) { completer.completeError( RemotePeerError( type: RemotePeerErrorType.authFailed, message: t.companionRemote.errors.authenticationFailed, ), ); } _errorController.add( RemotePeerError( type: RemotePeerErrorType.authFailed, message: t.companionRemote.errors.authenticationFailed, ), ); _connectionStateController.add(RemoteSessionStatus.error); } } } catch (e) { appLogger.e('CompanionRemote: Failed to parse message', error: e); } }, onDone: () { if (!_ownsRemoteChannel(channel, connectionGeneration)) return; appLogger.d('CompanionRemote: Connection closed'); if (!completer.isCompleted) { completer.completeError( RemotePeerError( type: RemotePeerErrorType.connectionFailed, message: t.companionRemote.pairing.failedToConnect(error: 'Connection closed before authentication'), ), ); } _deviceDisconnectedController.add(null); _connectionStateController.add(RemoteSessionStatus.disconnected); _isAuthenticated = false; _channel = null; _channelSubscription = null; _sessionEncKey = null; _selectedAuthContextId = null; _selectedHostClientId = null; stopKeepalive(); }, onError: (error) { if (!_ownsRemoteChannel(channel, connectionGeneration)) return; appLogger.e('CompanionRemote: Connection error', error: error); if (!completer.isCompleted) { completer.completeError(error); } _errorController.add( RemotePeerError( type: RemotePeerErrorType.connectionFailed, message: t.companionRemote.pairing.failedToConnect(error: error.toString()), originalError: error, ), ); _isAuthenticated = false; _channel = null; _channelSubscription = null; _sessionEncKey = null; _selectedAuthContextId = null; _selectedHostClientId = null; stopKeepalive(); _connectionStateController.add(RemoteSessionStatus.error); }, ); } catch (e) { if (!completer.isCompleted) { completer.completeError(e); } final channel = attemptedChannel; if (channel != null && _ownsRemoteChannel(channel, connectionGeneration)) { appLogger.e('CompanionRemote: Failed to connect', error: e); _errorController.add( RemotePeerError( type: RemotePeerErrorType.connectionFailed, message: t.companionRemote.pairing.failedToConnect(error: e.toString()), originalError: e, ), ); } } final channel = attemptedChannel; if (channel == null) return completer.future; return completer.future.timeout( const Duration(seconds: 15), onTimeout: () async { if (_ownsRemoteChannel(channel, connectionGeneration)) { try { await channel.sink.close(); } catch (e) { appLogger.d('CompanionRemote: channel close on timeout failed', error: e); } if (_ownsRemoteChannel(channel, connectionGeneration)) { _channel = null; } } throw RemotePeerError(type: RemotePeerErrorType.timeout, message: t.companionRemote.errors.joinTimedOut); }, ); } /// Race WebSocket connections and authenticate with the selected shared identity. Future joinSessionRacingWithContexts( String deviceName, String platform, List hostAddresses, List authContexts, { String? authContextId, String expectedHostClientId = '', }) async { if (authContexts.isEmpty) { throw RemotePeerError( type: RemotePeerErrorType.authFailed, message: t.companionRemote.errors.authenticationFailed, ); } if (hostAddresses.length == 1) { await joinSessionWithContexts( deviceName, platform, hostAddresses.first, authContexts, authContextId: authContextId, expectedHostClientId: expectedHostClientId, ); return hostAddresses.first; } appLogger.d('CompanionRemote: Racing connections to ${hostAddresses.length} addresses'); // Race: try to connect to all addresses, first one to get a challenge wins final completer = Completer(); final probes = <_RemoteAddressProbe>[]; Future cleanup() async { // A probe consumes a host admission slot until its WebSocket has fully // closed. Finish every cancellation and close before opening the managed // connection so probes cannot reject that connection at the per-source // limit. Cleanup errors are intentionally logged in address order and // never replace the race/authentication result. for (final probe in probes) { await probe.close(); } } for (final address in hostAddresses) { _RaceProbeConnection? connection; try { final url = 'ws://$address/ws'; connection = _raceProbeFactory(Uri.parse(url)); // Losing candidates fail their `ready` future (connect timeout, // no route to host, …); nothing awaits it here — the stream's // onError below is the visible signal — so swallow it or every // unreachable address becomes an unhandled async error. unawaited( connection.ready.catchError((Object e) { appLogger.d('CompanionRemote: race candidate $address failed to connect', error: e); }), ); probes.add( _RemoteAddressProbe( requestClose: connection.close, stream: connection.stream, onData: (data) { try { final json = jsonDecode(data as String) as Map; // First address to send us a challenge wins the race if (json['type'] == 'challenge' && !completer.isCompleted) { appLogger.d('CompanionRemote: Race winner: $address'); completer.complete(address); } } catch (e) { appLogger.d('CompanionRemote: race message parse skipped', error: e); } }, ), ); } catch (e) { appLogger.d('CompanionRemote: Race candidate $address failed to start: $e'); if (connection != null) { try { await connection.close(); } catch (closeError) { appLogger.d('CompanionRemote: failed race candidate close ignored', error: closeError); } } } } if (probes.isEmpty) { throw RemotePeerError( type: RemotePeerErrorType.connectionFailed, message: t.companionRemote.errors.failedToConnectAnyAddress, ); } try { final winner = await completer.future.namedTimeout( const Duration(seconds: 10), operation: 'CompanionRemote race connect', ); await cleanup(); // Set up the proper managed connection on the winning address await joinSessionWithContexts( deviceName, platform, winner, authContexts, authContextId: authContextId, expectedHostClientId: expectedHostClientId, ); return winner; } on TimeoutException { await cleanup(); throw RemotePeerError(type: RemotePeerErrorType.timeout, message: t.companionRemote.errors.joinTimedOut); } } // ── Encrypted send/receive ── // Serialize cryptographic operations so implicit nonce counters cannot // interleave when stream callbacks overlap. final SerialFutureQueue _encryptQueue = SerialFutureQueue(); final SerialFutureQueue _decryptQueue = SerialFutureQueue(); final SerialFutureQueue _sendQueue = SerialFutureQueue(); Future> _encryptOutgoing(String plaintext) { return _encryptQueue.run(() async { final encrypted = await RemoteAuthService.instance.encrypt( _sessionEncKey!, utf8.encode(plaintext), isHost: _role == RemoteSessionRole.host, counter: _sendCounter, ); _sendCounter++; return encrypted; }); } Future _sendEncryptedToSocket(WebSocket socket, String plaintext) async { if (_sessionEncKey == null) return; final encrypted = await _encryptOutgoing(plaintext); socket.add(encrypted); } Future _decryptIncoming(dynamic data) { if (_sessionEncKey == null) return Future.value(); return _decryptQueue.run(() => _decryptIncomingNow(data)); } Future _decryptIncomingNow(dynamic data) async { try { final bytes = data is List ? data : utf8.encode(data as String); final decrypted = await RemoteAuthService.instance.decrypt( bytes, _sessionEncKey!, fromHost: _role == RemoteSessionRole.remote, expectedCounter: _recvCounter, ); _recvCounter++; return utf8.decode(decrypted); } catch (e) { appLogger.e('CompanionRemote: Decryption failed (counter=$_recvCounter)', error: e); return null; } } Future _handleEncryptedCommand(dynamic data) async { final decrypted = await _decryptIncoming(data); if (decrypted == null) return; final command = RemoteCommand.fromJson(jsonDecode(decrypted) as Map); appLogger.d('CompanionRemote: Received command: ${command.type}'); if (_shouldSendAck(command)) { _sendAck(command); } _commandReceivedController.add(command); if (command.type == RemoteCommandType.ping) { _sendPong(); } } // ── Commands ── @override void sendPing() { if (isConnected) { sendCommand(const RemoteCommand(type: RemoteCommandType.ping)); } } @override void onPongTimeout() { // Not used — pong timeout is disabled for companion remote. } bool _shouldSendAck(RemoteCommand command) { return command.type != RemoteCommandType.ping && command.type != RemoteCommandType.pong && command.type != RemoteCommandType.ack && command.type != RemoteCommandType.deviceInfo; } void _sendAck(RemoteCommand _) { sendCommand(const RemoteCommand(type: RemoteCommandType.ack)); } void _sendPong() { sendCommand(const RemoteCommand(type: RemoteCommandType.pong)); } void sendDeviceInfo(String deviceName, String platform) { sendCommand( RemoteCommand( type: RemoteCommandType.deviceInfo, data: {'id': _myPeerId, 'name': deviceName, 'platform': platform, 'role': _role?.name}, ), ); } void sendCommand(RemoteCommand command) { if (_sessionEncKey == null || !_isAuthenticated) { appLogger.w('CompanionRemote: No connection to send command'); return; } // Chain sends to prevent counter interleaving from concurrent async encrypts _sendQueue.run(() async { try { final json = jsonEncode(command.toJson()); final encrypted = await _encryptOutgoing(json); if (_role == RemoteSessionRole.host && _clientSocket != null) { _clientSocket!.add(encrypted); } else if (_role == RemoteSessionRole.remote && _channel != null) { _channel!.sink.add(encrypted); } appLogger.d('CompanionRemote: Sent command: ${command.type}'); } catch (e) { appLogger.e('CompanionRemote: Failed to send command', error: e); _errorController.add( RemotePeerError( type: RemotePeerErrorType.dataChannelError, message: t.companionRemote.errors.commandFailed(error: e.toString()), originalError: e, ), ); } }); } Future _runDisconnectCleanup(Future? operation, String name) async { if (operation == null) return; try { await operation; } catch (e) { appLogger.d('CompanionRemote: $name cleanup ignored', error: e); } } Future disconnect() { if (_disposed) return Future.value(); final existing = _disconnectInProgress; if (existing != null) return existing; late final Future tracked; tracked = _disconnect().whenComplete(() { if (identical(_disconnectInProgress, tracked)) { _disconnectInProgress = null; } }); _disconnectInProgress = tracked; return tracked; } Future _disconnect() async { appLogger.d('CompanionRemote: Disconnecting'); _remoteConnectionGeneration++; _acceptingHostConnections = false; _isDisconnecting = true; _isAuthenticated = false; _authenticationCommitGeneration++; stopKeepalive(); final channel = _channel; final server = _server; _server = null; final serverClose = server?.close(force: true); final admissions = List<_HostAdmission>.of(_hostAdmissions); try { await Future.wait(admissions.map(_shutdownHostAdmission)); await _runDisconnectCleanup(_channelSubscription?.cancel(), 'channel listener'); _channelSubscription = null; // Decrypted commands may enqueue acknowledgements, and sends enqueue // encryption, so drain in dependency order. await _decryptQueue.settled; await _sendQueue.settled; await _encryptQueue.settled; await _runDisconnectCleanup(channel?.sink.close(), 'channel'); await _runDisconnectCleanup(serverClose, 'server'); } finally { for (final admission in List<_HostAdmission>.of(_hostAdmissions)) { _releaseHostAdmission(admission); } _hostAdmissions.clear(); _hostAdmissionsBySource.clear(); _hostAdmissionCount = 0; _currentHostAdmission = null; _clientSocket = null; _channel = null; _myPeerId = null; _hostAddress = null; _role = null; _selectedAuthContextId = null; _selectedHostClientId = null; _sessionEncKey = null; _sendCounter = 0; _recvCounter = 0; _sendQueue.reset(); _encryptQueue.reset(); _decryptQueue.reset(); _failedAuthAttempts.clear(); _authLockouts.clear(); _isDisconnecting = false; _connectionStateController.add(RemoteSessionStatus.disconnected); } } Future _shutdownHostAdmission(_HostAdmission admission) async { admission.phase = _HostAdmissionPhase.terminal; admission.authTimer?.cancel(); admission.authTimer = null; await admission.upgradeFinished.future; if (admission.released) return; final subscription = admission.subscription; final socketClose = _closeHostAdmissionSocket(admission); admission.subscription = null; await _runDisconnectCleanup(subscription?.cancel(), 'host listener'); await socketClose; await admission.terminal.future; } /// Whether the HTTP server is currently running. bool get isServerRunning => _server != null; Future dispose() { final existing = _disposeInProgress; if (existing != null) return existing; if (_disposed) return Future.value(); late final Future tracked; tracked = _dispose().whenComplete(() { if (identical(_disposeInProgress, tracked)) { _disposeInProgress = null; } }); _disposeInProgress = tracked; return tracked; } Future _dispose() async { await disconnect(); await _commandReceivedController.close(); await _deviceConnectedController.close(); await _deviceDisconnectedController.close(); await _errorController.close(); await _connectionStateController.close(); _disposed = true; } } class _RemoteAddressProbe { _RemoteAddressProbe({ required this._requestClose, required Stream stream, required void Function(dynamic data) onData, }) { _subscription = stream.listen( onData, onError: (Object _, StackTrace _) => _completeTerminal(), onDone: _completeTerminal, ); } static const _terminalTimeout = Duration(seconds: 5); final Future Function() _requestClose; final Completer _terminal = Completer(); late final StreamSubscription _subscription; void _completeTerminal() { if (!_terminal.isCompleted) { _terminal.complete(); } } Future close() async { try { await _requestClose(); } catch (error) { appLogger.d('CompanionRemote: race candidate close ignored', error: error); } try { await _terminal.future.timeout(_terminalTimeout); } on TimeoutException catch (error) { appLogger.d('CompanionRemote: race candidate terminal close timed out', error: error); } try { await _subscription.cancel(); } catch (error) { appLogger.d('CompanionRemote: race candidate listener cleanup ignored', error: error); } } } enum _HostAdmissionPhase { upgrading, awaitingAuth, authenticating, authenticated, terminal } class _HostAdmission { _HostAdmission({required this.sourceIp, required this.server}); final String sourceIp; final HttpServer server; final Completer upgradeFinished = Completer(); final Completer terminal = Completer(); WebSocket? socket; StreamSubscription? subscription; Timer? authTimer; _HostAdmissionPhase phase = _HostAdmissionPhase.upgrading; int? commitGeneration; bool released = false; Future? closeFuture; void completeUpgrade() { if (!upgradeFinished.isCompleted) { upgradeFinished.complete(); } } void completeTerminal() { if (!terminal.isCompleted) { terminal.complete(); } } }