minor bugfixes

This commit is contained in:
everbarry 2026-05-06 17:39:11 +02:00
parent 6ce6bf5675
commit 91b1d607bc
8 changed files with 132 additions and 35 deletions

View File

@ -37,7 +37,7 @@ python -c "import zkac; print(zkac.role_id('admin').hex())"
Run tests: `cargo test` and `pytest tests/test_zkac.py`. 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 ## License

View File

@ -45,7 +45,7 @@ zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server localhost:98
| `user create <id>` | Generate issuance keypair under `~/.zkac/<id>/` | | `user create <id>` | Generate issuance keypair under `~/.zkac/<id>/` |
| `user list` | List all local user ids | | `user list` | List all local user ids |
| `user show <id>` | Show issuance pk + owned registries + credentials | | `user show <id>` | Show issuance pk + owned registries + credentials |
| `serve <id> [--data-dir D]` | Run server; default data dir is `~/.zkac/<id>/server/` | | `serve <id> [--data-dir D]` | Run server; default data dir is `~/.zkac/<id>/server/` (loopback-only unless `--allow-non-loopback`) |
| `zkac-node-i2p-server <id> [--host H --port P]` | Same as `serve`, for I2P server-tunnel exposure (see below) | | `zkac-node-i2p-server <id> [--host H --port P]` | Same as `serve`, for I2P server-tunnel exposure (see below) |
| `server pin <id> <host:port> --key <hex>` | Pin server public key for that user | | `server pin <id> <host:port> --key <hex>` | Pin server public key for that user |
| `registry create <id> <server> --roles …` | Create registry on server | | `registry create <id> <server> --roles …` | Create registry on server |
@ -53,7 +53,7 @@ zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server localhost:98
| `registry get <id> <server> --registry R` | Fetch registry state | | `registry get <id> <server> --registry R` | Fetch registry state |
| `registry list <id>` | List registries this user owns locally | | `registry list <id>` | List registries this user owns locally |
| `grant <id> --server S --registry R --role X --to <blob>` | Admin direct p2p grant (recipient contact bundle must include peer endpoint) | | `grant <id> --server S --registry R --role X --to <blob>` | Admin direct p2p grant (recipient contact bundle must include peer endpoint) |
| `p2p-listen <id> [--host H --port P]` | Receive one direct p2p grant and store credential | | `p2p-listen <id> [--host H --port P]` | Receive one direct p2p grant and store credential (loopback-only unless `--allow-non-loopback`) |
| `credentials list <id>` | List local credentials | | `credentials list <id>` | List local credentials |
| `auth <id> --registry R --role X [--server S]` | Authenticated session | | `auth <id> --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 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: 3. Use `.b32.i2p:port` endpoints with normal commands:
```bash ```bash
@ -101,6 +103,8 @@ zkac-node auth bob --registry <REGISTRY_ID> --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`. 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: Connectivity sanity check:
```bash ```bash
@ -126,6 +130,6 @@ identity.json issuance keypair
p2p transport keypair p2p transport keypair
admin/<registry_id>.json BBS+ admin material for owned registries admin/<registry_id>.json BBS+ admin material for owned registries
credentials/<rid>_<role>.json received credentials credentials/<rid>_<role>.json received credentials
servers/<host_port>.json pinned server public keys servers/sha256_<...>.json pinned server public keys (includes original server string)
server/ (only if you run `serve <userid>`) server_key.json, registries/ server/ (only if you run `serve <userid>`) server_key.json, registries/
``` ```

View File

@ -14,6 +14,8 @@ from zkac.tcp import FramedSession, client_handshake_anon, server_handshake_anon
from . import store from . import store
DEFAULT_CONNECT_TIMEOUT_S = 8.0
def _b64(data: bytes) -> str: def _b64(data: bytes) -> str:
return base64.b64encode(data).decode() return base64.b64encode(data).decode()
@ -66,6 +68,28 @@ def _is_i2p_host(host: str) -> bool:
return host.strip().lower().endswith(".i2p") 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: def _recv_exact(sock: socket.socket, n: int) -> bytes:
buf = bytearray() buf = bytearray()
while len(buf) < n: 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") 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() 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 # 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. # 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): if proxy is None or not _is_i2p_host(host):
return socket.create_connection((host, port)) return _connect_with_timeout((host, port), connect_timeout_s)
sock = socket.create_connection(proxy) sock = _connect_with_timeout(proxy, connect_timeout_s)
try: try:
_socks5_connect(sock, host, port) _socks5_connect(sock, host, port)
return sock return sock
@ -129,6 +164,16 @@ def net_check(
"""Connectivity diagnostic for direct/server endpoints, with optional SOCKS5 and handshake.""" """Connectivity diagnostic for direct/server endpoints, with optional SOCKS5 and handshake."""
host, port = _parse_server(target) host, port = _parse_server(target)
proxy = _proxy_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) use_proxy = proxy is not None and _is_i2p_host(host)
via = "direct" if not use_proxy else f"socks5:{proxy[0]}:{proxy[1]}" via = "direct" if not use_proxy else f"socks5:{proxy[0]}:{proxy[1]}"
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 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: def _resolve_server_pk(userid: str, server: str) -> zkac.PublicKey:
pin = store.load_server_pin(userid, server) pin = store.load_server_pin(userid, server)
if pin is None: if pin is None:
hint = _maybe_i2p_pin_hint(userid, server)
raise RuntimeError( raise RuntimeError(
f"no pinned transport key for {server!r} under client {userid!r} " f"no pinned transport key for {server!r} under client {userid!r} "
f"(pins are per client identity in ~/.zkac/{userid}/, not the userid " f"(pins are per client identity in ~/.zkac/{userid}/, not the userid "
"passed to `zkac-node serve <…>` on the server). " "passed to `zkac-node serve <…>` on the server). "
f"Run: zkac-node server pin {userid} {server} --key <hex>" f"Run: zkac-node server pin {userid} {server} --key <hex>."
f"{hint}"
) )
return zkac.PublicKey.from_bytes(_unb64(pin["server_public_key_b64"])) 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]: def _mgmt_connect(userid: str, server: str) -> tuple[socket.socket, FramedSession, bytes]:
host, port = _parse_server(server) 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) server_pk = _resolve_server_pk(userid, server)
node = _local_node(userid) node = _local_node(userid)
session = client_handshake_anon(sock, node, server_pk) session = client_handshake_anon(sock, node, server_pk)
@ -457,7 +504,7 @@ def grant_p2p(
) )
host, port = _parse_server(peer) host, port = _parse_server(peer)
peer_transport_pk = zkac.PublicKey.from_bytes(bytes.fromhex(peer_transport_pk_hex)) 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: try:
session = client_handshake_anon(sock, _local_node(userid), peer_transport_pk) session = client_handshake_anon(sock, _local_node(userid), peer_transport_pk)
framed = FramedSession(sock, session) framed = FramedSession(sock, session)
@ -585,7 +632,7 @@ def authenticate(userid: str, registry_id_hex: str, role_name: str,
node = zkac.Node(zkac.Keypair()) node = zkac.Node(zkac.Keypair())
host, port = _parse_server(server) host, port = _parse_server(server)
sock = _connect(host, port) sock = _connect(host, port, connect_timeout_s=DEFAULT_CONNECT_TIMEOUT_S)
try: try:
session = client_handshake_anon(sock, node, server_pk) session = client_handshake_anon(sock, node, server_pk)
framed = FramedSession(sock, session) framed = FramedSession(sock, session)

View File

@ -55,6 +55,11 @@ use to connect (the .b32.i2p:port form, not localhost).
action="store_true", action="store_true",
help="suppress I2P tunnel reminder text", 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() args = p.parse_args()
data_dir = args.data_dir data_dir = args.data_dir
@ -71,7 +76,7 @@ use to connect (the .b32.i2p:port form, not localhost).
file=sys.stderr, 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__": if __name__ == "__main__":

View File

@ -11,6 +11,11 @@ from . import client, store
from .paths import user_dir 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 ────────────────────────────────────────────────────────────── # ── user ──────────────────────────────────────────────────────────────
def _cmd_user_create(args): def _cmd_user_create(args):
@ -73,6 +78,7 @@ def _cmd_serve(args):
max_connections=args.max_connections, max_connections=args.max_connections,
idle_timeout_s=args.idle_timeout, idle_timeout_s=args.idle_timeout,
listen_backlog=args.listen_backlog, 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): 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}") 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) result = client.receive_p2p(args.userid, args.host, args.port, timeout_s=args.timeout)
print("received credential") print("received credential")
@ -241,8 +254,13 @@ def main():
c.add_argument("--host", default="127.0.0.1") c.add_argument("--host", default="127.0.0.1")
c.add_argument("--port", type=int, default=9800) 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("--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("--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) c.set_defaults(func=_cmd_serve)
# server pin # server pin
@ -312,6 +330,11 @@ def main():
c.add_argument("--host", default="127.0.0.1") c.add_argument("--host", default="127.0.0.1")
c.add_argument("--port", type=int, default=9810) 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("--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) c.set_defaults(func=_cmd_p2p_listen)
# auth # auth

View File

@ -6,6 +6,7 @@ import base64
import json import json
import os import os
import socket import socket
import sys
import threading import threading
import traceback import traceback
from pathlib import Path from pathlib import Path
@ -36,6 +37,11 @@ def _write_private_json(path: Path, payload: dict):
_chmod_if_possible(path, 0o600) _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 ───────────────────────────────────────────── # ── Opaque server storage ─────────────────────────────────────────────
class _ServerStore: class _ServerStore:
@ -280,10 +286,11 @@ def serve(
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 9800, port: int = 9800,
max_connections: int = 64, max_connections: int = 64,
idle_timeout_s: float = 30.0, idle_timeout_s: float = 45.0,
listen_backlog: int = 64, listen_backlog: int = 64,
*, *,
debug: ServerDebugState | None = None, debug: ServerDebugState | None = None,
allow_non_loopback: bool = False,
): ):
dd = Path(data_dir) dd = Path(data_dir)
dd.mkdir(parents=True, exist_ok=True) dd.mkdir(parents=True, exist_ok=True)
@ -305,6 +312,18 @@ def serve(
print(f"loaded {n} registries") print(f"loaded {n} registries")
print(f"listening on {host}:{port}") 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 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port)) sock.bind((host, port))

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import json import json
import os import os
import secrets import secrets
@ -69,17 +70,6 @@ def create_user(userid: str) -> Path:
def load_identity(userid: str) -> dict: def load_identity(userid: str) -> dict:
p = _ud(userid) / "identity.json" p = _ud(userid) / "identity.json"
data = json.loads(p.read_text()) 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 { return {
"issuance_sk": _unb64(data["issuance_secret_b64"]), "issuance_sk": _unb64(data["issuance_secret_b64"]),
"issuance_pk": _unb64(data["issuance_public_b64"]), "issuance_pk": _unb64(data["issuance_public_b64"]),
@ -166,20 +156,25 @@ def list_users() -> list[str]:
# ── Server pins (per user) ─────────────────────────────────────────── # ── Server pins (per user) ───────────────────────────────────────────
def _server_key(server: str) -> str: 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): def pin_server(userid: str, server: str, server_pk_b64: str):
d = _ud(userid) / "servers" d = _ud(userid) / "servers"
_ensure_private_dir(d) _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: def load_server_pin(userid: str, server: str) -> dict | None:
p = _ud(userid) / "servers" / f"{_server_key(server)}.json" d = _ud(userid) / "servers"
if not p.exists(): new_path = d / f"{_server_key(server)}.json"
return None if new_path.exists():
return json.loads(p.read_text()) return json.loads(new_path.read_text())
return None
def known_servers(userid: str) -> list[str]: def known_servers(userid: str) -> list[str]:
@ -188,9 +183,13 @@ def known_servers(userid: str) -> list[str]:
d = _ud(userid) / "servers" d = _ud(userid) / "servers"
if d.exists(): if d.exists():
for p in d.glob("*.json"): for p in d.glob("*.json"):
host_port = p.stem.replace("_", ":") try:
if host_port not in seen: data = json.loads(p.read_text())
seen.append(host_port) 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): for rid in list_admin_registries(userid):
try: try:
reg = load_admin(userid, rid) reg = load_admin(userid, rid)

View File

@ -32,7 +32,7 @@ cargo fuzz build -s none handshake_respond
cargo fuzz run -s none handshake_respond -- -max_total_time=60 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? ### Did anything crash?