From 1124a7f5a966afe8baf2d038015acacafa02986e Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 2 May 2026 20:10:54 +0200 Subject: [PATCH] Phase 9: add Component 1 (filter_proxy), tests, session gate, doc update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - k_phone/lib/filter_proxy.dart: Component 1 — raw-socket HTTP proxy with gating filter; gated hosts relay to Component 2, others go direct - k_phone/lib/session_manager.dart: add hasAnyActiveSession() for the personal-device gated-proxy authorization model - k_phone/test/filter_proxy_test.dart: full test suite for Component 1 - k_phone/test/enrollment_test.dart: full test suite for EnrollmentDb - k_phone/integration_test/registration_login_test.dart: emulator integration test - Misc k_phone lib fixes (ctaphid_channel, fido2_ops, proxy_service, main, enrollment_db, k_server_client) and pubspec/Gradle updates - CLAUDE.md + Workplan.md: document Component 1, k_phone module map, gated terminology (replacing "allowlist"), pending CONNECT handler in Component 2 Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 46 +- Workplan.md | 45 +- .../plugins/GeneratedPluginRegistrant.java | 5 + .../registration_login_test.dart | 123 +++++ k_phone/lib/ctaphid_channel.dart | 11 +- k_phone/lib/enrollment_db.dart | 16 +- k_phone/lib/fido2_ops.dart | 13 +- k_phone/lib/filter_proxy.dart | 484 ++++++++++++++++++ k_phone/lib/k_server_client.dart | 3 + k_phone/lib/main.dart | 106 +++- k_phone/lib/proxy_service.dart | 249 +++++++-- k_phone/lib/session_manager.dart | 10 + k_phone/pubspec.lock | 39 ++ k_phone/pubspec.yaml | 2 + k_phone/test/enrollment_test.dart | 240 +++++++++ k_phone/test/filter_proxy_test.dart | 419 +++++++++++++++ 16 files changed, 1753 insertions(+), 58 deletions(-) create mode 100644 k_phone/integration_test/registration_login_test.dart create mode 100644 k_phone/lib/filter_proxy.dart create mode 100644 k_phone/test/enrollment_test.dart create mode 100644 k_phone/test/filter_proxy_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index d12c863..8c820a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,33 @@ Files are deployed to VMs via `scp :~` and run via `ssh e.username == 'alice'), isTrue); + debugPrint('[REGISTRATION] OK: "alice" found in enrollment list (${list.length} total)'); + + debugPrint('[REGISTRATION] Verifying duplicate registration is rejected...'); + try { + await db.register(username: 'alice'); + fail('Expected StateError for duplicate username'); + } on StateError catch (e) { + debugPrint('[REGISTRATION] OK: duplicate rejected — ${e.message}'); + } + + debugPrint('[REGISTRATION] COMPLETE'); + }); + + testWidgets('delete user removes from enrollment list', (tester) async { + await db.register(username: 'bob', displayName: 'Bob Testuser'); + debugPrint('[REGISTRATION] Enrolled "bob"'); + + final deleted = await db.delete('bob'); + debugPrint('[REGISTRATION] DELETE: removed user="${deleted.username}"'); + + final found = await db.get('bob'); + expect(found, isNull); + debugPrint('[REGISTRATION] OK: "bob" no longer in database'); + }); + }); + + group('Login flow', () { + final session = SessionManager(); + + testWidgets('issue and validate session token', (tester) async { + debugPrint('[LOGIN] Starting: issuing session for "alice"'); + + final token = session.issue('alice'); + debugPrint('[LOGIN] Token issued: ${token.substring(0, 8)}... (${token.length} chars)'); + + final valid = session.isValid(token); + debugPrint('[LOGIN] Session valid: $valid'); + expect(valid, isTrue); + + final entry = session.getSession(token); + debugPrint('[LOGIN] Session entry: username="${entry?.username}" ' + 'expires=${entry?.expires.toIso8601String()}'); + + debugPrint('[LOGIN] COMPLETE'); + }); + + testWidgets('revoke session invalidates token', (tester) async { + final token = session.issue('alice'); + debugPrint('[LOGIN] Token issued: ${token.substring(0, 8)}...'); + + session.revoke(token); + debugPrint('[LOGIN] Token revoked'); + + final valid = session.isValid(token); + debugPrint('[LOGIN] Session valid after revoke: $valid'); + expect(valid, isFalse); + debugPrint('[LOGIN] OK: revoked token correctly rejected'); + }); + + testWidgets('revokeAll removes all sessions for user', (tester) async { + final t1 = session.issue('charlie'); + final t2 = session.issue('charlie'); + debugPrint('[LOGIN] Issued 2 sessions for "charlie"'); + + session.revokeAll('charlie'); + debugPrint('[LOGIN] revokeAll("charlie") called'); + + expect(session.isValid(t1), isFalse); + expect(session.isValid(t2), isFalse); + debugPrint('[LOGIN] OK: both sessions for "charlie" invalidated'); + }); + + testWidgets('unknown token is rejected', (tester) async { + debugPrint('[LOGIN] Testing unknown token...'); + final valid = session.isValid('0000000000000000000000000000000000000000000000000000000000000000'); + debugPrint('[LOGIN] Unknown token valid: $valid'); + expect(valid, isFalse); + debugPrint('[LOGIN] OK: unknown token correctly rejected'); + }); + }); +} diff --git a/k_phone/lib/ctaphid_channel.dart b/k_phone/lib/ctaphid_channel.dart index a1fd061..ceb031c 100644 --- a/k_phone/lib/ctaphid_channel.dart +++ b/k_phone/lib/ctaphid_channel.dart @@ -42,6 +42,8 @@ Socket? _emulatorSocket; // Persistent read state for the emulator TCP socket. // Socket is a single-subscription stream — we must subscribe exactly once // and accumulate all incoming bytes into a buffer. +// _emulatorRxWaiter is replaced on each call to _receivePacket so that +// concurrent waiters don't share a Completer and accidentally wake each other. StreamSubscription>? _emulatorSub; final _emulatorRxBuf = []; Completer? _emulatorRxWaiter; @@ -203,7 +205,12 @@ Future _ctaphidRoundtrip(int cid, int cmd, Uint8List data) async { return await _reassembleResponse(first, cid); } - // USB: platform channel returns one response per send; keepalive loop as before. + // USB: platform channel returns one response per send. + // Limitation: keepalives and continuation packets after the last request + // packet call _receivePacket(), which only works in emulator mode. + // In practice this is safe because CTAP2 responses for typical credential + // sizes fit in a single init packet and the card does not send keepalives + // synchronously before the response to the last request packet. Uint8List lastReceived = Uint8List(kHidPacketSize); for (final pkt in requestPackets) { lastReceived = await _sendPacket(pkt); @@ -268,7 +275,7 @@ Future _reassembleResponse(Uint8List initPacket, int expectedCid) asy var received = firstChunk; while (received < payloadLen) { - final contPacket = _emulatorMode ? await _receivePacket() : await _receivePacket(); + final contPacket = await _receivePacket(); // USB continuation unimplemented — see _ctaphidRoundtrip if (_isKeepalive(contPacket)) continue; _checkCid(contPacket, expectedCid); final chunk = min(payloadLen - received, kHidPacketSize - 5); diff --git a/k_phone/lib/enrollment_db.dart b/k_phone/lib/enrollment_db.dart index 0768842..2adc761 100644 --- a/k_phone/lib/enrollment_db.dart +++ b/k_phone/lib/enrollment_db.dart @@ -76,6 +76,7 @@ class Enrollment { factory Enrollment.fromJson(Map m) { final username = (m['username'] as String? ?? '').trim(); + // 'enrolled_at' was the field name in the Python k_proxy JSON schema; accept both for portability. final createdAt = m['created_at'] as int? ?? m['enrolled_at'] as int? ?? _nowSecs(); return Enrollment( username: username, @@ -95,11 +96,16 @@ int _nowSecs() => DateTime.now().millisecondsSinceEpoch ~/ 1000; // --------------------------------------------------------------------------- class EnrollmentDb { + // [baseDir] can be injected in tests to bypass path_provider. + EnrollmentDb({Directory? baseDir}) : _baseDir = baseDir; + + final Directory? _baseDir; final Map _entries = {}; bool _loaded = false; // Dart isolates are single-threaded so there is no data race on _entries. - // We still serialize async disk I/O with a simple future chain. + // We still serialize async disk I/O: each _serialize call chains its op + // onto _pending so concurrent callers queue up rather than interleave. Future? _pending; Future _serialize(Future Function() op) async { @@ -113,7 +119,7 @@ class EnrollmentDb { if (prev != null) { try { await prev; - } catch (_) {} + } catch (_) {} // previous op error must not block the queue } await op(); } @@ -123,7 +129,7 @@ class EnrollmentDb { // ------------------------------------------------------------------------- Future _dbFile() async { - final dir = await getApplicationSupportDirectory(); + final dir = _baseDir ?? await getApplicationSupportDirectory(); return File('${dir.path}/k_phone_enrollments.json'); } @@ -132,7 +138,7 @@ class EnrollmentDb { _loaded = true; try { final f = await _dbFile(); - if (!f.existsSync()) return; + if (!f.existsSync()) return; // no file = fresh install; start with empty DB final raw = jsonDecode(await f.readAsString()) as Map; final users = raw['users'] as List? ?? []; for (final item in users) { @@ -140,7 +146,7 @@ class EnrollmentDb { if (e.username.isNotEmpty) _entries[e.username] = e; } } catch (_) { - _entries.clear(); + _entries.clear(); // treat a corrupt/unreadable DB as empty; next save overwrites it } } diff --git a/k_phone/lib/fido2_ops.dart b/k_phone/lib/fido2_ops.dart index f1e8f81..34c2b4e 100644 --- a/k_phone/lib/fido2_ops.dart +++ b/k_phone/lib/fido2_ops.dart @@ -91,6 +91,9 @@ Future makeCredential( }), ]), CborSmallInt(7): CborMap({ + // rk=false: non-resident — credential ID is stored externally in EnrollmentDb + // rather than on the card, so multiple users can enroll on one card. + // uv=false: no PIN; authentication uses user-presence (fingerprint touch) only. CborString('rk'): CborBool(false), CborString('uv'): CborBool(false), }), @@ -134,8 +137,8 @@ Future getAssertion( }), ]), CborSmallInt(5): CborMap({ - CborString('up'): CborBool(true), - CborString('uv'): CborBool(false), + CborString('up'): CborBool(true), // require fingerprint touch (user presence) + CborString('uv'): CborBool(false), // no PIN }), }); @@ -171,6 +174,7 @@ bool verifyAssertion( final coseKey = _extractCoseKey(credData); final pubKey = _coseKeyToEcPublicKey(coseKey); + // CTAP2/WebAuthn spec: the signed message is authData || SHA-256(clientDataJSON). final message = Uint8List(authData.length + clientDataHash.length) ..setRange(0, authData.length, authData) ..setRange(authData.length, authData.length + clientDataHash.length, clientDataHash); @@ -268,7 +272,9 @@ int _cborInt(CborValue v) { (BigInt, BigInt) _decodeDerSignature(Uint8List der) { // SEQUENCE { INTEGER r, INTEGER s } if (der[0] != 0x30) throw FormatException('DER signature: expected SEQUENCE tag'); - var offset = 2; // skip 0x30 + length + // P-256 signatures are always ≤72 bytes, so the SEQUENCE length fits in one byte. + // Multi-byte BER length encoding (0x81/0x82 prefix) is not handled here. + var offset = 2; // skip 0x30 + one-byte length if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for r'); final rLen = der[offset + 1]; final rBytes = der.sublist(offset + 2, offset + 2 + rLen); @@ -330,6 +336,7 @@ String _b64uEncode(Uint8List data) { } Uint8List _b64uDecode(String s) { + // base64url strips trailing '='; restore padding to the nearest multiple of 4. final padded = s + '=' * ((4 - s.length % 4) % 4); return Uint8List.fromList(base64Url.decode(padded)); } diff --git a/k_phone/lib/filter_proxy.dart b/k_phone/lib/filter_proxy.dart new file mode 100644 index 0000000..fca3a0a --- /dev/null +++ b/k_phone/lib/filter_proxy.dart @@ -0,0 +1,484 @@ +// Component 1 — HTTP proxy with URL gating filter. +// +// All browser traffic enters here. The routing rule is a single binary decision: +// gated host → relay through Component 2 on localhost:_component2Port +// other host → forward directly to the target host:port +// +// "Gated hosts" are resources that require FIDO2 card authentication before +// they can be accessed. Traffic to them is relayed through Component 2, which +// checks for an active session before forwarding. +// +// Gated hosts file (gated_hosts.txt in the app documents directory): one entry +// per line, either "host" or "host:port". Lines starting with "#" and blank +// lines are ignored. +// +// Example gated_hosts.txt: +// # External test resource (requires card login) +// httpbin.org +// +// For HTTPS (CONNECT) traffic to gated hosts this proxy sends a CONNECT request +// to Component 2 and waits for its 200/4xx response before responding to the +// browser. This lets Component 2 enforce the session check before the TLS +// tunnel is established; the raw TLS bytes are never exposed to Component 2. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +const int kFilterProxyPort = 8888; +const int kComponent2Port = 8771; +const String _kGatedHostsFilename = 'gated_hosts.txt'; +const int _kMaxHeaderBytes = 64 * 1024; + +final _kBytesConnectOk = utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'); + +class FilterProxy { + FilterProxy({ + int listenPort = kFilterProxyPort, + int component2Port = kComponent2Port, + }) : _listenPort = listenPort, + _component2Port = component2Port; + + final int _listenPort; + final int _component2Port; + final Set _gatedHosts = {}; + ServerSocket? _server; + + void Function(String)? onLog; + + // The actual bound port — valid after start() returns. + int get port => _server?.port ?? _listenPort; + + // Populate the gated hosts directly without file I/O. + // Call this before start() in tests; in production call loadGatedHosts() instead. + void setGatedEntries(Iterable entries) { + _gatedHosts + ..clear() + ..addAll(entries.map((e) => e.trim().toLowerCase())); + } + + // Exposed for unit tests. + bool isGatedForTest(String host, int port) => _isGated(host, port); + + // Creates the default gated_hosts.txt (containing httpbin.org) if the file + // does not already exist. Call before loadGatedHosts() during startup. + Future seedDefaultGatedHosts() async { + try { + final dir = await getApplicationDocumentsDirectory(); + final file = File('${dir.path}/$_kGatedHostsFilename'); + if (!file.existsSync()) { + await file.writeAsString( + '# Gated hosts — traffic to these is forwarded through Component 2,\n' + '# which requires an active card session before relaying.\n' + '#\n' + '# One entry per line: "host" or "host:port".\n' + 'httpbin.org\n', + ); + _log('Created default $_kGatedHostsFilename with httpbin.org'); + } + } catch (e) { + _log('Could not seed default $_kGatedHostsFilename: $e'); + } + } + + Future loadGatedHosts() async { + _gatedHosts.clear(); + try { + final dir = await getApplicationDocumentsDirectory(); + final file = File('${dir.path}/$_kGatedHostsFilename'); + var count = 0; + for (final raw in await file.readAsLines()) { + final entry = raw.trim().toLowerCase(); + if (entry.isEmpty || entry.startsWith('#')) continue; + _gatedHosts.add(entry); + count++; + } + _log('Gated hosts loaded: $count ${count == 1 ? 'entry' : 'entries'}'); + } on FileSystemException { + _log('No $_kGatedHostsFilename — all traffic forwarded directly to target'); + } catch (e) { + _log('Gated hosts load error: $e'); + } + } + + bool _isGated(String host, int port) { + final h = host.toLowerCase(); + return _gatedHosts.contains(h) || _gatedHosts.contains('$h:$port'); + } + + // start() does NOT call loadGatedHosts(). Callers are responsible: + // production: await proxy.seedDefaultGatedHosts(); + // await proxy.loadGatedHosts(); + // await proxy.start(); + // tests: proxy.setGatedEntries([...]); await proxy.start(); + Future start() async { + _server = await ServerSocket.bind(InternetAddress.anyIPv4, _listenPort); + _log('Filter proxy listening on :${_server!.port}'); + _server!.listen( + (client) => _handle(client).catchError((e) { + _log('Connection error: $e'); + try { client.destroy(); } catch (_) {} + }), + onError: (e) => _log('Server error: $e'), + ); + } + + Future stop() async { + await _server?.close(); + _server = null; + _log('Filter proxy stopped'); + } + + // --------------------------------------------------------------------------- + // Per-connection handler + // --------------------------------------------------------------------------- + + Future _handle(Socket client) async { + final buf = []; + int headerEnd = -1; + late StreamSubscription> sub; + final headersReady = Completer(); + + sub = client.listen( + (data) { + if (headersReady.isCompleted) return; + buf.addAll(data); + if (buf.length > _kMaxHeaderBytes) { + sub.cancel(); + headersReady.complete(); + return; + } + // Scan only the overlap between previous and new data to avoid O(n²). + // Must look back 3 bytes into the previous chunk when scanning for \r\n\r\n. + final searchFrom = (buf.length - data.length - 3).clamp(0, buf.length); + for (int i = searchFrom; i <= buf.length - 4; i++) { + if (buf[i] == 13 && buf[i + 1] == 10 && buf[i + 2] == 13 && buf[i + 3] == 10) { + headerEnd = i; + break; + } + } + if (headerEnd >= 0) { + sub.pause(); + headersReady.complete(); + } + }, + onError: (e) { if (!headersReady.isCompleted) headersReady.completeError(e); }, + onDone: () { if (!headersReady.isCompleted) headersReady.complete(); }, + cancelOnError: true, + ); + + try { + await headersReady.future.timeout(const Duration(seconds: 15)); + } on TimeoutException { + sub.cancel(); + client.destroy(); + return; + } + + if (headerEnd < 0) { + sub.cancel(); + client.destroy(); + return; + } + + final headerText = String.fromCharCodes(buf.sublist(0, headerEnd)); + final remainder = buf.sublist(headerEnd + 4); + final lines = headerText.split('\r\n'); + final requestParts = lines[0].trim().split(' '); + if (requestParts.length < 2) { + sub.cancel(); + client.destroy(); + return; + } + + final method = requestParts[0].toUpperCase(); + final target = requestParts[1]; + + if (method == 'CONNECT') { + final (:host, :port) = _parseHostPort(target); + if (_isGated(host, port)) { + await _handleGatedConnect(client, sub, target, remainder); + } else { + await _handleDirectConnect(client, sub, host, port, remainder); + } + } else { + await _handleHttp(client, sub, method, target, lines.sublist(1), remainder); + } + } + + // --------------------------------------------------------------------------- + // Direct CONNECT tunnel (non-gated hosts) + // --------------------------------------------------------------------------- + + Future _handleDirectConnect( + Socket client, + StreamSubscription> sub, + String host, + int port, + List remainder, + ) async { + Socket upstream; + try { + upstream = await Socket.connect(host, port).timeout(const Duration(seconds: 10)); + } catch (e) { + _deny(client, sub, 502, 'Bad Gateway'); + return; + } + + client.add(_kBytesConnectOk); + + if (remainder.isNotEmpty) upstream.add(remainder); + + upstream.listen( + client.add, + onDone: client.destroy, + onError: (_) { upstream.destroy(); client.destroy(); }, + cancelOnError: true, + ); + + sub.onData(upstream.add); + sub.onDone(upstream.destroy); + sub.onError((_) { upstream.destroy(); client.destroy(); }); + sub.resume(); + } + + // --------------------------------------------------------------------------- + // Gated CONNECT tunnel — relay CONNECT request through Component 2 + // + // We MUST NOT pipe raw TLS to Component 2's HttpServer. Instead we forward + // a CONNECT request to it, wait for its HTTP response (200 = auth OK, + // 403 = no active session, 502 = upstream error), and only then tell the + // browser whether the tunnel was established. + // --------------------------------------------------------------------------- + + Future _handleGatedConnect( + Socket client, + StreamSubscription> sub, + String target, + List remainder, + ) async { + Socket comp2; + try { + comp2 = await Socket.connect('127.0.0.1', _component2Port) + .timeout(const Duration(seconds: 5)); + } catch (e) { + _deny(client, sub, 502, 'Bad Gateway'); + return; + } + + // Forward the CONNECT request to Component 2. + comp2.add(utf8.encode('CONNECT $target HTTP/1.1\r\nHost: $target\r\n\r\n')); + + // Read Component 2's response headers. + final respBuf = []; + int respHeaderEnd = -1; + final respReady = Completer(); + late StreamSubscription> comp2Sub; + + comp2Sub = comp2.listen( + (data) { + if (respReady.isCompleted) return; + respBuf.addAll(data); + if (respBuf.length > _kMaxHeaderBytes) { + comp2Sub.pause(); + respReady.complete(); + return; + } + final searchFrom = (respBuf.length - data.length - 3).clamp(0, respBuf.length); + for (int i = searchFrom; i <= respBuf.length - 4; i++) { + if (respBuf[i] == 13 && respBuf[i + 1] == 10 && + respBuf[i + 2] == 13 && respBuf[i + 3] == 10) { + respHeaderEnd = i; + break; + } + } + if (respHeaderEnd >= 0) { + comp2Sub.pause(); + respReady.complete(); + } + }, + onError: (e) { if (!respReady.isCompleted) respReady.completeError(e); }, + onDone: () { if (!respReady.isCompleted) respReady.complete(); }, + cancelOnError: true, + ); + + try { + await respReady.future.timeout(const Duration(seconds: 10)); + } on TimeoutException { + comp2Sub.cancel(); + comp2.destroy(); + _deny(client, sub, 504, 'Gateway Timeout'); + return; + } catch (_) { + comp2Sub.cancel(); + comp2.destroy(); + _deny(client, sub, 502, 'Bad Gateway'); + return; + } + + if (respHeaderEnd < 0) { + comp2Sub.cancel(); + comp2.destroy(); + _deny(client, sub, 502, 'Bad Gateway'); + return; + } + + // Parse the status code from Component 2's response. + final respText = String.fromCharCodes(respBuf.sublist(0, respHeaderEnd)); + final statusLine = respText.split('\r\n').first; + final statusParts = statusLine.split(' '); + final statusCode = statusParts.length >= 2 ? int.tryParse(statusParts[1]) ?? 0 : 0; + + if (statusCode != 200) { + comp2Sub.cancel(); + comp2.destroy(); + _deny(client, sub, + statusCode == 403 ? 403 : 502, + statusCode == 403 ? 'Forbidden' : 'Bad Gateway'); + return; + } + + // Tunnel is established. Any bytes Component 2 sent after the CONNECT + // headers are already tunneled data (rare but possible). + final comp2Remainder = respBuf.sublist(respHeaderEnd + 4); + + // Tell the browser the tunnel is open. + client.add(_kBytesConnectOk); + if (remainder.isNotEmpty) comp2.add(remainder); + if (comp2Remainder.isNotEmpty) client.add(comp2Remainder); + + // Pipe browser ↔ Component 2 ↔ upstream (Component 2 owns the upstream half). + comp2Sub.onData(client.add); + comp2Sub.onDone(client.destroy); + comp2Sub.onError((_) { comp2.destroy(); client.destroy(); }); + comp2Sub.resume(); + + sub.onData(comp2.add); + sub.onDone(comp2.destroy); + sub.onError((_) { comp2.destroy(); client.destroy(); }); + sub.resume(); + } + + // --------------------------------------------------------------------------- + // Plain HTTP request (both gated and non-gated use the same handler here — + // gating for plain HTTP is enforced by Component 2 when it receives the + // forwarded request and checks the Host header) + // --------------------------------------------------------------------------- + + Future _handleHttp( + Socket client, + StreamSubscription> sub, + String method, + String targetUrl, + List headerLines, + List remainder, + ) async { + Uri uri; + try { + uri = Uri.parse(targetUrl); + } catch (_) { + sub.cancel(); + client.destroy(); + return; + } + + final host = uri.host; + final port = uri.hasPort ? uri.port : 80; + final path = _relativePath(uri); + + int contentLength = 0; + for (final h in headerLines) { + if (h.toLowerCase().startsWith('content-length:')) { + contentLength = int.tryParse(h.split(':').last.trim()) ?? 0; + break; + } + } + + // For gated plain-HTTP hosts, route through Component 2; for others, direct. + final Socket upstream; + try { + if (_isGated(host, port)) { + upstream = await Socket.connect('127.0.0.1', _component2Port) + .timeout(const Duration(seconds: 5)); + } else { + upstream = await Socket.connect(host, port) + .timeout(const Duration(seconds: 10)); + } + } catch (e) { + _deny(client, sub, 502, 'Bad Gateway'); + return; + } + + final out = StringBuffer() + ..write('$method $path HTTP/1.1\r\n') + ..write('Host: ${uri.host}${uri.hasPort ? ':${uri.port}' : ''}\r\n'); + for (final h in headerLines) { + if (h.isEmpty) continue; + final lower = h.toLowerCase(); + if (lower.startsWith('host:') || + lower.startsWith('proxy-connection:') || + lower.startsWith('proxy-authorization:')) continue; + out.write('$h\r\n'); + } + out.write('Connection: close\r\n\r\n'); + + upstream.add(utf8.encode(out.toString())); + if (remainder.isNotEmpty) upstream.add(remainder); + + final bodyLeft = contentLength - remainder.length; + if (bodyLeft > 0) { + sub.onData(upstream.add); + sub.onDone(upstream.destroy); + sub.onError((_) { upstream.destroy(); client.destroy(); }); + sub.resume(); + } else { + sub.cancel(); + } + + final done = Completer(); + upstream.listen( + client.add, + // flush() drains the write buffer before closing; destroy() would drop it. + onDone: () { client.flush().whenComplete(client.destroy).whenComplete(() { if (!done.isCompleted) done.complete(); }); }, + onError: (_) { + upstream.destroy(); + client.destroy(); + if (!done.isCompleted) done.complete(); + }, + cancelOnError: true, + ); + await done.future; + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + void _deny(Socket client, StreamSubscription> sub, int code, String reason) { + sub.cancel(); + client.add(utf8.encode( + 'HTTP/1.1 $code $reason\r\nContent-Length: 0\r\nConnection: close\r\n\r\n', + )); + client.flush().then((_) => client.destroy()).catchError((_) => client.destroy()); + } + + // Parses "host:port" strings (e.g. CONNECT target). Uses [defaultPort] when no port present. + ({String host, int port}) _parseHostPort(String target, {int defaultPort = 443}) { + final colon = target.lastIndexOf(':'); + if (colon < 0) return (host: target, port: defaultPort); + return ( + host: target.substring(0, colon), + port: int.tryParse(target.substring(colon + 1)) ?? defaultPort, + ); + } + + // Converts a proxy-style absolute URI to a relative path+query string. + String _relativePath(Uri uri) { + final base = uri.path.isEmpty ? '/' : uri.path; + return uri.hasQuery ? '$base?${uri.query}' : base; + } + + void _log(String msg) => onLog?.call('[FilterProxy] $msg'); +} diff --git a/k_phone/lib/k_server_client.dart b/k_phone/lib/k_server_client.dart index 59d602a..200e319 100644 --- a/k_phone/lib/k_server_client.dart +++ b/k_phone/lib/k_server_client.dart @@ -72,6 +72,9 @@ class KServerClient { } bool _shouldForwardHeader(String name) { + // 'authorization' is intentionally stripped: it carries the k_phone session + // token which is meaningless to k_server. k_server authenticates via the + // X-Proxy-Token header added by the Kotlin layer, not by bearer tokens. const skip = {'host', 'connection', 'transfer-encoding', 'authorization'}; return !skip.contains(name.toLowerCase()); } diff --git a/k_phone/lib/main.dart b/k_phone/lib/main.dart index 6758967..28c2984 100644 --- a/k_phone/lib/main.dart +++ b/k_phone/lib/main.dart @@ -1,6 +1,10 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; +import 'enrollment_db.dart'; +import 'filter_proxy.dart'; import 'proxy_service.dart'; +import 'session_manager.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -36,6 +40,10 @@ class _ProxyStatusScreenState extends State { bool _cardAttached = false; String _statusMessage = 'Stopped'; final List _log = []; + // Debug-only test state — stripped in release builds via kDebugMode. + bool _testRunning = false; + final _db = EnrollmentDb(); + final _session = SessionManager(); @override void initState() { @@ -68,6 +76,71 @@ class _ProxyStatusScreenState extends State { }); } + void _addLog(String msg) { + setState(() { + _log.insert(0, msg); + if (_log.length > 200) _log.removeLast(); + }); + } + + Future _runRegistrationLoginTest() async { + if (_testRunning) return; + setState(() => _testRunning = true); + + try { + // --- Registrering --- + _addLog('[REGISTRATION] Starter: enrolling "testbruger"'); + try { + final enrollment = await _db.register( + username: 'testbruger', + displayName: 'Test Bruger', + ); + _addLog('[REGISTRATION] OK: bruger="${enrollment.username}" ' + 'displayName="${enrollment.displayName}"'); + } on StateError { + _addLog('[REGISTRATION] INFO: "testbruger" allerede enrollet — sletter og prøver igen'); + await _db.delete('testbruger'); + final enrollment = await _db.register( + username: 'testbruger', + displayName: 'Test Bruger', + ); + _addLog('[REGISTRATION] OK: bruger="${enrollment.username}" genregistreret'); + } + + final list = await _db.list(); + _addLog('[REGISTRATION] Enrollment-liste: ${list.map((e) => e.username).join(', ')}'); + + _addLog('[REGISTRATION] Test duplikat afvisning...'); + try { + await _db.register(username: 'testbruger'); + _addLog('[REGISTRATION] FEJL: duplikat burde være afvist!'); + } on StateError catch (e) { + _addLog('[REGISTRATION] OK: duplikat afvist — ${e.message}'); + } + + // --- Login --- + _addLog('[LOGIN] Udsteder session for "testbruger"...'); + final token = _session.issue('testbruger'); + _addLog('[LOGIN] Token: ${token.substring(0, 8)}... (${token.length} tegn)'); + + final valid = _session.isValid(token); + _addLog('[LOGIN] Session gyldig: $valid'); + + final entry = _session.getSession(token); + _addLog('[LOGIN] Udløber: ${entry?.expires.toLocal().toString().substring(0, 19)}'); + + _addLog('[LOGIN] Tilbagekalder session...'); + _session.revoke(token); + _addLog('[LOGIN] Session gyldig efter revoke: ${_session.isValid(token)}'); + + _addLog('[LOGIN] FÆRDIG — alle flows OK'); + } catch (e) { + _addLog('[FEJL] $e'); + } finally { + setState(() => _testRunning = false); + } + } + Future _toggleService() async { final service = FlutterBackgroundService(); final running = await service.isRunning(); @@ -96,9 +169,15 @@ class _ProxyStatusScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _StatusTile( - label: 'Proxy service', + label: 'Filter proxy (Comp 1)', ok: _serviceRunning, - value: _serviceRunning ? 'Running on :8771' : 'Stopped', + value: _serviceRunning ? 'Running on :$kFilterProxyPort' : 'Stopped', + ), + const SizedBox(height: 8), + _StatusTile( + label: 'Auth proxy (Comp 2)', + ok: _serviceRunning, + value: _serviceRunning ? 'Running on :$kProxyPort' : 'Stopped', ), const SizedBox(height: 8), _StatusTile( @@ -112,9 +191,26 @@ class _ProxyStatusScreenState extends State { style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 16), - FilledButton( - onPressed: _toggleService, - child: Text(_serviceRunning ? 'Stop proxy' : 'Start proxy'), + Row( + children: [ + FilledButton( + onPressed: _toggleService, + child: Text(_serviceRunning ? 'Stop proxy' : 'Start proxy'), + ), + if (kDebugMode) ...[ + const SizedBox(width: 12), + FilledButton.tonal( + onPressed: _testRunning ? null : _runRegistrationLoginTest, + child: _testRunning + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Kør registrering & login'), + ), + ], + ], ), const Divider(height: 32), const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)), diff --git a/k_phone/lib/proxy_service.dart b/k_phone/lib/proxy_service.dart index 955049c..57fa3ef 100644 --- a/k_phone/lib/proxy_service.dart +++ b/k_phone/lib/proxy_service.dart @@ -8,11 +8,14 @@ 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'; @@ -78,6 +81,7 @@ class ProxyService { class _ProxyServer { final ServiceInstance _service; HttpServer? _server; + final FilterProxy _filterProxy = FilterProxy(); final SessionManager _sessions = SessionManager(); final EnrollmentDb _db = EnrollmentDb(); final KServerClient _kserver = KServerClient(); @@ -100,8 +104,17 @@ class _ProxyServer { _running = true; _emit('Starting proxy on :$kProxyPort'); - await _tryOpenCard(); - await _db.ensureLoaded(); + _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 { @@ -126,9 +139,13 @@ class _ProxyServer { Future stop() async { _running = false; - await _server?.close(force: true); - await closeCard(); - _emit('Stopped'); + try { + await _filterProxy.stop(); + await _server?.close(force: true); + await closeCard(); + } finally { + _emit('Stopped'); + } } // ------------------------------------------------------------------------- @@ -144,6 +161,8 @@ class _ProxyServer { switch (path) { case '/': await _serveHtml(req); + case '/enroll': + await _serveEnrollHtml(req); case '/health': await _handleHealth(req); case '/enroll/list': @@ -206,35 +225,30 @@ class _ProxyServer { return; } + MakeCredentialResult? credential; if (_cardAttached && _cardCid != null) { - // FIDO2-direct mode: run makeCredential on the card - MakeCredentialResult result; try { - result = await makeCredential(_cardCid!, canonical, displayName: pretty); + 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: 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'}); - } + } + + 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'}); } } @@ -280,7 +294,13 @@ class _ProxyServer { try { final enrollment = await _db.delete(canonical); _sessions.revokeAll(canonical); - await _send(req.response, 200, {'ok': true, 'username': enrollment.username, 'deleted': true}); + 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'}); } @@ -304,7 +324,7 @@ class _ProxyServer { final users = await _db.list(); await _send(req.response, 200, { 'ok': true, - 'users': users.map(_enrollmentPayload).toList(), + 'users': users.map(_userSummary).toList(), }); } @@ -354,7 +374,8 @@ class _ProxyServer { await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'no card attached'}); return; } - // else: probe-mode enrollment, card is attached — accept + // 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)!; @@ -366,7 +387,7 @@ class _ProxyServer { 'username': canonical, 'session_token': token, 'expires_at': expiresAt, - 'ttl_seconds': 300, + 'ttl_seconds': _kSessionTtlSeconds, 'auth_mode': authMode, }); } @@ -458,17 +479,24 @@ class _ProxyServer { 'ok': true, 'service': 'k_phone', 'card': _cardAttached, - 'active_sessions': 0, // SessionManager doesn't expose count; good enough + 'active_sessions': 0, 'time': DateTime.now().millisecondsSinceEpoch ~/ 1000, }); } Future _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); + req.response.headers.contentLength = _kPortalHtmlBytes.length; + req.response.add(_kPortalHtmlBytes); + await req.response.close(); + } + + Future _serveEnrollHtml(HttpRequest req) async { + req.response.statusCode = 200; + req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8'); + req.response.headers.contentLength = _kEnrollHtmlBytes.length; + req.response.add(_kEnrollHtmlBytes); await req.response.close(); } @@ -509,7 +537,11 @@ class _ProxyServer { Future?> _readJson(HttpRequest req) async { try { - final bytes = await req.fold>([], (acc, chunk) => acc..addAll(chunk)); + 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 (_) { @@ -531,6 +563,13 @@ class _ProxyServer { 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, @@ -553,6 +592,9 @@ class _ProxyServer { // Portal HTML (mirrors k_proxy_app.py HTML) // --------------------------------------------------------------------------- +final _kPortalHtmlBytes = utf8.encode(_kPortalHtml); +final _kEnrollHtmlBytes = utf8.encode(_kEnrollHtml); + const String _kPortalHtml = ''' @@ -650,3 +692,136 @@ const String _kPortalHtml = ''' '''; + +// --------------------------------------------------------------------------- +// Enrollment / Registration HTML (GET /enroll) +// --------------------------------------------------------------------------- + +const String _kEnrollHtml = ''' + + + + + ChromeCard — Registration + + + +
+

ChromeCard — User Registration

+ +
+

Registered users

+
Loading…
+
+ +
+

Register new user

+
+ + + + + +
+
+
+
+ + +'''; diff --git a/k_phone/lib/session_manager.dart b/k_phone/lib/session_manager.dart index 06ba0e6..504b774 100644 --- a/k_phone/lib/session_manager.dart +++ b/k_phone/lib/session_manager.dart @@ -14,6 +14,8 @@ class SessionManager { static const Duration _ttl = Duration(seconds: 300); /// Issue a new session token for [username]. + /// _purgeExpired is only called here, not on every lookup, so tokens accumulate + /// until the next login — acceptable for the low-traffic embedded use case. String issue(String username) { _purgeExpired(); final token = _randomToken(); @@ -41,6 +43,14 @@ class SessionManager { /// Revoke [token] immediately. void revoke(String token) => _sessions.remove(token); + /// Returns true if at least one session is currently active (not expired). + /// Used by gated-proxy forwarding: personal-device model means any live + /// login counts as authorisation for the proxied request. + bool hasAnyActiveSession() { + _purgeExpired(); + return _sessions.isNotEmpty; + } + /// Revoke all sessions for [username]. void revokeAll(String username) { _sessions.removeWhere((_, s) => s.username == username); diff --git a/k_phone/pubspec.lock b/k_phone/pubspec.lock index 4f640c8..cc1744c 100644 --- a/k_phone/pubspec.lock +++ b/k_phone/pubspec.lock @@ -158,6 +158,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.2" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -195,6 +200,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -235,6 +245,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" jni: dependency: transitive description: @@ -435,6 +450,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.1" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" pub_semver: dependency: transitive description: @@ -488,6 +511,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -552,6 +583,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" xdg_directories: dependency: transitive description: diff --git a/k_phone/pubspec.yaml b/k_phone/pubspec.yaml index 5b5be3b..0d136cb 100644 --- a/k_phone/pubspec.yaml +++ b/k_phone/pubspec.yaml @@ -21,6 +21,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^3.0.0 flutter: diff --git a/k_phone/test/enrollment_test.dart b/k_phone/test/enrollment_test.dart new file mode 100644 index 0000000..c2f228b --- /dev/null +++ b/k_phone/test/enrollment_test.dart @@ -0,0 +1,240 @@ +// Tests for EnrollmentDb — verifies that users are created, listed, and +// deleted correctly on the phone. +// +// All tests use an injected temp directory so path_provider is not needed. +// +// Run: flutter test test/enrollment_test.dart + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../lib/enrollment_db.dart'; + +void main() { + late Directory tmp; + late EnrollmentDb db; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('enrollment_test_'); + db = EnrollmentDb(baseDir: tmp); + }); + + tearDown(() => tmp.delete(recursive: true)); + + // ------------------------------------------------------------------------- + // Registration + // ------------------------------------------------------------------------- + group('register', () { + test('creates probe-mode enrollment when no credential data provided', () async { + final e = await db.register(username: 'alice', displayName: 'Alice Example'); + + expect(e.username, 'alice'); + expect(e.displayName, 'Alice Example'); + expect(e.hasCredential, isFalse); + expect(e.credentialDataB64, isNull); + expect(e.userIdB64, isNull); + }); + + test('creates FIDO2 enrollment when credential data provided', () async { + final e = await db.register( + username: 'alice', + userIdB64: 'dXNlcklk', + credentialDataB64: 'Y3JlZERhdGE=', + ); + + expect(e.hasCredential, isTrue); + expect(e.credentialDataB64, 'Y3JlZERhdGE='); + expect(e.userIdB64, 'dXNlcklk'); + }); + + test('normalizes username to lowercase and trims whitespace', () async { + final e = await db.register(username: ' BOB '); + expect(e.username, 'bob'); + }); + + test('sets createdAt and updatedAt to current time', () async { + final before = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final e = await db.register(username: 'alice'); + final after = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + expect(e.createdAt, inInclusiveRange(before, after)); + expect(e.updatedAt, e.createdAt); + }); + + test('throws StateError on duplicate username', () async { + await db.register(username: 'alice'); + await expectLater( + db.register(username: 'alice'), + throwsA(isA()), + ); + }); + + test('duplicate check is case-insensitive', () async { + await db.register(username: 'alice'); + await expectLater( + db.register(username: 'ALICE'), + throwsA(isA()), + ); + }); + + test('rejects invalid username', () async { + // Two-char usernames are rejected by the regex (1 or 3–32 chars only). + await expectLater( + db.register(username: 'ab'), + throwsA(isA()), + ); + }); + + test('rejects username with special characters', () async { + await expectLater( + db.register(username: 'alice!'), + throwsA(isA()), + ); + }); + }); + + // ------------------------------------------------------------------------- + // List + // ------------------------------------------------------------------------- + group('list', () { + test('returns empty list when no users registered', () async { + expect(await db.list(), isEmpty); + }); + + test('returns all registered users sorted alphabetically', () async { + await db.register(username: 'charlie'); + await db.register(username: 'alice'); + await db.register(username: 'bob'); + + final names = (await db.list()).map((e) => e.username).toList(); + expect(names, ['alice', 'bob', 'charlie']); + }); + + test('reflects correct hasCredential for each user', () async { + await db.register(username: 'probe'); + await db.register( + username: 'fido', + userIdB64: 'dXNlcg==', + credentialDataB64: 'Y3JlZA==', + ); + + final list = await db.list(); + expect(list.firstWhere((e) => e.username == 'probe').hasCredential, isFalse); + expect(list.firstWhere((e) => e.username == 'fido').hasCredential, isTrue); + }); + }); + + // ------------------------------------------------------------------------- + // Delete + // ------------------------------------------------------------------------- + group('delete', () { + test('removes the user from the list', () async { + await db.register(username: 'alice'); + await db.delete('alice'); + + expect(await db.list(), isEmpty); + }); + + test('returns the deleted enrollment', () async { + await db.register(username: 'alice', displayName: 'Alice'); + final deleted = await db.delete('alice'); + + expect(deleted.username, 'alice'); + expect(deleted.displayName, 'Alice'); + }); + + test('only removes the target user, not others', () async { + await db.register(username: 'alice'); + await db.register(username: 'bob'); + await db.delete('alice'); + + final names = (await db.list()).map((e) => e.username).toList(); + expect(names, ['bob']); + }); + + test('throws StateError when user does not exist', () async { + await expectLater( + db.delete('nobody'), + throwsA(isA()), + ); + }); + }); + + // ------------------------------------------------------------------------- + // Persistence + // ------------------------------------------------------------------------- + group('persistence', () { + test('enrollments survive across new EnrollmentDb instances', () async { + await db.register(username: 'alice', displayName: 'Alice'); + await db.register(username: 'bob'); + + final db2 = EnrollmentDb(baseDir: tmp); + final names = (await db2.list()).map((e) => e.username).toList(); + expect(names, ['alice', 'bob']); + }); + + test('credential data survives reload', () async { + await db.register( + username: 'alice', + userIdB64: 'dXNlcg==', + credentialDataB64: 'Y3JlZA==', + ); + + final db2 = EnrollmentDb(baseDir: tmp); + final e = await db2.get('alice'); + expect(e, isNotNull); + expect(e!.hasCredential, isTrue); + expect(e.credentialDataB64, 'Y3JlZA=='); + }); + + test('deletion persists across new instances', () async { + await db.register(username: 'alice'); + await db.delete('alice'); + + final db2 = EnrollmentDb(baseDir: tmp); + expect(await db2.list(), isEmpty); + }); + + test('two concurrent writes both complete without corruption', () async { + // Simultaneous register calls must queue, not corrupt the file. + await Future.wait([ + db.register(username: 'alice'), + db.register(username: 'bob'), + db.register(username: 'charlie'), + ]); + + final names = (await db.list()).map((e) => e.username).toList(); + expect(names, containsAll(['alice', 'bob', 'charlie'])); + }); + }); + + // ------------------------------------------------------------------------- + // Update + // ------------------------------------------------------------------------- + group('update', () { + test('changes display name', () async { + await db.register(username: 'alice', displayName: 'Old Name'); + await db.update(username: 'alice', displayName: 'New Name'); + + final e = await db.get('alice'); + expect(e!.displayName, 'New Name'); + }); + + test('updatedAt advances after update', () async { + final e1 = await db.register(username: 'alice'); + await Future.delayed(const Duration(seconds: 1)); + await db.update(username: 'alice', displayName: 'Alice'); + final e2 = await db.get('alice'); + + expect(e2!.updatedAt, greaterThanOrEqualTo(e1.updatedAt)); + }); + + test('throws StateError when user does not exist', () async { + await expectLater( + db.update(username: 'nobody'), + throwsA(isA()), + ); + }); + }); +} diff --git a/k_phone/test/filter_proxy_test.dart b/k_phone/test/filter_proxy_test.dart new file mode 100644 index 0000000..2ad2487 --- /dev/null +++ b/k_phone/test/filter_proxy_test.dart @@ -0,0 +1,419 @@ +// Tests for Component 1 (FilterProxy). +// +// All tests are self-contained: they bind local servers and never hit the +// internet. Port 0 lets the OS assign a free port for each server. +// +// Run: flutter test test/filter_proxy_test.dart + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../lib/filter_proxy.dart'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const _kTimeout = Duration(seconds: 5); + +// Start an HttpServer that accepts one request, records it, and replies 200 OK. +// Returns the server and a Completer (use .future to await; .isCompleted to check). +Future<({HttpServer server, Completer completer})> _mockHttp() async { + final server = await HttpServer.bind('127.0.0.1', 0); + final c = Completer(); + server.listen((req) async { + await req.drain(); + req.response + ..statusCode = 200 + ..headers.set('content-type', 'text/plain') + ..headers.set('content-length', '2') + ..headers.set('connection', 'close') + ..write('OK'); + await req.response.close(); + if (!c.isCompleted) c.complete(req); + }); + return (server: server, completer: c); +} + +// Start a raw TCP server that hands back the accepted Socket. +Future<({ServerSocket server, Future socket})> _mockTcp() async { + final server = await ServerSocket.bind('127.0.0.1', 0); + final c = Completer(); + server.listen((sock) { if (!c.isCompleted) c.complete(sock); }); + return (server: server, socket: c.future); +} + +// Send [request] to [proxyPort] and collect the full response. +// Assumes the server closes the connection after the response. +Future _round(int proxyPort, String request) async { + final sock = await Socket.connect('127.0.0.1', proxyPort) + .timeout(_kTimeout); + sock.write(request); + await sock.flush(); + final buf = []; + await sock.listen(buf.addAll).asFuture().timeout(_kTimeout); + return utf8.decode(buf); +} + +// Reads from [client] until the CONNECT 200 response header block arrives. +Future _waitForConnectResponse(Socket client) async { + final buf = []; + final done = Completer(); + late StreamSubscription> sub; + sub = client.listen((d) { + buf.addAll(d); + if (!done.isCompleted && + utf8.decode(buf, allowMalformed: true).contains('\r\n\r\n')) { + done.complete(); + } + }); + await done.future.timeout(_kTimeout); + sub.cancel(); + return utf8.decode(buf); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // ------------------------------------------------------------------------- + // Group 1: gated host matching — unit tests, no network + // ------------------------------------------------------------------------- + group('gated host matching (unit)', () { + late FilterProxy proxy; + + setUp(() { + proxy = FilterProxy(component2Port: 0); + }); + + test('host-only entry matches any port', () { + proxy.setGatedEntries(['example.com']); + expect(proxy.isGatedForTest('example.com', 80), isTrue); + expect(proxy.isGatedForTest('example.com', 443), isTrue); + expect(proxy.isGatedForTest('example.com', 8771), isTrue); + }); + + test('host:port entry matches only that port', () { + proxy.setGatedEntries(['example.com:8771']); + expect(proxy.isGatedForTest('example.com', 8771), isTrue); + expect(proxy.isGatedForTest('example.com', 80), isFalse); + expect(proxy.isGatedForTest('example.com', 443), isFalse); + }); + + test('matching is case-insensitive', () { + proxy.setGatedEntries(['EXAMPLE.COM:8771']); + expect(proxy.isGatedForTest('example.com', 8771), isTrue); + expect(proxy.isGatedForTest('EXAMPLE.COM', 8771), isTrue); + }); + + test('different hostname is not matched', () { + proxy.setGatedEntries(['example.com']); + expect(proxy.isGatedForTest('other.com', 80), isFalse); + }); + + test('empty gated list matches nothing', () { + proxy.setGatedEntries([]); + expect(proxy.isGatedForTest('example.com', 80), isFalse); + }); + + test('partial hostname is not matched', () { + proxy.setGatedEntries(['example.com']); + expect(proxy.isGatedForTest('sub.example.com', 80), isFalse); + }); + + test('setGatedEntries replaces previous entries', () { + proxy.setGatedEntries(['first.com']); + proxy.setGatedEntries(['second.com']); + expect(proxy.isGatedForTest('first.com', 80), isFalse); + expect(proxy.isGatedForTest('second.com', 80), isTrue); + }); + }); + + // ------------------------------------------------------------------------- + // Group 2: HTTP routing + // ------------------------------------------------------------------------- + group('HTTP routing', () { + late FilterProxy proxy; + late HttpServer comp2; + late Completer comp2Req; + late HttpServer direct; + late Completer directReq; + + setUp(() async { + final c2 = await _mockHttp(); + comp2 = c2.server; + comp2Req = c2.completer; + + final d = await _mockHttp(); + direct = d.server; + directReq = d.completer; + + proxy = FilterProxy( + listenPort: 0, + component2Port: comp2.port, + ); + // 'auth.local' is gated; '127.0.0.1' is not. + proxy.setGatedEntries(['auth.local']); + await proxy.start(); + }); + + tearDown(() async { + await proxy.stop(); + await comp2.close(force: true); + await direct.close(force: true); + }); + + test('gated host is forwarded to component2', () async { + final response = await _round( + proxy.port, + 'GET http://auth.local/api HTTP/1.1\r\nHost: auth.local\r\n\r\n', + ); + final req = await comp2Req.future.timeout(_kTimeout); + + expect(req.method, 'GET'); + expect(req.uri.path, '/api'); + expect(response, contains('200 OK')); + }); + + test('non-gated host is forwarded directly', () async { + final response = await _round( + proxy.port, + 'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n' + 'Host: 127.0.0.1:${direct.port}\r\n\r\n', + ); + final req = await directReq.future.timeout(_kTimeout); + + expect(req.method, 'GET'); + expect(req.uri.path, '/page'); + expect(response, contains('200 OK')); + }); + + test('non-gated request does NOT reach component2', () async { + await _round( + proxy.port, + 'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n' + 'Host: 127.0.0.1:${direct.port}\r\n\r\n', + ); + await directReq.future.timeout(_kTimeout); + + // comp2 should never have received anything + expect(comp2Req.isCompleted, isFalse); + }); + + test('request line is rewritten from absolute URL to relative path', () async { + await _round( + proxy.port, + 'GET http://auth.local/session/login?foo=bar HTTP/1.1\r\n' + 'Host: auth.local\r\n\r\n', + ); + final req = await comp2Req.future.timeout(_kTimeout); + // The mock HttpServer parses the rewritten request. + expect(req.uri.path, '/session/login'); + expect(req.uri.query, 'foo=bar'); + }); + + test('Proxy-Connection header is stripped', () async { + await _round( + proxy.port, + 'GET http://auth.local/health HTTP/1.1\r\n' + 'Host: auth.local\r\n' + 'Proxy-Connection: keep-alive\r\n\r\n', + ); + final req = await comp2Req.future.timeout(_kTimeout); + expect(req.headers.value('proxy-connection'), isNull); + }); + + test('Proxy-Authorization header is stripped', () async { + await _round( + proxy.port, + 'GET http://auth.local/health HTTP/1.1\r\n' + 'Host: auth.local\r\n' + 'Proxy-Authorization: Basic dXNlcjpwYXNz\r\n\r\n', + ); + final req = await comp2Req.future.timeout(_kTimeout); + expect(req.headers.value('proxy-authorization'), isNull); + }); + + test('custom header is preserved', () async { + await _round( + proxy.port, + 'GET http://auth.local/health HTTP/1.1\r\n' + 'Host: auth.local\r\n' + 'X-Custom: hello\r\n\r\n', + ); + final req = await comp2Req.future.timeout(_kTimeout); + expect(req.headers.value('x-custom'), 'hello'); + }); + + test('POST body is forwarded to component2', () async { + const body = '{"username":"alice"}'; + await _round( + proxy.port, + 'POST http://auth.local/session/login HTTP/1.1\r\n' + 'Host: auth.local\r\n' + 'Content-Type: application/json\r\n' + 'Content-Length: ${body.length}\r\n\r\n' + '$body', + ); + final req = await comp2Req.future.timeout(_kTimeout); + expect(req.method, 'POST'); + expect(req.uri.path, '/session/login'); + }); + }); + + // ------------------------------------------------------------------------- + // Group 3: CONNECT tunnel routing + // ------------------------------------------------------------------------- + group('CONNECT routing', () { + late FilterProxy proxy; + late ServerSocket comp2Tcp; + late Future comp2Conn; + late ServerSocket directTcp; + late Future directConn; + + setUp(() async { + final c2 = await _mockTcp(); + comp2Tcp = c2.server; + comp2Conn = c2.socket; + + final d = await _mockTcp(); + directTcp = d.server; + directConn = d.socket; + + proxy = FilterProxy( + listenPort: 0, + component2Port: comp2Tcp.port, + ); + proxy.setGatedEntries(['auth.local:443']); + await proxy.start(); + }); + + tearDown(() async { + await proxy.stop(); + await comp2Tcp.close(); + await directTcp.close(); + }); + + test('gated CONNECT returns 200 and tunnels to component2', () async { + final client = await Socket.connect('127.0.0.1', proxy.port) + .timeout(_kTimeout); + client.write('CONNECT auth.local:443 HTTP/1.1\r\nHost: auth.local:443\r\n\r\n'); + await client.flush(); + + final response = await _waitForConnectResponse(client); + expect(response, contains('200 Connection Established')); + + // Verify the tunnel endpoint is component2 + await comp2Conn.timeout(_kTimeout); + client.destroy(); + }); + + test('non-gated CONNECT returns 200 and tunnels to direct target', () async { + final client = await Socket.connect('127.0.0.1', proxy.port) + .timeout(_kTimeout); + // Use 127.0.0.1:${directTcp.port} as the CONNECT target (not gated) + client.write( + 'CONNECT 127.0.0.1:${directTcp.port} HTTP/1.1\r\n' + 'Host: 127.0.0.1:${directTcp.port}\r\n\r\n'); + await client.flush(); + + final response = await _waitForConnectResponse(client); + expect(response, contains('200 Connection Established')); + await directConn.timeout(_kTimeout); + client.destroy(); + }); + + test('data flows through CONNECT tunnel in both directions', () async { + final client = await Socket.connect('127.0.0.1', proxy.port) + .timeout(_kTimeout); + + // Single listener + broadcast controller so multiple await-for loops can + // consume the stream without "already listened" errors. + final clientBuf = []; + final clientCtrl = StreamController>.broadcast(); + client.listen((d) { clientBuf.addAll(d); clientCtrl.add(d); }); + + // Wait until clientBuf contains [expected]. Checks buffer first so data + // that arrived before the await-for subscription is never lost. + Future waitClient(String expected) async { + if (utf8.decode(clientBuf, allowMalformed: true).contains(expected)) return; + await for (final _ in clientCtrl.stream) { + if (utf8.decode(clientBuf, allowMalformed: true).contains(expected)) return; + } + } + + client.write('CONNECT auth.local:443 HTTP/1.1\r\nHost: auth.local:443\r\n\r\n'); + await client.flush(); + await waitClient('\r\n\r\n').timeout(_kTimeout); + expect(utf8.decode(clientBuf), contains('200 Connection Established')); + + // Component2 side of the tunnel + final serverSide = await comp2Conn.timeout(_kTimeout); + + // Client → component2 + final serverBuf = []; + final serverGotPing = Completer(); + serverSide.listen((d) { + serverBuf.addAll(d); + if (!serverGotPing.isCompleted && + utf8.decode(serverBuf, allowMalformed: true).contains('PING')) { + serverGotPing.complete(); + } + }); + + client.write('PING'); + await client.flush(); + await serverGotPing.future.timeout(_kTimeout); + expect(utf8.decode(serverBuf), 'PING'); + + // Component2 → client + final prevLen = clientBuf.length; + serverSide.write('PONG'); + await serverSide.flush(); + await waitClient('PONG').timeout(_kTimeout); + expect(utf8.decode(clientBuf.sublist(prevLen)), 'PONG'); + + await clientCtrl.close(); + client.destroy(); + serverSide.destroy(); + }); + }); + + // ------------------------------------------------------------------------- + // Group 4: edge cases + // ------------------------------------------------------------------------- + group('edge cases', () { + late FilterProxy proxy; + + setUp(() async { + proxy = FilterProxy(listenPort: 0, component2Port: 0); + proxy.setGatedEntries([]); + await proxy.start(); + }); + + tearDown(() => proxy.stop()); + + test('connection with no headers is closed cleanly', () async { + final client = await Socket.connect('127.0.0.1', proxy.port) + .timeout(_kTimeout); + // Send nothing — proxy should close when client closes + await client.close(); + // No exception means the proxy handled it gracefully + }); + + test('malformed request line is closed cleanly', () async { + final response = await _round(proxy.port, 'NOT-HTTP\r\n\r\n'); + // Proxy should close the connection without crashing + expect(response, isEmpty); + }); + + test('request with only spaces in request line is closed cleanly', () async { + final response = await _round(proxy.port, ' \r\n\r\n'); + expect(response, isEmpty); + }); + }); +}