@@ -3,8 +3,8 @@ import 'dart:convert';
33import 'dart:io' ;
44import 'package:web_socket_channel/web_socket_channel.dart' ;
55import 'package:web_socket_channel/io.dart' ;
6- import 'package:crypto/crypto.dart' ;
76import 'package:path/path.dart' as path;
7+ import '../security/device_pairing.dart' ;
88
99/// Manages connections from mobile clients
1010/// Handles authentication, task submission, and real-time updates
@@ -15,6 +15,15 @@ class MobileConnectionManager {
1515 final int port;
1616 final String authSecret;
1717
18+ /// Device pairing manager for secure authentication
19+ DevicePairingManager ? _pairingManager;
20+
21+ /// Whether to use device pairing for authentication (vs simple token)
22+ final bool useDevicePairing;
23+
24+ /// Host device ID for pairing
25+ String ? _hostDeviceId;
26+
1827 final StreamController <MobileTaskSubmission > _taskSubmissionController =
1928 StreamController .broadcast ();
2029
@@ -24,13 +33,25 @@ class MobileConnectionManager {
2433 /// Get list of connected device IDs
2534 List <String > get connectedClients => _activeConnections.keys.toList ();
2635
36+ /// Get the device pairing manager
37+ DevicePairingManager ? get pairingManager => _pairingManager;
38+
2739 MobileConnectionManager ({
2840 this .port = 8765 ,
2941 required this .authSecret,
42+ this .useDevicePairing = true ,
3043 });
3144
3245 /// Start the WebSocket server for mobile connections
3346 Future <void > start () async {
47+ // Initialize device pairing if enabled
48+ if (useDevicePairing) {
49+ _pairingManager = DevicePairingManager ();
50+ await _pairingManager! .initialize ();
51+ _hostDeviceId = await _loadOrGenerateHostDeviceId ();
52+ print ('✓ Device pairing initialized (host: ${_hostDeviceId !.substring (0 , 8 )}...)' );
53+ }
54+
3455 var currentPort = port;
3556 var maxRetries = 10 ;
3657 var retryCount = 0 ;
@@ -64,6 +85,29 @@ class MobileConnectionManager {
6485 );
6586 }
6687
88+ /// Load or generate host device ID
89+ Future <String > _loadOrGenerateHostDeviceId () async {
90+ final home = Platform .environment['HOME' ] ?? '.' ;
91+ final idFile = File ('$home /.opencli/device_id' );
92+
93+ if (await idFile.exists ()) {
94+ return (await idFile.readAsString ()).trim ();
95+ }
96+
97+ // Generate new device ID
98+ final id = _generateDeviceId ();
99+ await idFile.parent.create (recursive: true );
100+ await idFile.writeAsString (id);
101+ return id;
102+ }
103+
104+ /// Generate a unique device ID
105+ String _generateDeviceId () {
106+ final random = DateTime .now ().millisecondsSinceEpoch;
107+ final hostname = Platform .localHostname;
108+ return '${hostname }_$random ' ;
109+ }
110+
67111 /// Stop the server and close all connections
68112 Future <void > stop () async {
69113 // Copy values to avoid concurrent modification during iteration
@@ -74,6 +118,7 @@ class MobileConnectionManager {
74118 _activeConnections.clear ();
75119 await _server.close ();
76120 await _taskSubmissionController.close ();
121+ await _confirmationResponseController.close ();
77122 }
78123
79124 /// Handle new WebSocket connection from mobile client
@@ -91,6 +136,22 @@ class MobileConnectionManager {
91136 case 'auth' :
92137 deviceId = await _handleAuth (channel, data);
93138 break ;
139+ case 'pair' :
140+ // Handle device pairing request
141+ if (useDevicePairing && _pairingManager != null ) {
142+ deviceId = await _handlePairing (channel, data);
143+ } else {
144+ _sendError (channel, 'Device pairing not enabled' );
145+ }
146+ break ;
147+ case 'generate_pairing_code' :
148+ // Generate a pairing code for QR display
149+ if (useDevicePairing && _pairingManager != null ) {
150+ _handleGeneratePairingCode (channel);
151+ } else {
152+ _sendError (channel, 'Device pairing not enabled' );
153+ }
154+ break ;
94155 case 'submit_task' :
95156 if (deviceId != null ) {
96157 await _handleTaskSubmission (deviceId! , data);
@@ -106,6 +167,12 @@ class MobileConnectionManager {
106167 case 'heartbeat' :
107168 _sendMessage (channel, {'type' : 'heartbeat_ack' });
108169 break ;
170+ case 'confirm_response' :
171+ // Handle confirmation response from mobile
172+ if (deviceId != null ) {
173+ _handleConfirmResponse (data);
174+ }
175+ break ;
109176 default :
110177 _sendError (channel, 'Unknown message type: $type ' );
111178 }
@@ -142,27 +209,63 @@ class MobileConnectionManager {
142209 return null ;
143210 }
144211
145- // Verify timestamp (prevent replay attacks)
212+ // Use device pairing authentication if enabled and device is paired
213+ if (useDevicePairing && _pairingManager != null ) {
214+ if (_pairingManager! .isPaired (deviceId)) {
215+ // Verify using paired device credentials
216+ if (! _pairingManager! .verifyAuthentication (deviceId, token, timestamp)) {
217+ _sendError (channel, 'Invalid authentication token' );
218+ return null ;
219+ }
220+
221+ // Successfully authenticated with paired device
222+ final client = MobileClient (
223+ deviceId: deviceId,
224+ channel: channel,
225+ connectedAt: DateTime .now (),
226+ );
227+ _activeConnections[deviceId] = client;
228+
229+ final device = _pairingManager! .getDevice (deviceId);
230+ _sendMessage (channel, {
231+ 'type' : 'auth_success' ,
232+ 'device_id' : deviceId,
233+ 'device_name' : device? .deviceName,
234+ 'server_time' : DateTime .now ().millisecondsSinceEpoch,
235+ 'permissions' : device? .permissions,
236+ });
237+
238+ print ('Paired device authenticated: $deviceId ' );
239+ return deviceId;
240+ } else {
241+ // Device not paired, need to pair first
242+ _sendMessage (channel, {
243+ 'type' : 'auth_required' ,
244+ 'message' : 'Device not paired. Please scan the pairing QR code first.' ,
245+ 'requires_pairing' : true ,
246+ });
247+ return null ;
248+ }
249+ }
250+
251+ // Fallback: simple token-based authentication
146252 final now = DateTime .now ().millisecondsSinceEpoch;
147- if ((now - timestamp).abs () > 300000 ) { // 5 minutes
253+ if ((now - timestamp).abs () > 300000 ) {
148254 _sendError (channel, 'Authentication expired' );
149255 return null ;
150256 }
151257
152- // Verify token
153- final expectedToken = _generateAuthToken (deviceId, timestamp);
258+ final expectedToken = _generateSimpleAuthToken (deviceId, timestamp);
154259 if (token != expectedToken) {
155260 _sendError (channel, 'Invalid authentication token' );
156261 return null ;
157262 }
158263
159- // Create client session
160264 final client = MobileClient (
161265 deviceId: deviceId,
162266 channel: channel,
163267 connectedAt: DateTime .now (),
164268 );
165-
166269 _activeConnections[deviceId] = client;
167270
168271 _sendMessage (channel, {
@@ -171,16 +274,153 @@ class MobileConnectionManager {
171274 'server_time' : now,
172275 });
173276
174- print ('Mobile client authenticated: $deviceId ' );
277+ print ('Mobile client authenticated (simple) : $deviceId ' );
175278 return deviceId;
176279 }
177280
178- /// Generate authentication token
179- String _generateAuthToken (String deviceId, int timestamp) {
281+ /// Generate simple authentication token (fallback)
282+ String _generateSimpleAuthToken (String deviceId, int timestamp) {
180283 final input = '$deviceId :$timestamp :$authSecret ' ;
181284 final bytes = utf8.encode (input);
182- final digest = sha256.convert (bytes);
183- return digest.toString ();
285+ // Use a simple hash for fallback mode
286+ var hash = 0 ;
287+ for (var byte in bytes) {
288+ hash = ((hash << 5 ) - hash) + byte;
289+ hash = hash & 0xFFFFFFFF ;
290+ }
291+ return hash.toRadixString (16 );
292+ }
293+
294+ /// Handle device pairing request
295+ Future <String ?> _handlePairing (
296+ WebSocketChannel channel,
297+ Map <String , dynamic > data,
298+ ) async {
299+ final pairingCode = data['pairing_code' ] as String ? ;
300+ final deviceId = data['device_id' ] as String ? ;
301+ final deviceName = data['device_name' ] as String ? ;
302+ final platform = data['platform' ] as String ? ;
303+
304+ if (pairingCode == null || deviceId == null || deviceName == null ) {
305+ _sendError (channel, 'Missing pairing fields' );
306+ return null ;
307+ }
308+
309+ final device = await _pairingManager! .completePairing (
310+ pairingCode: pairingCode,
311+ deviceId: deviceId,
312+ deviceName: deviceName,
313+ platform: platform ?? 'unknown' ,
314+ );
315+
316+ if (device == null ) {
317+ _sendError (channel, 'Invalid or expired pairing code' );
318+ return null ;
319+ }
320+
321+ // Create client session
322+ final client = MobileClient (
323+ deviceId: deviceId,
324+ channel: channel,
325+ connectedAt: DateTime .now (),
326+ );
327+ _activeConnections[deviceId] = client;
328+
329+ _sendMessage (channel, {
330+ 'type' : 'pair_success' ,
331+ 'device_id' : deviceId,
332+ 'device_name' : deviceName,
333+ 'shared_secret' : device.sharedSecret,
334+ 'permissions' : device.permissions,
335+ });
336+
337+ print ('Device paired and connected: $deviceName ($deviceId )' );
338+ return deviceId;
339+ }
340+
341+ /// Handle generate pairing code request
342+ void _handleGeneratePairingCode (WebSocketChannel channel) {
343+ if (_hostDeviceId == null ) {
344+ _sendError (channel, 'Host device ID not initialized' );
345+ return ;
346+ }
347+
348+ final request = _pairingManager! .generatePairingRequest (
349+ hostDeviceId: _hostDeviceId! ,
350+ hostName: Platform .localHostname,
351+ port: port,
352+ );
353+
354+ _sendMessage (channel, {
355+ 'type' : 'pairing_code' ,
356+ 'code' : request.pairingCode,
357+ 'qr_data' : request.toQRData (),
358+ 'expires_at' : request.expiresAt.toIso8601String (),
359+ });
360+
361+ print ('Generated pairing code: ${request .pairingCode }' );
362+ }
363+
364+ /// Handle confirmation response from mobile
365+ void _handleConfirmResponse (Map <String , dynamic > data) {
366+ final requestId = data['request_id' ] as String ? ;
367+ final approved = data['approved' ] as bool ? ?? false ;
368+
369+ if (requestId == null ) return ;
370+
371+ // Notify confirmation listeners
372+ _confirmationResponseController.add (ConfirmationResponse (
373+ requestId: requestId,
374+ approved: approved,
375+ ));
376+ }
377+
378+ /// Stream of confirmation responses
379+ final StreamController <ConfirmationResponse > _confirmationResponseController =
380+ StreamController .broadcast ();
381+
382+ Stream <ConfirmationResponse > get confirmationResponses =>
383+ _confirmationResponseController.stream;
384+
385+ /// Send confirmation request to mobile device
386+ Future <void > sendConfirmationRequest ({
387+ required String deviceId,
388+ required String requestId,
389+ required String operation,
390+ required Map <String , dynamic > details,
391+ required int timeoutSeconds,
392+ }) async {
393+ final client = _activeConnections[deviceId];
394+ if (client == null ) return ;
395+
396+ _sendMessage (client.channel, {
397+ 'type' : 'confirmation_request' ,
398+ 'request_id' : requestId,
399+ 'operation' : operation,
400+ 'details' : details,
401+ 'timeout_seconds' : timeoutSeconds,
402+ });
403+ }
404+
405+ /// Generate a pairing code for display (e.g., in menu bar app)
406+ PairingRequest ? generatePairingCode () {
407+ if (! useDevicePairing || _pairingManager == null || _hostDeviceId == null ) {
408+ return null ;
409+ }
410+
411+ return _pairingManager! .generatePairingRequest (
412+ hostDeviceId: _hostDeviceId! ,
413+ hostName: Platform .localHostname,
414+ port: port,
415+ );
416+ }
417+
418+ /// Check if a device is paired
419+ bool isDevicePaired (String deviceId) {
420+ if (! useDevicePairing || _pairingManager == null ) {
421+ return true ; // If pairing not enabled, consider all devices "paired"
422+ }
423+ return _pairingManager! .isPaired (deviceId);
184424 }
185425
186426 /// Handle task submission from mobile client
@@ -360,3 +600,14 @@ class MobileTaskSubmission {
360600 };
361601 }
362602}
603+
604+ /// Represents a confirmation response from mobile
605+ class ConfirmationResponse {
606+ final String requestId;
607+ final bool approved;
608+
609+ ConfirmationResponse ({
610+ required this .requestId,
611+ required this .approved,
612+ });
613+ }
0 commit comments