k_card/k_phone/lib/proxy_service.dart

653 lines
26 KiB
Dart

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 'fido2_ops.dart';
import 'k_server_client.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 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<void> start() async {
_running = true;
_emit('Starting proxy on :$kProxyPort');
await _tryOpenCard();
await _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<void> stop() async {
_running = false;
await _server?.close(force: true);
await closeCard();
_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 _serveHtml(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 {
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 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;
}
if (_cardAttached && _cardCid != null) {
// FIDO2-direct mode: run makeCredential on the card
MakeCredentialResult result;
try {
result = 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: result.userIdB64,
credentialDataB64: result.credentialDataB64,
);
await _send(req.response, 200, _enrollmentPayload(enrollment, created: true));
} on StateError {
await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'});
}
return;
} else {
// Probe mode: metadata-only enrollment
try {
final enrollment = await _db.register(username: canonical, displayName: pretty);
await _send(req.response, 200, _enrollmentPayload(enrollment, created: true));
} 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 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<void> _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);
await _send(req.response, 200, {'ok': true, 'username': enrollment.username, 'deleted': true});
} 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(_enrollmentPayload).toList(),
});
}
// -------------------------------------------------------------------------
// Session endpoints
// -------------------------------------------------------------------------
Future<void> _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, card is attached — accept
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': 300,
'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});
}
// -------------------------------------------------------------------------
// Resource forwarding
// -------------------------------------------------------------------------
Future<void> _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<String, dynamic> upstream;
try {
upstream = jsonDecode(utf8.decode(result.body)) as Map<String, dynamic>;
} catch (_) {
upstream = {};
}
await _send(req.response, 200, {
'ok': true,
'username': session.username,
'session_reused': true,
'upstream': upstream,
});
}
// -------------------------------------------------------------------------
// Health + HTML
// -------------------------------------------------------------------------
Future<void> _handleHealth(HttpRequest req) async {
await _send(req.response, 200, {
'ok': true,
'service': 'k_phone',
'card': _cardAttached,
'active_sessions': 0, // SessionManager doesn't expose count; good enough
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
});
}
Future<void> _serveHtml(HttpRequest req) async {
final data = utf8.encode(_kPortalHtml);
req.response.statusCode = 200;
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
req.response.headers.contentLength = data.length;
req.response.add(data);
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;
}
}
// -------------------------------------------------------------------------
// 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 bytes = await req.fold<List<int>>([], (acc, chunk) => acc..addAll(chunk));
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();
}
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<SecurityContext> _loadTlsContext() async {
throw UnimplementedError('TLS cert loading not yet wired up');
}
}
// ---------------------------------------------------------------------------
// Portal HTML (mirrors k_proxy_app.py HTML)
// ---------------------------------------------------------------------------
const String _kPortalHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard k_phone Portal</title>
<style>
:root {
--bg: #f1eee8; --panel: #fffdf8; --ink: #171615; --muted: #645f56;
--line: #d6cbb9; --accent: #0c6a60; --accent-2: #8e5b2d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background: radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
}
main { max-width: 900px; margin: 0 auto; padding: 32px 20px 56px; }
.hero, .card { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 16px 34px rgba(49,38,21,0.08); }
.hero { padding: 24px; margin-bottom: 20px; }
h1 { margin: 0 0 10px; font-size: clamp(2rem,4vw,3.5rem); line-height: 0.95; letter-spacing: -0.04em; }
.subtitle { margin: 0; color: var(--muted); max-width: 64ch; }
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.card { padding: 18px; }
.card h2 { margin: 0 0 12px; font-size: 1.15rem; }
label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--line); background: #fff; font: inherit; color: var(--ink); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
button { border: 0; padding: 10px 14px; font: inherit; color: #fff; background: var(--accent); cursor: pointer; }
button.secondary { background: var(--accent-2); }
.status { display: grid; gap: 8px; margin-top: 14px; color: var(--muted); }
pre { margin: 18px 0 0; min-height: 300px; padding: 16px; overflow: auto; border: 1px solid var(--line); background: #141210; color: #efe6d8; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9rem; line-height: 1.45; }
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard k_phone Portal</h1>
<p class="subtitle">Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.</p>
</section>
<section class="grid">
<div class="card">
<h2>Enrollment</h2>
<label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off">
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="Alice Example" autocomplete="off">
<div class="actions">
<button id="enrollBtn">Enroll User</button>
<button id="updateBtn" class="secondary">Update User</button>
<button id="deleteBtn" class="secondary">Delete User</button>
<button id="checkBtn" class="secondary">Check Enrollment</button>
<button id="listBtn" class="secondary">List Users</button>
</div>
<div class="status">
<div>Stored username: <strong id="storedUser">none</strong></div>
<div>Session active: <strong id="sessionActive">no</strong></div>
</div>
</div>
<div class="card">
<h2>Session Flow</h2>
<div class="actions">
<button id="loginBtn">Login</button>
<button id="statusBtn" class="secondary">Status</button>
<button id="counterBtn">Counter</button>
<button id="logoutBtn" class="secondary">Logout</button>
</div>
</div>
</section>
<pre id="log"></pre>
</main>
<script>
const USER_KEY="chromecard.proxy.username", TOKEN_KEY="chromecard.proxy.session_token", EXP_KEY="chromecard.proxy.expires_at";
const logNode=document.getElementById("log"), usernameNode=document.getElementById("username"),
displayNameNode=document.getElementById("displayName"), storedUserNode=document.getElementById("storedUser"),
sessionActiveNode=document.getElementById("sessionActive");
function getStoredUser(){return localStorage.getItem(USER_KEY)||"";}
function getStoredToken(){return localStorage.getItem(TOKEN_KEY)||"";}
function syncState(){const u=getStoredUser();storedUserNode.textContent=u||"none";sessionActiveNode.textContent=getStoredToken()?"yes":"no";if(u&&!usernameNode.value)usernameNode.value=u;}
function log(msg,payload){const stamp=new Date().toLocaleTimeString();let line=`[\${stamp}] \${msg}`;if(payload!==undefined)line+="\\n"+JSON.stringify(payload,null,2);logNode.textContent=line+"\\n\\n"+logNode.textContent;}
async function jsonRequest(method,path,payload,withToken=false){const headers={"Content-Type":"application/json"};if(withToken&&getStoredToken())headers["Authorization"]="Bearer "+getStoredToken();const resp=await fetch(path,{method,headers,body:payload===undefined?undefined:JSON.stringify(payload)});const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));return data;}
document.getElementById("enrollBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/register",{username:usernameNode.value.trim(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,usernameNode.value.trim());syncState();log("Enrolled",data);}catch(err){log("Enroll failed",{error:err.message});}});
document.getElementById("checkBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const resp=await fetch("/enroll/status?username="+encodeURIComponent(u));const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Enrollment status",data);if(data.display_name)displayNameNode.value=data.display_name;}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("updateBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/update",{username:usernameNode.value.trim()||getStoredUser(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,data.username);syncState();log("Updated",data);}catch(err){log("Update failed",{error:err.message});}});
document.getElementById("deleteBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/enroll/delete",{username:u});if(getStoredUser()===u){localStorage.removeItem(USER_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);}displayNameNode.value="";syncState();log("Deleted",data);}catch(err){log("Delete failed",{error:err.message});}});
document.getElementById("listBtn").addEventListener("click",async()=>{try{const resp=await fetch("/enroll/list");const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Users",data);}catch(err){log("List failed",{error:err.message});}});
document.getElementById("loginBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/session/login",{username:u});localStorage.setItem(USER_KEY,u);localStorage.setItem(TOKEN_KEY,data.session_token||"");localStorage.setItem(EXP_KEY,String(data.expires_at||""));syncState();log("Login ok",data);}catch(err){log("Login failed",{error:err.message});}});
document.getElementById("statusBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/status",{},true);log("Session status",data);}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("counterBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/resource/counter",{},true);log("Counter",data);}catch(err){log("Counter failed",{error:err.message});}});
document.getElementById("logoutBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/logout",{},true);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);syncState();log("Logout",data);}catch(err){log("Logout failed",{error:err.message});}});
syncState();
</script>
</body>
</html>''';