k_card/k_phone/lib/proxy_service.dart

656 lines
22 KiB
Dart

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<bool> 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<void> initialize() async {
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
const channel = AndroidNotificationChannel(
kNotificationChannelId,
kNotificationChannelName,
description: 'Shows when the ChromeCard proxy is running',
importance: Importance.low,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.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<void> 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<void> stop() async {
_running = false;
try {
await _filterProxy.stop();
await _server?.close(force: true);
await closeCard();
} finally {
_emit('Stopped');
}
}
// -------------------------------------------------------------------------
// Request dispatch
// -------------------------------------------------------------------------
Future<void> _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<void> _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;
await _ensureCardOpen();
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<void> _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<void> _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<void> _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<void> _handleEnrollList(HttpRequest req) async {
final users = await _db.list();
await _send(req.response, 200, {
'ok': true,
'users': users.map(_userSummary).toList(),
});
}
// -------------------------------------------------------------------------
// Session endpoints
// -------------------------------------------------------------------------
Future<void> _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;
}
await _ensureCardOpen();
if (enrollment.hasCredential && _cardCid != null) {
// FIDO2-direct: getAssertion + local verify.
// Random challenge is intentional here: session login only proves the
// user CAN authenticate (user-presence check). The resulting session token
// is for portal access. Per-request resource binding (challenge = SHA256
// of url|method|nonce) happens in _handleAuthGetToken, not here.
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<void> _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<void> _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<void> _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 — domain-level token binding)
//
// Component 1 (filter_proxy) and Component 3 (Go binary) call this with
// {host, nonce} for each gated request. A fresh FIDO2 assertion is
// produced with challenge = SHA256(host|nonce). The self-contained
// assertion bundle is returned as a base64url Bearer token the server can
// verify without calling back to this service.
// -------------------------------------------------------------------------
Future<void> _handleAuthGetToken(HttpRequest req) async {
final body = await _readJson(req);
if (body == null) return;
final host = body['host'] as String? ?? '';
final nonce = body['nonce'] as String? ?? '';
if (host.isEmpty || nonce.isEmpty) {
await _send(req.response, 400, {'ok': false, 'error': 'host, nonce required'});
return;
}
await _ensureCardOpen();
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(host | "|" | nonce)
final challenge = Uint8List.fromList(
sha256.convert(utf8.encode('$host|$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,
'host': host,
'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<void> _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<void> _serveHtmlBytes(HttpRequest req, List<int> 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<void> _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;
}
}
/// Re-open the card if the socket has been closed since startup.
/// Called before card operations so a bridge restart doesn't require an app restart.
Future<void> _ensureCardOpen() async {
if (!_cardAttached || _cardCid == null || !(await isCardAttached())) {
_emit('Card not open — reconnecting...');
_cardAttached = false;
_cardCid = null;
await _tryOpenCard();
}
}
// -------------------------------------------------------------------------
// 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<Map<String, dynamic>?> _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<String, dynamic>;
} catch (_) {
await _send(req.response, 400, {'ok': false, 'error': 'invalid json'});
return null;
}
}
Future<void> _drainBody(HttpRequest req) async {
await req.fold<void>(null, (_, __) {});
}
Future<void> _send(HttpResponse res, int status, Map<String, dynamic> 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<String, dynamic> _userSummary(Enrollment e) => {
'username': e.username,
'display_name': e.displayName,
'has_credential': e.hasCredential,
};
Map<String, dynamic> _enrollmentPayload(Enrollment e, {bool? created}) {
final m = <String, dynamic>{
'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<String?> _parseUsername(HttpRequest req, Map<String, dynamic> 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<String, dynamic> 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;
}
}
}