minor bugfixes
This commit is contained in:
parent
6ce6bf5675
commit
91b1d607bc
@ -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
|
||||
|
||||
|
||||
@ -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/
|
||||
```
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user