diff --git a/.gitignore b/.gitignore index abaa3f3..f100d59 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ __pycache__/ *.pyc tls/ +node_modules/ +playwright-report/ +test-results/ # Keep firmware SDK tree out of this workspace-tracking repo CR_SDK_CK-main/ diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index d5f4446..08d415c 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -187,11 +187,34 @@ REQUESTS=50 PARALLELISM=12 /home/user/chromecard/phase5_chain_regression.sh /home/user/chromecard/phase5_chain_regression.sh --username alice --client-host k_client ``` +For the browser-facing `k_client` page, use the Playwright regression spec: + +```bash +npm install +npx playwright install +npm run test:k-client +``` + +Notes: + +- default target is `http://127.0.0.1:8766` +- override with `PORTAL_BASE_URL=http://127.0.0.1:8766` +- the spec expects manual card confirmation during register and login +- timeouts can be tuned with `CARD_REGISTRATION_TIMEOUT_MS` and `CARD_LOGIN_TIMEOUT_MS` +- from this host, a forwarded portal URL was used successfully: + - `PORTAL_BASE_URL=http://127.0.0.1:18766 npm run test:k-client` + Verified result on 2026-04-25: - Live split-VM chain passed end-to-end. - Login, session status, counter reuse, and logout all worked from `k_client`. - A `20` request / `8` worker concurrency burst returned unique, gap-free counter values `23..42`. +- The Playwright browser regression for `k_client_portal.py` also passed end-to-end: + - register + - login + - protected counter + - logout + - unregister ## Current Limitation diff --git a/Setup.md b/Setup.md index 80d938b..2c062ef 100644 --- a/Setup.md +++ b/Setup.md @@ -371,6 +371,13 @@ Session note (2026-04-25, k_client browser flow page): - lets the operator select a listed user into the username field - lets the operator unregister users from the browser page - login now uses the current username field instead of only the portal's last remembered user +- Added a browser regression harness for the `k_client` page: + - `/home/user/chromecard/tests/k_client_portal.spec.js` + - `/home/user/chromecard/playwright.config.js` + - `/home/user/chromecard/package.json` + - intended flow: register, login, call `k_server`, logout, unregister + - verified passing live on 2026-04-25 from this host via forwarded portal URL: + - `PORTAL_BASE_URL=http://127.0.0.1:18766 npm run test:k-client` - It also makes the negative path explicit: - if login is denied on the card, the page reports that `k_server` was not called - Primary browser-facing app logic still lives on `k_proxy`, but the `k_client` page is now a concrete demo/control surface rather than just a redirect. diff --git a/Workplan.md b/Workplan.md index 8be6eef..ded6c01 100644 --- a/Workplan.md +++ b/Workplan.md @@ -324,6 +324,8 @@ Status (2026-04-25): - the `k_client` page now also lists registered users from `k_proxy` - the `k_client` page can unregister users from the browser - the portal login action now uses the current username field instead of only the remembered local user + - a Playwright regression spec now exists for the browser flow in `tests/k_client_portal.spec.js` + - the Playwright browser regression has now passed end-to-end once from this host against a forwarded portal URL - Verified end-to-end through the portal: - enroll `alice` - login succeeds diff --git a/k_client_portal.py b/k_client_portal.py index 82f4b13..b396c13 100644 --- a/k_client_portal.py +++ b/k_client_portal.py @@ -550,10 +550,19 @@ class EnrollmentRecord: class ClientState: - def __init__(self, proxy_base_url: str, proxy_ca_file: str | None, enroll_db: Path): + def __init__( + self, + proxy_base_url: str, + proxy_ca_file: str | None, + enroll_db: Path, + interactive_timeout_s: float = 90.0, + default_timeout_s: float = 10.0, + ): self.proxy_base_url = proxy_base_url.rstrip("/") self.proxy_ca_file = proxy_ca_file self.enroll_db = enroll_db + self.interactive_timeout_s = interactive_timeout_s + self.default_timeout_s = default_timeout_s self.lock = threading.Lock() self.preferred_enrollment: EnrollmentRecord | None = None self.session_token: str | None = None @@ -565,7 +574,14 @@ class ClientState: return ssl.create_default_context(cafile=self.proxy_ca_file) return None - def _proxy_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]: + def _proxy_json( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + *, + timeout_s: float | None = None, + ) -> tuple[int, dict[str, Any]]: req = Request(f"{self.proxy_base_url}{path}", method=method) req.add_header("Content-Type", "application/json") token = self.get_session_token() @@ -573,7 +589,12 @@ class ClientState: req.add_header("Authorization", f"Bearer {token}") body = json.dumps(payload or {}).encode("utf-8") try: - with urlopen(req, data=body, timeout=10, context=self._ssl_context()) as resp: + with urlopen( + req, + data=body, + timeout=timeout_s or self.default_timeout_s, + context=self._ssl_context(), + ) as resp: return resp.status, json.loads(resp.read().decode("utf-8")) except HTTPError as exc: try: @@ -605,7 +626,12 @@ class ClientState: username = username.strip() if not username: return {"ok": False, "error": "username required"} - status, data = self._proxy_json("POST", "/enroll/register", {"username": username}) + status, data = self._proxy_json( + "POST", + "/enroll/register", + {"username": username}, + timeout_s=self.interactive_timeout_s, + ) if status != 200: return data with self.lock: @@ -660,7 +686,12 @@ class ClientState: else: return 400, {"ok": False, "error": "no enrolled user"} - status, data = self._proxy_json("POST", "/session/login", {"username": username}) + status, data = self._proxy_json( + "POST", + "/session/login", + {"username": username}, + timeout_s=self.interactive_timeout_s, + ) if status == 200 and data.get("session_token"): with self.lock: self.preferred_enrollment = EnrollmentRecord(username=username) diff --git a/k_proxy_app.py b/k_proxy_app.py index c411220..f762927 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -516,6 +516,8 @@ class ProxyState: self.rp_id = rp_id self.origin = origin self.direct_device_path = direct_device_path + self.direct_device_configured_path = direct_device_path + self.direct_device_active_path: str | None = None self.lock = threading.Lock() self.direct_device_lock = threading.RLock() self.direct_device: CtapHidDevice | None = None @@ -616,7 +618,7 @@ class ProxyState: return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=ProxyUserInteraction()) def _direct_device_candidates(self) -> list[str]: - configured = str(self.direct_device_path).strip() + configured = str(self.direct_device_configured_path).strip() candidates: list[str] = [] if configured: candidates.append(configured) @@ -628,13 +630,20 @@ class ProxyState: def _open_direct_device(self) -> CtapHidDevice: last_exc: Exception | None = None + recoverable: tuple[type[Exception], ...] = (FileNotFoundError, PermissionError) for candidate in self._direct_device_candidates(): try: descriptor = get_descriptor(candidate) device = CtapHidDevice(descriptor, open_connection(descriptor)) - self.direct_device_path = candidate + self.direct_device_active_path = candidate return device except Exception as exc: + # USB re-enumeration can leave stale hidraw paths behind, and some sibling + # nodes are vendor interfaces that are not readable to the normal user. + # Skip those and keep probing for a usable CTAPHID node. + if isinstance(exc, recoverable): + last_exc = exc + continue last_exc = exc if last_exc is None: raise FileNotFoundError(f"no hidraw devices available for direct auth (configured {self.direct_device_path})") @@ -643,15 +652,24 @@ class ProxyState: def _get_direct_device(self, *, force_reopen: bool = False) -> CtapHidDevice: with self.direct_device_lock: if force_reopen and self.direct_device is not None: - try: - self.direct_device.close() - except Exception: - pass - self.direct_device = None + self._drop_direct_device_locked() if self.direct_device is None: self.direct_device = self._open_direct_device() return self.direct_device + def _drop_direct_device_locked(self) -> None: + try: + if self.direct_device is not None: + self.direct_device.close() + except Exception: + pass + self.direct_device = None + self.direct_device_active_path = None + + def _drop_direct_device(self) -> None: + with self.direct_device_lock: + self._drop_direct_device_locked() + def _with_direct_ctap2(self, action): with self.direct_device_lock: last_exc: Exception | None = None @@ -661,12 +679,7 @@ class ProxyState: return action(Ctap2(device)) except Exception as exc: last_exc = exc - try: - if self.direct_device is not None: - self.direct_device.close() - except Exception: - pass - self.direct_device = None + self._drop_direct_device_locked() assert last_exc is not None raise last_exc @@ -768,6 +781,9 @@ class ProxyState: with self.lock: self.enrollments[canonical] = enrollment self._save_enrollments_locked() + # Freshly reopen for later assertion flow; some cards do not like immediate + # reuse of the same hidraw handle across makeCredential -> getAssertion. + self._drop_direct_device() return enrollment def register_enrollment(self, username: str, display_name: str | None) -> Enrollment: @@ -843,6 +859,9 @@ class ProxyState: if not enrollment.credential_data_b64: return False, "user has no registered credential" try: + # Start assertion from a fresh device open rather than reusing the + # post-registration handle, which has been flaky on this stack. + self._drop_direct_device() credential = AttestedCredentialData(b64u_decode(enrollment.credential_data_b64)) # Keep UV explicitly discouraged here. On the current card/library stack, # asking for stronger UV flows immediately trips PIN/UV capability errors. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8710030 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "chromecard-browser-regression", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chromecard-browser-regression", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "^1.54.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..17537fe --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "chromecard-browser-regression", + "private": true, + "version": "0.1.0", + "description": "Playwright regression checks for the k_client browser flow", + "scripts": { + "test:k-client": "playwright test tests/k_client_portal.spec.js" + }, + "devDependencies": { + "@playwright/test": "^1.54.2" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..67a2daf --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,18 @@ +// Minimal local Playwright config for the k_client browser flow. +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests", + timeout: 180_000, + expect: { + timeout: 15_000, + }, + use: { + baseURL: process.env.PORTAL_BASE_URL || "http://127.0.0.1:8766", + headless: process.env.PW_HEADLESS === "1", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + reporter: [["list"]], +}); diff --git a/tests/k_client_portal.spec.js b/tests/k_client_portal.spec.js new file mode 100644 index 0000000..424a375 --- /dev/null +++ b/tests/k_client_portal.spec.js @@ -0,0 +1,70 @@ +const { test, expect } = require("@playwright/test"); + +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 waitForActionResult(page, action, expectedText, timeoutMs) { + const flowResult = page.locator("#flowResult"); + await action(); + await expect(flowResult).toContainText(expectedText, { timeout: timeoutMs }); +} + +test.describe("k_client portal regression", () => { + test("registers, logs in, reads counter, logs out, and unregisters", async ({ page }) => { + const username = uniqueUsername(); + const usersList = page.locator("#usersList"); + const flowResult = page.locator("#flowResult"); + const sessionLine = page.locator("#stateSession"); + + test.setTimeout(registrationTimeoutMs + loginTimeoutMs + 90_000); + + await page.goto("/"); + await expect(page.getByRole("heading", { name: "ChromeCard Client Flow" })).toBeVisible(); + await page.getByLabel("Username").fill(username); + + await test.step("Register user", async () => { + // Card step: press yes on the registration prompt. + await waitForActionResult( + page, + () => page.getByRole("button", { name: "Register User" }).click(), + "User registration succeeded.", + registrationTimeoutMs + ); + await expect(usersList).toContainText(username); + }); + + await test.step("Login", async () => { + // Card step: press yes on the authentication prompt. + await waitForActionResult( + page, + () => page.getByRole("button", { name: "Login" }).click(), + "Login succeeded. You can now call k_server.", + loginTimeoutMs + ); + await expect(sessionLine).toContainText("Session active: yes"); + }); + + await test.step("Call k_server counter", async () => { + await page.getByRole("button", { name: "Call k_server" }).click(); + await expect(flowResult).toContainText("k_server was reached. Counter value:"); + }); + + await test.step("Logout", async () => { + await page.getByRole("button", { name: "Logout" }).click(); + await expect(flowResult).toContainText("Session cleared."); + await expect(sessionLine).toContainText("Session active: no"); + }); + + await test.step("Unregister user", async () => { + const row = usersList.locator(".user-row", { hasText: username }); + await expect(row).toBeVisible(); + await row.getByRole("button", { name: "Unregister" }).click(); + await expect(flowResult).toContainText(`User ${username} was unregistered.`); + await expect(usersList).not.toContainText(username); + }); + }); +});