diff --git a/README.md b/README.md index 6d9c95a..eba6704 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ python -c "import zkac; print(zkac.role_id('admin').hex())" Run tests: `cargo test` and `pytest tests/test_zkac.py`. -Local web UI over the CLI: `demo/cli_web_server.py` (see `demo/README.md`). Optional in-browser WASM for the legacy Flask demo: `wasm/README.md` and `./demo/build_wasm.sh` (needs **rustup** + `wasm32-unknown-unknown`). +Local web UI over the CLI: `demo/cli_web_server.py` (see `demo/README.md`). Optional in-browser WASM for the Flask demo: `wasm/README.md` and `./demo/build_wasm.sh` (needs **rustup** + `wasm32-unknown-unknown`). ## License diff --git a/cli/README.md b/cli/README.md index c4e28a3..240fddf 100644 --- a/cli/README.md +++ b/cli/README.md @@ -45,7 +45,7 @@ zkac-node auth bob --registry --role analyst --server localhost:98 | `user create ` | Generate issuance keypair under `~/.zkac//` | | `user list` | List all local user ids | | `user show ` | Show issuance pk + owned registries + credentials | -| `serve [--data-dir D]` | Run server; default data dir is `~/.zkac//server/` | +| `serve [--data-dir D]` | Run server; default data dir is `~/.zkac//server/` (loopback-only unless `--allow-non-loopback`) | | `zkac-node-i2p-server [--host H --port P]` | Same as `serve`, for I2P server-tunnel exposure (see below) | | `server pin --key ` | Pin server public key for that user | | `registry create --roles …` | Create registry on server | @@ -53,7 +53,7 @@ zkac-node auth bob --registry --role analyst --server localhost:98 | `registry get --registry R` | Fetch registry state | | `registry list ` | List registries this user owns locally | | `grant --server S --registry R --role X --to ` | Admin direct p2p grant (recipient contact bundle must include peer endpoint) | -| `p2p-listen [--host H --port P]` | Receive one direct p2p grant and store credential | +| `p2p-listen [--host H --port P]` | Receive one direct p2p grant and store credential (loopback-only unless `--allow-non-loopback`) | | `credentials list ` | List local credentials | | `auth --registry R --role X [--server S]` | Authenticated session | @@ -92,6 +92,8 @@ Outbound client connections can be proxied through an I2P SOCKS tunnel. export ZKAC_SOCKS5_PROXY=127.0.0.1:4447 ``` +If the destination ends in `.i2p` and `ZKAC_SOCKS5_PROXY` is not set, `zkac-node` fails fast instead of attempting a direct clearnet connect. + 3. Use `.b32.i2p:port` endpoints with normal commands: ```bash @@ -101,6 +103,8 @@ zkac-node auth bob --registry --role analyst --server exampledesti For inbound direct grants, run `p2p-listen` on a local port and expose it via an I2P inbound tunnel. Peers dial your published I2P destination/port; your tunnel forwards to local `host:port`. +`p2p-listen` now refuses non-loopback binds by default (use `--allow-non-loopback` only when intentional). + Connectivity sanity check: ```bash @@ -126,6 +130,6 @@ identity.json issuance keypair p2p transport keypair admin/.json BBS+ admin material for owned registries credentials/_.json received credentials -servers/.json pinned server public keys +servers/sha256_<...>.json pinned server public keys (includes original server string) server/ (only if you run `serve `) server_key.json, registries/ ``` diff --git a/cli/zkac_cli/client.py b/cli/zkac_cli/client.py index 153d0ce..90bbda3 100644 --- a/cli/zkac_cli/client.py +++ b/cli/zkac_cli/client.py @@ -14,6 +14,8 @@ from zkac.tcp import FramedSession, client_handshake_anon, server_handshake_anon from . import store +DEFAULT_CONNECT_TIMEOUT_S = 8.0 + def _b64(data: bytes) -> str: return base64.b64encode(data).decode() @@ -66,6 +68,28 @@ def _is_i2p_host(host: str) -> bool: return host.strip().lower().endswith(".i2p") +def _maybe_i2p_pin_hint(userid: str, server: str) -> str: + host, _port = _parse_server(server) + is_i2p_target = _is_i2p_host(host) + known = store.known_servers(userid) + candidates: list[str] = [] + for known_server in known: + try: + known_host, _ = _parse_server(known_server) + except ValueError: + continue + if _is_i2p_host(known_host) == is_i2p_target: + continue + candidates.append(known_server) + if not candidates: + return "" + choices = ", ".join(sorted(candidates)[:3]) + return ( + " You appear to have pins under a different address style " + f"({choices}). Pins are exact host:port keys; pin and connect with the same endpoint string." + ) + + def _recv_exact(sock: socket.socket, n: int) -> bytes: buf = bytearray() while len(buf) < n: @@ -103,13 +127,24 @@ def _socks5_connect(sock: socket.socket, host: str, port: int): raise RuntimeError("invalid SOCKS5 address type in reply") -def _connect(host: str, port: int) -> socket.socket: +def _connect_with_timeout(address: tuple[str, int], timeout_s: float = DEFAULT_CONNECT_TIMEOUT_S) -> socket.socket: + sock = socket.create_connection(address, timeout=timeout_s) + sock.settimeout(None) + return sock + + +def _connect(host: str, port: int, *, connect_timeout_s: float = DEFAULT_CONNECT_TIMEOUT_S) -> socket.socket: proxy = _proxy_target() + if _is_i2p_host(host) and proxy is None: + raise RuntimeError( + "destination is .i2p but ZKAC_SOCKS5_PROXY is not set. " + "Set ZKAC_SOCKS5_PROXY=127.0.0.1:4447 (or your I2P SOCKS endpoint)." + ) # Only route through SOCKS5 for I2P destinations. This keeps local/direct # peer delivery (e.g. 127.0.0.1) working even when ZKAC_SOCKS5_PROXY is set. if proxy is None or not _is_i2p_host(host): - return socket.create_connection((host, port)) - sock = socket.create_connection(proxy) + return _connect_with_timeout((host, port), connect_timeout_s) + sock = _connect_with_timeout(proxy, connect_timeout_s) try: _socks5_connect(sock, host, port) return sock @@ -129,6 +164,16 @@ def net_check( """Connectivity diagnostic for direct/server endpoints, with optional SOCKS5 and handshake.""" host, port = _parse_server(target) proxy = _proxy_target() + if _is_i2p_host(host) and proxy is None: + return { + "ok": False, + "target": target, + "via": "direct", + "error": ( + "destination is .i2p but ZKAC_SOCKS5_PROXY is not set; " + "configure your I2P SOCKS proxy (for example 127.0.0.1:4447)" + ), + } use_proxy = proxy is not None and _is_i2p_host(host) via = "direct" if not use_proxy else f"socks5:{proxy[0]}:{proxy[1]}" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -182,11 +227,13 @@ def parse_spec(spec: str) -> tuple[str, str, str]: def _resolve_server_pk(userid: str, server: str) -> zkac.PublicKey: pin = store.load_server_pin(userid, server) if pin is None: + hint = _maybe_i2p_pin_hint(userid, server) raise RuntimeError( f"no pinned transport key for {server!r} under client {userid!r} " f"(pins are per client identity in ~/.zkac/{userid}/, not the userid " "passed to `zkac-node serve <…>` on the server). " - f"Run: zkac-node server pin {userid} {server} --key " + f"Run: zkac-node server pin {userid} {server} --key ." + f"{hint}" ) return zkac.PublicKey.from_bytes(_unb64(pin["server_public_key_b64"])) @@ -199,7 +246,7 @@ def _local_node(userid: str) -> zkac.Node: def _mgmt_connect(userid: str, server: str) -> tuple[socket.socket, FramedSession, bytes]: host, port = _parse_server(server) - sock = _connect(host, port) + sock = _connect(host, port, connect_timeout_s=DEFAULT_CONNECT_TIMEOUT_S) server_pk = _resolve_server_pk(userid, server) node = _local_node(userid) session = client_handshake_anon(sock, node, server_pk) @@ -457,7 +504,7 @@ def grant_p2p( ) host, port = _parse_server(peer) peer_transport_pk = zkac.PublicKey.from_bytes(bytes.fromhex(peer_transport_pk_hex)) - sock = _connect(host, port) + sock = _connect(host, port, connect_timeout_s=DEFAULT_CONNECT_TIMEOUT_S) try: session = client_handshake_anon(sock, _local_node(userid), peer_transport_pk) framed = FramedSession(sock, session) @@ -585,7 +632,7 @@ def authenticate(userid: str, registry_id_hex: str, role_name: str, node = zkac.Node(zkac.Keypair()) host, port = _parse_server(server) - sock = _connect(host, port) + sock = _connect(host, port, connect_timeout_s=DEFAULT_CONNECT_TIMEOUT_S) try: session = client_handshake_anon(sock, node, server_pk) framed = FramedSession(sock, session) diff --git a/cli/zkac_cli/i2p_serve.py b/cli/zkac_cli/i2p_serve.py index 638cbf5..b26b7f8 100644 --- a/cli/zkac_cli/i2p_serve.py +++ b/cli/zkac_cli/i2p_serve.py @@ -55,6 +55,11 @@ use to connect (the .b32.i2p:port form, not localhost). action="store_true", help="suppress I2P tunnel reminder text", ) + p.add_argument( + "--allow-non-loopback", + action="store_true", + help="allow --host values outside loopback (default blocks non-loopback binds)", + ) args = p.parse_args() data_dir = args.data_dir @@ -71,7 +76,7 @@ use to connect (the .b32.i2p:port form, not localhost). file=sys.stderr, ) - serve(data_dir, args.host, args.port) + serve(data_dir, args.host, args.port, allow_non_loopback=args.allow_non_loopback) if __name__ == "__main__": diff --git a/cli/zkac_cli/main.py b/cli/zkac_cli/main.py index 5131aa7..6990e94 100644 --- a/cli/zkac_cli/main.py +++ b/cli/zkac_cli/main.py @@ -11,6 +11,11 @@ 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): @@ -73,6 +78,7 @@ def _cmd_serve(args): max_connections=args.max_connections, idle_timeout_s=args.idle_timeout, listen_backlog=args.listen_backlog, + allow_non_loopback=args.allow_non_loopback, ) @@ -174,6 +180,13 @@ def _cmd_credentials_list(args): 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") @@ -241,8 +254,13 @@ def main(): 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=30.0, help="per-connection idle timeout seconds") + 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 @@ -312,6 +330,11 @@ def main(): 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 diff --git a/cli/zkac_cli/server.py b/cli/zkac_cli/server.py index edc265d..293e6f4 100644 --- a/cli/zkac_cli/server.py +++ b/cli/zkac_cli/server.py @@ -6,6 +6,7 @@ import base64 import json import os import socket +import sys import threading import traceback from pathlib import Path @@ -36,6 +37,11 @@ def _write_private_json(path: Path, payload: dict): _chmod_if_possible(path, 0o600) +def _is_loopback_host(host: str) -> bool: + value = host.strip().lower() + return value in {"127.0.0.1", "::1", "localhost"} + + # ── Opaque server storage ───────────────────────────────────────────── class _ServerStore: @@ -280,10 +286,11 @@ def serve( host: str = "127.0.0.1", port: int = 9800, max_connections: int = 64, - idle_timeout_s: float = 30.0, + idle_timeout_s: float = 45.0, listen_backlog: int = 64, *, debug: ServerDebugState | None = None, + allow_non_loopback: bool = False, ): dd = Path(data_dir) dd.mkdir(parents=True, exist_ok=True) @@ -305,6 +312,18 @@ def serve( print(f"loaded {n} registries") print(f"listening on {host}:{port}") + if not _is_loopback_host(host): + if not allow_non_loopback: + raise RuntimeError( + "refusing to bind outside loopback. " + "Use --allow-non-loopback only when you intentionally expose this listener." + ) + print( + f"[warning] binding outside loopback: {host}:{port}. " + "Ensure network exposure is intentional.", + file=sys.stderr, + ) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) diff --git a/cli/zkac_cli/store.py b/cli/zkac_cli/store.py index c81d503..17673fb 100644 --- a/cli/zkac_cli/store.py +++ b/cli/zkac_cli/store.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import hashlib import json import os import secrets @@ -69,17 +70,6 @@ def create_user(userid: str) -> Path: def load_identity(userid: str) -> dict: p = _ud(userid) / "identity.json" data = json.loads(p.read_text()) - changed = False - if "transport_secret_b64" not in data or "transport_public_b64" not in data: - transport_kp = zkac.Keypair() - data["transport_secret_b64"] = _b64(transport_kp.secret_key_bytes()) - data["transport_public_b64"] = _b64(transport_kp.public_key().to_bytes()) - changed = True - if "grant_token_b64" not in data: - data["grant_token_b64"] = _b64(secrets.token_bytes(32)) - changed = True - if changed: - _write_private_json(p, data) return { "issuance_sk": _unb64(data["issuance_secret_b64"]), "issuance_pk": _unb64(data["issuance_public_b64"]), @@ -166,20 +156,25 @@ def list_users() -> list[str]: # ── Server pins (per user) ─────────────────────────────────────────── def _server_key(server: str) -> str: - return server.replace(":", "_") + digest = hashlib.sha256(server.encode("utf-8")).hexdigest() + return f"sha256_{digest}" def pin_server(userid: str, server: str, server_pk_b64: str): d = _ud(userid) / "servers" _ensure_private_dir(d) - _write_private_json(d / f"{_server_key(server)}.json", {"server_public_key_b64": server_pk_b64}) + _write_private_json( + d / f"{_server_key(server)}.json", + {"server": server, "server_public_key_b64": server_pk_b64}, + ) def load_server_pin(userid: str, server: str) -> dict | None: - p = _ud(userid) / "servers" / f"{_server_key(server)}.json" - if not p.exists(): - return None - return json.loads(p.read_text()) + d = _ud(userid) / "servers" + new_path = d / f"{_server_key(server)}.json" + if new_path.exists(): + return json.loads(new_path.read_text()) + return None def known_servers(userid: str) -> list[str]: @@ -188,9 +183,13 @@ def known_servers(userid: str) -> list[str]: d = _ud(userid) / "servers" if d.exists(): for p in d.glob("*.json"): - host_port = p.stem.replace("_", ":") - if host_port not in seen: - seen.append(host_port) + try: + data = json.loads(p.read_text()) + except Exception: + continue + server = data.get("server") + if isinstance(server, str) and server and server not in seen: + seen.append(server) for rid in list_admin_registries(userid): try: reg = load_admin(userid, rid) diff --git a/docs/FUZZING.md b/docs/FUZZING.md index 569d069..febfcfc 100644 --- a/docs/FUZZING.md +++ b/docs/FUZZING.md @@ -32,7 +32,7 @@ cargo fuzz build -s none handshake_respond cargo fuzz run -s none handshake_respond -- -max_total_time=60 ``` -The helper script sets `SANITIZER=none` by default for broad compatibility. It also prepends `~/.cargo/bin` to `PATH` so `cargo-fuzz` is found after `cargo install cargo-fuzz`. +The helper script sets `SANITIZER=none` by default for stable-toolchain usage. It also prepends `~/.cargo/bin` to `PATH` so `cargo-fuzz` is found after `cargo install cargo-fuzz`. ### Did anything crash?