828 lines
33 KiB
Dart
828 lines
33 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 '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<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();
|
|
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');
|
|
|
|
_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<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 _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 {
|
|
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;
|
|
}
|
|
|
|
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 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);
|
|
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 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<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,
|
|
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
});
|
|
}
|
|
|
|
Future<void> _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<void> _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<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 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<SecurityContext> _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 = '''<!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>''';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Enrollment / Registration HTML (GET /enroll)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const String _kEnrollHtml = '''<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>ChromeCard — Registration</title>
|
|
<style>
|
|
:root{--g:#0c6a60;--r:#dc2626;--bg:#f5f4f1;--panel:#fff;--line:#e0dbd3;--muted:#6b6560}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:#181614;padding:2rem 1rem}
|
|
main{max-width:520px;margin:0 auto;display:grid;gap:2rem}
|
|
h1{font-size:1.25rem;font-weight:700}
|
|
h2{font-size:.75rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.6rem}
|
|
/* user list */
|
|
#userList{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
#userList table{width:100%;border-collapse:collapse}
|
|
#userList td{padding:.65rem 1rem;border-bottom:1px solid var(--line);vertical-align:middle}
|
|
#userList tr:last-child td{border-bottom:none}
|
|
.uname{font-weight:600;font-size:.95rem}
|
|
.udisp{display:block;font-size:.78rem;color:var(--muted);margin-top:1px}
|
|
.badge{font-size:.68rem;font-weight:700;letter-spacing:.04em;padding:2px 7px;border-radius:3px;white-space:nowrap}
|
|
.fido2{background:#d1fae5;color:#065f46}
|
|
.probe{background:#fef3c7;color:#92400e}
|
|
.btn-del{background:none;border:1px solid var(--r);color:var(--r);padding:3px 10px;border-radius:4px;cursor:pointer;font:.82rem system-ui,sans-serif}
|
|
.btn-del:hover{background:var(--r);color:#fff}
|
|
.empty{padding:1.2rem 1rem;color:var(--muted);font-size:.9rem}
|
|
/* form */
|
|
form{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:1rem;display:grid;gap:.55rem}
|
|
label{font-size:.8rem;color:var(--muted)}
|
|
input{width:100%;padding:.5rem .7rem;border:1px solid var(--line);border-radius:4px;font:inherit}
|
|
input:focus{outline:2px solid var(--g);border-color:transparent}
|
|
#regBtn{padding:.55rem 1rem;background:var(--g);color:#fff;border:none;border-radius:4px;cursor:pointer;font:inherit;font-weight:600;justify-self:start;margin-top:.2rem}
|
|
#regBtn:disabled{opacity:.5;cursor:default}
|
|
/* status */
|
|
#msg{font-size:.85rem;min-height:1.3em;padding:.25rem 0}
|
|
#msg.ok{color:#065f46}
|
|
#msg.err{color:var(--r)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>ChromeCard — User Registration</h1>
|
|
|
|
<section>
|
|
<h2>Registered users</h2>
|
|
<div id="userList"><div class="empty">Loading…</div></div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Register new user</h2>
|
|
<form id="regForm">
|
|
<label for="uname">Username</label>
|
|
<input id="uname" placeholder="alice" autocomplete="off" required>
|
|
<label for="dname">Display name (optional)</label>
|
|
<input id="dname" placeholder="Alice Example" autocomplete="off">
|
|
<button type="submit" id="regBtn">Register — touch card fingerprint</button>
|
|
</form>
|
|
<div id="msg"></div>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
var listEl=document.getElementById("userList"),
|
|
regForm=document.getElementById("regForm"),
|
|
unameEl=document.getElementById("uname"),
|
|
dnameEl=document.getElementById("dname"),
|
|
regBtn=document.getElementById("regBtn"),
|
|
msgEl=document.getElementById("msg");
|
|
|
|
function setMsg(t,ok){msgEl.textContent=t;msgEl.className=ok?"ok":"err";}
|
|
function clearMsg(){msgEl.textContent="";msgEl.className="";}
|
|
|
|
function renderUsers(users){
|
|
if(!users||!users.length){listEl.innerHTML='<div class="empty">No users registered yet</div>';return;}
|
|
var rows=users.map(function(u){
|
|
var disp=u.display_name?('<span class="udisp">'+u.display_name+'</span>'):'';
|
|
var mode=u.has_credential?'fido2':'probe';
|
|
var label=u.has_credential?'FIDO2':'probe';
|
|
return '<tr>'
|
|
+'<td><span class="uname">'+u.username+'</span>'+disp+'</td>'
|
|
+'<td><span class="badge '+mode+'">'+label+'</span></td>'
|
|
+'<td><button class="btn-del" data-u="'+u.username+'">Delete</button></td>'
|
|
+'</tr>';
|
|
}).join("");
|
|
listEl.innerHTML="<table><tbody>"+rows+"</tbody></table>";
|
|
listEl.querySelectorAll(".btn-del").forEach(function(b){
|
|
b.addEventListener("click",function(){del(b.dataset.u);});
|
|
});
|
|
}
|
|
|
|
async function loadUsers(){
|
|
try{
|
|
var r=await fetch("/enroll/list"),d=await r.json();
|
|
renderUsers(d.users||[]);
|
|
}catch(e){listEl.innerHTML='<div class="empty">Could not load users</div>';}
|
|
}
|
|
|
|
async function del(username){
|
|
if(!confirm('Delete user "'+username+'"?'))return;
|
|
clearMsg();
|
|
try{
|
|
var r=await fetch("/enroll/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username})});
|
|
var d=await r.json();
|
|
if(!r.ok)throw new Error(d.error||"Delete failed");
|
|
renderUsers(d.users||[]);
|
|
setMsg('"'+username+'" deleted.',true);
|
|
}catch(e){setMsg(e.message,false);}
|
|
}
|
|
|
|
regForm.addEventListener("submit",async function(e){
|
|
e.preventDefault();clearMsg();
|
|
var username=unameEl.value.trim();
|
|
var display_name=dnameEl.value.trim()||undefined;
|
|
regBtn.disabled=true;regBtn.textContent="Waiting for card fingerprint…";
|
|
try{
|
|
var r=await fetch("/enroll/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username,display_name:display_name})});
|
|
var d=await r.json();
|
|
if(!r.ok)throw new Error(d.error||"Registration failed");
|
|
renderUsers(d.users||[]);
|
|
setMsg('"'+d.username+'" registered ('+(d.has_credential?"FIDO2":"probe mode")+').',true);
|
|
unameEl.value="";dnameEl.value="";
|
|
}catch(e){setMsg(e.message,false);}
|
|
finally{regBtn.disabled=false;regBtn.textContent="Register — touch card fingerprint";}
|
|
});
|
|
|
|
loadUsers();
|
|
</script>
|
|
</body>
|
|
</html>''';
|