k_card/k_proxy_app.py

1195 lines
42 KiB
Python

#!/usr/bin/env python3
"""
Minimal k_proxy service for Phase 5 bring-up.
Behavior:
- Creates short-lived sessions after a card-backed auth gate.
- Reuses valid sessions to access k_server protected counter endpoint.
- Supports enrollment, session status, and logout.
Notes:
- Default runtime still uses the legacy card-presence probe gate.
- Experimental direct FIDO2 registration/assertion lives behind `--auth-mode fido2-direct`.
- This is still a prototype and not a final production auth design.
"""
from __future__ import annotations
import argparse
import base64
import http.client
import json
import queue
import re
import secrets
import ssl
import subprocess
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
from fido2.client import Fido2Client, UserInteraction, verify_rp_id
from fido2.hid import CtapHidDevice
from fido2.server import Fido2Server
from fido2.webauthn import (
AttestedCredentialData,
PublicKeyCredentialCreationOptions,
PublicKeyCredentialRequestOptions,
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
UserVerificationRequirement,
)
try:
from fido2.client import ClientDataCollector, CollectedClientData
except ImportError:
ClientDataCollector = None
CollectedClientData = None
HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard Proxy Portal</title>
<style>
:root {
--bg: #f1eee8;
--panel: #fffdf8;
--ink: #171615;
--muted: #645f56;
--line: #d6cbb9;
--accent: #0c6a60;
--accent-2: #8e5b2d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background:
radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
}
main {
max-width: 900px;
margin: 0 auto;
padding: 32px 20px 56px;
}
.hero, .card {
background: var(--panel);
border: 1px solid var(--line);
box-shadow: 0 16px 34px rgba(49, 38, 21, 0.08);
}
.hero {
padding: 24px;
margin-bottom: 20px;
}
h1 {
margin: 0 0 10px;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 0.95;
letter-spacing: -0.04em;
}
.subtitle {
margin: 0;
color: var(--muted);
max-width: 64ch;
}
.grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.card { padding: 18px; }
.card h2 {
margin: 0 0 12px;
font-size: 1.15rem;
}
label {
display: block;
margin-bottom: 8px;
font-size: 0.92rem;
color: var(--muted);
}
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: 300px;
padding: 16px;
overflow: auto;
border: 1px solid var(--line);
background: #141210;
color: #efe6d8;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.9rem;
line-height: 1.45;
}
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard Proxy Portal</h1>
<p class="subtitle">
Primary browser entry point for the current prototype. Browser traffic now targets k_proxy directly.
Enrollment, card-backed login, session reuse, counter access, and logout all happen on this TLS endpoint.
</p>
</section>
<section class="grid">
<div class="card">
<h2>Enrollment</h2>
<label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off">
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="Alice Example" autocomplete="off">
<div class="actions">
<button id="enrollBtn">Enroll User</button>
<button id="updateBtn" class="secondary">Update User</button>
<button id="deleteBtn" class="secondary">Delete User</button>
<button id="checkBtn" class="secondary">Check Enrollment</button>
<button id="listBtn" class="secondary">List Users</button>
</div>
<div class="status">
<div>Stored username: <strong id="storedUser">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 USER_KEY = "chromecard.proxy.username";
const TOKEN_KEY = "chromecard.proxy.session_token";
const EXP_KEY = "chromecard.proxy.expires_at";
const logNode = document.getElementById("log");
const usernameNode = document.getElementById("username");
const displayNameNode = document.getElementById("displayName");
const storedUserNode = document.getElementById("storedUser");
const sessionActiveNode = document.getElementById("sessionActive");
function getStoredUser() { return localStorage.getItem(USER_KEY) || ""; }
function getStoredToken() { return localStorage.getItem(TOKEN_KEY) || ""; }
function syncState() {
const user = getStoredUser();
storedUserNode.textContent = user || "none";
sessionActiveNode.textContent = getStoredToken() ? "yes" : "no";
if (user && !usernameNode.value) {
usernameNode.value = user;
}
}
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 jsonRequest(method, path, payload, withToken = false) {
const headers = {"Content-Type": "application/json"};
if (withToken && getStoredToken()) {
headers["Authorization"] = "Bearer " + getStoredToken();
}
const resp = await fetch(path, {
method,
headers,
body: payload === undefined ? undefined : JSON.stringify(payload)
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(JSON.stringify(data));
}
return data;
}
document.getElementById("enrollBtn").addEventListener("click", async () => {
try {
const username = usernameNode.value.trim();
const display_name = displayNameNode.value.trim();
const data = await jsonRequest("POST", "/enroll/register", {username, display_name});
localStorage.setItem(USER_KEY, username);
syncState();
log("Enrollment updated", data);
} catch (err) {
log("Enrollment failed", {error: err.message});
}
});
document.getElementById("checkBtn").addEventListener("click", async () => {
try {
const username = usernameNode.value.trim() || getStoredUser();
const resp = await fetch("/enroll/status?username=" + encodeURIComponent(username));
const data = await resp.json();
if (!resp.ok) throw new Error(JSON.stringify(data));
log("Enrollment status", data);
if (data.display_name) {
displayNameNode.value = data.display_name;
}
} catch (err) {
log("Enrollment status failed", {error: err.message});
}
});
document.getElementById("updateBtn").addEventListener("click", async () => {
try {
const username = usernameNode.value.trim() || getStoredUser();
const display_name = displayNameNode.value.trim();
const data = await jsonRequest("POST", "/enroll/update", {username, display_name});
localStorage.setItem(USER_KEY, username);
syncState();
log("Enrollment updated", data);
} catch (err) {
log("Enrollment update failed", {error: err.message});
}
});
document.getElementById("deleteBtn").addEventListener("click", async () => {
try {
const username = usernameNode.value.trim() || getStoredUser();
const data = await jsonRequest("POST", "/enroll/delete", {username});
if (getStoredUser() === username) {
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXP_KEY);
}
displayNameNode.value = "";
syncState();
log("Enrollment deleted", data);
} catch (err) {
log("Enrollment delete failed", {error: err.message});
}
});
document.getElementById("listBtn").addEventListener("click", async () => {
try {
const resp = await fetch("/enroll/list");
const data = await resp.json();
if (!resp.ok) throw new Error(JSON.stringify(data));
log("Enrollment list", data);
} catch (err) {
log("Enrollment list failed", {error: err.message});
}
});
document.getElementById("loginBtn").addEventListener("click", async () => {
try {
const username = usernameNode.value.trim() || getStoredUser();
const data = await jsonRequest("POST", "/session/login", {username});
localStorage.setItem(USER_KEY, username);
localStorage.setItem(TOKEN_KEY, data.session_token || "");
localStorage.setItem(EXP_KEY, String(data.expires_at || ""));
syncState();
log("Login ok", data);
} catch (err) {
log("Login failed", {error: err.message});
}
});
document.getElementById("statusBtn").addEventListener("click", async () => {
try {
const data = await jsonRequest("POST", "/session/status", {}, true);
log("Session status", data);
} catch (err) {
log("Status failed", {error: err.message});
}
});
document.getElementById("counterBtn").addEventListener("click", async () => {
try {
const data = await jsonRequest("POST", "/resource/counter", {}, true);
log("Counter response", data);
} catch (err) {
log("Counter failed", {error: err.message});
}
});
document.getElementById("logoutBtn").addEventListener("click", async () => {
try {
const data = await jsonRequest("POST", "/session/logout", {}, true);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXP_KEY);
syncState();
log("Logout response", data);
} catch (err) {
log("Logout failed", {error: err.message});
}
});
syncState();
</script>
</body>
</html>
"""
@dataclass
class Session:
username: str
expires_at: float
@dataclass
class Enrollment:
username: str
display_name: str | None
created_at: int
updated_at: int
user_id_b64: str | None = None
credential_data_b64: str | None = None
USERNAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?$")
AUTH_MODE_PROBE = "probe"
AUTH_MODE_FIDO2_DIRECT = "fido2-direct"
def normalize_username(raw: str) -> str:
username = raw.strip().lower()
if not USERNAME_PATTERN.fullmatch(username):
raise ValueError(
"username must be 3-32 chars of lowercase letters, digits, dot, underscore, or dash"
)
return username
def normalize_display_name(raw: str | None) -> str | None:
value = (raw or "").strip()
if not value:
return None
if len(value) > 64:
raise ValueError("display_name must be 64 characters or fewer")
return value
def b64u_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def b64u_decode(data: str) -> bytes:
pad = "=" * ((4 - len(data) % 4) % 4)
return base64.urlsafe_b64decode((data + pad).encode("ascii"))
def enrollment_payload(enrollment: "Enrollment", *, created: bool | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {
"ok": True,
"username": enrollment.username,
"display_name": enrollment.display_name,
"created_at": enrollment.created_at,
"updated_at": enrollment.updated_at,
"has_credential": bool(enrollment.credential_data_b64),
}
if created is not None:
payload["created"] = created
return payload
if ClientDataCollector is not None and CollectedClientData is not None:
class ProxyClientDataCollector(ClientDataCollector):
def __init__(self, origin: str, rp_id: str):
if not verify_rp_id(rp_id, origin):
raise ValueError(f"origin {origin!r} is not valid for rp_id {rp_id!r}")
self.origin = origin
self.rp_id = rp_id
def collect_client_data(
self,
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
) -> tuple[CollectedClientData, str]:
if isinstance(options, PublicKeyCredentialCreationOptions):
request_type = "webauthn.create"
requested_rp_id = options.rp.id
challenge = options.challenge
elif isinstance(options, PublicKeyCredentialRequestOptions):
request_type = "webauthn.get"
requested_rp_id = options.rp_id
challenge = options.challenge
else:
raise TypeError(f"unsupported options type: {type(options)!r}")
if requested_rp_id != self.rp_id:
raise ValueError(f"rp_id mismatch: expected {self.rp_id}, got {requested_rp_id}")
return CollectedClientData.create(
type=request_type,
challenge=challenge,
origin=self.origin,
), self.rp_id
else:
ProxyClientDataCollector = None
class ProxyUserInteraction(UserInteraction):
def prompt_up(self) -> None:
print("Touch the ChromeCard to continue...", flush=True)
super().prompt_up()
def request_pin(self, permissions, rp_id: str | None) -> str | None:
print("Authenticator PIN is required but not supported by this prototype.", flush=True)
return super().request_pin(permissions, rp_id)
class ProxyState:
def __init__(
self,
session_ttl_s: int,
auth_mode: str,
auth_command: str,
server_base_url: str,
server_ca_file: str | None,
server_max_connections: int,
proxy_token: str,
enrollment_db: Path,
rp_id: str,
rp_name: str,
origin: str,
):
self.session_ttl_s = session_ttl_s
self.auth_mode = auth_mode
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.rp_id = rp_id
self.origin = origin
self.lock = threading.Lock()
self.sessions: dict[str, Session] = {}
self.enrollments: dict[str, Enrollment] = {}
self.rp = PublicKeyCredentialRpEntity(id=rp_id, name=rp_name)
self.fido_server = Fido2Server(self.rp)
self.client_data_collector = (
ProxyClientDataCollector(origin=origin, rp_id=rp_id) if ProxyClientDataCollector else None
)
self.upstream = UpstreamPool(
server_base_url=self.server_base_url,
server_ca_file=self.server_ca_file,
max_connections=server_max_connections,
)
self._load_enrollments()
def uses_direct_fido2(self) -> bool:
return self.auth_mode == AUTH_MODE_FIDO2_DIRECT
def auth_mode_label(self) -> str:
return "fido2_assertion" if self.uses_direct_fido2() else "card_presence_probe"
def _now(self) -> float:
return time.time()
def _gc_locked(self) -> None:
now = self._now()
dead = [token for token, sess in self.sessions.items() if sess.expires_at <= now]
for token in dead:
del self.sessions[token]
def create_session(self, username: str) -> tuple[str, float]:
token = secrets.token_urlsafe(32)
now = self._now()
expires_at = now + self.session_ttl_s
with self.lock:
self._gc_locked()
self.sessions[token] = Session(username=username, expires_at=expires_at)
return token, expires_at
def get_session(self, token: str) -> Session | None:
with self.lock:
self._gc_locked()
return self.sessions.get(token)
def invalidate_session(self, token: str) -> bool:
with self.lock:
return self.sessions.pop(token, None) is not None
def active_session_count(self) -> int:
with self.lock:
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
created_at = int(item.get("created_at", item.get("enrolled_at", int(self._now()))))
updated_at = int(item.get("updated_at", created_at))
self.enrollments[username] = Enrollment(
username=username,
display_name=normalize_display_name(item.get("display_name")),
created_at=created_at,
updated_at=updated_at,
user_id_b64=item.get("user_id_b64"),
credential_data_b64=item.get("credential_data_b64"),
)
except Exception:
self.enrollments = {}
def _save_enrollments_locked(self) -> None:
self.enrollment_db.parent.mkdir(parents=True, exist_ok=True)
users = [
{
"username": enrollment.username,
"display_name": enrollment.display_name,
"created_at": enrollment.created_at,
"updated_at": enrollment.updated_at,
"user_id_b64": enrollment.user_id_b64,
"credential_data_b64": enrollment.credential_data_b64,
}
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 _new_fido_client(self) -> Fido2Client:
try:
device = next(CtapHidDevice.list_devices())
except StopIteration as exc:
raise RuntimeError("no CTAP HID devices found") from exc
# Newer python-fido2 builds accept a custom client-data collector, while the
# VM-side package still expects an origin string plus verifier callback.
if self.client_data_collector is not None:
return Fido2Client(device, self.client_data_collector, ProxyUserInteraction())
return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=ProxyUserInteraction())
def _user_entity(self, username: str, display_name: str | None, user_id: bytes) -> PublicKeyCredentialUserEntity:
return PublicKeyCredentialUserEntity(
id=user_id,
name=username,
display_name=display_name or username,
)
def _register_metadata_only(self, username: str, display_name: str | None) -> Enrollment:
canonical = normalize_username(username)
pretty = normalize_display_name(display_name)
now = int(self._now())
with self.lock:
existing = self.enrollments.get(canonical)
if existing:
raise FileExistsError("user already enrolled")
enrollment = Enrollment(
username=canonical,
display_name=pretty,
created_at=now,
updated_at=now,
)
self.enrollments[canonical] = enrollment
self._save_enrollments_locked()
return enrollment
def _register_direct_fido2(self, username: str, display_name: str | None) -> Enrollment:
canonical = normalize_username(username)
pretty = normalize_display_name(display_name)
now = int(self._now())
with self.lock:
existing = self.enrollments.get(canonical)
if existing and existing.credential_data_b64:
raise FileExistsError("user already enrolled")
user_id = b64u_decode(existing.user_id_b64) if existing and existing.user_id_b64 else secrets.token_bytes(32)
created_at = existing.created_at if existing else now
options, state = self.fido_server.register_begin(
self._user_entity(canonical, pretty, user_id),
user_verification=UserVerificationRequirement.DISCOURAGED,
)
try:
auth_data = self.fido_server.register_complete(
state,
self._new_fido_client().make_credential(options.public_key),
)
except Exception as exc:
raise RuntimeError(f"card registration failed: {exc}") from exc
credential_data = auth_data.credential_data
if credential_data is None:
raise RuntimeError("card registration returned no credential data")
enrollment = Enrollment(
username=canonical,
display_name=pretty,
created_at=created_at,
updated_at=now,
user_id_b64=b64u_encode(user_id),
credential_data_b64=b64u_encode(bytes(credential_data)),
)
with self.lock:
self.enrollments[canonical] = enrollment
self._save_enrollments_locked()
return enrollment
def register_enrollment(self, username: str, display_name: str | None) -> Enrollment:
if self.uses_direct_fido2():
return self._register_direct_fido2(username, display_name)
return self._register_metadata_only(username, display_name)
def update_enrollment(self, username: str, display_name: str | None) -> Enrollment:
canonical = normalize_username(username)
pretty = normalize_display_name(display_name)
now = int(self._now())
with self.lock:
existing = self.enrollments.get(canonical)
if not existing:
raise KeyError("user not enrolled")
existing.display_name = pretty
existing.updated_at = now
self._save_enrollments_locked()
return existing
def delete_enrollment(self, username: str) -> Enrollment:
canonical = normalize_username(username)
with self.lock:
existing = self.enrollments.pop(canonical, None)
if not existing:
raise KeyError("user not enrolled")
dead_tokens = [token for token, sess in self.sessions.items() if sess.username == canonical]
for token in dead_tokens:
del self.sessions[token]
self._save_enrollments_locked()
return existing
def list_enrollments(self) -> list[Enrollment]:
with self.lock:
return [self.enrollments[key] for key in sorted(self.enrollments)]
def get_enrollment(self, username: str) -> Enrollment | None:
try:
canonical = normalize_username(username)
except ValueError:
return None
with self.lock:
return self.enrollments.get(canonical)
def has_enrollment(self, username: str) -> bool:
return self.get_enrollment(username) is not None
def _authenticate_with_probe(self) -> tuple[bool, str]:
try:
proc = subprocess.run(
self.auth_command,
shell=True,
capture_output=True,
text=True,
timeout=10,
check=False,
)
except Exception as exc:
return False, f"auth command failed: {exc}"
if proc.returncode != 0:
stderr = proc.stderr.strip()
stdout = proc.stdout.strip()
details = stderr if stderr else stdout
return False, details or f"auth command exit code {proc.returncode}"
return True, "card presence check succeeded"
def _authenticate_with_direct_fido2(self, username: str) -> tuple[bool, str]:
enrollment = self.get_enrollment(username)
if not enrollment:
return False, "user not enrolled"
if not enrollment.credential_data_b64:
return False, "user has no registered credential"
try:
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.
options, state = self.fido_server.authenticate_begin(
[credential],
user_verification=UserVerificationRequirement.DISCOURAGED,
)
selection = self._new_fido_client().get_assertion(options.public_key)
assertion = selection.get_response(0)
self.fido_server.authenticate_complete(state, [credential], assertion)
except Exception as exc:
return False, f"assertion verification failed: {exc}"
return True, "assertion verified"
def authenticate_with_card(self, username: str) -> tuple[bool, str]:
if not self.uses_direct_fido2():
return self._authenticate_with_probe()
return self._authenticate_with_direct_fido2(username)
def fetch_counter(self) -> tuple[int, dict[str, Any]]:
return self.upstream.request_json(
path="/resource/counter",
headers={"X-Proxy-Token": self.proxy_token},
payload={},
)
class UpstreamPool:
def __init__(self, server_base_url: str, server_ca_file: str | None, max_connections: int = 4):
parsed = urlparse(server_base_url)
self.scheme = parsed.scheme
self.host = parsed.hostname or "127.0.0.1"
self.port = parsed.port or (443 if parsed.scheme == "https" else 80)
self.base_path = parsed.path.rstrip("/")
self.server_ca_file = server_ca_file
self.timeout = 5
self.max_connections = max_connections
self.idle: queue.LifoQueue[http.client.HTTPConnection] = queue.LifoQueue()
self.capacity = threading.BoundedSemaphore(max_connections)
def _new_connection(self) -> http.client.HTTPConnection:
if self.scheme == "https":
context = ssl.create_default_context(cafile=self.server_ca_file)
return http.client.HTTPSConnection(
self.host,
self.port,
timeout=self.timeout,
context=context,
)
return http.client.HTTPConnection(self.host, self.port, timeout=self.timeout)
def _acquire(self) -> http.client.HTTPConnection:
self.capacity.acquire()
try:
return self.idle.get_nowait()
except queue.Empty:
return self._new_connection()
def _release(self, conn: http.client.HTTPConnection | None, reusable: bool) -> None:
try:
if conn is not None and reusable:
self.idle.put(conn)
elif conn is not None:
conn.close()
finally:
self.capacity.release()
def request_json(self, path: str, headers: dict[str, str], payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
conn = self._acquire()
reusable = False
full_path = f"{self.base_path}{path}" if self.base_path else path
try:
body = json.dumps(payload).encode("utf-8")
req_headers = {"Content-Type": "application/json", **headers}
conn.request("POST", full_path, body=body, headers=req_headers)
resp = conn.getresponse()
raw = resp.read()
reusable = not resp.will_close
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
except Exception:
data = {"ok": False, "error": f"server http error {resp.status}"}
return resp.status, data
except (http.client.HTTPException, OSError, ssl.SSLError) as exc:
return 502, {"ok": False, "error": f"server unavailable: {exc}"}
except Exception as exc:
return 502, {"ok": False, "error": f"server call failed: {exc}"}
finally:
self._release(conn, reusable)
class Handler(BaseHTTPRequestHandler):
state: ProxyState
protocol_version = "HTTP/1.1"
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 _discard_request_body(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
if length > 0:
self.rfile.read(length)
def _bearer_token(self) -> str | None:
value = self.headers.get("Authorization", "")
if not value.startswith("Bearer "):
return None
token = value[7:].strip()
return token or None
def _require_session(self) -> tuple[str, Session] | None:
token = self._bearer_token()
if not token:
self._json(401, {"ok": False, "error": "missing bearer token"})
return None
session = self.state.get_session(token)
if not session:
self._json(401, {"ok": False, "error": "invalid or expired session"})
return None
return token, session
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_proxy",
"active_sessions": self.state.active_session_count(),
"time": int(time.time()),
},
)
return
if path.startswith("/enroll/status"):
self._enroll_status()
return
if path == "/enroll/list":
self._enroll_list()
return
self.send_error(404)
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/session/login":
self._session_login()
return
if path == "/enroll/register":
self._enroll_register()
return
if path == "/enroll/update":
self._enroll_update()
return
if path == "/enroll/delete":
self._enroll_delete()
return
if path == "/session/status":
self._session_status()
return
if path == "/session/logout":
self._session_logout()
return
if path == "/resource/counter":
self._resource_counter()
return
self.send_error(404)
def _session_login(self) -> None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
username = normalize_username(str(data.get("username", "")))
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
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(username)
if not ok:
self._json(401, {"ok": False, "error": "card auth failed", "details": message})
return
token, expires_at = self.state.create_session(username)
self._json(
200,
{
"ok": True,
"username": username,
"session_token": token,
"expires_at": int(expires_at),
"ttl_seconds": self.state.session_ttl_s,
"auth_mode": self.state.auth_mode_label(),
},
)
def _enroll_register(self) -> None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
enrollment = self.state.register_enrollment(
str(data.get("username", "")),
data.get("display_name"),
)
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
except FileExistsError:
self._json(409, {"ok": False, "error": "user already enrolled"})
return
except RuntimeError as exc:
self._json(401, {"ok": False, "error": str(exc)})
return
self._json(200, enrollment_payload(enrollment, created=enrollment.created_at == enrollment.updated_at))
def _enroll_update(self) -> None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
enrollment = self.state.update_enrollment(
str(data.get("username", "")),
data.get("display_name"),
)
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
except KeyError:
self._json(404, {"ok": False, "error": "user not enrolled"})
return
self._json(200, enrollment_payload(enrollment))
def _enroll_delete(self) -> None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
enrollment = self.state.delete_enrollment(str(data.get("username", "")))
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
except KeyError:
self._json(404, {"ok": False, "error": "user not enrolled"})
return
self._json(200, {"ok": True, "username": enrollment.username, "deleted": True})
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, enrollment_payload(enrollment))
def _enroll_list(self) -> None:
users = [enrollment_payload(item) for item in self.state.list_enrollments()]
self._json(200, {"ok": True, "users": users})
def _session_status(self) -> None:
self._discard_request_body()
got = self._require_session()
if not got:
return
_, session = got
self._json(
200,
{
"ok": True,
"username": session.username,
"expires_at": int(session.expires_at),
"seconds_remaining": max(0, int(session.expires_at - time.time())),
},
)
def _session_logout(self) -> None:
self._discard_request_body()
token = self._bearer_token()
if not token:
self._json(401, {"ok": False, "error": "missing bearer token"})
return
removed = self.state.invalidate_session(token)
self._json(200, {"ok": True, "invalidated": removed})
def _resource_counter(self) -> None:
self._discard_request_body()
got = self._require_session()
if not got:
return
_, session = got
status, upstream = self.state.fetch_counter()
if status != 200:
self._json(status, {"ok": False, "error": "upstream failed", "upstream": upstream})
return
self._json(
200,
{
"ok": True,
"username": session.username,
"session_reused": True,
"upstream": upstream,
},
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run k_proxy session gateway")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8770)
parser.add_argument("--tls-certfile", help="PEM certificate chain for HTTPS listener")
parser.add_argument("--tls-keyfile", help="PEM private key for HTTPS listener")
parser.add_argument("--session-ttl", type=int, default=300, help="Session TTL in seconds")
parser.add_argument(
"--auth-mode",
choices=(AUTH_MODE_PROBE, AUTH_MODE_FIDO2_DIRECT),
default=AUTH_MODE_PROBE,
help="Session auth mode: legacy card-presence probe or experimental direct FIDO2 registration/assertion",
)
parser.add_argument(
"--auth-command",
default="python3 /home/user/chromecard/fido2_probe.py --json",
help="Command used for legacy probe auth mode",
)
parser.add_argument(
"--rp-id",
default="localhost",
help="Relying party ID used for direct card-backed registration and assertion verification",
)
parser.add_argument(
"--rp-name",
default="ChromeCard Proxy",
help="Relying party name used for direct card-backed registration",
)
parser.add_argument(
"--origin",
default="https://localhost",
help="Synthetic origin used by the local FIDO2 client when talking directly to the attached card",
)
parser.add_argument(
"--server-base-url",
default="http://127.0.0.1:8780",
help="Base URL for k_server",
)
parser.add_argument(
"--server-ca-file",
help="CA certificate used to verify HTTPS certificate presented by k_server",
)
parser.add_argument(
"--server-max-connections",
type=int,
default=1,
help="Maximum concurrent pooled upstream connections from k_proxy to k_server",
)
parser.add_argument(
"--proxy-token",
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()
def main() -> int:
args = parse_args()
if bool(args.tls_certfile) != bool(args.tls_keyfile):
raise SystemExit("Both --tls-certfile and --tls-keyfile are required to enable HTTPS")
if args.server_base_url.startswith("https://") and not args.server_ca_file:
raise SystemExit("--server-ca-file is required when --server-base-url uses https")
state = ProxyState(
session_ttl_s=args.session_ttl,
auth_mode=args.auth_mode,
auth_command=args.auth_command,
server_base_url=args.server_base_url,
server_ca_file=args.server_ca_file,
server_max_connections=args.server_max_connections,
proxy_token=args.proxy_token,
enrollment_db=Path(args.enrollment_db),
rp_id=args.rp_id,
rp_name=args.rp_name,
origin=args.origin,
)
Handler.state = state
server = ThreadingHTTPServer((args.host, args.port), Handler)
scheme = "http"
if args.tls_certfile:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=args.tls_certfile, keyfile=args.tls_keyfile)
server.socket = context.wrap_socket(server.socket, server_side=True)
scheme = "https"
print(f"k_proxy listening on {scheme}://{args.host}:{args.port}")
server.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())