diff --git a/Setup.md b/Setup.md index 08877a2..48cac0d 100644 --- a/Setup.md +++ b/Setup.md @@ -1,6 +1,6 @@ # Setup -Last updated: 2026-04-29 +Last updated: 2026-05-08 This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`. Update this file whenever environment status or verified behavior changes. @@ -668,6 +668,29 @@ Session note (2026-04-27, card emulator and bug fixes): sign-count monotonicity, wrong RP rejection, empty allow-list rejection - total test count is now 122, all passing locally without card or VMs +Session note (2026-05-08, per-request token binding + Playwright acceptance tests): +- Per-request FIDO2 token binding implemented across the full stack: + - `k_phone/lib/fido2_ops.dart`: `GetAssertionResult` carries `clientDataJson`; `getAssertion()` accepts optional bound challenge. + - `k_phone/lib/proxy_service.dart`: `_handleAuthGetToken` rewritten — accepts `{url, method, nonce}`, derives `challenge = SHA256(url|method|nonce)`, returns a self-contained assertion bundle as base64url Bearer token. No session created. + - `k_phone/lib/filter_proxy.dart`: `_getAuthToken(uri, method)` generates a 16-byte secure nonce, POSTs `{url, method, nonce}` to Component 2. + - `component3/phone.go`: rewritten as stateless `GetTokenForRequest(url, method)` — no session cache, no mutex. + - `k_server_app.py`: `_verify_assertion_token()` added — verifies path+method, challenge, and ECDSA-P256 signature from the self-contained bundle. `_is_proxy_authorized()` accepts legacy `X-Proxy-Token` or `Authorization: Bearer `. +- Test coverage added: + - `tests/test_k_server.py`: 17 Python tests for `_verify_assertion_token` — 12 unit + 5 CardEmulator round-trips. All pass. + Run: `uv run --python 3.12 --with fido2 --with cbor2 --with cryptography python3 -m unittest tests/test_k_server.py` + - `k_phone/test/filter_proxy_test.dart`: 2 new tests verify `/auth/get-token` body fields. 48/48 pass. +- Playwright acceptance tests added (three specs, all in `tests/`): + - `k_phone_portal.spec.js`: portal UI flow — enroll → login → status → list → logout → delete. DOM assertions only; no phone screen needed. + Run: `K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_portal.spec.js` + - `k_phone_proxy.spec.js`: 4 serial proxy-routing tests using Node `http` module. + 1. No users → non-gated passes. 2. No users → gated rejected (407). 3. Enroll (card) → non-gated still passes. 4. Gated succeeds with card assertion (200 + Bearer token in response). + Run: `K_PHONE_PROXY=http://127.0.0.1:8888 K_PHONE_BASE_URL=http://127.0.0.1:8771 npx playwright test tests/k_phone_proxy.spec.js` (requires `adb forward tcp:8888 tcp:8888 && adb forward tcp:8771 tcp:8771`) + - `k_phone_android.spec.js`: same 4 tests but Chrome runs inside the Android emulator via Playwright Android (`playwright.android.devices()`). No adb port-forward needed — `127.0.0.1:8888` is Component 1 from inside the emulator. Auto-skips if no ADB device found. + Prerequisite: `npm install playwright` + card_emulator_bridge.py running. + Run: `npx playwright test tests/k_phone_android.spec.js [--headed]` +- card_emulator_bridge.py auto-approves all FIDO2 operations instantly — no physical fingerprint or card needed for emulator tests. The `CARD_REGISTRATION_TIMEOUT_MS` / `CARD_LOGIN_TIMEOUT_MS` timeouts exist only for physical ChromeCard use. +- Flutter analyze: no issues. `go build ./...`: clean. 48/48 Flutter tests pass. + Session note (2026-04-29, Phase 9 k_phone bring-up): - Phase 9 approved and started: Flutter Android app (`k_phone`) replaces `k_proxy` in the auth chain. - Development is happening on Mac (not Qubes) — Android emulator is incompatible with Qubes' Xen hypervisor. diff --git a/Workplan.md b/Workplan.md index aa3fc15..1a1046a 100644 --- a/Workplan.md +++ b/Workplan.md @@ -1,6 +1,6 @@ # Workplan -Last updated: 2026-04-29 +Last updated: 2026-05-08 This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine. @@ -665,7 +665,24 @@ Component 3 (`component3/`) and Component 1 (`k_phone/lib/filter_proxy.dart`) im - `component3/proxy.go`: `handleHTTP` uses `GetTokenForRequest(r.URL.String(), r.Method)`. - `component3/main.go`: `--user` flag removed (Component 2 picks the enrolled user). - `k_server_app.py`: `_verify_assertion_token()` added — decodes bundle, verifies path+method match, verifies challenge claim, verifies ECDSA-P256 signature over authData||clientDataHash using public key extracted from bundle's credentialData. `_is_proxy_authorized()` accepts either X-Proxy-Token (legacy k_proxy path) or Bearer assertion token. -- 46/46 Flutter tests pass; `go build ./...` clean; `flutter analyze` no issues. +- `filter_proxy_test.dart`: 2 new tests for `/auth/get-token` body fields (url, method, nonce). 48/48 tests pass. +- `tests/test_k_server.py`: 17 Python tests for `_verify_assertion_token` — 12 unit tests with synthetic P-256 keys, 5 round-trip tests via `CardEmulator`. All pass. +- 48/48 Flutter tests pass; `go build ./...` clean; `flutter analyze` no issues. + +### Work completed (2026-05-08, Playwright acceptance tests for k_phone) + +- `tests/k_phone_portal.spec.js` (new): Portal UI acceptance tests (enroll → login → status → list → logout → delete). DOM assertions against `#storedUser`, `#sessionActive`, `#log`. Also tests empty-username and unknown-user error paths. + - Run: `K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_portal.spec.js` + +- `tests/k_phone_proxy.spec.js` (new): Proxy routing acceptance tests. Four serial tests that prove Component 1's routing decisions: + 1. No users → non-gated request passes through (< 500). + 2. No users → gated request rejected with 407 (Component 2 has no enrolled user). + 3. Register user (card fingerprint) → non-gated still passes through. + 4. With enrolled user → gated request succeeds after card assertion (200); response body proves Bearer token was forwarded to target. + - Uses Node `http` module for proxy requests (absolute URI / proxy protocol). + - Uses Playwright `page` fixture for enrollment in test 3 (card interaction). + - `GATED_URL` defaults to `http://httpbin.org/get`; point at `http://k-server-ip:8780/resource/counter` (GATED_METHOD=POST) for full chain validation including token signature verification. + - Run: `K_PHONE_PROXY=http://phone-ip:8888 K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_proxy.spec.js` ### Next action diff --git a/tests/k_phone_android.spec.js b/tests/k_phone_android.spec.js new file mode 100644 index 0000000..0622aac --- /dev/null +++ b/tests/k_phone_android.spec.js @@ -0,0 +1,264 @@ +/** + * Acceptance tests for k_phone proxy routing — Chrome inside the Android emulator. + * + * Same four serial tests as k_phone_proxy.spec.js, but the browser runs inside + * the emulator via Playwright's Android module. From inside the emulator + * 127.0.0.1:8888 IS Component 1 (filter_proxy.dart) — no adb port-forward needed. + * + * Prerequisites: + * 1. Android emulator running with the k_phone app started. + * 2. ADB connected: adb devices shows the emulator. + * 3. card_emulator_bridge.py running on the Mac (auto-approves FIDO2 assertions): + * uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \ + * tests/card_emulator_bridge.py + * + * Run: + * npx playwright test tests/k_phone_android.spec.js + * npx playwright test tests/k_phone_android.spec.js --headed # shows emulator Chrome + * + * Tests skip automatically if no Android device/emulator is found via ADB. + * + * Env vars: + * GATED_URL URL of a gated resource (default: http://httpbin.org/get) + * GATED_METHOD HTTP method for gated request (default: GET) + * UNGATED_URL URL of a non-gated resource (default: http://example.com) + * CARD_REGISTRATION_TIMEOUT_MS (default: 90000) + * CARD_LOGIN_TIMEOUT_MS (default: 90000) + * + * Note on proxy bypass: + * Chrome bypasses --proxy-server for 127.0.0.1 / localhost by default. + * Portal API calls (127.0.0.1:8771) therefore reach Component 2 directly. + * External host requests (httpbin.org, example.com) go through Component 1. + */ + +const { test, expect } = require('@playwright/test'); + +let android = null; +try { + android = require('playwright').android; +} catch { + // playwright not installed separately — tests will be skipped. +} + +const GATED_URL = process.env.GATED_URL || 'http://httpbin.org/get'; +const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase(); +const UNGATED_URL = process.env.UNGATED_URL || 'http://example.com'; + +const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || '90000'); +const cardAssertionTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || '90000'); + +// Component 2 is always at 127.0.0.1:8771 from inside the emulator. +const PORTAL = 'http://127.0.0.1:8771'; + +function uniqueUsername() { + return `pw_${Date.now().toString(36)}`; +} + +// --------------------------------------------------------------------------- +// chromeFetch — makes an HTTP request from inside Android Chrome. +// +// fetch() in the page context uses Chrome's --proxy-server for external hosts +// and bypasses the proxy for 127.0.0.1. Returns { status, ok, body }. +// --------------------------------------------------------------------------- +async function chromeFetch(page, url, { method = 'GET', data = null, timeoutMs = 15_000 } = {}) { + return page.evaluate( + async ({ url, method, data, timeoutMs }) => { + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const opts = { method, signal: ctrl.signal }; + if (data !== null) { + opts.headers = { 'Content-Type': 'application/json' }; + opts.body = JSON.stringify(data); + } + const r = await fetch(url, opts); + clearTimeout(tid); + let body = null; + try { body = await r.clone().json(); } catch { body = await r.text(); } + return { status: r.status, ok: r.ok, body }; + } catch (e) { + clearTimeout(tid); + return { status: 0, ok: false, error: e.message }; + } + }, + { url, method, data, timeoutMs }, + ); +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +test.describe.serial('k_phone proxy routing — Android Chrome', () => { + let device = null; + let proxyCtx = null; // Chrome launched with --proxy-server=127.0.0.1:8888 + let skipReason = null; + let enrolledUser = null; + + test.beforeAll(async () => { + if (!android) { + skipReason = 'playwright package not found — run: npm install playwright'; + return; + } + try { + const devices = await android.devices(); + if (!devices.length) { + skipReason = 'No Android emulator connected — run: adb devices'; + return; + } + device = devices[0]; + + // Launch Chrome inside the emulator. + // --proxy-server points at Component 1 on the emulator's own loopback. + proxyCtx = await device.launchBrowser({ + args: ['--proxy-server=127.0.0.1:8888'], + }); + + // Clean state: delete any users left from previous runs. + // 127.0.0.1 bypasses the proxy, so these calls reach Component 2 directly. + const page = await proxyCtx.newPage(); + const list = await chromeFetch(page, `${PORTAL}/enroll/list`); + for (const u of list.body?.users ?? []) { + await chromeFetch(page, `${PORTAL}/enroll/delete`, { + method: 'POST', + data: { username: u.username }, + }); + } + await page.close(); + } catch (e) { + skipReason = `Android setup failed: ${e.message}`; + } + }); + + // Skip every test in the suite if the emulator was not found. + test.beforeEach(async () => { + if (skipReason) test.skip(true, skipReason); + }); + + test.afterAll(async () => { + if (enrolledUser && proxyCtx) { + const page = await proxyCtx.newPage().catch(() => null); + if (page) { + await chromeFetch(page, `${PORTAL}/enroll/delete`, { + method: 'POST', + data: { username: enrolledUser }, + }).catch(() => {}); + await page.close().catch(() => {}); + } + } + await proxyCtx?.close().catch(() => {}); + await device?.close().catch(() => {}); + }); + + // --------------------------------------------------------------------------- + // Test 1: no users — non-gated request passes through. + // + // Chrome navigates to a non-gated host. Component 1 forwards the traffic + // directly without contacting Component 2 or the card. + // --------------------------------------------------------------------------- + test('1. no users: non-gated request passes through', async () => { + const page = await proxyCtx.newPage(); + try { + const response = await page.goto(UNGATED_URL, { + timeout: 15_000, + waitUntil: 'commit', + }); + expect(response?.status()).toBeLessThan(500); + } finally { + await page.close(); + } + }); + + // --------------------------------------------------------------------------- + // Test 2: no users — gated request blocked. + // + // Component 1 asks Component 2 for a token; Component 2 finds no enrolled + // user and returns an error; Component 1 responds 407. Chrome's fetch() + // surfaces this as either a 407 response or a network error. + // --------------------------------------------------------------------------- + test('2. no users: gated request blocked', async () => { + const page = await proxyCtx.newPage(); + try { + const result = await chromeFetch(page, GATED_URL, { method: GATED_METHOD }); + // 407 (proxy auth required) or status 0 (network error) both mean blocked. + expect(result.ok).toBe(false); + } finally { + await page.close(); + } + }); + + // --------------------------------------------------------------------------- + // Test 3: register user — non-gated request still passes through. + // + // Card step: makeCredential. card_emulator_bridge.py auto-approves instantly + // — no physical fingerprint touch needed. + // --------------------------------------------------------------------------- + test('3. enroll user: non-gated request still passes through', async () => { + test.setTimeout(registrationTimeoutMs + 30_000); + + enrolledUser = uniqueUsername(); + const page = await proxyCtx.newPage(); + try { + // The portal at 127.0.0.1 bypasses the proxy and loads directly from Component 2. + await page.goto(`${PORTAL}/`); + await page.locator('#username').fill(enrolledUser); + await page.locator('#displayName').fill('Android Chrome Test'); + await page.locator('#enrollBtn').click(); + await expect(page.locator('#log')).toContainText('Enrolled', { + timeout: registrationTimeoutMs, + }); + await expect(page.locator('#storedUser')).toHaveText(enrolledUser); + + // Non-gated traffic must still be forwarded directly after enrollment. + const response = await page.goto(UNGATED_URL, { + timeout: 15_000, + waitUntil: 'commit', + }); + expect(response?.status()).toBeLessThan(500); + } finally { + await page.close(); + } + }); + + // --------------------------------------------------------------------------- + // Test 4: with enrolled user — gated request succeeds after card assertion. + // + // Card step: getAssertion. card_emulator_bridge.py auto-approves instantly. + // + // fetch() inside Chrome flows: + // Chrome → Component 1 (127.0.0.1:8888) → POST /auth/get-token → + // Component 2 → card emulator bridge (10.0.2.2:8772) → assertion bundle → + // Component 1 → gated endpoint with Authorization: Bearer → 200 response. + // + // Verification: + // httpbin.org echoes the Authorization: Bearer header back in the JSON body. + // k_server (GATED_URL=http://k-server-ip:8780/resource/counter) validates + // the assertion cryptographically and returns {ok, resource, value}. + // --------------------------------------------------------------------------- + test('4. enrolled user: gated request succeeds — card asserted', async () => { + test.setTimeout(cardAssertionTimeoutMs + 30_000); + + const page = await proxyCtx.newPage(); + try { + const result = await chromeFetch(page, GATED_URL, { + method: GATED_METHOD, + timeoutMs: cardAssertionTimeoutMs, + }); + + // 200 proves Component 2 performed the FIDO2 assertion successfully. + expect(result.status).toBe(200); + + if (result.body?.headers?.Authorization !== undefined) { + // httpbin.org echoes request headers — the Bearer token must be present. + expect(result.body.headers.Authorization).toMatch(/^Bearer /i); + } else if (result.body?.resource !== undefined) { + // k_server validated the assertion token and incremented the counter. + expect(result.body.ok).toBe(true); + expect(result.body.resource).toBe('counter'); + expect(typeof result.body.value).toBe('number'); + } + } finally { + await page.close(); + } + }); +}); diff --git a/tests/k_phone_portal.spec.js b/tests/k_phone_portal.spec.js new file mode 100644 index 0000000..7f59126 --- /dev/null +++ b/tests/k_phone_portal.spec.js @@ -0,0 +1,123 @@ +/** + * Playwright acceptance test for the k_phone portal (Component 2, port 8771). + * + * Run: + * K_PHONE_BASE_URL=http://192.168.x.x:8771 npx playwright test tests/k_phone_portal.spec.js + * + * Env vars: + * K_PHONE_BASE_URL Base URL of the k_phone proxy service (default: http://127.0.0.1:8771) + * CARD_REGISTRATION_TIMEOUT_MS Timeout for makeCredential card step (default: 90000) + * CARD_LOGIN_TIMEOUT_MS Timeout for getAssertion card step (default: 90000) + * PW_HEADLESS Set to "1" for headless mode + * + * Constraint: the test does not read the Android log — all assertions are + * made against visible DOM state and the #log pre element. + */ + +const { test, expect } = require("@playwright/test"); + +const BASE_URL = process.env.K_PHONE_BASE_URL || "http://127.0.0.1:8771"; +const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || "90000"); +const loginTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || "90000"); + +function uniqueUsername() { + return `pw_${Date.now().toString(36)}`; +} + +async function waitForLog(page, expectedText, timeoutMs = 10_000) { + await expect(page.locator("#log")).toContainText(expectedText, { timeout: timeoutMs }); +} + +test.describe("k_phone portal regression", () => { + test( + "enrolls, logs in, checks session status, logs out, and deletes user", + async ({ page }) => { + const username = uniqueUsername(); + + test.setTimeout(registrationTimeoutMs + loginTimeoutMs + 60_000); + + await page.goto(BASE_URL + "/"); + await expect( + page.getByRole("heading", { name: "ChromeCard k_phone Portal" }) + ).toBeVisible(); + + // Clear any leftover localStorage from a previous session so the test + // starts from a clean slate regardless of browser profile state. + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + await test.step("Initial state is unauthenticated", async () => { + await expect(page.locator("#storedUser")).toHaveText("none"); + await expect(page.locator("#sessionActive")).toHaveText("no"); + }); + + await test.step("Enroll user", async () => { + await page.locator("#username").fill(username); + await page.locator("#displayName").fill("Playwright Test"); + // Card step: makeCredential — touch user fingerprint on ChromeCard. + await page.locator("#enrollBtn").click(); + await waitForLog(page, "Enrolled", registrationTimeoutMs); + await expect(page.locator("#storedUser")).toHaveText(username); + }); + + await test.step("Login", async () => { + // Card step: getAssertion — touch user fingerprint on ChromeCard. + await page.locator("#loginBtn").click(); + await waitForLog(page, "Login ok", loginTimeoutMs); + await expect(page.locator("#sessionActive")).toHaveText("yes"); + }); + + await test.step("Session status reflects active session", async () => { + await page.locator("#statusBtn").click(); + await waitForLog(page, "Session status"); + }); + + await test.step("List users includes enrolled user", async () => { + await page.locator("#listBtn").click(); + await waitForLog(page, username); + }); + + await test.step("Logout clears session", async () => { + await page.locator("#logoutBtn").click(); + // "Logout" is a substring of "Logout failed", so assert the semantic + // outcome (sessionActive → no) rather than the log message text. + await expect(page.locator("#sessionActive")).toHaveText("no", { + timeout: 10_000, + }); + }); + + await test.step("Delete user clears stored identity", async () => { + await page.locator("#deleteBtn").click(); + // "Deleted" is not a substring of "Delete failed" — safe to match. + await waitForLog(page, "Deleted"); + await expect(page.locator("#storedUser")).toHaveText("none"); + await expect(page.locator("#sessionActive")).toHaveText("no"); + }); + } + ); + + test("enrollment failure is surfaced in log", async ({ page }) => { + await page.goto(BASE_URL + "/"); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + // Submit enroll with an empty username — server must reject it. + await page.locator("#username").fill(""); + await page.locator("#enrollBtn").click(); + await waitForLog(page, "Enroll failed"); + // No username must have been stored on failure. + await expect(page.locator("#storedUser")).toHaveText("none"); + }); + + test("login without enrollment fails gracefully", async ({ page }) => { + await page.goto(BASE_URL + "/"); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + // Attempt login with a username that is not enrolled. + await page.locator("#username").fill("no_such_user_pw"); + await page.locator("#loginBtn").click(); + await waitForLog(page, "Login failed"); + await expect(page.locator("#sessionActive")).toHaveText("no"); + }); +}); diff --git a/tests/k_phone_proxy.spec.js b/tests/k_phone_proxy.spec.js new file mode 100644 index 0000000..4bba859 --- /dev/null +++ b/tests/k_phone_proxy.spec.js @@ -0,0 +1,204 @@ +/** + * Acceptance tests for k_phone Component 1 (filter_proxy) routing behaviour. + * + * Four tests run serially, building shared state: + * 1. No users — non-gated request passes through directly. + * 2. No users — gated request is rejected (407 Proxy Authentication Required). + * 3. Register user — non-gated request still passes through. + * 4. (User enrolled) gated request succeeds after card assertion. + * + * HTTP proxy requests are made with Node's `http` module so the proxy protocol + * (absolute URI in the request line) is exact and Playwright's browser proxy + * handling is not involved. The portal page is used for enrollment (test 3) + * because that step requires the user to touch the card fingerprint. + * + * Run: + * K_PHONE_PROXY=http://phone-ip:8888 \ + * K_PHONE_BASE_URL=http://phone-ip:8771 \ + * GATED_URL=http://httpbin.org/get \ + * npx playwright test tests/k_phone_proxy.spec.js + * + * Env vars: + * K_PHONE_PROXY Component 1 proxy URL (default: http://127.0.0.1:8888) + * K_PHONE_BASE_URL Component 2 portal URL (default: http://127.0.0.1:8771) + * GATED_URL URL of a gated resource (default: http://httpbin.org/get) + * GATED_METHOD HTTP method for gated request (default: GET) + * UNGATED_URL URL of a non-gated resource (default: http://example.com) + * CARD_REGISTRATION_TIMEOUT_MS makeCredential card step (default: 90000) + * CARD_LOGIN_TIMEOUT_MS getAssertion card step (default: 90000) + * + * Gated host configuration: + * gated_hosts.txt on the phone must contain the host from GATED_URL. + * The app seeds httpbin.org by default; no manual edit needed for the default case. + * For full chain validation against k_server (which verifies the FIDO2 token): + * GATED_URL=http://k-server-ip:8780/resource/counter GATED_METHOD=POST + */ + +const { test, expect } = require('@playwright/test'); +const http = require('http'); + +const PROXY_URL = process.env.K_PHONE_PROXY || 'http://127.0.0.1:8888'; +const PORTAL_URL = process.env.K_PHONE_BASE_URL || 'http://127.0.0.1:8771'; +const GATED_URL = process.env.GATED_URL || 'http://httpbin.org/get'; +const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase(); +const UNGATED_URL = process.env.UNGATED_URL || 'http://example.com'; + +const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || '90000'); +const cardAssertionTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || '90000'); + +function uniqueUsername() { + return `pw_${Date.now().toString(36)}`; +} + +// --------------------------------------------------------------------------- +// HTTP proxy helper — sends one request through Component 1. +// +// Sends `method targetUrl HTTP/1.1` (absolute URI — the proxy protocol) to +// the proxy host:port and returns { status, body }. The caller sets the +// timeout via the options object. +// --------------------------------------------------------------------------- + +function proxyRequest(proxyUrl, method, targetUrl, timeoutMs = 15_000) { + return new Promise((resolve, reject) => { + const proxy = new URL(proxyUrl); + const target = new URL(targetUrl); + + const req = http.request( + { + hostname: proxy.hostname, + port: Number(proxy.port) || 80, + method, + path: targetUrl, // absolute URI → proxy protocol + headers: { Host: target.host }, + }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); + }, + ); + req.setTimeout(timeoutMs, () => req.destroy(new Error(`proxy request to ${targetUrl} timed out after ${timeoutMs} ms`))); + req.on('error', reject); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe.serial('k_phone proxy routing', () => { + let enrolledUser = null; + + // Ensure no users are enrolled before the suite runs so tests 1 and 2 start + // from a clean state — a gated request with no user must be rejected. + test.beforeAll(async ({ request }) => { + const resp = await request.get(`${PORTAL_URL}/enroll/list`); + const { users } = await resp.json(); + for (const u of users ?? []) { + await request.post(`${PORTAL_URL}/enroll/delete`, { + data: { username: u.username }, + headers: { 'Content-Type': 'application/json' }, + }); + } + }); + + // Remove the user enrolled in test 3 after the suite finishes. + test.afterAll(async ({ request }) => { + if (enrolledUser) { + await request.post(`${PORTAL_URL}/enroll/delete`, { + data: { username: enrolledUser }, + headers: { 'Content-Type': 'application/json' }, + }); + } + }); + + // --------------------------------------------------------------------------- + // Test 1: no users — non-gated request passes through. + // + // Component 1 forwards non-gated traffic directly to the target host on + // port 80 without touching Component 2 or the card. + // --------------------------------------------------------------------------- + test('1. no users: non-gated request passes through', async () => { + const { status } = await proxyRequest(PROXY_URL, 'GET', UNGATED_URL); + expect(status).toBeLessThan(500); + }); + + // --------------------------------------------------------------------------- + // Test 2: no users — gated request rejected with 407. + // + // Component 1 calls Component 2 for a Bearer token. Component 2 has no + // enrolled user and returns an error. Component 1 replies with + // 407 Proxy Authentication Required. + // --------------------------------------------------------------------------- + test('2. no users: gated request rejected with 407', async () => { + const { status } = await proxyRequest(PROXY_URL, GATED_METHOD, GATED_URL); + expect(status).toBe(407); + }); + + // --------------------------------------------------------------------------- + // Test 3: register user — non-gated request still passes through. + // + // Card step: makeCredential (touch user fingerprint on ChromeCard). + // --------------------------------------------------------------------------- + test('3. enroll user: non-gated request still passes through', async ({ page }) => { + test.setTimeout(registrationTimeoutMs + 30_000); + + enrolledUser = uniqueUsername(); + + // Enroll via portal — requires card fingerprint for makeCredential. + await page.goto(`${PORTAL_URL}/`); + await page.locator('#username').fill(enrolledUser); + await page.locator('#displayName').fill('Playwright Proxy Test'); + await page.locator('#enrollBtn').click(); + await expect(page.locator('#log')).toContainText('Enrolled', { + timeout: registrationTimeoutMs, + }); + await expect(page.locator('#storedUser')).toHaveText(enrolledUser); + + // Non-gated traffic must still be forwarded directly — enrollment must not + // break the direct-forward path. + const { status } = await proxyRequest(PROXY_URL, 'GET', UNGATED_URL); + expect(status).toBeLessThan(500); + }); + + // --------------------------------------------------------------------------- + // Test 4: enrolled user — gated request succeeds after card assertion. + // + // Card step: getAssertion (touch user fingerprint on ChromeCard). + // + // The 200 response proves: + // - Component 1 fetched a token from Component 2. + // - Component 2 performed a FIDO2 assertion against the enrolled credential. + // - Component 1 forwarded the request to the gated endpoint with the token. + // + // Response body check (both targets): + // httpbin.org — echoes the Authorization: Bearer header in its JSON response. + // k_server — validates the assertion cryptographically and returns + // { ok: true, resource: "counter", value: N }. + // --------------------------------------------------------------------------- + test('4. enrolled user: gated request succeeds — card asserted', async () => { + test.setTimeout(cardAssertionTimeoutMs + 30_000); + + const { status, body } = await proxyRequest( + PROXY_URL, GATED_METHOD, GATED_URL, cardAssertionTimeoutMs, + ); + + // 200 proves the card assertion was performed and the token was accepted. + expect(status).toBe(200); + + // Verify the token was actually forwarded to the target endpoint. + let parsed = null; + try { parsed = JSON.parse(body); } catch (_) {} + + if (parsed?.headers?.Authorization !== undefined) { + // httpbin.org echoes request headers — the Bearer token must be present. + expect(parsed.headers.Authorization).toMatch(/^Bearer /i); + } else if (parsed?.resource !== undefined) { + // k_server validated the assertion and returned the counter value. + expect(parsed.ok).toBe(true); + expect(parsed.resource).toBe('counter'); + expect(typeof parsed.value).toBe('number'); + } + }); +});