368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""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 <name> 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 <userid> --peer <host:port>"
|
|
)
|
|
|
|
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 / collect ───────────────────────────────────────────
|
|
|
|
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}")
|
|
|
|
print("\npending grants: removed (use direct p2p listener)")
|
|
|
|
|
|
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/<userid>/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="credentials (local + pending)")
|
|
cred_sub = cred_p.add_subparsers(dest="action", required=True)
|
|
c = cred_sub.add_parser("list", help="show local + pending 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()
|