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`.
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

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 list` | List all local user ids |
| `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) |
| `server pin <id> <host:port> --key <hex>` | Pin server public key for that user |
| `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 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) |
| `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 |
| `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
```
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 <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`.
`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/<registry_id>.json BBS+ admin material for owned registries
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/
```

View File

@ -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 <hex>"
f"Run: zkac-node server pin {userid} {server} --key <hex>."
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)

View File

@ -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__":

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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?