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]]
|
[[package]]
|
||||||
name = "zkac"
|
name = "zkac"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake2",
|
"blake2",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
4
uv.lock
generated
@ -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" },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user