ZKAC/demo/zkac_cli_adapter.py
everbarry d5ae07973a polish and self-contain file-share demo UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:39:39 +02:00

421 lines
14 KiB
Python

"""
Subprocess adapter around the existing ``zkac-node`` CLI.
The demo deliberately does not import ``cli/zkac_cli/*``: every interaction
with identity, registry, grant or p2p-listen flows runs through the CLI as a
black box, exactly the way a third-party application would integrate.
The only direct filesystem touch is reading the CLI-managed ``identity.json``
under ``ZKAC_HOME`` (default ``~/.ZKAC-FS/<userid>/`` for this demo). That file is the CLI's
public on-disk format and is needed locally to obtain the user's transport and
issuance secrets for the file-share session and role-grant decryption.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import threading
from dataclasses import dataclass
from pathlib import Path
import zkac
ROOT = Path(__file__).resolve().parents[1]
CLI_DIR = ROOT / "cli"
DEFAULT_TIMEOUT_S = 60.0
DEFAULT_LISTEN_TIMEOUT_S = 600.0
DEFAULT_DEMO_ZKAC_HOME = Path.home() / ".ZKAC-FS"
# ── helpers ──────────────────────────────────────────────────────────
def _b64_decode(s: str) -> bytes:
return base64.b64decode(s)
def zkac_home() -> Path:
return Path(os.environ.get("ZKAC_HOME", DEFAULT_DEMO_ZKAC_HOME))
def user_identity_path(userid: str) -> Path:
return zkac_home() / userid / "identity.json"
def user_exists(userid: str) -> bool:
return user_identity_path(userid).is_file()
def list_local_users() -> list[str]:
home = zkac_home()
if not home.is_dir():
return []
return sorted(d.name for d in home.iterdir() if (d / "identity.json").is_file())
def _zkac_invocation() -> tuple[list[str], dict[str, str]]:
"""Resolve a runnable ``zkac-node`` command, falling back to the source tree."""
exe = shutil.which("zkac-node")
if exe:
return [exe], {}
return [sys.executable, "-m", "zkac_cli.main"], {"PYTHONPATH": str(CLI_DIR)}
@dataclass
class CliResult:
rc: int
stdout: str
stderr: str
def ok(self) -> bool:
return self.rc == 0
def raise_for_status(self) -> "CliResult":
if self.rc != 0:
raise RuntimeError(
f"zkac-node failed (rc={self.rc}): {self.stderr.strip() or self.stdout.strip()}"
)
return self
def run_cli(
args: list[str],
*,
timeout_s: float = DEFAULT_TIMEOUT_S,
extra_env: dict[str, str] | None = None,
) -> CliResult:
prefix, env_overrides = _zkac_invocation()
env = {
**os.environ,
**env_overrides,
"ZKAC_HOME": str(zkac_home()),
**(extra_env or {}),
}
try:
p = subprocess.run(
prefix + args,
capture_output=True,
text=True,
timeout=timeout_s,
cwd=str(ROOT),
env=env,
)
except subprocess.TimeoutExpired as exc:
return CliResult(rc=124, stdout=exc.stdout or "", stderr=f"timed out after {timeout_s}s")
return CliResult(rc=p.returncode, stdout=p.stdout, stderr=p.stderr)
# ── identity ─────────────────────────────────────────────────────────
@dataclass
class Identity:
userid: str
issuance_pk_hex: str
issuance_secret_hex: str
transport_pk_hex: str
transport_secret_hex: str
grant_token_b64: str
contact_bundle: str # value of ``zkac-node user show <userid> --peer ...``
def load_identity_secrets(userid: str) -> dict[str, str]:
"""Read the CLI-managed ``identity.json`` (creator: ``zkac-node user create``)."""
path = user_identity_path(userid)
if not path.is_file():
raise FileNotFoundError(f"unknown user {userid!r} (run: zkac-node user create {userid})")
data = json.loads(path.read_text())
def hexed(b64_field: str) -> str:
return _b64_decode(data[b64_field]).hex()
return {
"issuance_pk_hex": hexed("issuance_public_b64"),
"issuance_secret_hex": hexed("issuance_secret_b64"),
"transport_pk_hex": hexed("transport_public_b64"),
"transport_secret_hex": hexed("transport_secret_b64"),
"grant_token_b64": data["grant_token_b64"],
}
def create_user(userid: str) -> CliResult:
return run_cli(["user", "create", userid])
def show_user_contact(userid: str, peer: str | None = None) -> str:
args = ["user", "show", userid]
if peer:
args += ["--peer", peer]
res = run_cli(args).raise_for_status()
for line in res.stdout.splitlines():
line = line.strip()
# contact bundle is the indented blob after "share contact:"
if line and not line.startswith(("user:", "share contact:", "issuance pk:", "p2p transport pk:", "(", "registries owned:", "credentials:", "contact peer endpoint:", "owner admin")):
if re.fullmatch(r"[A-Za-z0-9_\-]+", line):
return line
raise RuntimeError("could not parse contact bundle from `zkac-node user show`")
def get_identity(userid: str, peer: str | None = None) -> Identity:
secrets = load_identity_secrets(userid)
contact = show_user_contact(userid, peer=peer)
return Identity(
userid=userid,
issuance_pk_hex=secrets["issuance_pk_hex"],
issuance_secret_hex=secrets["issuance_secret_hex"],
transport_pk_hex=secrets["transport_pk_hex"],
transport_secret_hex=secrets["transport_secret_hex"],
grant_token_b64=secrets["grant_token_b64"],
contact_bundle=contact,
)
# ── registry / pinning / grants ──────────────────────────────────────
def server_pin(userid: str, server: str, server_pk_hex: str) -> CliResult:
return run_cli(["server", "pin", userid, server, "--key", server_pk_hex])
def load_pinned_server_key(userid: str, server: str) -> str | None:
digest = hashlib.sha256(server.encode("utf-8")).hexdigest()
pin_file = zkac_home() / userid / "servers" / f"sha256_{digest}.json"
if not pin_file.is_file():
return None
data = json.loads(pin_file.read_text())
server_pk_b64 = data.get("server_public_key_b64")
if not isinstance(server_pk_b64, str):
return None
return _b64_decode(server_pk_b64).hex()
def registry_create(userid: str, server: str, roles: list[str]) -> str:
res = run_cli(["registry", "create", userid, server, "--roles", ",".join(roles)]).raise_for_status()
for line in res.stdout.splitlines():
line = line.strip()
if line.startswith("registry created:"):
return line.split(":", 1)[1].strip()
raise RuntimeError(f"could not parse registry id from output:\n{res.stdout}")
def registry_list(userid: str) -> list[dict]:
res = run_cli(["registry", "list", userid]).raise_for_status()
out: list[dict] = []
for line in res.stdout.splitlines():
s = line.strip()
m = re.match(
r"^([0-9a-fA-F]+)\s+@\s+(\S+)\s+roles=\[(.*)\]\s*$",
s,
)
if m:
roles_raw = m.group(3)
roles = [r.strip().strip("'\"") for r in roles_raw.split(",") if r.strip()]
out.append({"registry_id": m.group(1), "server": m.group(2), "roles": roles})
return out
def registry_add_roles(userid: str, server: str, registry_id: str, add_roles: list[str]) -> CliResult:
return run_cli([
"registry", "update", userid, server,
"--registry", registry_id,
"--add-roles", ",".join(add_roles),
]).raise_for_status()
def credentials_list(userid: str) -> list[dict]:
"""Return locally held BBS credentials as [{registry_id, role}]."""
res = run_cli(["credentials", "list", userid]).raise_for_status()
creds: list[dict] = []
in_local = False
for line in res.stdout.splitlines():
if line.startswith("local credentials:"):
in_local = True
continue
if not line.strip():
continue
if line.startswith(("owner admin capability:", "registries owned:")):
in_local = False
continue
if in_local:
s = line.strip()
if s == "(none)":
continue
if ":" in s:
rid, role = s.rsplit(":", 1)
creds.append({"registry_id": rid.strip(), "role": role.strip()})
return creds
def grant(
userid: str,
server: str,
registry_id: str,
role_name: str,
recipient_contact: str,
) -> CliResult:
return run_cli([
"grant", userid,
"--server", server,
"--registry", registry_id,
"--role", role_name,
"--to", recipient_contact,
]).raise_for_status()
# ── background p2p-listen ────────────────────────────────────────────
class P2PListener:
"""Background ``zkac-node p2p-listen`` process the TUI can stop and watch."""
def __init__(
self,
userid: str,
host: str = "127.0.0.1",
port: int = 0,
timeout_s: float = DEFAULT_LISTEN_TIMEOUT_S,
) -> None:
self.userid = userid
self.host = host
self.port = port if port else _pick_random_port(host)
self.timeout_s = timeout_s
self._proc: subprocess.Popen[str] | None = None
self._stdout_buf = ""
self._lock = threading.Lock()
@property
def address(self) -> str:
return f"{self.host}:{self.port}"
def start(self) -> None:
prefix, env_overrides = _zkac_invocation()
env = {
**os.environ,
**env_overrides,
"PYTHONUNBUFFERED": "1",
"ZKAC_HOME": str(zkac_home()),
}
args = prefix + [
"p2p-listen", self.userid,
"--host", self.host,
"--port", str(self.port),
"--timeout", str(self.timeout_s),
]
self._proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=str(ROOT),
env=env,
bufsize=1,
)
threading.Thread(target=self._drain, daemon=True).start()
def _drain(self) -> None:
if self._proc is None or self._proc.stdout is None:
return
for line in self._proc.stdout:
with self._lock:
self._stdout_buf += line
def is_running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def stop(self) -> None:
if self._proc is None:
return
if self._proc.poll() is None:
self._proc.terminate()
try:
self._proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc.wait()
def output(self) -> str:
with self._lock:
return self._stdout_buf
def parse_received(self) -> dict | None:
"""Extract ``{registry_id, role}`` from CLI output once a grant has been received."""
out = self.output()
rid = role = None
for line in out.splitlines():
s = line.strip()
if s.startswith("registry:"):
rid = s.split(":", 1)[1].strip()
elif s.startswith("role:"):
role = s.split(":", 1)[1].strip()
if rid and role:
return {"registry_id": rid, "role": role}
return None
def _pick_random_port(host: str) -> int:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind((host, 0))
return s.getsockname()[1]
finally:
s.close()
# ── credentials and admin material (CLI on-disk format, read-only) ────
def _user_dir(userid: str) -> Path:
return zkac_home() / userid
def load_credential(userid: str, registry_id_hex: str, role_name: str) -> zkac.Credential:
"""Reconstruct a finalized BBS credential previously stored by ``zkac-node grant``."""
p = _user_dir(userid) / "credentials" / f"{registry_id_hex}_{role_name}.json"
if not p.is_file():
raise FileNotFoundError(
f"no credential for {role_name!r} on {registry_id_hex[:16]}…; "
"ask the registry admin to grant via `zkac-node grant`."
)
data = json.loads(p.read_text())
pk = zkac.BbsPublicKey.from_bytes(_b64_decode(data["issuer_pk_b64"]))
return zkac.Credential.finalize(
_b64_decode(data["blind_sig_b64"]),
_b64_decode(data["member_secret_b64"]),
_b64_decode(data["prover_blind_b64"]),
zkac.role_id(data["role_name"]),
int(data["epoch"]),
pk,
)
def load_admin_material(userid: str, registry_id_hex: str) -> dict:
p = _user_dir(userid) / "admin" / f"{registry_id_hex}.json"
if not p.is_file():
raise FileNotFoundError(
f"no admin material for {registry_id_hex[:16]}… under {userid!r}; "
"you are not the owner of this registry"
)
return json.loads(p.read_text())
def load_admin_credential(userid: str, registry_id_hex: str) -> zkac.Credential:
"""Reconstruct the registry-admin BBS credential (role = ``admin_role_id``)."""
data = load_admin_material(userid, registry_id_hex)
pk = zkac.BbsPublicKey.from_bytes(_b64_decode(data["bbs_issuer_public_b64"]))
return zkac.Credential.finalize(
_b64_decode(data["admin_blind_sig_b64"]),
_b64_decode(data["admin_member_secret_b64"]),
_b64_decode(data["admin_prover_blind_b64"]),
zkac.admin_role_id(),
0,
pk,
)
def is_registry_admin(userid: str, registry_id_hex: str) -> bool:
return (_user_dir(userid) / "admin" / f"{registry_id_hex}.json").is_file()