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]] [[package]]
name = "zkac" name = "zkac"
version = "0.6.0" version = "0.7.0"
dependencies = [ dependencies = [
"blake2", "blake2",
"chacha20poly1305", "chacha20poly1305",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "zkac" name = "zkac"
version = "0.6.0" version = "0.7.0"
edition = "2021" edition = "2021"
description = "Zero-Knowledge Access Control: BBS+ anonymous credentials (BLS12-381) with encrypted transport (X25519/ChaCha20-Poly1305)" description = "Zero-Knowledge Access Control: BBS+ anonymous credentials (BLS12-381) with encrypted transport (X25519/ChaCha20-Poly1305)"

View File

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

View File

@ -7,6 +7,7 @@ import json
import os import os
import socket import socket
import struct import struct
import time
import zkac import zkac
from zkac.tcp import FramedSession, client_handshake_anon, server_handshake_anon 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.""" """Connectivity diagnostic for direct/server endpoints, with optional SOCKS5 and handshake."""
host, port = _parse_server(target) host, port = _parse_server(target)
proxy = _proxy_target() proxy = _proxy_target()
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 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout_s) sock.settimeout(timeout_s)
try: try:
if proxy is None: if not use_proxy:
sock.connect((host, port)) sock.connect((host, port))
else: else:
assert proxy is not None
sock.connect(proxy) sock.connect(proxy)
_socks5_connect(sock, host, port) _socks5_connect(sock, host, port)
@ -194,7 +197,7 @@ def _local_node(userid: str) -> zkac.Node:
return zkac.Node(keypair) 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) host, port = _parse_server(server)
sock = _connect(host, port) sock = _connect(host, port)
server_pk = _resolve_server_pk(userid, server) 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) session = client_handshake_anon(sock, node, server_pk)
framed = FramedSession(sock, session) framed = FramedSession(sock, session)
framed.send(json.dumps({"op": "mgmt"}).encode()) 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: 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()) return json.loads(framed.recv())
def _mgmt_single(userid: str, server: str, cmd: dict) -> dict: def _mgmt_single(
sock, framed = _mgmt_connect(userid, server) 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: 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)) return _ok(_mgmt_cmd(framed, cmd))
finally: finally:
sock.close() sock.close()
@ -243,12 +259,15 @@ def create_registry(userid: str, server: str, role_names: list[str]) -> str:
"cmd": "create_registry", "cmd": "create_registry",
"state_bytes_b64": _b64(state_bytes), "state_bytes_b64": _b64(state_bytes),
"state_cert_b64": _b64(bytes(state_cert)), "state_cert_b64": _b64(bytes(state_cert)),
}) }, auth_registry_id=registry_id.hex(), admin_cred=admin_cred)
rid_hex = resp["registry_id"] rid_hex = resp["registry_id"]
store.save_admin(userid, rid_hex, { store.save_admin(userid, rid_hex, {
"server": server, "server": server,
"roles": role_names, "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, **admin_mat,
}) })
return rid_hex 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, { cur = _mgmt_single(userid, server, {
"cmd": "get_registry", "registry_id": registry_id_hex, "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"])) old_state = zkac.RegistryState.deserialize(_unb64(cur["state_bytes_b64"]))
prev_hash = old_state.state_hash() 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 new_version = old_state.version() + 1
old_roles = admin_data.get("roles", []) 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, "registry_id": registry_id_hex,
"state_bytes_b64": _b64(new_state.serialize()), "state_bytes_b64": _b64(new_state.serialize()),
"state_cert_b64": _b64(bytes(new_cert)), "state_cert_b64": _b64(bytes(new_cert)),
}) }, auth_registry_id=registry_id_hex, admin_cred=admin_cred)
admin_data["roles"] = all_roles 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) store.save_admin(userid, registry_id_hex, admin_data)
def get_registry(userid: str, server: str, registry_id_hex: str) -> dict: 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, { return _mgmt_single(userid, server, {
"cmd": "get_registry", "registry_id": registry_id_hex, "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]: def list_own_registries(userid: str) -> list[dict]:
@ -311,6 +353,7 @@ def grant_p2p(
registry_id_hex: str, registry_id_hex: str,
role_name: str, role_name: str,
recipient_pk_hex: str, recipient_pk_hex: str,
recipient_grant_token_b64: str,
peer: str, peer: str,
peer_transport_pk_hex: str, peer_transport_pk_hex: str,
) -> dict: ) -> dict:
@ -321,7 +364,13 @@ def grant_p2p(
bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data) bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data)
role_rid = zkac.role_id(role_name) 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() req = zkac.prepare_blind_request()
blind_sig = bbs_issuer.issue_blind(req.commitment_with_proof(), role_rid, epoch) 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) recipient_pk = bytes.fromhex(recipient_pk_hex)
eph_kp = zkac.IssuanceKeypair() eph_kp = zkac.IssuanceKeypair()
ciphertext = eph_kp.encrypt(recipient_pk, payload) 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) host, port = _parse_server(peer)
peer_transport_pk = zkac.PublicKey.from_bytes(bytes.fromhex(peer_transport_pk_hex)) peer_transport_pk = zkac.PublicKey.from_bytes(bytes.fromhex(peer_transport_pk_hex))
sock = _connect(host, port) sock = _connect(host, port)
@ -353,6 +408,7 @@ def grant_p2p(
raise RuntimeError("peer did not accept grant session") raise RuntimeError("peer did not accept grant session")
framed.send(json.dumps({ framed.send(json.dumps({
"op": "p2p_grant", "op": "p2p_grant",
"grant_token_b64": recipient_grant_token_b64,
"registry_id": registry_id_hex, "registry_id": registry_id_hex,
"registry_state_bytes_b64": reg["state_bytes_b64"], "registry_state_bytes_b64": reg["state_bytes_b64"],
"registry_state_cert_b64": reg["state_cert_b64"], "registry_state_cert_b64": reg["state_cert_b64"],
@ -367,50 +423,84 @@ def grant_p2p(
return {"status": ack.get("status", "ok"), "peer": peer} 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) ident = store.load_identity(userid)
receiver_kp = zkac.IssuanceKeypair.from_secret(ident["issuance_sk"]) 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 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((host, port)) listener.bind((host, port))
listener.listen(1) listener.listen(1)
conn, _addr = listener.accept() listener.settimeout(timeout_s)
deadline = time.monotonic() + timeout_s
try: try:
session = server_handshake_anon(conn, _local_node(userid)) while True:
framed = FramedSession(conn, session) remaining = deadline - time.monotonic()
framed.send(json.dumps({"ok": True, "op": "ready_for_grant"}).encode()) if remaining <= 0:
msg = _ok(json.loads(framed.recv())) raise RuntimeError("timed out waiting for authenticated grant sender")
if msg.get("op") != "p2p_grant": listener.settimeout(remaining)
raise RuntimeError("unexpected p2p message") conn, _addr = listener.accept()
registry_id_hex = msg["registry_id"] try:
state_bytes = _unb64(msg["registry_state_bytes_b64"]) conn.settimeout(min(remaining, 30.0))
state_cert = _unb64(msg["registry_state_cert_b64"]) session = server_handshake_anon(conn, _local_node(userid))
mgr = zkac.RegistryManager() framed = FramedSession(conn, session)
mgr.restore(state_bytes, state_cert) framed.send(json.dumps({"ok": True, "op": "ready_for_grant"}).encode())
if not mgr.verify_admin( msg = _ok(json.loads(framed.recv()))
bytes.fromhex(registry_id_hex), if msg.get("op") != "p2p_grant":
_unb64(msg["admin_proof_b64"]), raise RuntimeError("unexpected p2p message")
bytes(session.transcript_hash()), if msg.get("grant_token_b64") != expected_grant_token_b64:
): raise RuntimeError("grant pairing token mismatch")
raise RuntimeError("sender admin proof failed") registry_id_hex = msg["registry_id"]
payload = json.loads( expected_role_name = msg.get("role_name")
receiver_kp.decrypt(_unb64(msg["eph_pk_b64"]), _unb64(msg["ciphertext_b64"])) if not isinstance(expected_role_name, str) or not expected_role_name:
) raise RuntimeError("grant message missing required role_name")
cred_data = { state_bytes = _unb64(msg["registry_state_bytes_b64"])
"blind_sig_b64": payload["blind_sig_b64"], state_cert = _unb64(msg["registry_state_cert_b64"])
"member_secret_b64": payload["member_secret_b64"], mgr = zkac.RegistryManager()
"prover_blind_b64": payload["prover_blind_b64"], restored_registry_id = mgr.restore(state_bytes, state_cert).hex()
"role_name": payload["role_name"], if restored_registry_id != registry_id_hex:
"epoch": payload["epoch"], raise RuntimeError("registry snapshot does not match announced registry_id")
"issuer_pk_b64": payload["issuer_pk_b64"], if not mgr.verify_admin(
} bytes.fromhex(registry_id_hex),
cred = store.reconstruct_credential(cred_data) _unb64(msg["admin_proof_b64"]),
cred.present(b"self-test") bytes(session.transcript_hash()),
store.save_credential(userid, payload["registry_id"], payload["role_name"], cred_data) ):
framed.send(json.dumps({"ok": True, "status": "stored"}).encode()) raise RuntimeError("sender admin proof failed")
return {"registry_id": payload["registry_id"], "role": payload["role_name"]} 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: finally:
conn.close()
listener.close() listener.close()

View File

@ -66,7 +66,14 @@ def _cmd_serve(args):
data_dir = args.data_dir data_dir = args.data_dir
if data_dir is None: if data_dir is None:
data_dir = str(user_dir(args.userid) / "server") 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 ─────────────────────────────────────────────────────── # ── server pin ───────────────────────────────────────────────────────
@ -114,7 +121,13 @@ def _cmd_grant(args):
parsed = store.parse_contact_bundle(args.to) parsed = store.parse_contact_bundle(args.to)
to = parsed["issuance_pk_hex"] to = parsed["issuance_pk_hex"]
peer_key = parsed["transport_pk_hex"] peer_key = parsed["transport_pk_hex"]
grant_token = parsed.get("grant_token_b64")
peer = parsed.get("peer") 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: if not peer:
raise RuntimeError( raise RuntimeError(
"contact bundle is missing peer endpoint. " "contact bundle is missing peer endpoint. "
@ -122,7 +135,7 @@ def _cmd_grant(args):
) )
result = client.grant_p2p( 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"granted {args.role!r} to {to[:16]}")
print(f" delivery: direct p2p ({result['peer']})") print(f" delivery: direct p2p ({result['peer']})")
@ -143,7 +156,7 @@ def _cmd_credentials_list(args):
def _cmd_p2p_listen(args): def _cmd_p2p_listen(args):
print(f"listening for p2p grant on {args.host}:{args.port}") print(f"listening for p2p grant on {args.host}:{args.port}")
result = client.receive_p2p(args.userid, args.host, args.port) result = client.receive_p2p(args.userid, args.host, args.port, timeout_s=args.timeout)
print("received credential") print("received credential")
print(f" registry: {result['registry_id']}") print(f" registry: {result['registry_id']}")
print(f" role: {result['role']}") 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("--data-dir", default=None, help="override server data directory")
c.add_argument("--host", default="127.0.0.1") c.add_argument("--host", default="127.0.0.1")
c.add_argument("--port", type=int, default=9800) c.add_argument("--port", type=int, default=9800)
c.add_argument("--max-connections", type=int, default=64, help="max concurrent client connections")
c.add_argument("--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) c.set_defaults(func=_cmd_serve)
# server pin # server pin
@ -268,6 +284,7 @@ def main():
c.add_argument("userid") c.add_argument("userid")
c.add_argument("--host", default="127.0.0.1") c.add_argument("--host", default="127.0.0.1")
c.add_argument("--port", type=int, default=9810) c.add_argument("--port", type=int, default=9810)
c.add_argument("--timeout", type=float, default=60.0, help="max wait for authenticated sender")
c.set_defaults(func=_cmd_p2p_listen) c.set_defaults(func=_cmd_p2p_listen)
# auth # auth

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import os
import socket import socket
import threading import threading
import traceback import traceback
@ -23,6 +24,18 @@ def _unb64(s: str) -> bytes:
return base64.b64decode(s) 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 ───────────────────────────────────────────── # ── Opaque server storage ─────────────────────────────────────────────
class _ServerStore: class _ServerStore:
@ -32,6 +45,8 @@ class _ServerStore:
self._dir = data_dir self._dir = data_dir
self._reg_dir = data_dir / "registries" self._reg_dir = data_dir / "registries"
self._reg_dir.mkdir(parents=True, exist_ok=True) 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() self._lock = threading.Lock()
# ── server key ──────────────────────────────────────────────────── # ── server key ────────────────────────────────────────────────────
@ -42,10 +57,10 @@ class _ServerStore:
data = json.loads(kf.read_text()) data = json.loads(kf.read_text())
return zkac.Keypair.from_secret_key(_unb64(data["secret_b64"])) return zkac.Keypair.from_secret_key(_unb64(data["secret_b64"]))
kp = zkac.Keypair() kp = zkac.Keypair()
kf.write_text(json.dumps({ _write_private_json(kf, {
"secret_b64": _b64(kp.secret_key_bytes()), "secret_b64": _b64(kp.secret_key_bytes()),
"public_b64": _b64(kp.public_key().to_bytes()), "public_b64": _b64(kp.public_key().to_bytes()),
}, indent=2)) })
return kp return kp
# ── registries ──────────────────────────────────────────────────── # ── registries ────────────────────────────────────────────────────
@ -81,6 +96,20 @@ def _dispatch(
) -> dict: ) -> dict:
try: try:
action = cmd.get("cmd") 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": if action == "server_info":
return {"ok": True, "server_public_key_b64": server_pk_b64} return {"ok": True, "server_public_key_b64": server_pk_b64}
@ -88,12 +117,29 @@ def _dispatch(
if action == "create_registry": if action == "create_registry":
state_bytes = _unb64(cmd["state_bytes_b64"]) state_bytes = _unb64(cmd["state_bytes_b64"])
state_cert = _unb64(cmd["state_cert_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) rid = mgr.create(state_bytes, state_cert)
store.save_registry(rid.hex(), state_bytes, state_cert) store.save_registry(rid.hex(), state_bytes, state_cert)
return {"ok": True, "registry_id": rid.hex()} return {"ok": True, "registry_id": rid.hex()}
if action == "get_registry": 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) state_bytes, state_cert = mgr.get(rid)
return { return {
"ok": True, "ok": True,
@ -102,11 +148,13 @@ def _dispatch(
} }
if action == "update_registry": 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_bytes = _unb64(cmd["state_bytes_b64"])
state_cert = _unb64(cmd["state_cert_b64"]) state_cert = _unb64(cmd["state_cert_b64"])
mgr.update(rid, state_bytes, state_cert) 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 {"ok": True}
return {"error": f"unknown command: {action}"} return {"error": f"unknown command: {action}"}
@ -124,12 +172,15 @@ def _handle_conn(
mgr: zkac.RegistryManager, mgr: zkac.RegistryManager,
store: _ServerStore, store: _ServerStore,
server_pk_b64: str, server_pk_b64: str,
idle_timeout_s: float,
slots: threading.BoundedSemaphore,
debug: ServerDebugState | None = None, debug: ServerDebugState | None = None,
): ):
peer = f"{addr[0]}:{addr[1]}" peer = f"{addr[0]}:{addr[1]}"
cid = debug.open_connection(peer) if debug else None cid = debug.open_connection(peer) if debug else None
err: str | None = None err: str | None = None
try: try:
conn.settimeout(idle_timeout_s)
if debug and cid: if debug and cid:
debug.update_connection(cid, phase="handshake") debug.update_connection(cid, phase="handshake")
session = server_handshake_anon(conn, node) session = server_handshake_anon(conn, node)
@ -219,6 +270,7 @@ def _handle_conn(
if debug and cid: if debug and cid:
debug.close_connection(cid, error=err) debug.close_connection(cid, error=err)
conn.close() conn.close()
slots.release()
# ── Public entry point ──────────────────────────────────────────────── # ── Public entry point ────────────────────────────────────────────────
@ -227,6 +279,9 @@ def serve(
data_dir: str, data_dir: str,
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 9800, port: int = 9800,
max_connections: int = 64,
idle_timeout_s: float = 30.0,
listen_backlog: int = 64,
*, *,
debug: ServerDebugState | None = None, debug: ServerDebugState | None = None,
): ):
@ -253,13 +308,17 @@ def serve(
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port)) sock.bind((host, port))
sock.listen(8) slots = threading.BoundedSemaphore(max_connections)
sock.listen(listen_backlog)
try: try:
while True: while True:
conn, addr = sock.accept() conn, addr = sock.accept()
if not slots.acquire(blocking=False):
conn.close()
continue
threading.Thread( threading.Thread(
target=_handle_conn, 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, daemon=True,
).start() ).start()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -4,6 +4,8 @@ from __future__ import annotations
import base64 import base64
import json import json
import os
import secrets
from pathlib import Path from pathlib import Path
import zkac import zkac
@ -23,10 +25,28 @@ def _ud(userid: str) -> Path:
return user_dir(userid) 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 ──────────────────────────────────────────────────── # ── User identity ────────────────────────────────────────────────────
def create_user(userid: str) -> Path: def create_user(userid: str) -> Path:
d = ensure_user(userid) d = ensure_user(userid)
_chmod_if_possible(d, 0o700)
p = d / "identity.json" p = d / "identity.json"
if p.exists(): if p.exists():
raise FileExistsError(f"user {userid!r} already exists at {d}") 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()), "issuance_public_b64": _b64(issuance_kp.public_key_bytes()),
"transport_secret_b64": _b64(transport_kp.secret_key_bytes()), "transport_secret_b64": _b64(transport_kp.secret_key_bytes()),
"transport_public_b64": _b64(transport_kp.public_key().to_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"): for sub in ("admin", "credentials", "servers"):
(d / sub).mkdir(exist_ok=True) _ensure_private_dir(d / sub)
return d return d
def load_identity(userid: str) -> dict: def load_identity(userid: str) -> dict:
p = _ud(userid) / "identity.json" p = _ud(userid) / "identity.json"
data = json.loads(p.read_text()) data = json.loads(p.read_text())
changed = False
if "transport_secret_b64" not in data or "transport_public_b64" not in data: if "transport_secret_b64" not in data or "transport_public_b64" not in data:
transport_kp = zkac.Keypair() transport_kp = zkac.Keypair()
data["transport_secret_b64"] = _b64(transport_kp.secret_key_bytes()) data["transport_secret_b64"] = _b64(transport_kp.secret_key_bytes())
data["transport_public_b64"] = _b64(transport_kp.public_key().to_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 { return {
"issuance_sk": _unb64(data["issuance_secret_b64"]), "issuance_sk": _unb64(data["issuance_secret_b64"]),
"issuance_pk": _unb64(data["issuance_public_b64"]), "issuance_pk": _unb64(data["issuance_public_b64"]),
"transport_sk": _unb64(data["transport_secret_b64"]), "transport_sk": _unb64(data["transport_secret_b64"]),
"transport_pk": _unb64(data["transport_public_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.""" """One-string public contact bundle for out-of-band sharing."""
ident = load_identity(userid) ident = load_identity(userid)
payload = { payload = {
"v": 2, "v": 3,
"issuance_pk_hex": ident["issuance_pk"].hex(), "issuance_pk_hex": ident["issuance_pk"].hex(),
"transport_pk_hex": ident["transport_pk"].hex(), "transport_pk_hex": ident["transport_pk"].hex(),
"grant_token_b64": _b64(ident["grant_token"]),
} }
if peer: if peer:
payload["peer"] = peer payload["peer"] = peer
@ -86,8 +115,8 @@ def parse_contact_bundle(bundle: str) -> dict:
raise ValueError("invalid contact bundle encoding") from exc raise ValueError("invalid contact bundle encoding") from exc
version = data.get("v") version = data.get("v")
if version not in (1, 2): if version != 3:
raise ValueError("unsupported contact bundle version") raise ValueError("unsupported contact bundle version (requires v3)")
issuance_hex = data.get("issuance_pk_hex", "") issuance_hex = data.get("issuance_pk_hex", "")
transport_hex = data.get("transport_pk_hex", "") transport_hex = data.get("transport_pk_hex", "")
if not isinstance(issuance_hex, str) or not isinstance(transport_hex, str): 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, "issuance_pk_hex": issuance_hex,
"transport_pk_hex": transport_hex, "transport_pk_hex": transport_hex,
} }
if version == 2: tok = data.get("grant_token_b64", "")
peer = data.get("peer") if not isinstance(tok, str):
if peer is not None and not isinstance(peer, str): raise ValueError("invalid contact bundle grant token field")
raise ValueError("invalid contact bundle peer field") try:
if isinstance(peer, str) and peer.strip(): token = _unb64(tok)
parsed["peer"] = peer.strip() 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 return parsed
@ -132,10 +171,8 @@ def _server_key(server: str) -> str:
def pin_server(userid: str, server: str, server_pk_b64: str): def pin_server(userid: str, server: str, server_pk_b64: str):
d = _ud(userid) / "servers" d = _ud(userid) / "servers"
d.mkdir(parents=True, exist_ok=True) _ensure_private_dir(d)
(d / f"{_server_key(server)}.json").write_text( _write_private_json(d / f"{_server_key(server)}.json", {"server_public_key_b64": server_pk_b64})
json.dumps({"server_public_key_b64": server_pk_b64}, indent=2)
)
def load_server_pin(userid: str, server: str) -> dict | None: 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): def save_admin(userid: str, registry_id_hex: str, info: dict):
d = _ud(userid) / "admin" d = _ud(userid) / "admin"
d.mkdir(parents=True, exist_ok=True) _ensure_private_dir(d)
(d / f"{registry_id_hex}.json").write_text(json.dumps(info, indent=2)) _write_private_json(d / f"{registry_id_hex}.json", info)
def load_admin(userid: str, registry_id_hex: str) -> dict: 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): def save_credential(userid: str, registry_id_hex: str, role_name: str, cred_data: dict):
d = _ud(userid) / "credentials" d = _ud(userid) / "credentials"
d.mkdir(parents=True, exist_ok=True) _ensure_private_dir(d)
(d / f"{registry_id_hex}_{role_name}.json").write_text(json.dumps(cred_data, indent=2)) _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: def load_credential_data(userid: str, registry_id_hex: str, role_name: str) -> dict:

View File

@ -1,5 +1,5 @@
Metadata-Version: 2.4 Metadata-Version: 2.4
Name: zkac-node Name: zkac-node
Version: 0.6.0 Version: 0.7.0
Requires-Python: >=3.10 Requires-Python: >=3.10
Requires-Dist: zkac Requires-Dist: zkac

View File

@ -1,6 +1,6 @@
# ZKAC Python API Reference # 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 ```python
import zkac 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). 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. - Authorization proofs: BBS+ presentations over BLS12-381.
- Registry integrity: certified `RegistryState` with BBS+ admin proof checks. - Registry integrity: certified `RegistryState` with BBS+ admin proof checks.
- Grant payload encryption: X25519 + HKDF-SHA256 + ChaCha20-Poly1305 issuance envelope. - 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 ## 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. - transcript-bound admin BBS+ proof.
4. Recipient verifies: 4. Recipient verifies:
- registry state certificate, - registry state certificate,
- restored registry ID matches the announced outer `registry_id`,
- admin proof bound to current session transcript. - 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 ## Threat model
@ -49,6 +56,9 @@ This document summarizes the direct peer-to-peer grant model, with transcript-bo
- `anonymous_authorized` mode: - `anonymous_authorized` mode:
- Recipient learns sender is authorized for the registry, not a mandatory stable real-world identity. - 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. - 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: - Registry freshness:
- Recipient verifies cryptographic validity of received state; deployment policy should define freshness expectations. - Recipient verifies cryptographic validity of received state; deployment policy should define freshness expectations.
- Key management remains operationally critical: - Key management remains operationally critical:

View File

@ -86,8 +86,9 @@ The architecture separates concerns:
1. Sender and recipient establish a secure session. 1. Sender and recipient establish a secure session.
2. Sender generates a transcript-bound BBS+ authorization proof. 2. Sender generates a transcript-bound BBS+ authorization proof.
3. Sender sends encrypted credential payload plus registry-state evidence. 3. Sender sends encrypted credential payload plus registry-state evidence.
4. Recipient verifies proof against registry state. 4. Recipient verifies the certified registry state and validates sender admin proof against the live session transcript.
5. Recipient decrypts payload and stores credential locally. 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. 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. 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) ### 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. **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 #### Game G5: Malicious Node Knowledge Gain

View File

@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "zkac" name = "zkac"
version = "0.6.0" version = "0.7.0"
description = "Zero-Knowledge Access Control: BBS+ anonymous credentials with encrypted transport" description = "Zero-Knowledge Access Control: BBS+ anonymous credentials with encrypted transport"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" 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). BBS+ anonymous credentials (BLS12-381) with encrypted transport (Ristretto255 / X25519).
""" """
__version__ = "0.6.0" __version__ = "0.7.0"
from zkac._zkac import ( from zkac._zkac import (
MAX_BBS_AUTH_PROOF_BYTES, 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; let num_roles = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4; 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); let mut roles = Vec::with_capacity(num_roles);
for _ in 0..num_roles { for _ in 0..num_roles {
if data.len() < pos + 32 + 4 { if data.len() < pos + 32 + 4 {

View File

@ -7,14 +7,17 @@
use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::ChaCha20Poly1305; use chacha20poly1305::ChaCha20Poly1305;
use hkdf::Hkdf; use hkdf::Hkdf;
use rand::rngs::OsRng;
use rand::{CryptoRng, RngCore}; use rand::{CryptoRng, RngCore};
use sha2::Sha256; use sha2::Sha256;
use subtle::ConstantTimeEq;
use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret}; use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret};
use crate::{Error, Result}; use crate::{Error, Result};
const ISSUANCE_HKDF_INFO: &[u8] = b"zkac-issuance-v1"; const ISSUANCE_HKDF_INFO_USER_TO_ADMIN: &[u8] = b"zkac-issuance-v1:user-to-admin";
const NONCE_BYTES: [u8; 12] = [0u8; 12]; 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). /// Admin-side issuance keypair (X25519 static secret for DH).
pub struct IssuanceKeypair { pub struct IssuanceKeypair {
@ -47,22 +50,26 @@ impl IssuanceKeypair {
pub fn decrypt(&self, eph_pk_bytes: &[u8; 32], ciphertext: &[u8]) -> Result<Vec<u8>> { pub fn decrypt(&self, eph_pk_bytes: &[u8; 32], ciphertext: &[u8]) -> Result<Vec<u8>> {
let eph_pk = X25519Public::from(*eph_pk_bytes); let eph_pk = X25519Public::from(*eph_pk_bytes);
let shared = self.secret.diffie_hellman(&eph_pk); 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) let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?;
cipher.decrypt(&NONCE_BYTES.into(), ciphertext) decrypt_with_prefixed_nonce(&cipher, ciphertext)
.map_err(|_| Error::DecryptionFailed)
} }
/// Encrypt a response to the user (uses same shared secret). /// Encrypt a response to the user (uses same shared secret).
pub fn encrypt(&self, eph_pk_bytes: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> { pub fn encrypt(&self, eph_pk_bytes: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let eph_pk = X25519Public::from(*eph_pk_bytes); let eph_pk = X25519Public::from(*eph_pk_bytes);
let shared = self.secret.diffie_hellman(&eph_pk); 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) let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?;
cipher.encrypt(&NONCE_BYTES.into(), plaintext) encrypt_with_random_nonce(&cipher, plaintext)
.map_err(|_| Error::CredentialError("issuance encryption failed".into()))
} }
} }
@ -78,12 +85,14 @@ pub fn encrypt_for_admin<R: CryptoRng + RngCore>(
let admin_pk = X25519Public::from(*admin_issuance_pk); let admin_pk = X25519Public::from(*admin_issuance_pk);
let shared = eph_secret.diffie_hellman(&admin_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) let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?;
let ciphertext = cipher.encrypt(&NONCE_BYTES.into(), plaintext) let ciphertext = encrypt_with_random_nonce(&cipher, plaintext)?;
.map_err(|_| Error::CredentialError("issuance encryption failed".into()))?;
Ok((*eph_public.as_bytes(), ciphertext)) Ok((*eph_public.as_bytes(), ciphertext))
} }
@ -97,20 +106,50 @@ pub fn decrypt_from_admin(
let eph_secret = StaticSecret::from(*eph_secret_bytes); let eph_secret = StaticSecret::from(*eph_secret_bytes);
let admin_pk = X25519Public::from(*admin_issuance_pk); let admin_pk = X25519Public::from(*admin_issuance_pk);
let shared = eph_secret.diffie_hellman(&admin_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) let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; .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) .map_err(|_| Error::DecryptionFailed)
} }
fn derive_key(shared_secret: &[u8]) -> [u8; 32] { fn is_zero(bytes: &[u8; 32]) -> bool {
let hk = Hkdf::<Sha256>::new(None, shared_secret); bytes.ct_eq(&[0u8; 32]).into()
let mut key = [0u8; 32];
hk.expand(ISSUANCE_HKDF_INFO, &mut key)
.expect("HKDF expand should not fail for 32 bytes");
key
} }
#[cfg(test)] #[cfg(test)]
@ -154,10 +193,10 @@ mod tests {
// Encrypt commitment // Encrypt commitment
let shared = user_secret.diffie_hellman(&X25519Public::from(admin_pk)); 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 cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap();
let commitment = b"test commitment"; 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 // Admin decrypts
let decrypted = admin_kp.decrypt(&eph_pk, &encrypted).unwrap(); let decrypted = admin_kp.decrypt(&eph_pk, &encrypted).unwrap();
@ -172,6 +211,21 @@ mod tests {
assert_eq!(dec_response, response); 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] #[test]
fn wrong_key_fails() { fn wrong_key_fails() {
let admin_kp = IssuanceKeypair::generate(&mut OsRng); let admin_kp = IssuanceKeypair::generate(&mut OsRng);

View File

@ -330,6 +330,9 @@ impl PyRoleRegistry {
if role_id.len() != 32 { if role_id.len() != 32 {
return Err(PyValueError::new_err("role_id must be 32 bytes")); 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]; let mut rid = [0u8; 32];
rid.copy_from_slice(role_id); rid.copy_from_slice(role_id);
let pres = bbs::Presentation::from_bytes(proof_bytes.to_vec()); 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> { 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")?; let rid = to_32(registry_id, "registry_id")?;
match self.inner.verify_admin(&rid, proof_bytes, nonce) { match self.inner.verify_admin(&rid, proof_bytes, nonce) {
Ok(()) => Ok(true), 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> { 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 rid = to_32(registry_id, "registry_id")?;
let roid = to_32(role_id, "role_id")?; let roid = to_32(role_id, "role_id")?;
let pres = bbs::Presentation::from_bytes(proof_bytes.to_vec()); let pres = bbs::Presentation::from_bytes(proof_bytes.to_vec());

4
uv.lock generated
View File

@ -1918,7 +1918,7 @@ wheels = [
[[package]] [[package]]
name = "zkac" name = "zkac"
version = "0.6.0" version = "0.7.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "ipykernel" }, { name = "ipykernel" },
@ -1964,7 +1964,7 @@ dev = [
[[package]] [[package]]
name = "zkac-node" name = "zkac-node"
version = "0.6.0" version = "0.7.0"
source = { editable = "cli" } source = { editable = "cli" }
dependencies = [ dependencies = [
{ name = "zkac" }, { name = "zkac" },