diff --git a/Cargo.lock b/Cargo.lock index 498f846..ae38dd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,7 +748,7 @@ dependencies = [ [[package]] name = "zkac" -version = "0.6.0" +version = "0.7.0" dependencies = [ "blake2", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index 058f5ef..72f42f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zkac" -version = "0.6.0" +version = "0.7.0" edition = "2021" description = "Zero-Knowledge Access Control: BBS+ anonymous credentials (BLS12-381) with encrypted transport (X25519/ChaCha20-Poly1305)" diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 8060621..6e531d6 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "zkac-node" -version = "0.6.0" +version = "0.7.0" requires-python = ">=3.10" dependencies = ["zkac"] diff --git a/cli/zkac_cli/client.py b/cli/zkac_cli/client.py index 3ed3c80..7bee4bd 100644 --- a/cli/zkac_cli/client.py +++ b/cli/zkac_cli/client.py @@ -7,6 +7,7 @@ import json import os import socket import struct +import time import zkac from zkac.tcp import FramedSession, client_handshake_anon, server_handshake_anon @@ -128,13 +129,15 @@ def net_check( """Connectivity diagnostic for direct/server endpoints, with optional SOCKS5 and handshake.""" host, port = _parse_server(target) proxy = _proxy_target() - via = "direct" if proxy is None else f"socks5:{proxy[0]}:{proxy[1]}" + 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) sock.settimeout(timeout_s) try: - if proxy is None: + if not use_proxy: sock.connect((host, port)) else: + assert proxy is not None sock.connect(proxy) _socks5_connect(sock, host, port) @@ -194,7 +197,7 @@ def _local_node(userid: str) -> zkac.Node: return zkac.Node(keypair) -def _mgmt_connect(userid: str, server: str) -> tuple[socket.socket, FramedSession]: +def _mgmt_connect(userid: str, server: str) -> tuple[socket.socket, FramedSession, bytes]: host, port = _parse_server(server) sock = _connect(host, port) server_pk = _resolve_server_pk(userid, server) @@ -202,7 +205,7 @@ def _mgmt_connect(userid: str, server: str) -> tuple[socket.socket, FramedSessio session = client_handshake_anon(sock, node, server_pk) framed = FramedSession(sock, session) framed.send(json.dumps({"op": "mgmt"}).encode()) - return sock, framed + return sock, framed, bytes(session.transcript_hash()) def _mgmt_cmd(framed: FramedSession, cmd: dict) -> dict: @@ -210,9 +213,22 @@ def _mgmt_cmd(framed: FramedSession, cmd: dict) -> dict: return json.loads(framed.recv()) -def _mgmt_single(userid: str, server: str, cmd: dict) -> dict: - sock, framed = _mgmt_connect(userid, server) +def _mgmt_single( + userid: str, + server: str, + cmd: dict, + *, + auth_registry_id: str | None = None, + admin_cred: zkac.Credential | None = None, +) -> dict: + sock, framed, transcript_hash = _mgmt_connect(userid, server) try: + if auth_registry_id is not None: + if admin_cred is None: + raise RuntimeError("admin credential required for authenticated management command") + cmd = dict(cmd) + cmd["auth_registry_id"] = auth_registry_id + cmd["admin_proof_b64"] = _b64(admin_cred.present(transcript_hash)) return _ok(_mgmt_cmd(framed, cmd)) finally: sock.close() @@ -243,12 +259,15 @@ def create_registry(userid: str, server: str, role_names: list[str]) -> str: "cmd": "create_registry", "state_bytes_b64": _b64(state_bytes), "state_cert_b64": _b64(bytes(state_cert)), - }) + }, auth_registry_id=registry_id.hex(), admin_cred=admin_cred) rid_hex = resp["registry_id"] store.save_admin(userid, rid_hex, { "server": server, "roles": role_names, + "role_epochs": {name: 1 for name in role_names}, + "last_known_version": 1, + "last_known_state_hash_b64": _b64(state.state_hash()), **admin_mat, }) return rid_hex @@ -261,10 +280,22 @@ def update_registry(userid: str, server: str, registry_id_hex: str, add_roles: l cur = _mgmt_single(userid, server, { "cmd": "get_registry", "registry_id": registry_id_hex, - }) + }, auth_registry_id=registry_id_hex, admin_cred=admin_cred) old_state = zkac.RegistryState.deserialize(_unb64(cur["state_bytes_b64"])) prev_hash = old_state.state_hash() + server_version = old_state.version() + local_version = admin_data.get("last_known_version") + local_hash_b64 = admin_data.get("last_known_state_hash_b64") + if local_version != server_version: + raise RuntimeError( + "local admin metadata is stale versus server state version; " + "refetch/synchronize admin metadata before updating" + ) + if not isinstance(local_hash_b64, str) or _unb64(local_hash_b64) != bytes(prev_hash): + raise RuntimeError( + "local admin metadata state hash mismatch; refusing update to avoid accidental role/state clobber" + ) new_version = old_state.version() + 1 old_roles = admin_data.get("roles", []) @@ -281,16 +312,27 @@ def update_registry(userid: str, server: str, registry_id_hex: str, add_roles: l "registry_id": registry_id_hex, "state_bytes_b64": _b64(new_state.serialize()), "state_cert_b64": _b64(bytes(new_cert)), - }) + }, auth_registry_id=registry_id_hex, admin_cred=admin_cred) admin_data["roles"] = all_roles + role_epochs = admin_data.get("role_epochs", {}) + if not isinstance(role_epochs, dict): + role_epochs = {} + for name in all_roles: + if name not in role_epochs: + role_epochs[name] = 1 + admin_data["role_epochs"] = role_epochs + admin_data["last_known_version"] = new_version + admin_data["last_known_state_hash_b64"] = _b64(new_state.state_hash()) store.save_admin(userid, registry_id_hex, admin_data) def get_registry(userid: str, server: str, registry_id_hex: str) -> dict: + admin_data = store.load_admin(userid, registry_id_hex) + _bbs_issuer, _bbs_pk, admin_cred = store.reconstruct_admin(admin_data) return _mgmt_single(userid, server, { "cmd": "get_registry", "registry_id": registry_id_hex, - }) + }, auth_registry_id=registry_id_hex, admin_cred=admin_cred) def list_own_registries(userid: str) -> list[dict]: @@ -311,6 +353,7 @@ def grant_p2p( registry_id_hex: str, role_name: str, recipient_pk_hex: str, + recipient_grant_token_b64: str, peer: str, peer_transport_pk_hex: str, ) -> dict: @@ -321,7 +364,13 @@ def grant_p2p( bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data) role_rid = zkac.role_id(role_name) - epoch = 1 + role_epochs = admin_data.get("role_epochs", {}) + if not isinstance(role_epochs, dict) or role_name not in role_epochs: + raise RuntimeError( + f"missing epoch metadata for role {role_name!r}; " + "refresh local admin metadata before granting" + ) + epoch = int(role_epochs[role_name]) req = zkac.prepare_blind_request() blind_sig = bbs_issuer.issue_blind(req.commitment_with_proof(), role_rid, epoch) @@ -339,7 +388,13 @@ def grant_p2p( recipient_pk = bytes.fromhex(recipient_pk_hex) eph_kp = zkac.IssuanceKeypair() ciphertext = eph_kp.encrypt(recipient_pk, payload) - reg = _mgmt_single(userid, server, {"cmd": "get_registry", "registry_id": registry_id_hex}) + reg = _mgmt_single( + userid, + server, + {"cmd": "get_registry", "registry_id": registry_id_hex}, + auth_registry_id=registry_id_hex, + admin_cred=admin_cred, + ) host, port = _parse_server(peer) peer_transport_pk = zkac.PublicKey.from_bytes(bytes.fromhex(peer_transport_pk_hex)) sock = _connect(host, port) @@ -353,6 +408,7 @@ def grant_p2p( raise RuntimeError("peer did not accept grant session") framed.send(json.dumps({ "op": "p2p_grant", + "grant_token_b64": recipient_grant_token_b64, "registry_id": registry_id_hex, "registry_state_bytes_b64": reg["state_bytes_b64"], "registry_state_cert_b64": reg["state_cert_b64"], @@ -367,50 +423,84 @@ def grant_p2p( return {"status": ack.get("status", "ok"), "peer": peer} -def receive_p2p(userid: str, host: str, port: int) -> dict: +def receive_p2p(userid: str, host: str, port: int, *, timeout_s: float = 60.0) -> dict: ident = store.load_identity(userid) receiver_kp = zkac.IssuanceKeypair.from_secret(ident["issuance_sk"]) + expected_grant_token_b64 = _b64(ident["grant_token"]) listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind((host, port)) listener.listen(1) - conn, _addr = listener.accept() + listener.settimeout(timeout_s) + deadline = time.monotonic() + timeout_s try: - session = server_handshake_anon(conn, _local_node(userid)) - framed = FramedSession(conn, session) - framed.send(json.dumps({"ok": True, "op": "ready_for_grant"}).encode()) - msg = _ok(json.loads(framed.recv())) - if msg.get("op") != "p2p_grant": - raise RuntimeError("unexpected p2p message") - registry_id_hex = msg["registry_id"] - state_bytes = _unb64(msg["registry_state_bytes_b64"]) - state_cert = _unb64(msg["registry_state_cert_b64"]) - mgr = zkac.RegistryManager() - mgr.restore(state_bytes, state_cert) - if not mgr.verify_admin( - bytes.fromhex(registry_id_hex), - _unb64(msg["admin_proof_b64"]), - bytes(session.transcript_hash()), - ): - raise RuntimeError("sender admin proof failed") - payload = json.loads( - receiver_kp.decrypt(_unb64(msg["eph_pk_b64"]), _unb64(msg["ciphertext_b64"])) - ) - cred_data = { - "blind_sig_b64": payload["blind_sig_b64"], - "member_secret_b64": payload["member_secret_b64"], - "prover_blind_b64": payload["prover_blind_b64"], - "role_name": payload["role_name"], - "epoch": payload["epoch"], - "issuer_pk_b64": payload["issuer_pk_b64"], - } - cred = store.reconstruct_credential(cred_data) - cred.present(b"self-test") - store.save_credential(userid, payload["registry_id"], payload["role_name"], cred_data) - framed.send(json.dumps({"ok": True, "status": "stored"}).encode()) - return {"registry_id": payload["registry_id"], "role": payload["role_name"]} + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError("timed out waiting for authenticated grant sender") + listener.settimeout(remaining) + conn, _addr = listener.accept() + try: + conn.settimeout(min(remaining, 30.0)) + session = server_handshake_anon(conn, _local_node(userid)) + framed = FramedSession(conn, session) + framed.send(json.dumps({"ok": True, "op": "ready_for_grant"}).encode()) + msg = _ok(json.loads(framed.recv())) + if msg.get("op") != "p2p_grant": + raise RuntimeError("unexpected p2p message") + if msg.get("grant_token_b64") != expected_grant_token_b64: + raise RuntimeError("grant pairing token mismatch") + registry_id_hex = msg["registry_id"] + expected_role_name = msg.get("role_name") + if not isinstance(expected_role_name, str) or not expected_role_name: + raise RuntimeError("grant message missing required role_name") + state_bytes = _unb64(msg["registry_state_bytes_b64"]) + state_cert = _unb64(msg["registry_state_cert_b64"]) + mgr = zkac.RegistryManager() + restored_registry_id = mgr.restore(state_bytes, state_cert).hex() + if restored_registry_id != registry_id_hex: + raise RuntimeError("registry snapshot does not match announced registry_id") + if not mgr.verify_admin( + bytes.fromhex(registry_id_hex), + _unb64(msg["admin_proof_b64"]), + bytes(session.transcript_hash()), + ): + raise RuntimeError("sender admin proof failed") + payload = json.loads( + receiver_kp.decrypt(_unb64(msg["eph_pk_b64"]), _unb64(msg["ciphertext_b64"])) + ) + if payload["registry_id"] != registry_id_hex: + raise RuntimeError("grant payload registry_id does not match authenticated registry") + if expected_role_name and payload["role_name"] != expected_role_name: + raise RuntimeError("grant payload role does not match announced role") + cred_data = { + "blind_sig_b64": payload["blind_sig_b64"], + "member_secret_b64": payload["member_secret_b64"], + "prover_blind_b64": payload["prover_blind_b64"], + "role_name": payload["role_name"], + "epoch": payload["epoch"], + "issuer_pk_b64": payload["issuer_pk_b64"], + } + cred = store.reconstruct_credential(cred_data) + nonce = bytes(session.transcript_hash()) + cred_proof = cred.present(nonce) + role_id = zkac.role_id(payload["role_name"]) + if not mgr.verify_presentation( + bytes.fromhex(registry_id_hex), + role_id, + cred_proof, + nonce, + ): + raise RuntimeError("grant credential does not verify against certified registry state") + store.save_credential(userid, registry_id_hex, payload["role_name"], cred_data) + framed.send(json.dumps({"ok": True, "status": "stored"}).encode()) + return {"registry_id": registry_id_hex, "role": payload["role_name"]} + except (RuntimeError, ValueError, KeyError, json.JSONDecodeError): + # Keep listening until timeout; this prevents first-connector DoS. + continue + finally: + conn.close() finally: - conn.close() listener.close() diff --git a/cli/zkac_cli/main.py b/cli/zkac_cli/main.py index cae37e8..98cf76e 100644 --- a/cli/zkac_cli/main.py +++ b/cli/zkac_cli/main.py @@ -66,7 +66,14 @@ def _cmd_serve(args): 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) + serve( + data_dir, + args.host, + args.port, + max_connections=args.max_connections, + idle_timeout_s=args.idle_timeout, + listen_backlog=args.listen_backlog, + ) # ── server pin ─────────────────────────────────────────────────────── @@ -114,7 +121,13 @@ 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. " @@ -122,7 +135,7 @@ def _cmd_grant(args): ) result = client.grant_p2p( - args.userid, args.server, args.registry, args.role, to, peer, peer_key, + 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']})") @@ -143,7 +156,7 @@ def _cmd_credentials_list(args): def _cmd_p2p_listen(args): print(f"listening for p2p grant on {args.host}:{args.port}") - result = client.receive_p2p(args.userid, 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']}") @@ -208,6 +221,9 @@ def main(): 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=30.0, help="per-connection idle timeout seconds") + c.add_argument("--listen-backlog", type=int, default=64, help="TCP listen backlog") c.set_defaults(func=_cmd_serve) # server pin @@ -268,6 +284,7 @@ def main(): 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.set_defaults(func=_cmd_p2p_listen) # auth diff --git a/cli/zkac_cli/server.py b/cli/zkac_cli/server.py index bcd380d..edc265d 100644 --- a/cli/zkac_cli/server.py +++ b/cli/zkac_cli/server.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import json +import os import socket import threading import traceback @@ -23,6 +24,18 @@ def _unb64(s: str) -> bytes: return base64.b64decode(s) +def _chmod_if_possible(path: Path, mode: int): + try: + os.chmod(path, mode) + except OSError: + pass + + +def _write_private_json(path: Path, payload: dict): + path.write_text(json.dumps(payload, indent=2)) + _chmod_if_possible(path, 0o600) + + # ── Opaque server storage ───────────────────────────────────────────── class _ServerStore: @@ -32,6 +45,8 @@ class _ServerStore: self._dir = data_dir self._reg_dir = data_dir / "registries" self._reg_dir.mkdir(parents=True, exist_ok=True) + _chmod_if_possible(self._dir, 0o700) + _chmod_if_possible(self._reg_dir, 0o700) self._lock = threading.Lock() # ── server key ──────────────────────────────────────────────────── @@ -42,10 +57,10 @@ class _ServerStore: data = json.loads(kf.read_text()) return zkac.Keypair.from_secret_key(_unb64(data["secret_b64"])) kp = zkac.Keypair() - kf.write_text(json.dumps({ + _write_private_json(kf, { "secret_b64": _b64(kp.secret_key_bytes()), "public_b64": _b64(kp.public_key().to_bytes()), - }, indent=2)) + }) return kp # ── registries ──────────────────────────────────────────────────── @@ -81,6 +96,20 @@ def _dispatch( ) -> dict: try: action = cmd.get("cmd") + rid_hex = cmd.get("auth_registry_id") + admin_proof_b64 = cmd.get("admin_proof_b64") + + def _require_admin_for_registry(target_rid_hex: str): + if rid_hex != target_rid_hex: + raise RuntimeError("auth_registry_id must match command registry_id") + if not isinstance(admin_proof_b64, str) or not admin_proof_b64: + raise RuntimeError("missing admin_proof_b64") + if not mgr.verify_admin( + bytes.fromhex(target_rid_hex), + _unb64(admin_proof_b64), + transcript_hash, + ): + raise RuntimeError("admin authorization failed") if action == "server_info": return {"ok": True, "server_public_key_b64": server_pk_b64} @@ -88,12 +117,29 @@ def _dispatch( if action == "create_registry": state_bytes = _unb64(cmd["state_bytes_b64"]) state_cert = _unb64(cmd["state_cert_b64"]) + auth_rid = cmd.get("auth_registry_id") + if not isinstance(auth_rid, str): + raise RuntimeError("missing auth_registry_id") + if not isinstance(admin_proof_b64, str) or not admin_proof_b64: + raise RuntimeError("missing admin_proof_b64") + tmp_mgr = zkac.RegistryManager() + expected_rid = tmp_mgr.create(state_bytes, state_cert).hex() + if expected_rid != auth_rid: + raise RuntimeError("auth_registry_id does not match certified state") + if not tmp_mgr.verify_admin( + bytes.fromhex(expected_rid), + _unb64(admin_proof_b64), + transcript_hash, + ): + raise RuntimeError("admin authorization failed for create_registry") rid = mgr.create(state_bytes, state_cert) store.save_registry(rid.hex(), state_bytes, state_cert) return {"ok": True, "registry_id": rid.hex()} if action == "get_registry": - rid = bytes.fromhex(cmd["registry_id"]) + rid_hex_cmd = cmd["registry_id"] + _require_admin_for_registry(rid_hex_cmd) + rid = bytes.fromhex(rid_hex_cmd) state_bytes, state_cert = mgr.get(rid) return { "ok": True, @@ -102,11 +148,13 @@ def _dispatch( } if action == "update_registry": - rid = bytes.fromhex(cmd["registry_id"]) + rid_hex_cmd = cmd["registry_id"] + _require_admin_for_registry(rid_hex_cmd) + rid = bytes.fromhex(rid_hex_cmd) state_bytes = _unb64(cmd["state_bytes_b64"]) state_cert = _unb64(cmd["state_cert_b64"]) mgr.update(rid, state_bytes, state_cert) - store.save_registry(cmd["registry_id"], state_bytes, state_cert) + store.save_registry(rid_hex_cmd, state_bytes, state_cert) return {"ok": True} return {"error": f"unknown command: {action}"} @@ -124,12 +172,15 @@ def _handle_conn( mgr: zkac.RegistryManager, store: _ServerStore, server_pk_b64: str, + idle_timeout_s: float, + slots: threading.BoundedSemaphore, debug: ServerDebugState | None = None, ): peer = f"{addr[0]}:{addr[1]}" cid = debug.open_connection(peer) if debug else None err: str | None = None try: + conn.settimeout(idle_timeout_s) if debug and cid: debug.update_connection(cid, phase="handshake") session = server_handshake_anon(conn, node) @@ -219,6 +270,7 @@ def _handle_conn( if debug and cid: debug.close_connection(cid, error=err) conn.close() + slots.release() # ── Public entry point ──────────────────────────────────────────────── @@ -227,6 +279,9 @@ def serve( data_dir: str, host: str = "127.0.0.1", port: int = 9800, + max_connections: int = 64, + idle_timeout_s: float = 30.0, + listen_backlog: int = 64, *, debug: ServerDebugState | None = None, ): @@ -253,13 +308,17 @@ def serve( sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) - sock.listen(8) + slots = threading.BoundedSemaphore(max_connections) + sock.listen(listen_backlog) try: while True: conn, addr = sock.accept() + if not slots.acquire(blocking=False): + conn.close() + continue threading.Thread( target=_handle_conn, - args=(conn, addr, node, mgr, store, server_pk_b64, debug), + args=(conn, addr, node, mgr, store, server_pk_b64, idle_timeout_s, slots, debug), daemon=True, ).start() except KeyboardInterrupt: diff --git a/cli/zkac_cli/store.py b/cli/zkac_cli/store.py index 2fae7ca..c81d503 100644 --- a/cli/zkac_cli/store.py +++ b/cli/zkac_cli/store.py @@ -4,6 +4,8 @@ from __future__ import annotations import base64 import json +import os +import secrets from pathlib import Path import zkac @@ -23,10 +25,28 @@ def _ud(userid: str) -> Path: return user_dir(userid) +def _chmod_if_possible(path: Path, mode: int): + try: + os.chmod(path, mode) + except OSError: + pass + + +def _ensure_private_dir(path: Path): + path.mkdir(parents=True, exist_ok=True) + _chmod_if_possible(path, 0o700) + + +def _write_private_json(path: Path, payload: dict): + path.write_text(json.dumps(payload, indent=2)) + _chmod_if_possible(path, 0o600) + + # ── User identity ──────────────────────────────────────────────────── def create_user(userid: str) -> Path: d = ensure_user(userid) + _chmod_if_possible(d, 0o700) p = d / "identity.json" if p.exists(): raise FileExistsError(f"user {userid!r} already exists at {d}") @@ -38,26 +58,34 @@ def create_user(userid: str) -> Path: "issuance_public_b64": _b64(issuance_kp.public_key_bytes()), "transport_secret_b64": _b64(transport_kp.secret_key_bytes()), "transport_public_b64": _b64(transport_kp.public_key().to_bytes()), + "grant_token_b64": _b64(secrets.token_bytes(32)), } - p.write_text(json.dumps(identity, indent=2)) + _write_private_json(p, identity) for sub in ("admin", "credentials", "servers"): - (d / sub).mkdir(exist_ok=True) + _ensure_private_dir(d / sub) return d 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()) - p.write_text(json.dumps(data, indent=2)) + 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"]), "transport_sk": _unb64(data["transport_secret_b64"]), "transport_pk": _unb64(data["transport_public_b64"]), + "grant_token": _unb64(data["grant_token_b64"]), } @@ -65,9 +93,10 @@ def export_contact_bundle(userid: str, peer: str | None = None) -> str: """One-string public contact bundle for out-of-band sharing.""" ident = load_identity(userid) payload = { - "v": 2, + "v": 3, "issuance_pk_hex": ident["issuance_pk"].hex(), "transport_pk_hex": ident["transport_pk"].hex(), + "grant_token_b64": _b64(ident["grant_token"]), } if peer: payload["peer"] = peer @@ -86,8 +115,8 @@ def parse_contact_bundle(bundle: str) -> dict: raise ValueError("invalid contact bundle encoding") from exc version = data.get("v") - if version not in (1, 2): - raise ValueError("unsupported contact bundle version") + if version != 3: + raise ValueError("unsupported contact bundle version (requires v3)") issuance_hex = data.get("issuance_pk_hex", "") transport_hex = data.get("transport_pk_hex", "") if not isinstance(issuance_hex, str) or not isinstance(transport_hex, str): @@ -105,12 +134,22 @@ def parse_contact_bundle(bundle: str) -> dict: "issuance_pk_hex": issuance_hex, "transport_pk_hex": transport_hex, } - if version == 2: - peer = data.get("peer") - if peer is not None and not isinstance(peer, str): - raise ValueError("invalid contact bundle peer field") - if isinstance(peer, str) and peer.strip(): - parsed["peer"] = peer.strip() + tok = data.get("grant_token_b64", "") + if not isinstance(tok, str): + raise ValueError("invalid contact bundle grant token field") + try: + token = _unb64(tok) + except Exception as exc: + raise ValueError("invalid contact bundle grant token encoding") from exc + if len(token) != 32: + raise ValueError("grant token must decode to 32 bytes") + parsed["grant_token_b64"] = tok + + peer = data.get("peer") + if peer is not None and not isinstance(peer, str): + raise ValueError("invalid contact bundle peer field") + if isinstance(peer, str) and peer.strip(): + parsed["peer"] = peer.strip() return parsed @@ -132,10 +171,8 @@ def _server_key(server: str) -> str: def pin_server(userid: str, server: str, server_pk_b64: str): d = _ud(userid) / "servers" - d.mkdir(parents=True, exist_ok=True) - (d / f"{_server_key(server)}.json").write_text( - json.dumps({"server_public_key_b64": server_pk_b64}, indent=2) - ) + _ensure_private_dir(d) + _write_private_json(d / f"{_server_key(server)}.json", {"server_public_key_b64": server_pk_b64}) def load_server_pin(userid: str, server: str) -> dict | None: @@ -198,8 +235,8 @@ def reconstruct_admin(data: dict) -> tuple: def save_admin(userid: str, registry_id_hex: str, info: dict): d = _ud(userid) / "admin" - d.mkdir(parents=True, exist_ok=True) - (d / f"{registry_id_hex}.json").write_text(json.dumps(info, indent=2)) + _ensure_private_dir(d) + _write_private_json(d / f"{registry_id_hex}.json", info) def load_admin(userid: str, registry_id_hex: str) -> dict: @@ -217,8 +254,8 @@ def list_admin_registries(userid: str) -> list[str]: def save_credential(userid: str, registry_id_hex: str, role_name: str, cred_data: dict): d = _ud(userid) / "credentials" - d.mkdir(parents=True, exist_ok=True) - (d / f"{registry_id_hex}_{role_name}.json").write_text(json.dumps(cred_data, indent=2)) + _ensure_private_dir(d) + _write_private_json(d / f"{registry_id_hex}_{role_name}.json", cred_data) def load_credential_data(userid: str, registry_id_hex: str, role_name: str) -> dict: diff --git a/cli/zkac_node.egg-info/PKG-INFO b/cli/zkac_node.egg-info/PKG-INFO index 7d589c0..24e5913 100644 --- a/cli/zkac_node.egg-info/PKG-INFO +++ b/cli/zkac_node.egg-info/PKG-INFO @@ -1,5 +1,5 @@ Metadata-Version: 2.4 Name: zkac-node -Version: 0.6.0 +Version: 0.7.0 Requires-Python: >=3.10 Requires-Dist: zkac diff --git a/docs/PYTHON_API.md b/docs/PYTHON_API.md index dc98256..337e572 100644 --- a/docs/PYTHON_API.md +++ b/docs/PYTHON_API.md @@ -1,6 +1,6 @@ # ZKAC Python API Reference -Version 0.6.0. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures). +Version 0.7.0. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures). ```python import zkac diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 538e534..9684bad 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,4 +1,4 @@ -# Security model (ZKAC 0.6.0) +# Security model (ZKAC 0.7.0) This document summarizes the direct peer-to-peer grant model, with transcript-bound BBS+ authorization (Option C). @@ -16,6 +16,8 @@ This document summarizes the direct peer-to-peer grant model, with transcript-bo - Authorization proofs: BBS+ presentations over BLS12-381. - Registry integrity: certified `RegistryState` with BBS+ admin proof checks. - Grant payload encryption: X25519 + HKDF-SHA256 + ChaCha20-Poly1305 issuance envelope. + - The issuance envelope format is `nonce || ciphertext` where `nonce` is a random 96-bit value generated per message. + - KDF context is direction-separated (`user->admin` vs `admin->user`) to avoid cross-direction key reuse. ## Direct p2p grant flow @@ -27,8 +29,13 @@ This document summarizes the direct peer-to-peer grant model, with transcript-bo - transcript-bound admin BBS+ proof. 4. Recipient verifies: - registry state certificate, + - restored registry ID matches the announced outer `registry_id`, - admin proof bound to current session transcript. -5. Recipient decrypts payload, validates credential data, and stores the credential locally. +5. Recipient decrypts payload and enforces binding checks: + - payload `registry_id` equals authenticated outer `registry_id`, + - payload role matches announced role metadata, + - reconstructed credential verifies against the certified registry state (`verify_presentation`) before storage. +6. Recipient stores credential only after all checks pass. ## Threat model @@ -49,6 +56,9 @@ This document summarizes the direct peer-to-peer grant model, with transcript-bo - `anonymous_authorized` mode: - Recipient learns sender is authorized for the registry, not a mandatory stable real-world identity. - Network metadata (IP/endpoint timing) is still visible to direct peers. +- Grant-context binding: + - Recipient rejects grants where outer authenticated context and inner decrypted payload disagree. + - Recipient rejects credential material that does not verify against the supplied certified registry state. - Registry freshness: - Recipient verifies cryptographic validity of received state; deployment policy should define freshness expectations. - Key management remains operationally critical: diff --git a/docs/WHITEPAPER.md b/docs/WHITEPAPER.md index 1da433d..57898c4 100644 --- a/docs/WHITEPAPER.md +++ b/docs/WHITEPAPER.md @@ -86,8 +86,9 @@ The architecture separates concerns: 1. Sender and recipient establish a secure session. 2. Sender generates a transcript-bound BBS+ authorization proof. 3. Sender sends encrypted credential payload plus registry-state evidence. -4. Recipient verifies proof against registry state. -5. Recipient decrypts payload and stores credential locally. +4. Recipient verifies the certified registry state and validates sender admin proof against the live session transcript. +5. Recipient decrypts payload and enforces context binding checks (outer announced registry/role context must match inner decrypted fields). +6. Recipient verifies reconstructed credential material against certified registry role keys before local storage. This design intentionally favors direct peer exchange over private mailbox retrieval constructions. In practice, PIR/ORAM-style mailbox systems can provide stronger access-pattern privacy, but they introduce high implementation complexity, high constant-factor costs, larger hint/material transfers, and operationally difficult performance tuning. For interactive service environments, direct end-to-end grant exchange offers a better complexity/performance trade-off while preserving the core trustless authorization properties. @@ -115,7 +116,7 @@ Operationally, proof verification is done against certified registry state (issu Credential payloads are encrypted end-to-end to recipient key material. Intermediate nodes may relay or host state, but cannot decrypt protected credential contents. -The credential issuance envelope uses ECDH-derived symmetric keys with AEAD integrity protection, so tampering causes decryption/validation failure rather than silent corruption. +The credential issuance envelope uses ECDH-derived symmetric keys with AEAD integrity protection, so tampering causes decryption/validation failure rather than silent corruption. The concrete envelope is `nonce || ciphertext`, where `nonce` is a random 96-bit value per message. HKDF context is direction-separated for `user->admin` and `admin->user` traffic so both directions do not share one AEAD key domain. ### 6.5 Primitive suite (current profile) @@ -185,7 +186,7 @@ This section states explicit adversarial games suitable for academic evaluation. **Win condition:** recipient accepts altered semantics without detection. -**Target claim:** negligible advantage due to AEAD integrity plus credential-structure verification/finalization checks. +**Target claim:** negligible advantage due to AEAD integrity, strict outer/inner context binding checks (registry and role), and credential verification against certified registry role keys before storage. #### Game G5: Malicious Node Knowledge Gain diff --git a/pyproject.toml b/pyproject.toml index d601ca2..2659ab7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "zkac" -version = "0.6.0" +version = "0.7.0" description = "Zero-Knowledge Access Control: BBS+ anonymous credentials with encrypted transport" readme = "README.md" requires-python = ">=3.10" diff --git a/python/zkac/__init__.py b/python/zkac/__init__.py index 83d9c25..521b2a4 100644 --- a/python/zkac/__init__.py +++ b/python/zkac/__init__.py @@ -4,7 +4,7 @@ ZKAC — Zero-Knowledge Access Control BBS+ anonymous credentials (BLS12-381) with encrypted transport (Ristretto255 / X25519). """ -__version__ = "0.6.0" +__version__ = "0.7.0" from zkac._zkac import ( MAX_BBS_AUTH_PROOF_BYTES, diff --git a/src/credential/registry.rs b/src/credential/registry.rs index a20c44a..ef770cb 100644 --- a/src/credential/registry.rs +++ b/src/credential/registry.rs @@ -150,6 +150,13 @@ impl RegistryState { let num_roles = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize; pos += 4; + let remaining = data.len().saturating_sub(pos); + let min_role_entry_len = 32 + 4 + 8; // role_id + pk_len + epoch + let max_roles_by_len = remaining / min_role_entry_len; + if num_roles > max_roles_by_len { + return Err(err("invalid role count for provided state length")); + } + let mut roles = Vec::with_capacity(num_roles); for _ in 0..num_roles { if data.len() < pos + 32 + 4 { diff --git a/src/issuance.rs b/src/issuance.rs index b2598cb..00fe566 100644 --- a/src/issuance.rs +++ b/src/issuance.rs @@ -7,14 +7,17 @@ use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::ChaCha20Poly1305; use hkdf::Hkdf; +use rand::rngs::OsRng; use rand::{CryptoRng, RngCore}; use sha2::Sha256; +use subtle::ConstantTimeEq; use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret}; use crate::{Error, Result}; -const ISSUANCE_HKDF_INFO: &[u8] = b"zkac-issuance-v1"; -const NONCE_BYTES: [u8; 12] = [0u8; 12]; +const ISSUANCE_HKDF_INFO_USER_TO_ADMIN: &[u8] = b"zkac-issuance-v1:user-to-admin"; +const ISSUANCE_HKDF_INFO_ADMIN_TO_USER: &[u8] = b"zkac-issuance-v1:admin-to-user"; +const NONCE_LEN: usize = 12; /// Admin-side issuance keypair (X25519 static secret for DH). pub struct IssuanceKeypair { @@ -47,22 +50,26 @@ impl IssuanceKeypair { pub fn decrypt(&self, eph_pk_bytes: &[u8; 32], ciphertext: &[u8]) -> Result> { let eph_pk = X25519Public::from(*eph_pk_bytes); let shared = self.secret.diffie_hellman(&eph_pk); - let key = derive_key(shared.as_bytes()); + if is_zero(shared.as_bytes()) { + return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); + } + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; - cipher.decrypt(&NONCE_BYTES.into(), ciphertext) - .map_err(|_| Error::DecryptionFailed) + decrypt_with_prefixed_nonce(&cipher, ciphertext) } /// Encrypt a response to the user (uses same shared secret). pub fn encrypt(&self, eph_pk_bytes: &[u8; 32], plaintext: &[u8]) -> Result> { let eph_pk = X25519Public::from(*eph_pk_bytes); let shared = self.secret.diffie_hellman(&eph_pk); - let key = derive_key(shared.as_bytes()); + if is_zero(shared.as_bytes()) { + return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); + } + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_ADMIN_TO_USER); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; - cipher.encrypt(&NONCE_BYTES.into(), plaintext) - .map_err(|_| Error::CredentialError("issuance encryption failed".into())) + encrypt_with_random_nonce(&cipher, plaintext) } } @@ -78,12 +85,14 @@ pub fn encrypt_for_admin( let admin_pk = X25519Public::from(*admin_issuance_pk); let shared = eph_secret.diffie_hellman(&admin_pk); + if is_zero(shared.as_bytes()) { + return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); + } - let key = derive_key(shared.as_bytes()); + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; - let ciphertext = cipher.encrypt(&NONCE_BYTES.into(), plaintext) - .map_err(|_| Error::CredentialError("issuance encryption failed".into()))?; + let ciphertext = encrypt_with_random_nonce(&cipher, plaintext)?; Ok((*eph_public.as_bytes(), ciphertext)) } @@ -97,20 +106,50 @@ pub fn decrypt_from_admin( let eph_secret = StaticSecret::from(*eph_secret_bytes); let admin_pk = X25519Public::from(*admin_issuance_pk); let shared = eph_secret.diffie_hellman(&admin_pk); + if is_zero(shared.as_bytes()) { + return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); + } - let key = derive_key(shared.as_bytes()); + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_ADMIN_TO_USER); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; - cipher.decrypt(&NONCE_BYTES.into(), ciphertext) + decrypt_with_prefixed_nonce(&cipher, ciphertext) +} + +fn derive_key(shared_secret: &[u8], info: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(None, shared_secret); + let mut key = [0u8; 32]; + hk.expand(info, &mut key) + .expect("HKDF expand should not fail for 32 bytes"); + key +} + +fn encrypt_with_random_nonce(cipher: &ChaCha20Poly1305, plaintext: &[u8]) -> Result> { + let mut nonce = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce); + let mut out = Vec::with_capacity(NONCE_LEN + plaintext.len() + 16); + out.extend_from_slice(&nonce); + let mut ct = cipher + .encrypt(&nonce.into(), plaintext) + .map_err(|_| Error::CredentialError("issuance encryption failed".into()))?; + out.append(&mut ct); + Ok(out) +} + +fn decrypt_with_prefixed_nonce(cipher: &ChaCha20Poly1305, blob: &[u8]) -> Result> { + if blob.len() < NONCE_LEN { + return Err(Error::DecryptionFailed); + } + let (nonce, ciphertext) = blob.split_at(NONCE_LEN); + let mut nonce_arr = [0u8; NONCE_LEN]; + nonce_arr.copy_from_slice(nonce); + cipher + .decrypt(&nonce_arr.into(), ciphertext) .map_err(|_| Error::DecryptionFailed) } -fn derive_key(shared_secret: &[u8]) -> [u8; 32] { - let hk = Hkdf::::new(None, shared_secret); - let mut key = [0u8; 32]; - hk.expand(ISSUANCE_HKDF_INFO, &mut key) - .expect("HKDF expand should not fail for 32 bytes"); - key +fn is_zero(bytes: &[u8; 32]) -> bool { + bytes.ct_eq(&[0u8; 32]).into() } #[cfg(test)] @@ -154,10 +193,10 @@ mod tests { // Encrypt commitment let shared = user_secret.diffie_hellman(&X25519Public::from(admin_pk)); - let key = derive_key(shared.as_bytes()); + let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap(); let commitment = b"test commitment"; - let encrypted = cipher.encrypt(&NONCE_BYTES.into(), commitment.as_slice()).unwrap(); + let encrypted = encrypt_with_random_nonce(&cipher, commitment.as_slice()).unwrap(); // Admin decrypts let decrypted = admin_kp.decrypt(&eph_pk, &encrypted).unwrap(); @@ -172,6 +211,21 @@ mod tests { assert_eq!(dec_response, response); } + #[test] + fn encrypt_uses_random_nonce_prefix() { + let admin_kp = IssuanceKeypair::generate(&mut OsRng); + let admin_pk = admin_kp.public_key_bytes(); + let payload = b"same payload"; + + let (_eph_pk_a, c1) = encrypt_for_admin(OsRng, &admin_pk, payload).unwrap(); + let (_eph_pk_b, c2) = encrypt_for_admin(OsRng, &admin_pk, payload).unwrap(); + + assert!(c1.len() > NONCE_LEN); + assert!(c2.len() > NONCE_LEN); + assert_ne!(&c1[..NONCE_LEN], &c2[..NONCE_LEN]); + assert_ne!(c1, c2); + } + #[test] fn wrong_key_fails() { let admin_kp = IssuanceKeypair::generate(&mut OsRng); diff --git a/src/python.rs b/src/python.rs index a9d8745..746d0d4 100644 --- a/src/python.rs +++ b/src/python.rs @@ -330,6 +330,9 @@ impl PyRoleRegistry { if role_id.len() != 32 { return Err(PyValueError::new_err("role_id must be 32 bytes")); } + if proof_bytes.len() > crate::node::MAX_BBS_AUTH_PROOF_BYTES { + return Ok(false); + } let mut rid = [0u8; 32]; rid.copy_from_slice(role_id); let pres = bbs::Presentation::from_bytes(proof_bytes.to_vec()); @@ -490,6 +493,9 @@ impl PyRegistryManager { } fn verify_admin(&self, registry_id: &[u8], proof_bytes: &[u8], nonce: &[u8]) -> PyResult { + if proof_bytes.len() > crate::node::MAX_BBS_AUTH_PROOF_BYTES { + return Ok(false); + } let rid = to_32(registry_id, "registry_id")?; match self.inner.verify_admin(&rid, proof_bytes, nonce) { Ok(()) => Ok(true), @@ -499,6 +505,9 @@ impl PyRegistryManager { } fn verify_presentation(&self, registry_id: &[u8], role_id: &[u8], proof_bytes: &[u8], nonce: &[u8]) -> PyResult { + if proof_bytes.len() > crate::node::MAX_BBS_AUTH_PROOF_BYTES { + return Ok(false); + } let rid = to_32(registry_id, "registry_id")?; let roid = to_32(role_id, "role_id")?; let pres = bbs::Presentation::from_bytes(proof_bytes.to_vec()); diff --git a/uv.lock b/uv.lock index d76ea76..0cd5519 100644 --- a/uv.lock +++ b/uv.lock @@ -1918,7 +1918,7 @@ wheels = [ [[package]] name = "zkac" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "ipykernel" }, @@ -1964,7 +1964,7 @@ dev = [ [[package]] name = "zkac-node" -version = "0.6.0" +version = "0.7.0" source = { editable = "cli" } dependencies = [ { name = "zkac" },