ZKAC/demo/server.py
2026-04-15 11:32:01 +02:00

197 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""
HTTP static site + ZKAC TCP service. Authenticated /api is accessed over TCP with zkac.tcp
after setup_demo.py has created creds/.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import threading
import http.server
import socket
import traceback
from pathlib import Path
import zkac
from zkac.tcp import FramedSession, server_handshake
def load_registry(creds_dir: Path, epoch: int) -> zkac.RoleRegistry:
"""Load issuer public key and register every demo role at the same epoch."""
iss = json.loads((creds_dir / "issuer.json").read_text(encoding="utf-8"))
issuer_pk = zkac.BbsPublicKey.from_bytes(
base64.b64decode(iss["issuer_public_key_b64"])
)
reg = zkac.RoleRegistry()
for name in ("analyst", "operator"):
reg.register_role(zkac.role_id(name), issuer_pk, epoch)
return reg
def _role_debug_label(role_id: bytes) -> str:
"""Map verified role_id bytes to a short label for logs (demo only)."""
for name in ("analyst", "operator"):
if role_id == zkac.role_id(name):
return name
return "unknown"
def api_body_for_role(role_id: bytes) -> dict:
"""JSON returned for the logical /api resource after ZKAC auth; varies by credential role."""
analyst = zkac.role_id("analyst")
operator = zkac.role_id("operator")
if role_id == analyst:
return {
"path": "/api",
"role": "analyst",
"datasets": ["summary", "aggregated_metrics"],
"note": "Analyst tier: aggregated data only.",
}
if role_id == operator:
return {
"path": "/api",
"role": "operator",
"datasets": ["summary", "aggregated_metrics", "raw_logs", "pii"],
"note": "Operator tier: full API slice including raw logs.",
}
return {"error": "unknown role", "path": "/api"}
def handle_zkac_client(
conn: socket.socket,
client_addr: tuple,
creds_dir: Path,
registry: zkac.RoleRegistry,
) -> None:
"""
One TCP connection: ZKAC handshake + BBS+ auth, then one framed JSON request and response.
Each handler rebuilds the server Node from persisted secret (Keypair is consumed by Node).
"""
peer = f"{client_addr[0]}:{client_addr[1]}"
print(f"[zkac] connect peer={peer}")
try:
# Same long-term server identity every time; from_secret_key because Node consumes Keypair.
t = json.loads((creds_dir / "transport.json").read_text(encoding="utf-8"))
sk = base64.b64decode(t["server_secret_key_b64"])
server_kp = zkac.Keypair.from_secret_key(sk)
node = zkac.Node(server_kp)
session, role_id = server_handshake(conn, node, registry)
label = _role_debug_label(role_id)
print(
f"[zkac] handshake_ok peer={peer} role_id={role_id.hex()} role={label!r}"
)
framed = FramedSession(conn, session)
raw = framed.recv()
print(
f"[zkac] request peer={peer} plaintext_bytes={len(raw)} raw={raw!r}"
)
req = json.loads(raw.decode("utf-8"))
print(f"[zkac] request_json peer={peer} parsed={req!r}")
path = req.get("path")
if path != "/api":
err_body = {"error": "unsupported path", "allowed": ["/api"], "got": path}
out = json.dumps(err_body).encode()
framed.send(out)
print(
f"[zkac] response peer={peer} status=reject path={path!r} response_bytes={len(out)}"
)
return
body = api_body_for_role(role_id)
out_bytes = json.dumps(body).encode()
framed.send(out_bytes)
print(
f"[zkac] response peer={peer} status=ok path=/api role={label!r} "
f"response_bytes={len(out_bytes)} body_keys={list(body.keys())}"
)
except (ConnectionError, BrokenPipeError, OSError) as e:
print(f"[zkac] peer={peer} connection_error: {e!r}")
except (json.JSONDecodeError, ValueError) as e:
print(f"[zkac] peer={peer} protocol_error: {e!r}")
except Exception as e:
print(f"[zkac] peer={peer} unexpected_error: {e!r}")
traceback.print_exc()
finally:
conn.close()
print(f"[zkac] closed peer={peer}")
def run_http(host: str, port: int, static_root: Path) -> None:
# Process-wide CWD: only this thread should rely on relative paths after chdir.
os.chdir(static_root)
class Handler(http.server.SimpleHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
# Default fmt is like '%s - - [%s] %s' — include client address for debugging.
try:
line = fmt % args if args else fmt
except (TypeError, ValueError):
line = f"{fmt} {args}"
peer_ip = self.client_address[0] if self.client_address else "?"
peer_port = self.client_address[1] if len(self.client_address) > 1 else "?"
print(f"[http] peer={peer_ip}:{peer_port} | {line.strip()}")
http.server.HTTPServer((host, port), Handler).serve_forever()
def main() -> None:
ap = argparse.ArgumentParser(description="ZKAC demo HTTP + TCP server")
ap.add_argument(
"--creds-dir",
type=Path,
default=Path(__file__).resolve().parent / "creds",
)
ap.add_argument("--http-host", default="127.0.0.1")
ap.add_argument("--http-port", type=int, default=8765)
ap.add_argument("--zkac-host", default="127.0.0.1")
ap.add_argument("--zkac-port", type=int, default=9876)
args = ap.parse_args()
creds_dir: Path = args.creds_dir
if not (creds_dir / "transport.json").is_file():
raise SystemExit(f"Missing {creds_dir}/transport.json — run setup_demo.py first.")
# Epoch must match the member files issued at setup (any member file is enough).
member = json.loads((creds_dir / "member_analyst.json").read_text(encoding="utf-8"))
epoch = int(member["epoch"])
registry = load_registry(creds_dir, epoch)
static_root = Path(__file__).resolve().parent / "static"
if not static_root.is_dir():
raise SystemExit(f"Missing static directory: {static_root}")
http_thread = threading.Thread(
target=run_http,
args=(args.http_host, args.http_port, static_root),
daemon=True,
)
http_thread.start()
print(
f"HTTP http://{args.http_host}:{args.http_port}/ (static demo page)\n"
f"ZKAC {args.zkac_host}:{args.zkac_port} (authenticated /api over TCP)"
)
zkac_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
zkac_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
zkac_sock.bind((args.zkac_host, args.zkac_port))
zkac_sock.listen(8)
while True:
conn, addr = zkac_sock.accept()
threading.Thread(
target=handle_zkac_client,
args=(conn, addr, creds_dir, registry),
daemon=True,
).start()
if __name__ == "__main__":
main()