fix: companion remote discovery leak, reconnect, and auth hardening

- Dispose old discovery service before creating new one in loadRecentSessions()
- Host waits for client reconnect instead of calling joinSession
- Clear stale error messages on successful reconnect
- Rate limit auth attempts (5 tries, 30s lockout)
- Use Random.secure() for peer ID
- Extract shared ACK helper methods
This commit is contained in:
edde746
2026-02-10 09:02:22 +01:00
parent e30c0b4370
commit 660267b504
2 changed files with 61 additions and 28 deletions
+18 -2
View File
@@ -45,6 +45,7 @@ class CompanionRemoteProvider with ChangeNotifier {
StreamSubscription<void>? _deviceDisconnectedSubscription; StreamSubscription<void>? _deviceDisconnectedSubscription;
StreamSubscription<RemotePeerError>? _errorSubscription; StreamSubscription<RemotePeerError>? _errorSubscription;
StreamSubscription<RemoteSessionStatus>? _statusSubscription; StreamSubscription<RemoteSessionStatus>? _statusSubscription;
StreamSubscription<List<RecentRemoteSession>>? _recentSessionsSubscription;
CommandReceivedCallback? onCommandReceived; CommandReceivedCallback? onCommandReceived;
DeviceApprovalCallback? onDeviceApprovalRequired; DeviceApprovalCallback? onDeviceApprovalRequired;
@@ -136,6 +137,15 @@ class CompanionRemoteProvider with ChangeNotifier {
clearConnectedDevice: true, clearConnectedDevice: true,
); );
notifyListeners(); notifyListeners();
} else if (isHost) {
// Host keeps the server running — the client will reconnect on its own
_session = _session?.copyWith(
status: RemoteSessionStatus.reconnecting,
clearConnectedDevice: true,
clearErrorMessage: true,
);
notifyListeners();
appLogger.d('CompanionRemote: Host waiting for client to reconnect');
} else { } else {
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting); _session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
notifyListeners(); notifyListeners();
@@ -314,7 +324,7 @@ class CompanionRemoteProvider with ChangeNotifier {
await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!); await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!);
_session = _session?.copyWith(status: RemoteSessionStatus.connected); _session = _session?.copyWith(status: RemoteSessionStatus.connected, clearErrorMessage: true);
_reconnectAttempts = 0; _reconnectAttempts = 0;
notifyListeners(); notifyListeners();
appLogger.d('CompanionRemote: Reconnected successfully'); appLogger.d('CompanionRemote: Reconnected successfully');
@@ -451,10 +461,15 @@ class CompanionRemoteProvider with ChangeNotifier {
/// Load recent sessions /// Load recent sessions
Future<void> loadRecentSessions() async { Future<void> loadRecentSessions() async {
try { try {
// Dispose previous discovery service and subscription to avoid leaks
_recentSessionsSubscription?.cancel();
_recentSessionsSubscription = null;
_discoveryService?.dispose();
_discoveryService = CompanionRemoteDiscoveryService(); _discoveryService = CompanionRemoteDiscoveryService();
// Listen for recent sessions updates // Listen for recent sessions updates
_discoveryService!.recentSessions.listen((sessions) { _recentSessionsSubscription = _discoveryService!.recentSessions.listen((sessions) {
_recentSessions.clear(); _recentSessions.clear();
_recentSessions.addAll(sessions); _recentSessions.addAll(sessions);
notifyListeners(); notifyListeners();
@@ -526,6 +541,7 @@ class CompanionRemoteProvider with ChangeNotifier {
void dispose() { void dispose() {
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
leaveSession(); leaveSession();
_recentSessionsSubscription?.cancel();
_discoveryService?.dispose(); _discoveryService?.dispose();
super.dispose(); super.dispose();
} }
@@ -55,6 +55,12 @@ class CompanionRemotePeerService {
Timer? _pingTimer; Timer? _pingTimer;
// Auth rate limiting
int _failedAuthAttempts = 0;
DateTime? _authLockoutUntil;
static const int _maxFailedAuthAttempts = 5;
static const Duration _authLockoutDuration = Duration(seconds: 30);
Stream<RemoteCommand> get onCommandReceived => _commandReceivedController.stream; Stream<RemoteCommand> get onCommandReceived => _commandReceivedController.stream;
Stream<RemoteDevice> get onDeviceConnected => _deviceConnectedController.stream; Stream<RemoteDevice> get onDeviceConnected => _deviceConnectedController.stream;
Stream<void> get onDeviceDisconnected => _deviceDisconnectedController.stream; Stream<void> get onDeviceDisconnected => _deviceDisconnectedController.stream;
@@ -198,12 +204,21 @@ class CompanionRemotePeerService {
if (!isAuthenticated) { if (!isAuthenticated) {
// First message must be authentication // First message must be authentication
if (json['type'] == 'auth') { if (json['type'] == 'auth') {
// Rate limiting: reject if locked out
if (_authLockoutUntil != null && DateTime.now().isBefore(_authLockoutUntil!)) {
appLogger.w('CompanionRemote: Auth attempt rejected (rate limited)');
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Too many attempts. Try again later.'}));
socket.close(4005, 'Rate limited');
return;
}
final sessionId = json['sessionId'] as String?; final sessionId = json['sessionId'] as String?;
final pin = json['pin'] as String?; final pin = json['pin'] as String?;
final deviceName = json['deviceName'] as String?; final deviceName = json['deviceName'] as String?;
final platform = json['platform'] as String?; final platform = json['platform'] as String?;
if (sessionId == _sessionId && pin == _pin) { if (sessionId == _sessionId && pin == _pin) {
_failedAuthAttempts = 0;
isAuthenticated = true; isAuthenticated = true;
authTimeout?.cancel(); authTimeout?.cancel();
@@ -234,7 +249,12 @@ class CompanionRemotePeerService {
// Note: Client sends keepalive pings, host only responds with pongs // Note: Client sends keepalive pings, host only responds with pongs
} else { } else {
appLogger.w('CompanionRemote: Invalid credentials'); _failedAuthAttempts++;
if (_failedAuthAttempts >= _maxFailedAuthAttempts) {
_authLockoutUntil = DateTime.now().add(_authLockoutDuration);
appLogger.w('CompanionRemote: Too many failed auth attempts, locked out for ${_authLockoutDuration.inSeconds}s');
}
appLogger.w('CompanionRemote: Invalid credentials (attempt $_failedAuthAttempts/$_maxFailedAuthAttempts)');
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Invalid session ID or PIN'})); socket.add(jsonEncode({'type': 'authFailed', 'message': 'Invalid session ID or PIN'}));
socket.close(4003, 'Invalid credentials'); socket.close(4003, 'Invalid credentials');
} }
@@ -247,18 +267,8 @@ class CompanionRemotePeerService {
final command = RemoteCommand.fromJson(json); final command = RemoteCommand.fromJson(json);
appLogger.d('CompanionRemote: Received command: ${command.type}'); appLogger.d('CompanionRemote: Received command: ${command.type}');
// Send acknowledgment for non-ping/pong/ack commands if (_shouldSendAck(command)) {
if (command.type != RemoteCommandType.ping && _sendAck(command, hostDeviceName);
command.type != RemoteCommandType.pong &&
command.type != RemoteCommandType.ack &&
command.type != RemoteCommandType.deviceInfo) {
final ackCommand = RemoteCommand(
type: RemoteCommandType.ack,
deviceId: _myPeerId ?? 'unknown',
deviceName: hostDeviceName,
data: {'originalCommand': command.type.toString()},
);
socket.add(jsonEncode(ackCommand.toJson()));
} }
_commandReceivedController.add(command); _commandReceivedController.add(command);
@@ -304,7 +314,7 @@ class CompanionRemotePeerService {
_sessionId = sessionId.toUpperCase(); _sessionId = sessionId.toUpperCase();
_pin = pin; _pin = pin;
_hostAddress = hostAddress; _hostAddress = hostAddress;
_myPeerId = 'remote-${Random().nextInt(99999)}'; _myPeerId = 'remote-${Random.secure().nextInt(99999)}';
final completer = Completer<void>(); final completer = Completer<void>();
@@ -364,18 +374,8 @@ class CompanionRemotePeerService {
final command = RemoteCommand.fromJson(json); final command = RemoteCommand.fromJson(json);
appLogger.d('CompanionRemote: Received command: ${command.type}'); appLogger.d('CompanionRemote: Received command: ${command.type}');
// Send acknowledgment for non-ping/pong/ack commands if (_shouldSendAck(command)) {
if (command.type != RemoteCommandType.ping && _sendAck(command, deviceName);
command.type != RemoteCommandType.pong &&
command.type != RemoteCommandType.ack &&
command.type != RemoteCommandType.deviceInfo) {
final ackCommand = RemoteCommand(
type: RemoteCommandType.ack,
deviceId: _myPeerId ?? 'unknown',
deviceName: deviceName,
data: {'originalCommand': command.type.toString()},
);
_channel!.sink.add(jsonEncode(ackCommand.toJson()));
} }
_commandReceivedController.add(command); _commandReceivedController.add(command);
@@ -451,6 +451,23 @@ class CompanionRemotePeerService {
_pingTimer = null; _pingTimer = null;
} }
bool _shouldSendAck(RemoteCommand command) {
return command.type != RemoteCommandType.ping &&
command.type != RemoteCommandType.pong &&
command.type != RemoteCommandType.ack &&
command.type != RemoteCommandType.deviceInfo;
}
void _sendAck(RemoteCommand command, String deviceName) {
final ackCommand = RemoteCommand(
type: RemoteCommandType.ack,
deviceId: _myPeerId ?? 'unknown',
deviceName: deviceName,
data: {'originalCommand': command.type.toString()},
);
sendCommand(ackCommand);
}
void _sendPong(String deviceName, String platform) { void _sendPong(String deviceName, String platform) {
sendCommand( sendCommand(
RemoteCommand( RemoteCommand(