Phase 9: add Component 1 (filter_proxy), tests, session gate, doc update
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
83a6382270
commit
1124a7f5a9
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -77,6 +77,33 @@ Files are deployed to VMs via `scp <file> <host>:~` and run via `ssh <host> <cmd
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
### Target production architecture (4-device system)
|
||||||
|
|
||||||
|
Four physical devices: optional client computer, phone, chromecard, server.
|
||||||
|
|
||||||
|
**Devices:**
|
||||||
|
- **Client (optional):** Computer with browser configured to use the phone as HTTP/HTTPS proxy. No knowledge of the auth system.
|
||||||
|
- **Phone:** Central hub. Runs two components, hosts registration page, connects to chromecard via USB or WiFi.
|
||||||
|
- **Chromecard:** FIDO2 hardware security module. All crypto happens on-card; private keys never leave. Two fingerprint types: *user* (login) and *admin* (registration/deletion).
|
||||||
|
- **Server:** Accepts TLS only. Runs WebAuthn service that validates FIDO2 tokens before granting access to protected resources.
|
||||||
|
|
||||||
|
**Components on the phone:**
|
||||||
|
- **Component 1 — Proxy + gating filter:** Listens on a local port. Binary decision per request: host is gated → forward to Component 2 (TLS); host is not gated → forward directly to internet on port 80 (no TLS).
|
||||||
|
- **Component 2 — FIDO2 client + URL recognition:** Receives all requests from Component 1. Detects registration-URL → triggers admin registration flow; other gated URLs → triggers FIDO2 assertion flow (contacts card, gets token, forwards to server via TLS).
|
||||||
|
- **Registration page:** Local web app on phone. Requires admin fingerprint on the card for enrollment/deletion.
|
||||||
|
|
||||||
|
**Three flows:**
|
||||||
|
- **Flow A (authenticated proxy):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → Server (WebAuthn validates token) → resource returned.
|
||||||
|
- **Flow B (registration):** Browser → Component 1 → Component 2 (detects registration URL) → Card (admin fingerprint) → user created/deleted on card.
|
||||||
|
- **Flow C (unauthenticated):** Host not gated → Component 1 forwards directly to internet via port 80 (unencrypted, bypasses Component 2 and card). By design for normal web traffic.
|
||||||
|
|
||||||
|
**Open architectural decisions:**
|
||||||
|
- PIN on card (in addition to biometrics) — not yet decided
|
||||||
|
- User database location: on-card only vs. external — not yet decided
|
||||||
|
- Network-level access control on registration page — not yet decided
|
||||||
|
|
||||||
|
### Development topology (Qubes 3-VM)
|
||||||
|
|
||||||
**Qubes 3-VM topology:** `k_client` → `k_proxy` → `k_server`, each a Debian 13 AppVM.
|
**Qubes 3-VM topology:** `k_client` → `k_proxy` → `k_server`, each a Debian 13 AppVM.
|
||||||
|
|
||||||
Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP routing). Validated chain:
|
Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP routing). Validated chain:
|
||||||
|
|
@ -101,9 +128,26 @@ Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP ro
|
||||||
|
|
||||||
**Key session endpoints on k_proxy:** `POST /session/login`, `POST /session/status`, `POST /session/logout`, `POST /resource/counter`.
|
**Key session endpoints on k_proxy:** `POST /session/login`, `POST /session/status`, `POST /session/logout`, `POST /resource/counter`.
|
||||||
|
|
||||||
|
### k_phone Flutter app (Phase 9 — replaces k_proxy)
|
||||||
|
|
||||||
|
**`k_phone/lib/filter_proxy.dart`** — Component 1. Raw-socket HTTP proxy with gating filter. Per-connection: gated host → CONNECT or plain-HTTP relay through Component 2; non-gated → direct to target. Gated hosts loaded from `gated_hosts.txt` in app documents dir; defaults to `httpbin.org`. Use `setGatedEntries()` in tests to inject entries directly.
|
||||||
|
|
||||||
|
**`k_phone/lib/proxy_service.dart`** — Component 2. Background-service HTTP server (port 8771). Handles enrollment, session (login/status/logout), and resource/counter endpoints. **CONNECT handler not yet implemented** — gated HTTPS tunnels currently return 405.
|
||||||
|
|
||||||
|
**`k_phone/lib/session_manager.dart`** — in-memory session store. `hasAnyActiveSession()` is the gate check for proxied traffic (personal-device model: one live session authorises all gated requests).
|
||||||
|
|
||||||
|
**`k_phone/lib/fido2_ops.dart`** — `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification against the card via CTAPHID.
|
||||||
|
|
||||||
|
**`k_phone/lib/ctaphid_channel.dart`** — CTAPHID framing over USB (Kotlin platform channel) or emulator bridge TCP socket (`card_emulator_bridge.py` on `10.0.2.2:8772`).
|
||||||
|
|
||||||
|
**`k_phone/lib/enrollment_db.dart`** — enrollment model + JSON persistence via path_provider.
|
||||||
|
|
||||||
|
**Tests:** `flutter test test/filter_proxy_test.dart` and `flutter test test/enrollment_test.dart` (no device needed).
|
||||||
|
|
||||||
## Known limits and blockers
|
## Known limits and blockers
|
||||||
|
|
||||||
- Concurrency ceiling on the browser-facing forwarded path is ~10 in-flight requests; higher fan-out triggers Qubes vchan failures (`xs_transaction_start: No space left on device`).
|
- Concurrency ceiling on the browser-facing forwarded path is ~10 in-flight requests; higher fan-out triggers Qubes vchan failures (`xs_transaction_start: No space left on device`).
|
||||||
- If CTAPHID `INIT` packets get no reply after a card reattach, a full USB power cycle recovers the transport.
|
- If CTAPHID `INIT` packets get no reply after a card reattach, a full USB power cycle recovers the transport.
|
||||||
- `CR_SDK_CK-main` is missing role directories (`mvp`, `setup`, `components`, `samples`) required for the firmware build/flash flow (`./scripts/build_flash_mvp.sh`). `west` and `nrfjprog` must also be installed.
|
- `CR_SDK_CK-main` is missing role directories (`mvp`, `setup`, `components`, `samples`) required for the firmware build/flash flow (`./scripts/build_flash_mvp.sh`). `west` and `nrfjprog` must also be installed.
|
||||||
- Phases 7 (firmware build) and 9 (phone-wireless transport) are externally gated.
|
- Phase 7 (firmware build): blocked on Chrome Roads (card vendor).
|
||||||
|
- Phase 9 (phone): Component 2 CONNECT handler not yet implemented — HTTPS to gated hosts will fail until `_handleConnect` is added to `proxy_service.dart`.
|
||||||
|
|
|
||||||
45
Workplan.md
45
Workplan.md
|
|
@ -531,7 +531,23 @@ Exit criteria:
|
||||||
|
|
||||||
Status (2026-04-29): **ACTIVE — emulator integration verified**
|
Status (2026-04-29): **ACTIVE — emulator integration verified**
|
||||||
|
|
||||||
Architecture: `k_client browser → k_phone (Flutter Android) → USB HID → ChromeCard → k_server`
|
### Target architecture
|
||||||
|
|
||||||
|
Four physical devices: optional client computer, phone, chromecard, server.
|
||||||
|
|
||||||
|
**Phone components:**
|
||||||
|
- **Component 1 — Proxy + gating filter:** Listens on a local port. Per-request binary decision: host is gated → forward to Component 2 via TLS; host is not gated → forward directly to internet on port 80 (no TLS, bypasses auth entirely).
|
||||||
|
- **Component 2 — FIDO2 client + URL recognition:** Detects registration URL → admin registration flow (admin fingerprint + PIN); other gated URLs → FIDO2 assertion flow (user fingerprint → token → server via TLS).
|
||||||
|
- **Registration page:** Local web app on phone; admin fingerprint access control enforced by card.
|
||||||
|
|
||||||
|
**Three flows:**
|
||||||
|
- **Flow A:** Browser → phone (comp 1 + 2) → card (user biometric) → server WebAuthn → resource
|
||||||
|
- **Flow B:** Browser → phone (comp 1 + 2, registration URL) → card (admin biometric) → enroll/delete user
|
||||||
|
- **Flow C:** Non-gated host → comp 1 → internet port 80 (no TLS, no card)
|
||||||
|
|
||||||
|
**Open decisions (from architecture doc):** PIN on card; user DB on-card vs. external; network-level access control on registration page.
|
||||||
|
|
||||||
|
Development chain (Qubes): `k_client browser → k_phone (Flutter Android) → USB HID → ChromeCard → k_server`
|
||||||
|
|
||||||
The `k_phone` Flutter app replaces `k_proxy` entirely. It presents the same HTTP API as `k_proxy_app.py`
|
The `k_phone` Flutter app replaces `k_proxy` entirely. It presents the same HTTP API as `k_proxy_app.py`
|
||||||
so `k_client_portal.py` and the browser portal work without changes.
|
so `k_client_portal.py` and the browser portal work without changes.
|
||||||
|
|
@ -553,11 +569,29 @@ k_phone development and testing runs on the Mac with the Android emulator and `c
|
||||||
- `k_phone/lib/enrollment_db.dart`: enrollment model + JSON persistence via path_provider
|
- `k_phone/lib/enrollment_db.dart`: enrollment model + JSON persistence via path_provider
|
||||||
- `k_phone/lib/fido2_ops.dart`: CTAP2 `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification
|
- `k_phone/lib/fido2_ops.dart`: CTAP2 `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification
|
||||||
- Fixed: CTAP2 command prefix bytes (0x01/0x02) prepended to CBOR payload per CTAP2-over-CTAPHID spec
|
- Fixed: CTAP2 command prefix bytes (0x01/0x02) prepended to CBOR payload per CTAP2-over-CTAPHID spec
|
||||||
- `k_phone/lib/session_manager.dart`: in-memory bearer token sessions
|
- `k_phone/lib/session_manager.dart`: in-memory bearer token sessions; `hasAnyActiveSession()` added for gated-proxy forwarding (personal-device model: any live session authorises gated traffic)
|
||||||
- `k_phone/lib/k_server_client.dart`: HTTP forwarder to k_server
|
- `k_phone/lib/k_server_client.dart`: HTTP forwarder to k_server
|
||||||
- `k_phone/android/app/src/main/kotlin/.../MainActivity.kt`: USB HID Kotlin platform channel
|
- `k_phone/android/app/src/main/kotlin/.../MainActivity.kt`: USB HID Kotlin platform channel
|
||||||
- `tests/card_emulator_bridge.py`: asyncio CTAPHID TCP bridge wrapping `CardEmulator` for emulator dev
|
- `tests/card_emulator_bridge.py`: asyncio CTAPHID TCP bridge wrapping `CardEmulator` for emulator dev
|
||||||
|
|
||||||
|
### Work completed (2026-05-02)
|
||||||
|
|
||||||
|
- `k_phone/lib/filter_proxy.dart`: Component 1 implemented — HTTP proxy with gating filter
|
||||||
|
- Plain HTTP to gated host: rewritten to relative path and forwarded to Component 2
|
||||||
|
- HTTPS CONNECT to gated host: CONNECT request relayed to Component 2; tunnel opened on 200, denied on 4xx
|
||||||
|
- All other traffic forwarded directly to target host
|
||||||
|
- Gated hosts file: `gated_hosts.txt` in app documents directory (one `host` or `host:port` per line)
|
||||||
|
- Default seeded with `httpbin.org` on first run
|
||||||
|
- `k_phone/test/filter_proxy_test.dart`: full test suite for Component 1 (gated matching, HTTP routing, CONNECT routing, edge cases)
|
||||||
|
- `k_phone/test/enrollment_test.dart`: full test suite for `EnrollmentDb` (register, list, delete, persistence, update)
|
||||||
|
|
||||||
|
### Pending (Component 2 CONNECT handler)
|
||||||
|
|
||||||
|
- `proxy_service.dart` does not yet handle `CONNECT` method requests
|
||||||
|
- When Component 1 forwards a CONNECT for a gated host, Component 2 must: validate `hasAnyActiveSession()`, then connect to the upstream target and tunnel raw bytes
|
||||||
|
- Without this, HTTPS to gated hosts is blocked at Component 2 (returns 405)
|
||||||
|
- Next coding action: add `_handleConnect` to `_ProxyServer` and dispatch it from `_handleRequest`
|
||||||
|
|
||||||
### Verified on emulator (2026-04-29)
|
### Verified on emulator (2026-04-29)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -577,9 +611,10 @@ CTAP2 cmd=0x02 body=113 bytes → getAssertion OK auth_data=37 bytes sig=71 byte
|
||||||
|
|
||||||
### Next action
|
### Next action
|
||||||
|
|
||||||
- Deploy to a real Android phone with physical ChromeCard via USB
|
1. **Add `_handleConnect` to `proxy_service.dart`** — CONNECT handler for gated HTTPS tunnels; checks `hasAnyActiveSession()`, connects to upstream, detaches socket, pipes bytes. Tests needed.
|
||||||
- Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
|
2. Deploy to a real Android phone with physical ChromeCard via USB
|
||||||
- Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running
|
3. Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
|
||||||
|
4. Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running
|
||||||
|
|
||||||
### k_phone API contract (must match k_proxy_app.py exactly)
|
### k_phone API contract (must match k_proxy_app.py exactly)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ public final class GeneratedPluginRegistrant {
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
|
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
// Integration test: registration and login flows on device.
|
||||||
|
// Runs EnrollmentDb and SessionManager directly; no card required.
|
||||||
|
//
|
||||||
|
// Run with: flutter test integration_test/ -d emulator-5554
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
|
||||||
|
import 'package:k_phone/enrollment_db.dart';
|
||||||
|
import 'package:k_phone/session_manager.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('Registration flow', () {
|
||||||
|
late EnrollmentDb db;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = EnrollmentDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('register new user produces log entries', (tester) async {
|
||||||
|
debugPrint('[REGISTRATION] Starting: enrolling user "alice"');
|
||||||
|
|
||||||
|
final enrollment = await db.register(
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice Testuser',
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('[REGISTRATION] SUCCESS: user="${enrollment.username}" '
|
||||||
|
'displayName="${enrollment.displayName}" '
|
||||||
|
'createdAt=${enrollment.createdAt} '
|
||||||
|
'hasCredential=${enrollment.hasCredential}');
|
||||||
|
|
||||||
|
expect(enrollment.username, equals('alice'));
|
||||||
|
expect(enrollment.displayName, equals('Alice Testuser'));
|
||||||
|
expect(enrollment.hasCredential, isFalse);
|
||||||
|
|
||||||
|
debugPrint('[REGISTRATION] Verifying user appears in list...');
|
||||||
|
final list = await db.list();
|
||||||
|
expect(list.any((e) => 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,8 @@ Socket? _emulatorSocket;
|
||||||
// Persistent read state for the emulator TCP socket.
|
// Persistent read state for the emulator TCP socket.
|
||||||
// Socket is a single-subscription stream — we must subscribe exactly once
|
// Socket is a single-subscription stream — we must subscribe exactly once
|
||||||
// and accumulate all incoming bytes into a buffer.
|
// 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<List<int>>? _emulatorSub;
|
StreamSubscription<List<int>>? _emulatorSub;
|
||||||
final _emulatorRxBuf = <int>[];
|
final _emulatorRxBuf = <int>[];
|
||||||
Completer<void>? _emulatorRxWaiter;
|
Completer<void>? _emulatorRxWaiter;
|
||||||
|
|
@ -203,7 +205,12 @@ Future<Uint8List> _ctaphidRoundtrip(int cid, int cmd, Uint8List data) async {
|
||||||
return await _reassembleResponse(first, cid);
|
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);
|
Uint8List lastReceived = Uint8List(kHidPacketSize);
|
||||||
for (final pkt in requestPackets) {
|
for (final pkt in requestPackets) {
|
||||||
lastReceived = await _sendPacket(pkt);
|
lastReceived = await _sendPacket(pkt);
|
||||||
|
|
@ -268,7 +275,7 @@ Future<Uint8List> _reassembleResponse(Uint8List initPacket, int expectedCid) asy
|
||||||
|
|
||||||
var received = firstChunk;
|
var received = firstChunk;
|
||||||
while (received < payloadLen) {
|
while (received < payloadLen) {
|
||||||
final contPacket = _emulatorMode ? await _receivePacket() : await _receivePacket();
|
final contPacket = await _receivePacket(); // USB continuation unimplemented — see _ctaphidRoundtrip
|
||||||
if (_isKeepalive(contPacket)) continue;
|
if (_isKeepalive(contPacket)) continue;
|
||||||
_checkCid(contPacket, expectedCid);
|
_checkCid(contPacket, expectedCid);
|
||||||
final chunk = min(payloadLen - received, kHidPacketSize - 5);
|
final chunk = min(payloadLen - received, kHidPacketSize - 5);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ class Enrollment {
|
||||||
|
|
||||||
factory Enrollment.fromJson(Map<String, dynamic> m) {
|
factory Enrollment.fromJson(Map<String, dynamic> m) {
|
||||||
final username = (m['username'] as String? ?? '').trim();
|
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();
|
final createdAt = m['created_at'] as int? ?? m['enrolled_at'] as int? ?? _nowSecs();
|
||||||
return Enrollment(
|
return Enrollment(
|
||||||
username: username,
|
username: username,
|
||||||
|
|
@ -95,11 +96,16 @@ int _nowSecs() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class EnrollmentDb {
|
class EnrollmentDb {
|
||||||
|
// [baseDir] can be injected in tests to bypass path_provider.
|
||||||
|
EnrollmentDb({Directory? baseDir}) : _baseDir = baseDir;
|
||||||
|
|
||||||
|
final Directory? _baseDir;
|
||||||
final Map<String, Enrollment> _entries = {};
|
final Map<String, Enrollment> _entries = {};
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
|
|
||||||
// Dart isolates are single-threaded so there is no data race on _entries.
|
// 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<void>? _pending;
|
Future<void>? _pending;
|
||||||
|
|
||||||
Future<void> _serialize(Future<void> Function() op) async {
|
Future<void> _serialize(Future<void> Function() op) async {
|
||||||
|
|
@ -113,7 +119,7 @@ class EnrollmentDb {
|
||||||
if (prev != null) {
|
if (prev != null) {
|
||||||
try {
|
try {
|
||||||
await prev;
|
await prev;
|
||||||
} catch (_) {}
|
} catch (_) {} // previous op error must not block the queue
|
||||||
}
|
}
|
||||||
await op();
|
await op();
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +129,7 @@ class EnrollmentDb {
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
Future<File> _dbFile() async {
|
Future<File> _dbFile() async {
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = _baseDir ?? await getApplicationSupportDirectory();
|
||||||
return File('${dir.path}/k_phone_enrollments.json');
|
return File('${dir.path}/k_phone_enrollments.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,7 +138,7 @@ class EnrollmentDb {
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
try {
|
try {
|
||||||
final f = await _dbFile();
|
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<String, dynamic>;
|
final raw = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||||
final users = raw['users'] as List? ?? [];
|
final users = raw['users'] as List? ?? [];
|
||||||
for (final item in users) {
|
for (final item in users) {
|
||||||
|
|
@ -140,7 +146,7 @@ class EnrollmentDb {
|
||||||
if (e.username.isNotEmpty) _entries[e.username] = e;
|
if (e.username.isNotEmpty) _entries[e.username] = e;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_entries.clear();
|
_entries.clear(); // treat a corrupt/unreadable DB as empty; next save overwrites it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ Future<MakeCredentialResult> makeCredential(
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
CborSmallInt(7): CborMap({
|
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('rk'): CborBool(false),
|
||||||
CborString('uv'): CborBool(false),
|
CborString('uv'): CborBool(false),
|
||||||
}),
|
}),
|
||||||
|
|
@ -134,8 +137,8 @@ Future<GetAssertionResult> getAssertion(
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
CborSmallInt(5): CborMap({
|
CborSmallInt(5): CborMap({
|
||||||
CborString('up'): CborBool(true),
|
CborString('up'): CborBool(true), // require fingerprint touch (user presence)
|
||||||
CborString('uv'): CborBool(false),
|
CborString('uv'): CborBool(false), // no PIN
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -171,6 +174,7 @@ bool verifyAssertion(
|
||||||
final coseKey = _extractCoseKey(credData);
|
final coseKey = _extractCoseKey(credData);
|
||||||
final pubKey = _coseKeyToEcPublicKey(coseKey);
|
final pubKey = _coseKeyToEcPublicKey(coseKey);
|
||||||
|
|
||||||
|
// CTAP2/WebAuthn spec: the signed message is authData || SHA-256(clientDataJSON).
|
||||||
final message = Uint8List(authData.length + clientDataHash.length)
|
final message = Uint8List(authData.length + clientDataHash.length)
|
||||||
..setRange(0, authData.length, authData)
|
..setRange(0, authData.length, authData)
|
||||||
..setRange(authData.length, authData.length + clientDataHash.length, clientDataHash);
|
..setRange(authData.length, authData.length + clientDataHash.length, clientDataHash);
|
||||||
|
|
@ -268,7 +272,9 @@ int _cborInt(CborValue v) {
|
||||||
(BigInt, BigInt) _decodeDerSignature(Uint8List der) {
|
(BigInt, BigInt) _decodeDerSignature(Uint8List der) {
|
||||||
// SEQUENCE { INTEGER r, INTEGER s }
|
// SEQUENCE { INTEGER r, INTEGER s }
|
||||||
if (der[0] != 0x30) throw FormatException('DER signature: expected SEQUENCE tag');
|
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');
|
if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for r');
|
||||||
final rLen = der[offset + 1];
|
final rLen = der[offset + 1];
|
||||||
final rBytes = der.sublist(offset + 2, offset + 2 + rLen);
|
final rBytes = der.sublist(offset + 2, offset + 2 + rLen);
|
||||||
|
|
@ -330,6 +336,7 @@ String _b64uEncode(Uint8List data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List _b64uDecode(String s) {
|
Uint8List _b64uDecode(String s) {
|
||||||
|
// base64url strips trailing '='; restore padding to the nearest multiple of 4.
|
||||||
final padded = s + '=' * ((4 - s.length % 4) % 4);
|
final padded = s + '=' * ((4 - s.length % 4) % 4);
|
||||||
return Uint8List.fromList(base64Url.decode(padded));
|
return Uint8List.fromList(base64Url.decode(padded));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String> _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<String> 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<void> 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<void> 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<void> 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<void> stop() async {
|
||||||
|
await _server?.close();
|
||||||
|
_server = null;
|
||||||
|
_log('Filter proxy stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-connection handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _handle(Socket client) async {
|
||||||
|
final buf = <int>[];
|
||||||
|
int headerEnd = -1;
|
||||||
|
late StreamSubscription<List<int>> sub;
|
||||||
|
final headersReady = Completer<void>();
|
||||||
|
|
||||||
|
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<void> _handleDirectConnect(
|
||||||
|
Socket client,
|
||||||
|
StreamSubscription<List<int>> sub,
|
||||||
|
String host,
|
||||||
|
int port,
|
||||||
|
List<int> 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<void> _handleGatedConnect(
|
||||||
|
Socket client,
|
||||||
|
StreamSubscription<List<int>> sub,
|
||||||
|
String target,
|
||||||
|
List<int> 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>[];
|
||||||
|
int respHeaderEnd = -1;
|
||||||
|
final respReady = Completer<void>();
|
||||||
|
late StreamSubscription<List<int>> 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<void> _handleHttp(
|
||||||
|
Socket client,
|
||||||
|
StreamSubscription<List<int>> sub,
|
||||||
|
String method,
|
||||||
|
String targetUrl,
|
||||||
|
List<String> headerLines,
|
||||||
|
List<int> 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<void>();
|
||||||
|
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<List<int>> 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');
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,9 @@ class KServerClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldForwardHeader(String name) {
|
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'};
|
const skip = {'host', 'connection', 'transfer-encoding', 'authorization'};
|
||||||
return !skip.contains(name.toLowerCase());
|
return !skip.contains(name.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||||
|
import 'enrollment_db.dart';
|
||||||
|
import 'filter_proxy.dart';
|
||||||
import 'proxy_service.dart';
|
import 'proxy_service.dart';
|
||||||
|
import 'session_manager.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -36,6 +40,10 @@ class _ProxyStatusScreenState extends State<ProxyStatusScreen> {
|
||||||
bool _cardAttached = false;
|
bool _cardAttached = false;
|
||||||
String _statusMessage = 'Stopped';
|
String _statusMessage = 'Stopped';
|
||||||
final List<String> _log = [];
|
final List<String> _log = [];
|
||||||
|
// Debug-only test state — stripped in release builds via kDebugMode.
|
||||||
|
bool _testRunning = false;
|
||||||
|
final _db = EnrollmentDb();
|
||||||
|
final _session = SessionManager();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -68,6 +76,71 @@ class _ProxyStatusScreenState extends State<ProxyStatusScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _addLog(String msg) {
|
||||||
|
setState(() {
|
||||||
|
_log.insert(0, msg);
|
||||||
|
if (_log.length > 200) _log.removeLast();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _toggleService() async {
|
Future<void> _toggleService() async {
|
||||||
final service = FlutterBackgroundService();
|
final service = FlutterBackgroundService();
|
||||||
final running = await service.isRunning();
|
final running = await service.isRunning();
|
||||||
|
|
@ -96,9 +169,15 @@ class _ProxyStatusScreenState extends State<ProxyStatusScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_StatusTile(
|
_StatusTile(
|
||||||
label: 'Proxy service',
|
label: 'Filter proxy (Comp 1)',
|
||||||
ok: _serviceRunning,
|
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),
|
const SizedBox(height: 8),
|
||||||
_StatusTile(
|
_StatusTile(
|
||||||
|
|
@ -112,10 +191,27 @@ class _ProxyStatusScreenState extends State<ProxyStatusScreen> {
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _toggleService,
|
onPressed: _toggleService,
|
||||||
child: Text(_serviceRunning ? 'Stop proxy' : 'Start proxy'),
|
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 Divider(height: 32),
|
||||||
const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,14 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
import 'ctaphid_channel.dart';
|
import 'ctaphid_channel.dart';
|
||||||
import 'enrollment_db.dart';
|
import 'enrollment_db.dart';
|
||||||
|
import 'filter_proxy.dart';
|
||||||
import 'fido2_ops.dart';
|
import 'fido2_ops.dart';
|
||||||
import 'k_server_client.dart';
|
import 'k_server_client.dart';
|
||||||
import 'session_manager.dart';
|
import 'session_manager.dart';
|
||||||
|
|
||||||
const int kProxyPort = 8771;
|
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 kNotificationChannelId = 'kphone_proxy';
|
||||||
const String kNotificationChannelName = 'k_phone proxy service';
|
const String kNotificationChannelName = 'k_phone proxy service';
|
||||||
|
|
||||||
|
|
@ -78,6 +81,7 @@ class ProxyService {
|
||||||
class _ProxyServer {
|
class _ProxyServer {
|
||||||
final ServiceInstance _service;
|
final ServiceInstance _service;
|
||||||
HttpServer? _server;
|
HttpServer? _server;
|
||||||
|
final FilterProxy _filterProxy = FilterProxy();
|
||||||
final SessionManager _sessions = SessionManager();
|
final SessionManager _sessions = SessionManager();
|
||||||
final EnrollmentDb _db = EnrollmentDb();
|
final EnrollmentDb _db = EnrollmentDb();
|
||||||
final KServerClient _kserver = KServerClient();
|
final KServerClient _kserver = KServerClient();
|
||||||
|
|
@ -100,8 +104,17 @@ class _ProxyServer {
|
||||||
_running = true;
|
_running = true;
|
||||||
_emit('Starting proxy on :$kProxyPort');
|
_emit('Starting proxy on :$kProxyPort');
|
||||||
|
|
||||||
await _tryOpenCard();
|
_filterProxy.onLog = _emit;
|
||||||
await _db.ensureLoaded();
|
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;
|
SecurityContext? tlsCtx;
|
||||||
try {
|
try {
|
||||||
|
|
@ -126,10 +139,14 @@ class _ProxyServer {
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
_running = false;
|
_running = false;
|
||||||
|
try {
|
||||||
|
await _filterProxy.stop();
|
||||||
await _server?.close(force: true);
|
await _server?.close(force: true);
|
||||||
await closeCard();
|
await closeCard();
|
||||||
|
} finally {
|
||||||
_emit('Stopped');
|
_emit('Stopped');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Request dispatch
|
// Request dispatch
|
||||||
|
|
@ -144,6 +161,8 @@ class _ProxyServer {
|
||||||
switch (path) {
|
switch (path) {
|
||||||
case '/':
|
case '/':
|
||||||
await _serveHtml(req);
|
await _serveHtml(req);
|
||||||
|
case '/enroll':
|
||||||
|
await _serveEnrollHtml(req);
|
||||||
case '/health':
|
case '/health':
|
||||||
await _handleHealth(req);
|
await _handleHealth(req);
|
||||||
case '/enroll/list':
|
case '/enroll/list':
|
||||||
|
|
@ -206,36 +225,31 @@ class _ProxyServer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MakeCredentialResult? credential;
|
||||||
if (_cardAttached && _cardCid != null) {
|
if (_cardAttached && _cardCid != null) {
|
||||||
// FIDO2-direct mode: run makeCredential on the card
|
|
||||||
MakeCredentialResult result;
|
|
||||||
try {
|
try {
|
||||||
result = await makeCredential(_cardCid!, canonical, displayName: pretty);
|
credential = await makeCredential(_cardCid!, canonical, displayName: pretty);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'card registration failed: $e'});
|
await _send(req.response, 401, {'ok': false, 'error': 'card registration failed: $e'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final enrollment = await _db.register(
|
final enrollment = await _db.register(
|
||||||
username: canonical,
|
username: canonical,
|
||||||
displayName: pretty,
|
displayName: pretty,
|
||||||
userIdB64: result.userIdB64,
|
userIdB64: credential?.userIdB64,
|
||||||
credentialDataB64: result.credentialDataB64,
|
credentialDataB64: credential?.credentialDataB64,
|
||||||
);
|
);
|
||||||
await _send(req.response, 200, _enrollmentPayload(enrollment, created: true));
|
final users = await _db.list();
|
||||||
|
await _send(req.response, 200, {
|
||||||
|
..._enrollmentPayload(enrollment, created: true),
|
||||||
|
'users': users.map(_userSummary).toList(),
|
||||||
|
});
|
||||||
} on StateError {
|
} on StateError {
|
||||||
await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'});
|
await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'});
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// Probe mode: metadata-only enrollment
|
|
||||||
try {
|
|
||||||
final enrollment = await _db.register(username: canonical, displayName: pretty);
|
|
||||||
await _send(req.response, 200, _enrollmentPayload(enrollment, created: true));
|
|
||||||
} on StateError {
|
|
||||||
await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleEnrollUpdate(HttpRequest req) async {
|
Future<void> _handleEnrollUpdate(HttpRequest req) async {
|
||||||
|
|
@ -280,7 +294,13 @@ class _ProxyServer {
|
||||||
try {
|
try {
|
||||||
final enrollment = await _db.delete(canonical);
|
final enrollment = await _db.delete(canonical);
|
||||||
_sessions.revokeAll(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 {
|
} on StateError {
|
||||||
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'});
|
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'});
|
||||||
}
|
}
|
||||||
|
|
@ -304,7 +324,7 @@ class _ProxyServer {
|
||||||
final users = await _db.list();
|
final users = await _db.list();
|
||||||
await _send(req.response, 200, {
|
await _send(req.response, 200, {
|
||||||
'ok': true,
|
'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'});
|
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'no card attached'});
|
||||||
return;
|
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 token = _sessions.issue(canonical);
|
||||||
final session = _sessions.getSession(token)!;
|
final session = _sessions.getSession(token)!;
|
||||||
|
|
@ -366,7 +387,7 @@ class _ProxyServer {
|
||||||
'username': canonical,
|
'username': canonical,
|
||||||
'session_token': token,
|
'session_token': token,
|
||||||
'expires_at': expiresAt,
|
'expires_at': expiresAt,
|
||||||
'ttl_seconds': 300,
|
'ttl_seconds': _kSessionTtlSeconds,
|
||||||
'auth_mode': authMode,
|
'auth_mode': authMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -458,17 +479,24 @@ class _ProxyServer {
|
||||||
'ok': true,
|
'ok': true,
|
||||||
'service': 'k_phone',
|
'service': 'k_phone',
|
||||||
'card': _cardAttached,
|
'card': _cardAttached,
|
||||||
'active_sessions': 0, // SessionManager doesn't expose count; good enough
|
'active_sessions': 0,
|
||||||
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _serveHtml(HttpRequest req) async {
|
Future<void> _serveHtml(HttpRequest req) async {
|
||||||
final data = utf8.encode(_kPortalHtml);
|
|
||||||
req.response.statusCode = 200;
|
req.response.statusCode = 200;
|
||||||
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
||||||
req.response.headers.contentLength = data.length;
|
req.response.headers.contentLength = _kPortalHtmlBytes.length;
|
||||||
req.response.add(data);
|
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();
|
await req.response.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,7 +537,11 @@ class _ProxyServer {
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> _readJson(HttpRequest req) async {
|
Future<Map<String, dynamic>?> _readJson(HttpRequest req) async {
|
||||||
try {
|
try {
|
||||||
final bytes = await req.fold<List<int>>([], (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 {};
|
if (bytes.isEmpty) return {};
|
||||||
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
|
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -531,6 +563,13 @@ class _ProxyServer {
|
||||||
await res.close();
|
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}) {
|
Map<String, dynamic> _enrollmentPayload(Enrollment e, {bool? created}) {
|
||||||
final m = <String, dynamic>{
|
final m = <String, dynamic>{
|
||||||
'ok': true,
|
'ok': true,
|
||||||
|
|
@ -553,6 +592,9 @@ class _ProxyServer {
|
||||||
// Portal HTML (mirrors k_proxy_app.py HTML)
|
// Portal HTML (mirrors k_proxy_app.py HTML)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
final _kPortalHtmlBytes = utf8.encode(_kPortalHtml);
|
||||||
|
final _kEnrollHtmlBytes = utf8.encode(_kEnrollHtml);
|
||||||
|
|
||||||
const String _kPortalHtml = '''<!doctype html>
|
const String _kPortalHtml = '''<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -650,3 +692,136 @@ const String _kPortalHtml = '''<!doctype html>
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>''';
|
</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>''';
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ class SessionManager {
|
||||||
static const Duration _ttl = Duration(seconds: 300);
|
static const Duration _ttl = Duration(seconds: 300);
|
||||||
|
|
||||||
/// Issue a new session token for [username].
|
/// 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) {
|
String issue(String username) {
|
||||||
_purgeExpired();
|
_purgeExpired();
|
||||||
final token = _randomToken();
|
final token = _randomToken();
|
||||||
|
|
@ -41,6 +43,14 @@ class SessionManager {
|
||||||
/// Revoke [token] immediately.
|
/// Revoke [token] immediately.
|
||||||
void revoke(String token) => _sessions.remove(token);
|
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].
|
/// Revoke all sessions for [username].
|
||||||
void revokeAll(String username) {
|
void revokeAll(String username) {
|
||||||
_sessions.removeWhere((_, s) => s.username == username);
|
_sessions.removeWhere((_, s) => s.username == username);
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.2"
|
version: "5.1.2"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -195,6 +200,11 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -235,6 +245,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
integration_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
jni:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -435,6 +450,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.9.1"
|
version: "3.9.1"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.5"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -488,6 +511,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -552,6 +583,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
webdriver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webdriver
|
||||||
|
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ dependencies:
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
flutter_lints: ^3.0.0
|
flutter_lints: ^3.0.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
|
|
|
||||||
|
|
@ -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<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate check is case-insensitive', () async {
|
||||||
|
await db.register(username: 'alice');
|
||||||
|
await expectLater(
|
||||||
|
db.register(username: 'ALICE'),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects username with special characters', () async {
|
||||||
|
await expectLater(
|
||||||
|
db.register(username: 'alice!'),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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<HttpRequest> completer})> _mockHttp() async {
|
||||||
|
final server = await HttpServer.bind('127.0.0.1', 0);
|
||||||
|
final c = Completer<HttpRequest>();
|
||||||
|
server.listen((req) async {
|
||||||
|
await req.drain<void>();
|
||||||
|
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> socket})> _mockTcp() async {
|
||||||
|
final server = await ServerSocket.bind('127.0.0.1', 0);
|
||||||
|
final c = Completer<Socket>();
|
||||||
|
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<String> _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 = <int>[];
|
||||||
|
await sock.listen(buf.addAll).asFuture<void>().timeout(_kTimeout);
|
||||||
|
return utf8.decode(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads from [client] until the CONNECT 200 response header block arrives.
|
||||||
|
Future<String> _waitForConnectResponse(Socket client) async {
|
||||||
|
final buf = <int>[];
|
||||||
|
final done = Completer<void>();
|
||||||
|
late StreamSubscription<List<int>> 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<HttpRequest> comp2Req;
|
||||||
|
late HttpServer direct;
|
||||||
|
late Completer<HttpRequest> 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<Socket> comp2Conn;
|
||||||
|
late ServerSocket directTcp;
|
||||||
|
late Future<Socket> 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 = <int>[];
|
||||||
|
final clientCtrl = StreamController<List<int>>.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<void> 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 = <int>[];
|
||||||
|
final serverGotPing = Completer<void>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue