Add Phase 6 client portal and enrollment flow

This commit is contained in:
Morten V. Christiansen 2026-04-25 01:42:03 +02:00
parent 46fb878f8d
commit 4893eb8312
4 changed files with 664 additions and 0 deletions

View File

@ -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 - 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 - 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=<name>`
- 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): 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`. - Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`.
- Forwarders start and bind locally: - Forwarders start and bind locally:

View File

@ -262,6 +262,29 @@ Exit criteria:
Exit criteria: Exit criteria:
- Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`. - 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 ## Phase 6.5: Concurrency and Multi-Client Test Setup
1. Single-VM concurrency tests. 1. Single-VM concurrency tests.

483
k_client_portal.py Normal file
View File

@ -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 = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard Client Portal</title>
<style>
:root {
--bg: #f4efe4;
--panel: #fffaf1;
--ink: #1a1712;
--muted: #6d6456;
--line: #d7c9b2;
--accent: #1c6b57;
--accent-2: #ae6a2b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
background:
radial-gradient(circle at top left, rgba(174,106,43,0.16), transparent 34%),
linear-gradient(180deg, #f9f3e8 0%, var(--bg) 100%);
color: var(--ink);
}
main {
max-width: 880px;
margin: 0 auto;
padding: 32px 20px 56px;
}
.hero {
padding: 22px 24px;
border: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255,250,241,0.98), rgba(243,232,214,0.95));
box-shadow: 0 18px 40px rgba(55, 41, 19, 0.08);
}
h1 {
margin: 0 0 8px;
font-size: clamp(2rem, 4vw, 3.4rem);
line-height: 0.95;
letter-spacing: -0.04em;
}
.subtitle {
margin: 0;
color: var(--muted);
max-width: 62ch;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 18px;
margin-top: 20px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
padding: 18px;
}
.card h2 {
margin: 0 0 12px;
font-size: 1.15rem;
}
label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-size: 0.92rem;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
color: var(--ink);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
button {
border: 0;
padding: 10px 14px;
font: inherit;
color: #fff;
background: var(--accent);
cursor: pointer;
}
button.secondary { background: var(--accent-2); }
.status {
display: grid;
gap: 8px;
margin-top: 14px;
color: var(--muted);
}
pre {
margin: 18px 0 0;
min-height: 280px;
padding: 16px;
overflow: auto;
border: 1px solid var(--line);
background: #16130f;
color: #efe7da;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.9rem;
line-height: 1.45;
}
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard Client Portal</h1>
<p class="subtitle">
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.
</p>
</section>
<section class="grid">
<div class="card">
<h2>Enrollment</h2>
<label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off">
<div class="actions">
<button id="enrollBtn">Enroll User</button>
</div>
<div class="status">
<div>Enrolled user: <strong id="enrolledUser">none</strong></div>
<div>Session active: <strong id="sessionActive">no</strong></div>
</div>
</div>
<div class="card">
<h2>Session Flow</h2>
<div class="actions">
<button id="loginBtn">Login</button>
<button id="statusBtn" class="secondary">Status</button>
<button id="counterBtn">Counter</button>
<button id="logoutBtn" class="secondary">Logout</button>
</div>
</div>
</section>
<pre id="log"></pre>
</main>
<script>
const logNode = document.getElementById("log");
const enrolledUserNode = document.getElementById("enrolledUser");
const sessionActiveNode = document.getElementById("sessionActive");
const usernameNode = document.getElementById("username");
function log(message, payload) {
const stamp = new Date().toLocaleTimeString();
let line = `[${stamp}] ${message}`;
if (payload !== undefined) {
line += "\\n" + JSON.stringify(payload, null, 2);
}
logNode.textContent = line + "\\n\\n" + logNode.textContent;
}
async function api(path, payload) {
const resp = await fetch(path, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload || {})
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(JSON.stringify(data));
}
return data;
}
async function refreshState() {
const resp = await fetch("/api/client/state");
const data = await resp.json();
enrolledUserNode.textContent = data.enrolled_username || "none";
sessionActiveNode.textContent = data.session_active ? "yes" : "no";
if (data.enrolled_username && !usernameNode.value) {
usernameNode.value = data.enrolled_username;
}
}
document.getElementById("enrollBtn").addEventListener("click", async () => {
try {
const data = await api("/api/enroll", {username: usernameNode.value});
log("Enrollment updated", data);
await refreshState();
} catch (err) {
log("Enrollment failed", {error: err.message});
}
});
document.getElementById("loginBtn").addEventListener("click", async () => {
try {
const data = await api("/api/login");
log("Login ok", data);
await refreshState();
} catch (err) {
log("Login failed", {error: err.message});
}
});
document.getElementById("statusBtn").addEventListener("click", async () => {
try {
const data = await api("/api/status");
log("Session status", data);
await refreshState();
} catch (err) {
log("Status failed", {error: err.message});
}
});
document.getElementById("counterBtn").addEventListener("click", async () => {
try {
const data = await api("/api/resource/counter");
log("Counter response", data);
await refreshState();
} catch (err) {
log("Counter failed", {error: err.message});
}
});
document.getElementById("logoutBtn").addEventListener("click", async () => {
try {
const data = await api("/api/logout");
log("Logout response", data);
await refreshState();
} catch (err) {
log("Logout failed", {error: err.message});
}
});
refreshState().catch((err) => log("Initial state load failed", {error: err.message}));
</script>
</body>
</html>
"""
@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())

View File

@ -23,6 +23,7 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlparse from urllib.parse import urlparse
@ -35,6 +36,12 @@ class Session:
expires_at: float expires_at: float
@dataclass
class Enrollment:
username: str
enrolled_at: int
class ProxyState: class ProxyState:
def __init__( def __init__(
self, self,
@ -43,14 +50,18 @@ class ProxyState:
server_base_url: str, server_base_url: str,
server_ca_file: str | None, server_ca_file: str | None,
proxy_token: str, proxy_token: str,
enrollment_db: Path,
): ):
self.session_ttl_s = session_ttl_s self.session_ttl_s = session_ttl_s
self.auth_command = auth_command self.auth_command = auth_command
self.server_base_url = server_base_url.rstrip("/") self.server_base_url = server_base_url.rstrip("/")
self.server_ca_file = server_ca_file self.server_ca_file = server_ca_file
self.proxy_token = proxy_token self.proxy_token = proxy_token
self.enrollment_db = enrollment_db
self.lock = threading.Lock() self.lock = threading.Lock()
self.sessions: dict[str, Session] = {} self.sessions: dict[str, Session] = {}
self.enrollments: dict[str, Enrollment] = {}
self._load_enrollments()
def _now(self) -> float: def _now(self) -> float:
return time.time() return time.time()
@ -84,6 +95,48 @@ class ProxyState:
self._gc_locked() self._gc_locked()
return len(self.sessions) 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]: def authenticate_with_card(self) -> tuple[bool, str]:
try: try:
proc = subprocess.run( proc = subprocess.run(
@ -179,6 +232,9 @@ class Handler(BaseHTTPRequestHandler):
}, },
) )
return return
if path.startswith("/enroll/status"):
self._enroll_status()
return
self.send_error(404) self.send_error(404)
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
@ -186,6 +242,9 @@ class Handler(BaseHTTPRequestHandler):
if path == "/session/login": if path == "/session/login":
self._session_login() self._session_login()
return return
if path == "/enroll/register":
self._enroll_register()
return
if path == "/session/status": if path == "/session/status":
self._session_status() self._session_status()
return return
@ -208,6 +267,9 @@ class Handler(BaseHTTPRequestHandler):
if not username: if not username:
self._json(400, {"ok": False, "error": "username required"}) self._json(400, {"ok": False, "error": "username required"})
return 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() ok, message = self.state.authenticate_with_card()
if not ok: 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: def _session_status(self) -> None:
got = self._require_session() got = self._require_session()
if not got: if not got:
@ -296,6 +407,11 @@ def parse_args() -> argparse.Namespace:
default="dev-proxy-token", default="dev-proxy-token",
help="Shared token to authorize requests to k_server", 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() return parser.parse_args()
@ -312,6 +428,7 @@ def main() -> int:
server_base_url=args.server_base_url, server_base_url=args.server_base_url,
server_ca_file=args.server_ca_file, server_ca_file=args.server_ca_file,
proxy_token=args.proxy_token, proxy_token=args.proxy_token,
enrollment_db=Path(args.enrollment_db),
) )
Handler.state = state Handler.state = state
server = ThreadingHTTPServer((args.host, args.port), Handler) server = ThreadingHTTPServer((args.host, args.port), Handler)