"""CLI entry point for zkac-node.""" from __future__ import annotations import argparse import base64 import json import sys from . import client, store from .paths import user_dir def _is_loopback_host(host: str) -> bool: value = host.strip().lower() return value in {"127.0.0.1", "::1", "localhost"} # ── user ────────────────────────────────────────────────────────────── def _cmd_user_create(args): path = store.create_user(args.userid) ident = store.load_identity(args.userid) print(f"created user {args.userid!r}") print(f" issuance public key: {ident['issuance_pk'].hex()}") print(f" p2p transport public key: {ident['transport_pk'].hex()}") print(f" (share this key out-of-band to receive credentials)") print(f" stored in {path}") def _cmd_user_list(_args): users = store.list_users() if not users: print("no users found") return for u in users: print(u) def _cmd_user_show(args): ident = store.load_identity(args.userid) contact = store.export_contact_bundle(args.userid, peer=args.peer) print(f"user: {args.userid}") print(f" issuance pk: {ident['issuance_pk'].hex()}") print(f" p2p transport pk: {ident['transport_pk'].hex()}") print(" share contact:") print(f" {contact}") if args.peer: print(f" contact peer endpoint: {args.peer}") owned = [ {"registry_id": r, **store.load_admin(args.userid, r)} for r in store.list_admin_registries(args.userid) ] if owned: print(f" registries owned: {len(owned)}") for r in owned: print(f" {r['registry_id']} @ {r.get('server', '?')} roles={r.get('roles', [])}") creds = store.list_credentials(args.userid) if creds: print(f" credentials: {len(creds)}") for reg_hex, role in creds: print(f" {reg_hex[:16]}… / {role}") # ── serve ───────────────────────────────────────────────────────────── def _cmd_serve(args): from .server import serve data_dir = args.data_dir if data_dir is None: data_dir = str(user_dir(args.userid) / "server") serve( data_dir, args.host, args.port, max_connections=args.max_connections, idle_timeout_s=args.idle_timeout, listen_backlog=args.listen_backlog, allow_non_loopback=args.allow_non_loopback, ) # ── server pin ─────────────────────────────────────────────────────── def _cmd_server_pin(args): pk_bytes = bytes.fromhex(args.key) store.pin_server(args.userid, args.server, base64.b64encode(pk_bytes).decode()) print(f"pinned {args.server} for {args.userid} -> {args.key[:16]}…") # ── registry ────────────────────────────────────────────────────────── def _cmd_registry_create(args): roles = [r.strip() for r in args.roles.split(",")] rid = client.create_registry(args.userid, args.server, roles) print(f"registry created: {rid}") print(f" roles: {', '.join(roles)}") def _cmd_registry_update(args): add = [r.strip() for r in args.add_roles.split(",")] client.update_registry(args.userid, args.server, args.registry, add) print(f"registry updated: {args.registry[:16]}…") print(f" added roles: {', '.join(add)}") def _cmd_registry_get(args): info = client.get_registry(args.userid, args.server, args.registry) print(f"registry: {args.registry}") print(f" state bytes: {len(info.get('state_bytes_b64', ''))} chars (b64)") def _cmd_registry_list(args): regs = client.list_own_registries(args.userid) if not regs: print("no registries") return for r in regs: print(f" {r['registry_id']} @ {r['server']} roles={r['roles']}") def _cmd_registry_revoke(args): if args.all and args.role: raise RuntimeError("use either --role or --all, not both") if not args.all and not args.role: raise RuntimeError("missing target: pass --role or --all") client.revoke_registry( args.userid, args.server, args.registry, role_name=None if args.all else args.role, ) if args.all: print(f"registry revoked: {args.registry[:16]}…") print(" bumped epoch for all roles") else: print(f"registry revoked: {args.registry[:16]}…") print(f" bumped epoch for role: {args.role}") # ── grant ───────────────────────────────────────────────────────────── def _cmd_grant(args): parsed = store.parse_contact_bundle(args.to) to = parsed["issuance_pk_hex"] peer_key = parsed["transport_pk_hex"] grant_token = parsed.get("grant_token_b64") peer = parsed.get("peer") if not grant_token: raise RuntimeError( "contact bundle is missing grant pairing token. " "Ask recipient to regenerate bundle with current CLI." ) if not peer: raise RuntimeError( "contact bundle is missing peer endpoint. " "Ask recipient to regenerate with: zkac-node user show --peer " ) result = client.grant_p2p( args.userid, args.server, args.registry, args.role, to, grant_token, peer, peer_key, ) print(f"granted {args.role!r} to {to[:16]}…") print(f" delivery: direct p2p ({result['peer']})") # ── credentials ───────────────────────────────────────────────────── def _cmd_credentials_list(args): local = store.list_credentials(args.userid) print("local credentials:") if not local: print(" (none)") for reg_hex, role in local: print(f" {reg_hex}:{role}") owned = store.list_admin_registries(args.userid) print("\nowner admin capability:") if not owned: print(" (none)") else: for reg_hex in owned: print(f" {reg_hex}:__admin__ (reserved owner role)") def _cmd_p2p_listen(args): if not _is_loopback_host(args.host): if not args.allow_non_loopback: raise RuntimeError( "refusing to bind p2p-listen outside loopback. " "Use --allow-non-loopback only when exposure is intentional." ) print(f"warning: binding p2p-listen outside loopback: {args.host}:{args.port}", file=sys.stderr) print(f"listening for p2p grant on {args.host}:{args.port}") result = client.receive_p2p(args.userid, args.host, args.port, timeout_s=args.timeout) print("received credential") print(f" registry: {result['registry_id']}") print(f" role: {result['role']}") # ── auth ────────────────────────────────────────────────────────────── def _cmd_auth(args): resp = client.authenticate( args.userid, args.registry, args.role, server=args.server, ) print(json.dumps(resp, indent=2)) def _cmd_net_check(args): resp = client.net_check( args.target, timeout_s=args.timeout, handshake=args.handshake, userid=args.userid, server_key_hex=args.key, ) if resp.get("ok"): print("network check: ok") print(f" target: {resp['target']}") print(f" via: {resp['via']}") if args.handshake: print(" handshake: ok") return print("network check: failed") print(f" target: {resp['target']}") print(f" via: {resp['via']}") print(f" error: {resp.get('error', 'unknown')}") sys.exit(2) # ── argparse ───────────────────────────────────────────────────────── def main(): p = argparse.ArgumentParser(prog="zkac-node") sub = p.add_subparsers(dest="group", required=True) # user user_p = sub.add_parser("user", help="manage local user identities") user_sub = user_p.add_subparsers(dest="action", required=True) c = user_sub.add_parser("create", help="generate a new user identity") c.add_argument("userid") c.set_defaults(func=_cmd_user_create) c = user_sub.add_parser("list", help="list all local users") c.set_defaults(func=_cmd_user_list) c = user_sub.add_parser("show", help="show user keys + registries + credentials") c.add_argument("userid") c.add_argument("--peer", default=None, help="optional recipient p2p host:port to embed in contact bundle") c.set_defaults(func=_cmd_user_show) # serve c = sub.add_parser("serve", help="run as a ZKAC server node") c.add_argument("userid", help="user whose ~/.zkac//server/ holds server state (unless --data-dir)") c.add_argument("--data-dir", default=None, help="override server data directory") c.add_argument("--host", default="127.0.0.1") c.add_argument("--port", type=int, default=9800) c.add_argument("--max-connections", type=int, default=64, help="max concurrent client connections") c.add_argument("--idle-timeout", type=float, default=45.0, help="per-connection idle timeout seconds") c.add_argument("--listen-backlog", type=int, default=64, help="TCP listen backlog") c.add_argument( "--allow-non-loopback", action="store_true", help="allow --host values outside loopback (default blocks non-loopback binds)", ) c.set_defaults(func=_cmd_serve) # server pin srv_p = sub.add_parser("server", help="manage server pins") srv_sub = srv_p.add_subparsers(dest="action", required=True) c = srv_sub.add_parser("pin", help="pin a server's public key for a user") c.add_argument("userid") c.add_argument("server", help="host:port") c.add_argument("--key", required=True, help="server public key (hex)") c.set_defaults(func=_cmd_server_pin) # registry reg_p = sub.add_parser("registry", help="manage registries") reg_sub = reg_p.add_subparsers(dest="action", required=True) c = reg_sub.add_parser("create", help="create a new registry on a server") c.add_argument("userid") c.add_argument("server", help="host:port") c.add_argument("--roles", required=True, help="comma-separated role names") c.set_defaults(func=_cmd_registry_create) c = reg_sub.add_parser("update", help="add roles to a registry you own") c.add_argument("userid") c.add_argument("server", help="host:port") c.add_argument("--registry", required=True) c.add_argument("--add-roles", required=True, help="comma-separated new roles") c.set_defaults(func=_cmd_registry_update) c = reg_sub.add_parser("get", help="fetch registry state from server") c.add_argument("userid") c.add_argument("server", help="host:port") c.add_argument("--registry", required=True) c.set_defaults(func=_cmd_registry_get) c = reg_sub.add_parser("list", help="list locally owned registries") c.add_argument("userid") c.set_defaults(func=_cmd_registry_list) c = reg_sub.add_parser("revoke", help="revoke credentials by bumping role epoch(s)") c.add_argument("userid") c.add_argument("server", help="host:port") c.add_argument("--registry", required=True) c.add_argument("--role", default=None, help="revoke a single role by bumping its epoch") c.add_argument("--all", action="store_true", help="revoke all roles by bumping all epochs") c.set_defaults(func=_cmd_registry_revoke) # grant c = sub.add_parser("grant", help="issue a credential to a recipient (admin)") c.add_argument("userid") c.add_argument("--server", required=True, help="host:port") c.add_argument("--registry", required=True) c.add_argument("--role", required=True) c.add_argument("--to", required=True, help="recipient contact bundle") c.set_defaults(func=_cmd_grant) # credentials cred_p = sub.add_parser("credentials", help="local credentials") cred_sub = cred_p.add_subparsers(dest="action", required=True) c = cred_sub.add_parser("list", help="show local credentials") c.add_argument("userid") c.set_defaults(func=_cmd_credentials_list) # direct p2p receive c = sub.add_parser("p2p-listen", help="listen for one direct p2p credential grant") c.add_argument("userid") c.add_argument("--host", default="127.0.0.1") c.add_argument("--port", type=int, default=9810) c.add_argument("--timeout", type=float, default=60.0, help="max wait for authenticated sender") c.add_argument( "--allow-non-loopback", action="store_true", help="allow --host values outside loopback (default blocks non-loopback binds)", ) c.set_defaults(func=_cmd_p2p_listen) # auth c = sub.add_parser("auth", help="authenticate with a credential") c.add_argument("userid") c.add_argument("--registry", required=True) c.add_argument("--role", required=True) c.add_argument("--server", default=None, help="host:port (optional if known from registry)") c.set_defaults(func=_cmd_auth) # network diagnostics net_p = sub.add_parser("net", help="network diagnostics") net_sub = net_p.add_subparsers(dest="action", required=True) c = net_sub.add_parser("check", help="test TCP reachability (direct or SOCKS5 proxy)") c.add_argument("target", help="host:port (supports .b32.i2p via SOCKS5)") c.add_argument("--timeout", type=float, default=8.0, help="connect timeout in seconds") c.add_argument("--handshake", action="store_true", help="also run anonymous ZKAC handshake") c.add_argument("--userid", default=None, help="client userid to load pinned server key for --handshake") c.add_argument("--key", default=None, help="server public key hex for --handshake") c.set_defaults(func=_cmd_net_check) args = p.parse_args() if not hasattr(args, "func"): p.print_help() sys.exit(1) args.func(args) if __name__ == "__main__": main()