major bugfixes

This commit is contained in:
everbarry 2026-05-06 16:35:09 +02:00
parent 44fa5e6a2f
commit 3a3bd30e03
17 changed files with 398 additions and 114 deletions

2
Cargo.lock generated
View File

@ -748,7 +748,7 @@ dependencies = [
[[package]]
name = "zkac"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"blake2",
"chacha20poly1305",

View File

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

View File

@ -1,6 +1,6 @@
[project]
name = "zkac-node"
version = "0.6.0"
version = "0.7.0"
requires-python = ">=3.10"
dependencies = ["zkac"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Vec<u8>> {
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<Vec<u8>> {
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<R: CryptoRng + RngCore>(
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::<Sha256>::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<Vec<u8>> {
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<Vec<u8>> {
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::<Sha256>::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);

View File

@ -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<bool> {
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<bool> {
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());

4
uv.lock generated
View File

@ -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" },