import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'ctaphid_channel.dart'; import 'enrollment_db.dart'; import 'filter_proxy.dart'; import 'fido2_ops.dart'; import 'k_server_client.dart'; import 'session_manager.dart'; const int kProxyPort = 8771; // Must match SessionManager._ttl; used only in the API response payload. const int _kSessionTtlSeconds = 300; const String kNotificationChannelId = 'kphone_proxy'; const String kNotificationChannelName = 'k_phone proxy service'; // --------------------------------------------------------------------------- // Top-level entry points — required by flutter_background_service isolate // --------------------------------------------------------------------------- @pragma('vm:entry-point') Future onIosBackground(ServiceInstance service) async => true; @pragma('vm:entry-point') void onServiceStart(ServiceInstance service) async { final proxy = _ProxyServer(service); service.on('stop').listen((_) async { await proxy.stop(); service.stopSelf(); }); await proxy.start(); } // --------------------------------------------------------------------------- // Service bootstrap (called from main()) // --------------------------------------------------------------------------- @pragma('vm:entry-point') class ProxyService { static Future initialize() async { final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); const channel = AndroidNotificationChannel( kNotificationChannelId, kNotificationChannelName, description: 'Shows when the ChromeCard proxy is running', importance: Importance.low, ); await flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation() ?.createNotificationChannel(channel); final service = FlutterBackgroundService(); await service.configure( androidConfiguration: AndroidConfiguration( onStart: onServiceStart, autoStart: true, isForegroundMode: true, notificationChannelId: kNotificationChannelId, initialNotificationTitle: 'k_phone proxy', initialNotificationContent: 'Starting…', foregroundServiceNotificationId: 1, ), iosConfiguration: IosConfiguration( autoStart: false, onForeground: onServiceStart, onBackground: onIosBackground, ), ); } } // --------------------------------------------------------------------------- // Proxy server (runs inside the background service isolate) // --------------------------------------------------------------------------- class _ProxyServer { final ServiceInstance _service; HttpServer? _server; final FilterProxy _filterProxy = FilterProxy(); final SessionManager _sessions = SessionManager(); final EnrollmentDb _db = EnrollmentDb(); final KServerClient _kserver = KServerClient(); int? _cardCid; bool _cardAttached = false; bool _running = false; _ProxyServer(this._service); void _emit(String msg) { _service.invoke('status', { 'running': _running, 'cardAttached': _cardAttached, 'message': msg, 'log': '[${DateTime.now().toIso8601String()}] $msg', }); } Future start() async { _running = true; _emit('Starting proxy on :$kProxyPort'); _filterProxy.onLog = _emit; try { await _filterProxy.seedDefaultGatedHosts(); await _filterProxy.loadGatedHosts(); await _filterProxy.start(); } catch (e) { _emit('Filter proxy failed to start: $e'); } // Card detection and DB loading are independent — run in parallel. await Future.wait([_tryOpenCard(), _db.ensureLoaded()]); SecurityContext? tlsCtx; try { tlsCtx = await _loadTlsContext(); } catch (_) { _emit('No TLS certs found — running plain HTTP (dev mode)'); } try { if (tlsCtx != null) { _server = await HttpServer.bindSecure(InternetAddress.anyIPv4, kProxyPort, tlsCtx); } else { _server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort); } _emit('Listening on :$kProxyPort'); _server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e')); } catch (e) { _emit('FATAL: Could not bind :$kProxyPort — $e'); _running = false; } } Future stop() async { _running = false; try { await _filterProxy.stop(); await _server?.close(force: true); await closeCard(); } finally { _emit('Stopped'); } } // ------------------------------------------------------------------------- // Request dispatch // ------------------------------------------------------------------------- Future _handleRequest(HttpRequest req) async { final path = req.uri.path; _emit('${req.method} $path'); try { if (req.method == 'GET') { switch (path) { case '/': await _serveHtml(req); case '/enroll': await _serveEnrollHtml(req); case '/health': await _handleHealth(req); case '/enroll/list': await _handleEnrollList(req); default: if (path.startsWith('/enroll/status')) { await _handleEnrollStatus(req); } else { await _send(req.response, 404, {'ok': false, 'error': 'not found'}); } } } else if (req.method == 'POST') { switch (path) { case '/enroll/register': await _handleEnrollRegister(req); case '/enroll/update': await _handleEnrollUpdate(req); case '/enroll/delete': await _handleEnrollDelete(req); case '/session/login': await _handleSessionLogin(req); case '/session/status': await _handleSessionStatus(req); case '/session/logout': await _handleSessionLogout(req); case '/resource/counter': await _handleResourceCounter(req); default: await _send(req.response, 404, {'ok': false, 'error': 'not found'}); } } else if (req.method == 'CONNECT') { await _handleConnect(req); } else { await _send(req.response, 405, {'ok': false, 'error': 'method not allowed'}); } } catch (e) { _emit('Error handling $path: $e'); try { await _send(req.response, 500, {'ok': false, 'error': 'internal error'}); } catch (_) {} } } // ------------------------------------------------------------------------- // Enrollment endpoints // ------------------------------------------------------------------------- Future _handleEnrollRegister(HttpRequest req) async { final body = await _readJson(req); if (body == null) return; final rawUsername = body['username'] as String? ?? ''; final rawDisplay = body['display_name'] as String?; String canonical; String? pretty; try { canonical = normalizeUsername(rawUsername); pretty = normalizeDisplayName(rawDisplay); } on ArgumentError catch (e) { await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); return; } MakeCredentialResult? credential; if (_cardAttached && _cardCid != null) { try { credential = await makeCredential(_cardCid!, canonical, displayName: pretty); } catch (e) { await _send(req.response, 401, {'ok': false, 'error': 'card registration failed: $e'}); return; } } try { final enrollment = await _db.register( username: canonical, displayName: pretty, userIdB64: credential?.userIdB64, credentialDataB64: credential?.credentialDataB64, ); final users = await _db.list(); await _send(req.response, 200, { ..._enrollmentPayload(enrollment, created: true), 'users': users.map(_userSummary).toList(), }); } on StateError { await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'}); } } Future _handleEnrollUpdate(HttpRequest req) async { final body = await _readJson(req); if (body == null) return; final rawUsername = body['username'] as String? ?? ''; final rawDisplay = body['display_name'] as String?; String canonical; String? pretty; try { canonical = normalizeUsername(rawUsername); pretty = normalizeDisplayName(rawDisplay); } on ArgumentError catch (e) { await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); return; } try { final enrollment = await _db.update(username: canonical, displayName: pretty); await _send(req.response, 200, _enrollmentPayload(enrollment)); } on StateError { await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'}); } } Future _handleEnrollDelete(HttpRequest req) async { final body = await _readJson(req); if (body == null) return; final rawUsername = body['username'] as String? ?? ''; String canonical; try { canonical = normalizeUsername(rawUsername); } on ArgumentError catch (e) { await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); return; } try { final enrollment = await _db.delete(canonical); _sessions.revokeAll(canonical); final users = await _db.list(); await _send(req.response, 200, { 'ok': true, 'username': enrollment.username, 'deleted': true, 'users': users.map(_userSummary).toList(), }); } on StateError { await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'}); } } Future _handleEnrollStatus(HttpRequest req) async { final username = req.uri.queryParameters['username'] ?? ''; if (username.isEmpty) { await _send(req.response, 400, {'ok': false, 'error': 'username query required'}); return; } final enrollment = await _db.get(username); if (enrollment == null) { await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled', 'username': username}); return; } await _send(req.response, 200, _enrollmentPayload(enrollment)); } Future _handleEnrollList(HttpRequest req) async { final users = await _db.list(); await _send(req.response, 200, { 'ok': true, 'users': users.map(_userSummary).toList(), }); } // ------------------------------------------------------------------------- // Session endpoints // ------------------------------------------------------------------------- Future _handleSessionLogin(HttpRequest req) async { final body = await _readJson(req); if (body == null) return; final rawUsername = body['username'] as String? ?? ''; String canonical; try { canonical = normalizeUsername(rawUsername); } on ArgumentError catch (e) { await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); return; } final enrollment = await _db.get(canonical); if (enrollment == null) { await _send(req.response, 403, {'ok': false, 'error': 'user not enrolled', 'username': canonical}); return; } if (enrollment.hasCredential && _cardCid != null) { // FIDO2-direct: getAssertion + verify GetAssertionResult assertionResult; try { assertionResult = await getAssertion(_cardCid!, enrollment.credentialDataB64!); } catch (e) { await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': e.toString()}); return; } final ok = verifyAssertion( enrollment.credentialDataB64!, assertionResult.authData, assertionResult.signature, assertionResult.clientDataHash, ); if (!ok) { await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'signature verification failed'}); return; } } else if (!_cardAttached) { await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'no card attached'}); return; } // else: probe-mode enrollment (no FIDO2 credential stored) and card is // physically attached — card presence is the only factor, accept the login. final token = _sessions.issue(canonical); final session = _sessions.getSession(token)!; final expiresAt = session.expires.millisecondsSinceEpoch ~/ 1000; final authMode = enrollment.hasCredential ? 'fido2_assertion' : 'card_presence_probe'; await _send(req.response, 200, { 'ok': true, 'username': canonical, 'session_token': token, 'expires_at': expiresAt, 'ttl_seconds': _kSessionTtlSeconds, 'auth_mode': authMode, }); } Future _handleSessionStatus(HttpRequest req) async { await _drainBody(req); final token = _extractBearerToken(req); if (token == null) { await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'}); return; } final session = _sessions.getSession(token); if (session == null) { await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'}); return; } final expiresAt = session.expires.millisecondsSinceEpoch ~/ 1000; final secondsRemaining = session.expires.difference(DateTime.now()).inSeconds.clamp(0, 99999); await _send(req.response, 200, { 'ok': true, 'username': session.username, 'expires_at': expiresAt, 'seconds_remaining': secondsRemaining, }); } Future _handleSessionLogout(HttpRequest req) async { await _drainBody(req); final token = _extractBearerToken(req); if (token == null) { await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'}); return; } final wasValid = _sessions.isValid(token); _sessions.revoke(token); await _send(req.response, 200, {'ok': true, 'invalidated': wasValid}); } // ------------------------------------------------------------------------- // CONNECT tunnel (gated HTTPS) // ------------------------------------------------------------------------- Future _handleConnect(HttpRequest req) async { if (!_sessions.hasAnyActiveSession()) { await _send(req.response, 407, {'ok': false, 'error': 'authentication required'}); return; } // Extract host:port from the Host header (CONNECT target is "host:port") final hostHeader = req.headers.value('host') ?? ''; final lastColon = hostHeader.lastIndexOf(':'); String connectHost; int connectPort; if (lastColon > 0) { connectHost = hostHeader.substring(0, lastColon); connectPort = int.tryParse(hostHeader.substring(lastColon + 1)) ?? 443; } else { connectHost = hostHeader; connectPort = 443; } if (connectHost.isEmpty) { await _send(req.response, 400, {'ok': false, 'error': 'missing CONNECT target'}); return; } Socket upstream; try { upstream = await Socket.connect(connectHost, connectPort) .timeout(const Duration(seconds: 10)); } catch (e) { _emit('CONNECT $connectHost:$connectPort failed: $e'); await _send(req.response, 502, {'ok': false, 'error': 'cannot connect to upstream'}); return; } final clientSocket = await req.response.detachSocket(writeHeaders: false); clientSocket.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n')); await clientSocket.flush(); clientSocket.listen( upstream.add, onDone: upstream.destroy, onError: (_) => upstream.destroy(), cancelOnError: true, ); upstream.listen( clientSocket.add, onDone: clientSocket.destroy, onError: (_) => clientSocket.destroy(), cancelOnError: true, ); } // ------------------------------------------------------------------------- // Resource forwarding // ------------------------------------------------------------------------- Future _handleResourceCounter(HttpRequest req) async { await _drainBody(req); final token = _extractBearerToken(req); if (token == null) { await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'}); return; } final session = _sessions.getSession(token); if (session == null) { await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'}); return; } final result = await _kserver.forward( method: 'POST', path: '/resource/counter', headers: req.headers, body: Uint8List(0), ); if (result.statusCode != 200) { await _send(req.response, result.statusCode, {'ok': false, 'error': 'upstream failed'}); return; } Map upstream; try { upstream = jsonDecode(utf8.decode(result.body)) as Map; } catch (_) { upstream = {}; } await _send(req.response, 200, { 'ok': true, 'username': session.username, 'session_reused': true, 'upstream': upstream, }); } // ------------------------------------------------------------------------- // Health + HTML // ------------------------------------------------------------------------- Future _handleHealth(HttpRequest req) async { await _send(req.response, 200, { 'ok': true, 'service': 'k_phone', 'card': _cardAttached, 'active_sessions': 0, 'time': DateTime.now().millisecondsSinceEpoch ~/ 1000, }); } Future _serveHtml(HttpRequest req) async { req.response.statusCode = 200; req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8'); req.response.headers.contentLength = _kPortalHtmlBytes.length; req.response.add(_kPortalHtmlBytes); await req.response.close(); } Future _serveEnrollHtml(HttpRequest req) async { req.response.statusCode = 200; req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8'); req.response.headers.contentLength = _kEnrollHtmlBytes.length; req.response.add(_kEnrollHtmlBytes); await req.response.close(); } // ------------------------------------------------------------------------- // Card management // ------------------------------------------------------------------------- Future _tryOpenCard() async { try { _cardAttached = await openCard(); if (!_cardAttached) { _emit('No USB card — trying emulator bridge on 10.0.2.2:8772'); useEmulator(host: '10.0.2.2'); _cardAttached = await openCard(); } if (_cardAttached) { _cardCid = await ctaphidInit(); _emit('Card open, CID=0x${_cardCid!.toRadixString(16)}'); } else { _emit('No card and no emulator bridge — card operations unavailable'); } } catch (e) { _emit('Card open failed: $e'); _cardAttached = false; } } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- String? _extractBearerToken(HttpRequest req) { final auth = req.headers.value('authorization') ?? ''; if (!auth.startsWith('Bearer ')) return null; final token = auth.substring(7).trim(); return token.isEmpty ? null : token; } Future?> _readJson(HttpRequest req) async { try { final bb = BytesBuilder(copy: false); await for (final chunk in req) { bb.add(chunk); } final bytes = bb.takeBytes(); if (bytes.isEmpty) return {}; return jsonDecode(utf8.decode(bytes)) as Map; } catch (_) { await _send(req.response, 400, {'ok': false, 'error': 'invalid json'}); return null; } } Future _drainBody(HttpRequest req) async { await req.fold(null, (_, __) {}); } Future _send(HttpResponse res, int status, Map body) async { final encoded = utf8.encode(jsonEncode(body)); res.statusCode = status; res.headers.contentType = ContentType.json; res.headers.contentLength = encoded.length; res.add(encoded); await res.close(); } // Compact user entry for embedded lists in register/delete/list responses. Map _userSummary(Enrollment e) => { 'username': e.username, 'display_name': e.displayName, 'has_credential': e.hasCredential, }; Map _enrollmentPayload(Enrollment e, {bool? created}) { final m = { 'ok': true, 'username': e.username, 'display_name': e.displayName, 'created_at': e.createdAt, 'updated_at': e.updatedAt, 'has_credential': e.hasCredential, }; if (created != null) m['created'] = created; return m; } Future _loadTlsContext() async { throw UnimplementedError('TLS cert loading not yet wired up'); } } // --------------------------------------------------------------------------- // Portal HTML (mirrors k_proxy_app.py HTML) // --------------------------------------------------------------------------- final _kPortalHtmlBytes = utf8.encode(_kPortalHtml); final _kEnrollHtmlBytes = utf8.encode(_kEnrollHtml); const String _kPortalHtml = ''' ChromeCard k_phone Portal

ChromeCard k_phone Portal

Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.

Enrollment

Stored username: none
Session active: no

Session Flow


  
'''; // --------------------------------------------------------------------------- // Enrollment / Registration HTML (GET /enroll) // --------------------------------------------------------------------------- const String _kEnrollHtml = ''' ChromeCard — Registration

ChromeCard — User Registration

Registered users

Loading…

Register new user

''';