major bugfixes
This commit is contained in:
parent
44fa5e6a2f
commit
3a3bd30e03
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -748,7 +748,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zkac"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"blake2",
|
||||
"chacha20poly1305",
|
||||
|
||||
@ -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)"
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "zkac-node"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["zkac"]
|
||||
|
||||
|
||||
@ -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,26 +423,43 @@ 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)
|
||||
listener.settimeout(timeout_s)
|
||||
deadline = time.monotonic() + timeout_s
|
||||
try:
|
||||
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()
|
||||
mgr.restore(state_bytes, state_cert)
|
||||
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"]),
|
||||
@ -396,6 +469,10 @@ def receive_p2p(userid: str, host: str, port: int) -> dict:
|
||||
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"],
|
||||
@ -405,12 +482,25 @@ def receive_p2p(userid: str, host: str, port: int) -> dict:
|
||||
"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)
|
||||
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": payload["registry_id"], "role": payload["role_name"]}
|
||||
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:
|
||||
listener.close()
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,7 +134,17 @@ def parse_contact_bundle(bundle: str) -> dict:
|
||||
"issuance_pk_hex": issuance_hex,
|
||||
"transport_pk_hex": transport_hex,
|
||||
}
|
||||
if version == 2:
|
||||
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")
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
4
uv.lock
generated
@ -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" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user