import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; 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 'portal_html.dart'; import 'session_manager.dart'; const int kProxyPort = 8771; 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(); 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()]); _emit('No TLS certs found — running plain HTTP (dev mode)'); try { _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 _serveHtmlBytes(req, kPortalHtmlBytes); case '/enroll': await _serveHtmlBytes(req, kEnrollHtmlBytes); 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 '/auth/get-token': await _handleAuthGetToken(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 r = await _parseUsernameAndDisplay(req, body); if (r == null) return; final (canonical, pretty) = r; 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 r = await _parseUsernameAndDisplay(req, body); if (r == null) return; final (canonical, pretty) = r; 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 canonical = await _parseUsername(req, body); if (canonical == null) 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 canonical = await _parseUsername(req, body); if (canonical == null) 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': SessionManager.ttlSeconds, '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, ); } // ------------------------------------------------------------------------- // Auth token endpoint (v2 architecture — per-request token binding) // // Component 1 (filter_proxy) and Component 3 (Go binary) call this with // {url, method, nonce} for each gated request. A fresh FIDO2 assertion is // produced with challenge = SHA256(url|method|nonce). The self-contained // assertion bundle is returned as a base64url Bearer token the server can // verify without calling back to this service. // ------------------------------------------------------------------------- Future _handleAuthGetToken(HttpRequest req) async { final body = await _readJson(req); if (body == null) return; final url = body['url'] as String? ?? ''; final method = body['method'] as String? ?? ''; final nonce = body['nonce'] as String? ?? ''; if (url.isEmpty || method.isEmpty || nonce.isEmpty) { await _send(req.response, 400, {'ok': false, 'error': 'url, method, nonce required'}); return; } if (!_cardAttached || _cardCid == null) { await _send(req.response, 503, {'ok': false, 'error': 'card not available'}); return; } // Find first enrolled user with a FIDO2 credential. final users = await _db.list(); Enrollment? enrolled; for (final u in users) { if (u.hasCredential) { enrolled = u; break; } } if (enrolled == null) { await _send(req.response, 401, {'ok': false, 'error': 'no enrolled credential'}); return; } // Challenge = SHA256(url | "|" | method | "|" | nonce) final challenge = Uint8List.fromList( sha256.convert(utf8.encode('$url|$method|$nonce')).bytes, ); GetAssertionResult assertionResult; try { assertionResult = await getAssertion(_cardCid!, enrolled.credentialDataB64!, challenge: challenge); } catch (e) { await _send(req.response, 401, {'ok': false, 'error': 'card assertion failed: $e'}); return; } // Self-contained bundle the server can verify without calling back to the phone. final bundleJson = jsonEncode({ 'v': 1, 'url': url, 'method': method, 'nonce': nonce, 'authData': base64Url.encode(assertionResult.authData).replaceAll('=', ''), 'sig': base64Url.encode(assertionResult.signature).replaceAll('=', ''), 'cdj': base64Url.encode(utf8.encode(assertionResult.clientDataJson)).replaceAll('=', ''), 'cred': enrolled.credentialDataB64, 'user': enrolled.username, }); final token = base64Url.encode(utf8.encode(bundleJson)).replaceAll('=', ''); await _send(req.response, 200, {'ok': true, 'token': token, 'username': enrolled.username}); } // ------------------------------------------------------------------------- // 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 _serveHtmlBytes(HttpRequest req, List bytes) async { req.response.statusCode = 200; req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8'); req.response.headers.contentLength = bytes.length; req.response.add(bytes); 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 _parseUsername(HttpRequest req, Map body) async { try { return normalizeUsername(body['username'] as String? ?? ''); } on ArgumentError catch (e) { await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); return null; } } Future<(String, String?)?> _parseUsernameAndDisplay( HttpRequest req, Map body) async { try { return ( normalizeUsername(body['username'] as String? ?? ''), normalizeDisplayName(body['display_name'] as String?), ); } on ArgumentError catch (e) { await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); return null; } } }