283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""ZKAC server: all traffic over a single encrypted, server-authenticated channel.
|
|
|
|
Every connection performs an anonymous handshake (X25519 + Schnorr server
|
|
identity proof). The first encrypted frame selects the mode:
|
|
|
|
{"op": "mgmt"} management commands (create_registry, post_grant, ...)
|
|
{"op": "auth", "registry_id": hex, "bbs_auth_b64": ...} role authentication
|
|
|
|
The server stores only cryptographically verified opaque blobs:
|
|
<data_dir>/server_key.json Schnorr keypair
|
|
<data_dir>/registries/<rid>.state raw RegistryState bytes
|
|
<data_dir>/registries/<rid>.cert raw state cert bytes
|
|
<data_dir>/mailbox/<pk_hex>.json [{grant_id, eph_pk_b64, ciphertext_b64}, ...]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import socket
|
|
import threading
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
import zkac
|
|
from zkac.tcp import FramedSession, server_handshake_anon
|
|
|
|
|
|
def _b64(data: bytes) -> str:
|
|
return base64.b64encode(data).decode()
|
|
|
|
|
|
def _unb64(s: str) -> bytes:
|
|
return base64.b64decode(s)
|
|
|
|
|
|
# ── Opaque server storage ─────────────────────────────────────────────
|
|
|
|
class _ServerStore:
|
|
"""Thread-safe, opaque persistence for registries and mailbox."""
|
|
|
|
def __init__(self, data_dir: Path):
|
|
self._dir = data_dir
|
|
self._reg_dir = data_dir / "registries"
|
|
self._mbox_dir = data_dir / "mailbox"
|
|
self._reg_dir.mkdir(parents=True, exist_ok=True)
|
|
self._mbox_dir.mkdir(parents=True, exist_ok=True)
|
|
self._lock = threading.Lock()
|
|
|
|
# ── server key ────────────────────────────────────────────────────
|
|
|
|
def load_or_create_keypair(self) -> zkac.Keypair:
|
|
kf = self._dir / "server_key.json"
|
|
if kf.exists():
|
|
data = json.loads(kf.read_text())
|
|
return zkac.Keypair.from_secret_key(_unb64(data["secret_b64"]))
|
|
kp = zkac.Keypair()
|
|
kf.write_text(json.dumps({
|
|
"secret_b64": _b64(kp.secret_key_bytes()),
|
|
"public_b64": _b64(kp.public_key().to_bytes()),
|
|
}, indent=2))
|
|
return kp
|
|
|
|
# ── registries ────────────────────────────────────────────────────
|
|
|
|
def save_registry(self, rid_hex: str, state_bytes: bytes, cert_bytes: bytes):
|
|
with self._lock:
|
|
(self._reg_dir / f"{rid_hex}.state").write_bytes(state_bytes)
|
|
(self._reg_dir / f"{rid_hex}.cert").write_bytes(cert_bytes)
|
|
|
|
def load_all_registries(self, mgr: zkac.RegistryManager) -> int:
|
|
count = 0
|
|
for p in sorted(self._reg_dir.glob("*.state")):
|
|
rid_hex = p.stem
|
|
cert_path = self._reg_dir / f"{rid_hex}.cert"
|
|
if not cert_path.exists():
|
|
continue
|
|
try:
|
|
mgr.create(p.read_bytes(), cert_path.read_bytes())
|
|
count += 1
|
|
except Exception as exc:
|
|
print(f"[server] skip registry {rid_hex}: {exc}")
|
|
return count
|
|
|
|
# ── mailbox ───────────────────────────────────────────────────────
|
|
|
|
def _mbox_path(self, pk_hex: str) -> Path:
|
|
return self._mbox_dir / f"{pk_hex}.json"
|
|
|
|
def post_grant(self, recipient_pk_hex: str, entry: dict) -> str:
|
|
grant_id = os.urandom(16).hex()
|
|
entry = {"grant_id": grant_id, **entry}
|
|
with self._lock:
|
|
p = self._mbox_path(recipient_pk_hex)
|
|
entries = json.loads(p.read_text()) if p.exists() else []
|
|
entries.append(entry)
|
|
p.write_text(json.dumps(entries, indent=2))
|
|
return grant_id
|
|
|
|
def list_grants(self, recipient_pk_hex: str) -> list[dict]:
|
|
with self._lock:
|
|
p = self._mbox_path(recipient_pk_hex)
|
|
if not p.exists():
|
|
return []
|
|
return json.loads(p.read_text())
|
|
|
|
def claim_grant(self, recipient_pk_hex: str, grant_id: str) -> dict | None:
|
|
with self._lock:
|
|
p = self._mbox_path(recipient_pk_hex)
|
|
if not p.exists():
|
|
return None
|
|
entries = json.loads(p.read_text())
|
|
for i, e in enumerate(entries):
|
|
if e["grant_id"] == grant_id:
|
|
del entries[i]
|
|
if entries:
|
|
p.write_text(json.dumps(entries, indent=2))
|
|
else:
|
|
p.unlink(missing_ok=True)
|
|
return e
|
|
return None
|
|
|
|
|
|
# ── Command dispatch (inside encrypted session) ──────────────────────
|
|
|
|
def _dispatch(cmd: dict, mgr: zkac.RegistryManager, store: _ServerStore,
|
|
server_pk_b64: str, transcript_hash: bytes) -> dict:
|
|
try:
|
|
action = cmd.get("cmd")
|
|
|
|
if action == "server_info":
|
|
return {"ok": True, "server_public_key_b64": server_pk_b64}
|
|
|
|
if action == "create_registry":
|
|
state_bytes = _unb64(cmd["state_bytes_b64"])
|
|
state_cert = _unb64(cmd["state_cert_b64"])
|
|
rid = mgr.create(state_bytes, state_cert)
|
|
store.save_registry(rid.hex(), state_bytes, state_cert)
|
|
return {"ok": True, "registry_id": rid.hex()}
|
|
|
|
if action == "get_registry":
|
|
rid = bytes.fromhex(cmd["registry_id"])
|
|
state_bytes, state_cert = mgr.get(rid)
|
|
return {
|
|
"ok": True,
|
|
"state_bytes_b64": _b64(state_bytes),
|
|
"state_cert_b64": _b64(state_cert),
|
|
}
|
|
|
|
if action == "update_registry":
|
|
rid = bytes.fromhex(cmd["registry_id"])
|
|
state_bytes = _unb64(cmd["state_bytes_b64"])
|
|
state_cert = _unb64(cmd["state_cert_b64"])
|
|
mgr.update(rid, state_bytes, state_cert)
|
|
store.save_registry(cmd["registry_id"], state_bytes, state_cert)
|
|
return {"ok": True}
|
|
|
|
if action == "post_grant":
|
|
rid = bytes.fromhex(cmd["registry_id"])
|
|
proof = _unb64(cmd["admin_proof_b64"])
|
|
if not mgr.verify_admin(rid, proof, transcript_hash):
|
|
return {"error": "admin proof failed"}
|
|
entry = {
|
|
"eph_pk_b64": cmd["eph_pk_b64"],
|
|
"ciphertext_b64": cmd["ciphertext_b64"],
|
|
}
|
|
gid = store.post_grant(cmd["recipient_pk_hex"], entry)
|
|
return {"ok": True, "grant_id": gid}
|
|
|
|
if action == "list_grants":
|
|
entries = store.list_grants(cmd["recipient_pk_hex"])
|
|
return {"ok": True, "grants": entries}
|
|
|
|
if action == "claim_grant":
|
|
entry = store.claim_grant(cmd["recipient_pk_hex"], cmd["grant_id"])
|
|
if entry is None:
|
|
return {"error": "grant not found"}
|
|
return {"ok": True, "grant": entry}
|
|
|
|
return {"error": f"unknown command: {action}"}
|
|
|
|
except Exception as exc:
|
|
return {"error": str(exc)}
|
|
|
|
|
|
# ── Connection handler ────────────────────────────────────────────────
|
|
|
|
def _handle_conn(conn: socket.socket, addr: tuple, node: zkac.Node,
|
|
mgr: zkac.RegistryManager, store: _ServerStore,
|
|
server_pk_b64: str):
|
|
peer = f"{addr[0]}:{addr[1]}"
|
|
try:
|
|
session = server_handshake_anon(conn, node)
|
|
framed = FramedSession(conn, session)
|
|
transcript_hash = bytes(session.transcript_hash())
|
|
|
|
hello = json.loads(framed.recv())
|
|
op = hello.get("op")
|
|
|
|
if op == "mgmt":
|
|
while True:
|
|
try:
|
|
data = framed.recv()
|
|
except (ConnectionError, OSError):
|
|
break
|
|
cmd = json.loads(data)
|
|
resp = _dispatch(cmd, mgr, store, server_pk_b64, transcript_hash)
|
|
framed.send(json.dumps(resp).encode())
|
|
|
|
elif op == "auth":
|
|
registry_id = bytes.fromhex(hello["registry_id"])
|
|
role_id = bytes.fromhex(hello["role_id"])
|
|
proof_bytes = _unb64(hello["bbs_auth_b64"])
|
|
|
|
ok = mgr.verify_presentation(
|
|
registry_id, role_id, proof_bytes, transcript_hash,
|
|
)
|
|
if not ok:
|
|
framed.send(json.dumps({"error": "auth failed"}).encode())
|
|
return
|
|
|
|
resp = {
|
|
"status": "authenticated",
|
|
"registry_id": registry_id.hex(),
|
|
"role_id": role_id.hex(),
|
|
}
|
|
framed.send(json.dumps(resp).encode())
|
|
|
|
# keep session open for app traffic
|
|
while True:
|
|
try:
|
|
data = framed.recv()
|
|
except (ConnectionError, OSError):
|
|
break
|
|
framed.send(data)
|
|
else:
|
|
framed.send(json.dumps({"error": f"unknown op: {op}"}).encode())
|
|
|
|
except (ConnectionError, BrokenPipeError, OSError):
|
|
pass
|
|
except Exception as exc:
|
|
print(f"[server] {peer} error: {exc}")
|
|
traceback.print_exc()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ── Public entry point ────────────────────────────────────────────────
|
|
|
|
def serve(data_dir: str, host: str = "127.0.0.1", port: int = 9800):
|
|
dd = Path(data_dir)
|
|
dd.mkdir(parents=True, exist_ok=True)
|
|
|
|
store = _ServerStore(dd)
|
|
kp = store.load_or_create_keypair()
|
|
server_pk_b64 = _b64(kp.public_key().to_bytes())
|
|
node = zkac.Node(kp)
|
|
|
|
mgr = zkac.RegistryManager()
|
|
n = store.load_all_registries(mgr)
|
|
|
|
print(f"server public key: {_unb64(server_pk_b64).hex()}")
|
|
print(f"loaded {n} registries")
|
|
print(f"listening on {host}:{port}")
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind((host, port))
|
|
sock.listen(8)
|
|
try:
|
|
while True:
|
|
conn, addr = sock.accept()
|
|
threading.Thread(
|
|
target=_handle_conn,
|
|
args=(conn, addr, node, mgr, store, server_pk_b64),
|
|
daemon=True,
|
|
).start()
|
|
except KeyboardInterrupt:
|
|
print("\nshutdown")
|
|
finally:
|
|
sock.close()
|