diff --git a/Setup.md b/Setup.md index 484ae49..187fe40 100644 --- a/Setup.md +++ b/Setup.md @@ -304,6 +304,47 @@ Session note (2026-04-25, Phase 2.5 ownership and concurrency): - race-free behavior is currently defined for session CRUD and counter increments inside one process per VM - persistence, distributed session authority, and multi-proxy/multi-server coordination are not implemented yet +Session note (2026-04-25, Phase 6 client portal prototype): +- Added browser-facing client process: + - `/home/user/chromecard/k_client_portal.py` +- Current Phase 6 prototype shape: + - portal runs in `k_client` on `http://127.0.0.1:8766` + - portal keeps local enrolled username state in `k_client` + - portal calls `k_proxy` over the validated TLS forward `https://127.0.0.1:9771` +- Current local enrollment model: + - enrollment is a client-local username selection stored by the portal + - no dedicated server-side enrollment API exists yet +- Verified portal API flow in `k_client`: + - `GET /health` returns `ok=true` + - `POST /api/enroll` with `alice` succeeds + - `POST /api/login` succeeds and returns a proxy session token + - `POST /api/status` succeeds + - `POST /api/resource/counter` succeeds twice with upstream values `3` and `4` + - `POST /api/logout` succeeds +- Current implication: + - `k_client` now has a concrete client-side process instead of only runbook curls + - browser-facing flow is now available through the local portal + - next hardening step is to replace client-local enrollment with the intended enrollment contract and decide whether browser traffic should eventually talk to `k_proxy` directly or continue through a local client portal + +Session note (2026-04-25, Phase 6 enrollment contract): +- Added proxy-side enrollment API and storage: + - `POST /enroll/register` + - `GET /enroll/status?username=` + - persisted prototype store at `/home/user/chromecard/k_proxy_enrollments.json` in `k_proxy` +- Current enrollment authority is now `k_proxy`, not the `k_client` portal. +- Current portal behavior: + - portal enrollment calls `k_proxy` over TLS + - portal keeps only a preferred local username for convenience + - portal login now depends on proxy-side enrollment existing +- Verified behavior: + - direct proxy login for unenrolled `bob` returns `{"ok": false, "error": "user not enrolled", ...}` + - portal enrollment of `alice` succeeds and persists in proxy-side enrollment storage + - proxy enrollment status for `alice` returns `ok=true` + - portal login and protected counter access still succeed after enrollment +- Practical meaning: + - Phase 6 now has a real `k_client -> k_proxy` enrollment request path + - the remaining gap is not basic routing; it is deciding the final enrollment semantics and whether the browser should stay behind a local portal or talk to `k_proxy` directly + Session note (2026-04-25, in-VM forwarding test): - Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`. - Forwarders start and bind locally: diff --git a/Workplan.md b/Workplan.md index 80c13e6..f28638b 100644 --- a/Workplan.md +++ b/Workplan.md @@ -262,6 +262,29 @@ Exit criteria: Exit criteria: - Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`. +Status (2026-04-25): +- Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`. +- Current prototype flow: + - browser talks to local portal on `k_client` + - portal stores only a preferred username locally + - portal calls `k_proxy` over `https://127.0.0.1:9771` + - `k_proxy` continues to authenticate with the card and forward to `k_server` +- Verified end-to-end through the portal: + - enroll `alice` + - login succeeds + - session status succeeds + - protected counter succeeds repeatedly with session reuse + - logout succeeds +- Enrollment contract progress: + - `k_proxy` now exposes prototype enrollment endpoints + - proxy-side enrollment storage exists and is checked before login is allowed + - `k_client` portal enrollment now routes to `k_proxy` over TLS instead of remaining client-local only +- Phase 6 is materially further along for the current prototype shape: + - client-side process exists + - login/resource flow is integrated + - enrollment now has a real client->proxy path + - final enrollment semantics and UI shape are still provisional + ## Phase 6.5: Concurrency and Multi-Client Test Setup 1. Single-VM concurrency tests. diff --git a/k_client_portal.py b/k_client_portal.py new file mode 100644 index 0000000..f50d420 --- /dev/null +++ b/k_client_portal.py @@ -0,0 +1,483 @@ +#!/usr/bin/env python3 +""" +Minimal browser-facing client portal for Phase 6 bring-up. + +This runs in k_client, keeps a local preferred username, and talks to k_proxy +over the localhost-forwarded TLS endpoint. +""" + +from __future__ import annotations + +import argparse +import json +import ssl +import threading +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + + +HTML = """ + + + + + ChromeCard Client Portal + + + +
+
+

ChromeCard Client Portal

+

+ Prototype browser flow for k_client. Enrollment state lives here. Login, + session status, counter access, and logout go through the current k_proxy TLS API. +

+
+ +
+
+

Enrollment

+ + +
+ +
+
+
Enrolled user: none
+
Session active: no
+
+
+ +
+

Session Flow

+
+ + + + +
+
+
+ +

+  
+ + + + +""" + + +@dataclass +class EnrollmentRecord: + username: str + + +class ClientState: + def __init__(self, proxy_base_url: str, proxy_ca_file: str | None, enroll_db: Path): + self.proxy_base_url = proxy_base_url.rstrip("/") + self.proxy_ca_file = proxy_ca_file + self.enroll_db = enroll_db + self.lock = threading.Lock() + self.preferred_enrollment: EnrollmentRecord | None = None + self.session_token: str | None = None + self.session_expires_at: int | None = None + self._load_preferred_enrollment() + + def _ssl_context(self): + if self.proxy_base_url.startswith("https://"): + 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]]: + req = Request(f"{self.proxy_base_url}{path}", method=method) + req.add_header("Content-Type", "application/json") + token = self.get_session_token() + if token: + 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: + return resp.status, json.loads(resp.read().decode("utf-8")) + except HTTPError as exc: + try: + return exc.code, json.loads(exc.read().decode("utf-8")) + except Exception: + return exc.code, {"ok": False, "error": f"proxy http error {exc.code}"} + except URLError as exc: + return 502, {"ok": False, "error": f"proxy unavailable: {exc.reason}"} + except Exception as exc: + return 502, {"ok": False, "error": f"proxy call failed: {exc}"} + + def _load_preferred_enrollment(self) -> None: + if not self.enroll_db.exists(): + return + try: + data = json.loads(self.enroll_db.read_text()) + username = str(data.get("username", "")).strip() + if username: + self.preferred_enrollment = EnrollmentRecord(username=username) + except Exception: + self.preferred_enrollment = None + + def _save_preferred_enrollment_locked(self) -> None: + self.enroll_db.parent.mkdir(parents=True, exist_ok=True) + payload = {"username": self.preferred_enrollment.username if self.preferred_enrollment else None} + self.enroll_db.write_text(json.dumps(payload, indent=2) + "\n") + + def enroll(self, username: str) -> dict[str, Any]: + username = username.strip() + if not username: + return {"ok": False, "error": "username required"} + status, data = self._proxy_json("POST", "/enroll/register", {"username": username}) + if status != 200: + return data + with self.lock: + self.preferred_enrollment = EnrollmentRecord(username=username) + self._save_preferred_enrollment_locked() + self.session_token = None + self.session_expires_at = None + return { + "ok": True, + "enrolled_username": username, + "proxy_enrollment": data, + } + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return { + "ok": True, + "enrolled_username": self.preferred_enrollment.username if self.preferred_enrollment else None, + "session_active": bool(self.session_token), + "session_expires_at": self.session_expires_at, + "proxy_base_url": self.proxy_base_url, + } + + def get_session_token(self) -> str | None: + with self.lock: + return self.session_token + + def login(self) -> tuple[int, dict[str, Any]]: + with self.lock: + if not self.preferred_enrollment: + return 400, {"ok": False, "error": "no enrolled user"} + username = self.preferred_enrollment.username + + status, data = self._proxy_json("POST", "/session/login", {"username": username}) + if status == 200 and data.get("session_token"): + with self.lock: + self.session_token = data["session_token"] + self.session_expires_at = int(data.get("expires_at", 0)) or None + return status, data + + def status(self) -> tuple[int, dict[str, Any]]: + return self._proxy_json("POST", "/session/status") + + def counter(self) -> tuple[int, dict[str, Any]]: + return self._proxy_json("POST", "/resource/counter") + + def logout(self) -> tuple[int, dict[str, Any]]: + status, data = self._proxy_json("POST", "/session/logout") + if status == 200: + with self.lock: + self.session_token = None + self.session_expires_at = None + return status, data + + +class Handler(BaseHTTPRequestHandler): + state: ClientState + + def _json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _html(self, body: str) -> None: + data = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _read_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/": + self._html(HTML) + return + if path == "/health": + self._json(200, {"ok": True, "service": "k_client_portal", "time": int(time.time())}) + return + if path == "/api/client/state": + self._json(200, self.state.snapshot()) + return + self.send_error(404) + + def do_POST(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/api/enroll": + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return + result = self.state.enroll(str(data.get("username", ""))) + self._json(200 if result.get("ok") else 400, result) + return + if path == "/api/login": + status, data = self.state.login() + self._json(status, data) + return + if path == "/api/status": + status, data = self.state.status() + self._json(status, data) + return + if path == "/api/resource/counter": + status, data = self.state.counter() + self._json(status, data) + return + if path == "/api/logout": + status, data = self.state.logout() + self._json(status, data) + return + self.send_error(404) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run browser-facing client portal in k_client") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8766) + parser.add_argument("--proxy-base-url", default="https://127.0.0.1:9771") + parser.add_argument("--proxy-ca-file", help="CA certificate used to verify k_proxy HTTPS certificate") + parser.add_argument("--enroll-db", default="/home/user/chromecard/k_client_enrollment.json") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.proxy_base_url.startswith("https://") and not args.proxy_ca_file: + raise SystemExit("--proxy-ca-file is required when --proxy-base-url uses https") + + Handler.state = ClientState( + proxy_base_url=args.proxy_base_url, + proxy_ca_file=args.proxy_ca_file, + enroll_db=Path(args.enroll_db), + ) + server = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"k_client_portal listening on http://{args.host}:{args.port}") + server.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/k_proxy_app.py b/k_proxy_app.py index 019c712..68fa94f 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -23,6 +23,7 @@ import threading import time from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlparse @@ -35,6 +36,12 @@ class Session: expires_at: float +@dataclass +class Enrollment: + username: str + enrolled_at: int + + class ProxyState: def __init__( self, @@ -43,14 +50,18 @@ class ProxyState: server_base_url: str, server_ca_file: str | None, proxy_token: str, + enrollment_db: Path, ): self.session_ttl_s = session_ttl_s self.auth_command = auth_command self.server_base_url = server_base_url.rstrip("/") self.server_ca_file = server_ca_file self.proxy_token = proxy_token + self.enrollment_db = enrollment_db self.lock = threading.Lock() self.sessions: dict[str, Session] = {} + self.enrollments: dict[str, Enrollment] = {} + self._load_enrollments() def _now(self) -> float: return time.time() @@ -84,6 +95,48 @@ class ProxyState: self._gc_locked() return len(self.sessions) + def _load_enrollments(self) -> None: + if not self.enrollment_db.exists(): + return + try: + payload = json.loads(self.enrollment_db.read_text()) + users = payload.get("users", []) + for item in users: + username = str(item.get("username", "")).strip() + if not username: + continue + enrolled_at = int(item.get("enrolled_at", int(self._now()))) + self.enrollments[username] = Enrollment(username=username, enrolled_at=enrolled_at) + except Exception: + self.enrollments = {} + + def _save_enrollments_locked(self) -> None: + self.enrollment_db.parent.mkdir(parents=True, exist_ok=True) + users = [ + {"username": enrollment.username, "enrolled_at": enrollment.enrolled_at} + for enrollment in sorted(self.enrollments.values(), key=lambda item: item.username) + ] + self.enrollment_db.write_text(json.dumps({"users": users}, indent=2) + "\n") + + def register_enrollment(self, username: str) -> tuple[bool, Enrollment]: + username = username.strip() + enrolled_at = int(self._now()) + with self.lock: + existing = self.enrollments.get(username) + if existing: + return False, existing + enrollment = Enrollment(username=username, enrolled_at=enrolled_at) + self.enrollments[username] = enrollment + self._save_enrollments_locked() + return True, enrollment + + def get_enrollment(self, username: str) -> Enrollment | None: + with self.lock: + return self.enrollments.get(username.strip()) + + def has_enrollment(self, username: str) -> bool: + return self.get_enrollment(username) is not None + def authenticate_with_card(self) -> tuple[bool, str]: try: proc = subprocess.run( @@ -179,6 +232,9 @@ class Handler(BaseHTTPRequestHandler): }, ) return + if path.startswith("/enroll/status"): + self._enroll_status() + return self.send_error(404) def do_POST(self) -> None: # noqa: N802 @@ -186,6 +242,9 @@ class Handler(BaseHTTPRequestHandler): if path == "/session/login": self._session_login() return + if path == "/enroll/register": + self._enroll_register() + return if path == "/session/status": self._session_status() return @@ -208,6 +267,9 @@ class Handler(BaseHTTPRequestHandler): if not username: self._json(400, {"ok": False, "error": "username required"}) return + if not self.state.has_enrollment(username): + self._json(403, {"ok": False, "error": "user not enrolled", "username": username}) + return ok, message = self.state.authenticate_with_card() if not ok: @@ -227,6 +289,55 @@ class Handler(BaseHTTPRequestHandler): }, ) + def _enroll_register(self) -> None: + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return + + username = str(data.get("username", "")).strip() + if not username: + self._json(400, {"ok": False, "error": "username required"}) + return + + created, enrollment = self.state.register_enrollment(username) + self._json( + 200, + { + "ok": True, + "username": enrollment.username, + "enrolled_at": enrollment.enrolled_at, + "created": created, + }, + ) + + def _enroll_status(self) -> None: + parsed = urlparse(self.path) + query = {} + if parsed.query: + for chunk in parsed.query.split("&"): + if "=" not in chunk: + continue + key, value = chunk.split("=", 1) + query[key] = value + username = query.get("username", "").strip() + if not username: + self._json(400, {"ok": False, "error": "username query required"}) + return + enrollment = self.state.get_enrollment(username) + if not enrollment: + self._json(404, {"ok": False, "error": "user not enrolled", "username": username}) + return + self._json( + 200, + { + "ok": True, + "username": enrollment.username, + "enrolled_at": enrollment.enrolled_at, + }, + ) + def _session_status(self) -> None: got = self._require_session() if not got: @@ -296,6 +407,11 @@ def parse_args() -> argparse.Namespace: default="dev-proxy-token", help="Shared token to authorize requests to k_server", ) + parser.add_argument( + "--enrollment-db", + default="/home/user/chromecard/k_proxy_enrollments.json", + help="JSON file used to persist enrolled usernames for the prototype", + ) return parser.parse_args() @@ -312,6 +428,7 @@ def main() -> int: server_base_url=args.server_base_url, server_ca_file=args.server_ca_file, proxy_token=args.proxy_token, + enrollment_db=Path(args.enrollment_db), ) Handler.state = state server = ThreadingHTTPServer((args.host, args.port), Handler)