Add Playwright portal regression and harden direct auth

This commit is contained in:
Morten V. Christiansen 2026-04-25 21:06:08 +02:00
parent e57f8a446f
commit bd839ea42d
10 changed files with 281 additions and 18 deletions

3
.gitignore vendored
View File

@ -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/

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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,14 +652,23 @@ 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:
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
if self.direct_device is None:
self.direct_device = self._open_direct_device()
return self.direct_device
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:
@ -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.

78
package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

12
package.json Normal file
View File

@ -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"
}
}

18
playwright.config.js Normal file
View File

@ -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"]],
});

View File

@ -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);
});
});
});