k_card/k_client_portal.py

833 lines
27 KiB
Python

#!/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 Flow</title>
<style>
:root {
--bg: #f3efe8;
--panel: #fffdf8;
--ink: #181614;
--muted: #655f56;
--line: #d9cfbf;
--accent: #0c6a60;
--accent-2: #8a5b2b;
--ok: #17653c;
--warn: #8f5b00;
--bad: #8a1f28;
--shadow: rgba(55, 41, 19, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
background:
radial-gradient(circle at top left, rgba(12,106,96,0.12), transparent 34%),
linear-gradient(180deg, #f9f3e8 0%, var(--bg) 100%);
color: var(--ink);
}
main {
max-width: 980px;
margin: 0 auto;
padding: 32px 20px 56px;
}
.hero, .panel {
padding: 22px 24px;
border: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255,253,248,0.98), rgba(242,237,228,0.94));
box-shadow: 0 18px 40px var(--shadow);
}
.hero {
margin-bottom: 18px;
}
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: minmax(0, 1.3fr) minmax(300px, 0.9fr);
gap: 18px;
align-items: start;
}
.stack {
display: grid;
gap: 18px;
}
.actions, .row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.actions {
margin-top: 18px;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
color: var(--ink);
}
label {
display: grid;
gap: 6px;
margin-top: 14px;
color: var(--muted);
font-size: 0.95rem;
}
button {
text-decoration: none;
border: 0;
padding: 10px 14px;
font: inherit;
color: #fff;
background: var(--accent);
cursor: pointer;
}
button.secondary { background: var(--accent-2); }
button.ghost {
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.status {
display: grid;
gap: 12px;
}
.status-card {
padding: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.86);
}
.status-card h2 {
margin: 0 0 6px;
font-size: 1rem;
}
.status-line {
font-size: 0.95rem;
color: var(--muted);
}
#usersList {
display: grid;
gap: 8px;
margin-top: 12px;
}
.user-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.86);
}
.user-meta {
display: grid;
gap: 2px;
}
.user-name {
font-weight: 600;
}
.user-subtle {
color: var(--muted);
font-size: 0.9rem;
}
.user-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.small {
padding: 8px 10px;
font-size: 0.92rem;
}
.badge {
display: inline-block;
padding: 4px 8px;
border: 1px solid var(--line);
font-size: 0.86rem;
background: #fff;
color: var(--ink);
margin-right: 6px;
margin-bottom: 6px;
}
.timeline {
display: grid;
gap: 10px;
margin-top: 16px;
}
.step {
display: grid;
grid-template-columns: 32px 1fr;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.84);
}
.step-index {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid var(--line);
background: #fff;
font-size: 0.88rem;
}
.hint {
margin-top: 14px;
padding: 12px 14px;
border-left: 4px solid var(--accent-2);
background: rgba(138,91,43,0.08);
color: var(--ink);
font-size: 0.95rem;
}
pre {
margin: 0;
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;
min-height: 360px;
}
@media (max-width: 860px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard Client Flow</h1>
<p class="subtitle">
This page runs in `k_client` and drives the real split-VM flow:
register a user, ask the card in `k_proxy` for approval, and then call
the protected counter on `k_server` only if auth succeeds.
</p>
</section>
<div class="grid">
<section class="stack">
<section class="panel">
<div class="row">
<span class="badge">Browser: k_client</span>
<span class="badge">Card: k_proxy</span>
<span class="badge">Resource: k_server</span>
</div>
<label>
Username
<input id="username" value="directtest" autocomplete="off">
</label>
<div class="actions">
<button id="registerBtn">Register User</button>
<button id="loginBtn">Login</button>
<button id="counterBtn">Call k_server</button>
<button id="logoutBtn" class="secondary">Logout</button>
<button id="runFlowBtn" class="ghost">Run Full Flow</button>
<button id="refreshBtn" class="ghost">Refresh State</button>
</div>
<div class="hint" id="hintBox">
Registration: press <strong>yes</strong> on the card to enroll.
Login: press <strong>yes</strong> to allow the identity check, or
<strong>no</strong> to deny it. If login is denied, this page will
show that `k_server` was not called.
</div>
<div class="timeline">
<div class="step">
<div class="step-index">1</div>
<div>
<strong>Register user</strong><br>
Creates or refreshes the enrolled identity in `k_proxy`.
</div>
</div>
<div class="step">
<div class="step-index">2</div>
<div>
<strong>Authenticate with the card</strong><br>
`k_proxy` asks the card for approval. Press `yes` to continue or `no` to reject.
</div>
</div>
<div class="step">
<div class="step-index">3</div>
<div>
<strong>Call `k_server`</strong><br>
The protected counter is only reached when login created a valid session.
</div>
</div>
</div>
</section>
<section class="panel status">
<div class="status-card">
<h2>Client State</h2>
<div class="status-line" id="stateUser">Enrolled user: unknown</div>
<div class="status-line" id="stateSession">Session: unknown</div>
<div class="status-line" id="stateExpires">Expires: unknown</div>
</div>
<div class="status-card">
<h2>Registered Users</h2>
<div class="status-line" id="usersSummary">Loading users...</div>
<div id="usersList"></div>
</div>
<div class="status-card">
<h2>Flow Result</h2>
<div class="status-line" id="flowResult">No flow run yet.</div>
</div>
</section>
</section>
<section class="panel">
<h2 style="margin-top:0">Event Log</h2>
<pre id="log"></pre>
</section>
</div>
</main>
<script>
const logNode = document.getElementById("log");
const hintBox = document.getElementById("hintBox");
const flowResult = document.getElementById("flowResult");
const stateUser = document.getElementById("stateUser");
const stateSession = document.getElementById("stateSession");
const stateExpires = document.getElementById("stateExpires");
const usersSummary = document.getElementById("usersSummary");
const usersList = document.getElementById("usersList");
const usernameInput = document.getElementById("username");
const buttons = Array.from(document.querySelectorAll("button"));
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;
}
function setBusy(busy) {
for (const button of buttons) button.disabled = busy;
}
function username() {
return usernameInput.value.trim();
}
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();
return {status: resp.status, data};
}
async function refreshState() {
const resp = await fetch("/api/client/state");
const data = await resp.json();
stateUser.textContent = `Enrolled user: ${data.enrolled_username || "none"}`;
stateSession.textContent = `Session active: ${data.session_active ? "yes" : "no"}`;
stateExpires.textContent = `Expires: ${data.session_expires_at || "none"}`;
return data;
}
function renderUsers(users) {
usersList.innerHTML = "";
if (!users.length) {
usersSummary.textContent = "No registered users in k_proxy.";
return;
}
usersSummary.textContent = `${users.length} registered user${users.length === 1 ? "" : "s"} visible in k_proxy.`;
for (const user of users) {
const row = document.createElement("div");
row.className = "user-row";
const meta = document.createElement("div");
meta.className = "user-meta";
meta.innerHTML =
`<div class="user-name">${user.username}</div>` +
`<div class="user-subtle">Credential present: ${user.has_credential ? "yes" : "no"}</div>`;
const actions = document.createElement("div");
actions.className = "user-actions";
const useBtn = document.createElement("button");
useBtn.className = "ghost small";
useBtn.textContent = "Use";
useBtn.addEventListener("click", () => {
usernameInput.value = user.username;
flowResult.textContent = `Selected user ${user.username}.`;
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "secondary small";
deleteBtn.textContent = "Unregister";
deleteBtn.addEventListener("click", async () => {
setBusy(true);
try { await deleteUser(user.username); } finally { setBusy(false); }
});
actions.appendChild(useBtn);
actions.appendChild(deleteBtn);
row.appendChild(meta);
row.appendChild(actions);
usersList.appendChild(row);
}
}
async function refreshUsers() {
const resp = await fetch("/api/enrollments");
const data = await resp.json();
renderUsers(data.users || []);
return data;
}
async function registerUser() {
hintBox.innerHTML = "Card step: if the card shows a <strong>registration</strong> prompt, press <strong>yes</strong> to enroll this user.";
const result = await api("/api/enroll", {username: username()});
log("Register user", result);
flowResult.textContent = result.status === 200 ? "User registration succeeded." : "User registration failed.";
await refreshState();
await refreshUsers();
return result;
}
async function loginUser() {
hintBox.innerHTML = "Card step: if the card shows an <strong>authentication</strong> prompt, press <strong>yes</strong> to allow login or <strong>no</strong> to deny it.";
const result = await api("/api/login", {username: username()});
log("Login", result);
await refreshState();
return result;
}
async function callCounter() {
const result = await api("/api/resource/counter", {});
log("Call k_server counter", result);
flowResult.textContent =
result.status === 200
? `k_server was reached. Counter value: ${result.data.upstream?.value}`
: "k_server was not reached successfully.";
return result;
}
async function logoutUser() {
const result = await api("/api/logout", {});
log("Logout", result);
flowResult.textContent = result.status === 200 ? "Session cleared." : "Logout failed.";
await refreshState();
return result;
}
async function deleteUser(usernameToDelete) {
const result = await api("/api/enroll/delete", {username: usernameToDelete});
log("Unregister user", result);
flowResult.textContent =
result.status === 200
? `User ${usernameToDelete} was unregistered.`
: `Could not unregister ${usernameToDelete}.`;
if (result.status === 200 && username() === usernameToDelete) {
usernameInput.value = "";
}
await refreshState();
await refreshUsers();
return result;
}
async function runFlow() {
setBusy(true);
flowResult.textContent = "Flow running...";
try {
const login = await loginUser();
if (login.status !== 200) {
flowResult.textContent = "Login denied or failed. `k_server` was not called.";
log("Flow stopped before k_server", {
reason: "login failed",
status: login.status,
response: login.data
});
return;
}
const counter = await callCounter();
if (counter.status === 200) {
flowResult.textContent = `Flow succeeded. k_server returned counter ${counter.data.upstream?.value}.`;
} else {
flowResult.textContent = "Login succeeded, but the protected k_server call failed.";
}
} finally {
setBusy(false);
}
}
document.getElementById("registerBtn").addEventListener("click", async () => {
setBusy(true);
try { await registerUser(); } finally { setBusy(false); }
});
document.getElementById("loginBtn").addEventListener("click", async () => {
setBusy(true);
try {
const result = await loginUser();
flowResult.textContent = result.status === 200 ? "Login succeeded. You can now call k_server." : "Login denied or failed. k_server was not called.";
} finally { setBusy(false); }
});
document.getElementById("counterBtn").addEventListener("click", async () => {
setBusy(true);
try { await callCounter(); } finally { setBusy(false); }
});
document.getElementById("logoutBtn").addEventListener("click", async () => {
setBusy(true);
try { await logoutUser(); } finally { setBusy(false); }
});
document.getElementById("runFlowBtn").addEventListener("click", runFlow);
document.getElementById("refreshBtn").addEventListener("click", async () => {
setBusy(true);
try {
const state = await refreshState();
const users = await refreshUsers();
log("State refreshed", {state, users});
} finally { setBusy(false); }
});
Promise.all([refreshState(), refreshUsers()]).then(([state, users]) => {
log("Client flow page ready", {state, users});
});
</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,
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
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,
*,
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()
if token:
req.add_header("Authorization", f"Bearer {token}")
body = json.dumps(payload or {}).encode("utf-8")
try:
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:
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},
timeout_s=self.interactive_timeout_s,
)
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 list_enrollments(self) -> tuple[int, dict[str, Any]]:
return self._proxy_json("GET", "/enroll/list")
def delete_enrollment(self, username: str) -> tuple[int, dict[str, Any]]:
username = username.strip()
if not username:
return 400, {"ok": False, "error": "username required"}
status, data = self._proxy_json("POST", "/enroll/delete", {"username": username})
if status == 200:
with self.lock:
if self.preferred_enrollment and self.preferred_enrollment.username == username:
self.preferred_enrollment = None
self._save_preferred_enrollment_locked()
self.session_token = None
self.session_expires_at = None
return status, 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, username: str | None = None) -> tuple[int, dict[str, Any]]:
requested = (username or "").strip()
with self.lock:
if requested:
username = requested
elif self.preferred_enrollment:
username = self.preferred_enrollment.username
else:
return 400, {"ok": False, "error": "no enrolled user"}
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)
self._save_preferred_enrollment_locked()
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
if path == "/api/enrollments":
status, data = self.state.list_enrollments()
self._json(status, data)
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":
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
status, data = self.state.login(str(data.get("username", "")))
self._json(status, data)
return
if path == "/api/enroll/delete":
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
status, data = self.state.delete_enrollment(str(data.get("username", "")))
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())