TCP/UDP demo

This commit is contained in:
everbarry 2026-04-15 11:32:01 +02:00
parent b7c901f8b3
commit 4b2543fad2
12 changed files with 951 additions and 0 deletions

89
demo/client_cli.py Normal file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
ZKAC TCP client: load a member credential, complete handshake, request /api (encrypted JSON).
The "API" is not HTTP on port 8765 it is one JSON request inside the ZKAC session on --port (default 9876).
"""
from __future__ import annotations
import argparse
import base64
import json
import socket
from pathlib import Path
import zkac
from zkac.tcp import FramedSession, client_handshake
def load_credential(member_json: Path) -> zkac.Credential:
"""Rebuild Credential from setup_demo.py output (same fields as zkac.Credential.finalize)."""
m = json.loads(member_json.read_text(encoding="utf-8"))
pk = zkac.BbsPublicKey.from_bytes(base64.b64decode(m["issuer_public_key_b64"]))
rid = bytes.fromhex(m["role_id_hex"])
return zkac.Credential.finalize(
base64.b64decode(m["blind_sig_b64"]),
base64.b64decode(m["member_secret_b64"]),
base64.b64decode(m["prover_blind_b64"]),
rid,
int(m["epoch"]),
pk,
)
def load_server_pk(creds_dir: Path) -> zkac.PublicKey:
"""Pinned server identity: must match the Keypair used by server.py (from transport.json)."""
t = json.loads((creds_dir / "transport.json").read_text(encoding="utf-8"))
raw = base64.b64decode(t["server_public_key_b64"])
return zkac.PublicKey.from_bytes(raw)
def main() -> None:
ap = argparse.ArgumentParser(description="ZKAC demo client (TCP + credential)")
ap.add_argument(
"--creds-dir",
type=Path,
default=Path(__file__).resolve().parent / "creds",
help="Directory with transport.json and member_*.json",
)
ap.add_argument(
"--member",
type=Path,
help="Path to member_*.json (default: creds-dir/member_analyst.json)",
)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9876)
args = ap.parse_args()
creds_dir: Path = args.creds_dir
member_path = args.member or (creds_dir / "member_analyst.json")
if not member_path.is_file():
raise SystemExit(f"Missing member file: {member_path}")
credential = load_credential(member_path)
server_pk = load_server_pk(creds_dir)
# Ephemeral client transport identity (not the BBS+ member secret — that is inside credential).
client_kp = zkac.Keypair()
node = zkac.Node(client_kp)
sock = socket.create_connection((args.host, args.port))
try:
# X25519 + server Schnorr + BBS+ auth; returns symmetric Session.
session = client_handshake(sock, node, server_pk, credential)
framed = FramedSession(sock, session)
# Logical GET /api: path is checked by server after decrypt.
request_obj = {"path": "/api"}
payload = json.dumps(request_obj).encode("utf-8")
framed.send(payload)
reply = framed.recv().decode("utf-8")
print(json.dumps(json.loads(reply), indent=2))
finally:
sock.close()
if __name__ == "__main__":
main()

2
demo/creds/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

196
demo/server.py Normal file
View File

@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
HTTP static site + ZKAC TCP service. Authenticated /api is accessed over TCP with zkac.tcp
after setup_demo.py has created creds/.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import threading
import http.server
import socket
import traceback
from pathlib import Path
import zkac
from zkac.tcp import FramedSession, server_handshake
def load_registry(creds_dir: Path, epoch: int) -> zkac.RoleRegistry:
"""Load issuer public key and register every demo role at the same epoch."""
iss = json.loads((creds_dir / "issuer.json").read_text(encoding="utf-8"))
issuer_pk = zkac.BbsPublicKey.from_bytes(
base64.b64decode(iss["issuer_public_key_b64"])
)
reg = zkac.RoleRegistry()
for name in ("analyst", "operator"):
reg.register_role(zkac.role_id(name), issuer_pk, epoch)
return reg
def _role_debug_label(role_id: bytes) -> str:
"""Map verified role_id bytes to a short label for logs (demo only)."""
for name in ("analyst", "operator"):
if role_id == zkac.role_id(name):
return name
return "unknown"
def api_body_for_role(role_id: bytes) -> dict:
"""JSON returned for the logical /api resource after ZKAC auth; varies by credential role."""
analyst = zkac.role_id("analyst")
operator = zkac.role_id("operator")
if role_id == analyst:
return {
"path": "/api",
"role": "analyst",
"datasets": ["summary", "aggregated_metrics"],
"note": "Analyst tier: aggregated data only.",
}
if role_id == operator:
return {
"path": "/api",
"role": "operator",
"datasets": ["summary", "aggregated_metrics", "raw_logs", "pii"],
"note": "Operator tier: full API slice including raw logs.",
}
return {"error": "unknown role", "path": "/api"}
def handle_zkac_client(
conn: socket.socket,
client_addr: tuple,
creds_dir: Path,
registry: zkac.RoleRegistry,
) -> None:
"""
One TCP connection: ZKAC handshake + BBS+ auth, then one framed JSON request and response.
Each handler rebuilds the server Node from persisted secret (Keypair is consumed by Node).
"""
peer = f"{client_addr[0]}:{client_addr[1]}"
print(f"[zkac] connect peer={peer}")
try:
# Same long-term server identity every time; from_secret_key because Node consumes Keypair.
t = json.loads((creds_dir / "transport.json").read_text(encoding="utf-8"))
sk = base64.b64decode(t["server_secret_key_b64"])
server_kp = zkac.Keypair.from_secret_key(sk)
node = zkac.Node(server_kp)
session, role_id = server_handshake(conn, node, registry)
label = _role_debug_label(role_id)
print(
f"[zkac] handshake_ok peer={peer} role_id={role_id.hex()} role={label!r}"
)
framed = FramedSession(conn, session)
raw = framed.recv()
print(
f"[zkac] request peer={peer} plaintext_bytes={len(raw)} raw={raw!r}"
)
req = json.loads(raw.decode("utf-8"))
print(f"[zkac] request_json peer={peer} parsed={req!r}")
path = req.get("path")
if path != "/api":
err_body = {"error": "unsupported path", "allowed": ["/api"], "got": path}
out = json.dumps(err_body).encode()
framed.send(out)
print(
f"[zkac] response peer={peer} status=reject path={path!r} response_bytes={len(out)}"
)
return
body = api_body_for_role(role_id)
out_bytes = json.dumps(body).encode()
framed.send(out_bytes)
print(
f"[zkac] response peer={peer} status=ok path=/api role={label!r} "
f"response_bytes={len(out_bytes)} body_keys={list(body.keys())}"
)
except (ConnectionError, BrokenPipeError, OSError) as e:
print(f"[zkac] peer={peer} connection_error: {e!r}")
except (json.JSONDecodeError, ValueError) as e:
print(f"[zkac] peer={peer} protocol_error: {e!r}")
except Exception as e:
print(f"[zkac] peer={peer} unexpected_error: {e!r}")
traceback.print_exc()
finally:
conn.close()
print(f"[zkac] closed peer={peer}")
def run_http(host: str, port: int, static_root: Path) -> None:
# Process-wide CWD: only this thread should rely on relative paths after chdir.
os.chdir(static_root)
class Handler(http.server.SimpleHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
# Default fmt is like '%s - - [%s] %s' — include client address for debugging.
try:
line = fmt % args if args else fmt
except (TypeError, ValueError):
line = f"{fmt} {args}"
peer_ip = self.client_address[0] if self.client_address else "?"
peer_port = self.client_address[1] if len(self.client_address) > 1 else "?"
print(f"[http] peer={peer_ip}:{peer_port} | {line.strip()}")
http.server.HTTPServer((host, port), Handler).serve_forever()
def main() -> None:
ap = argparse.ArgumentParser(description="ZKAC demo HTTP + TCP server")
ap.add_argument(
"--creds-dir",
type=Path,
default=Path(__file__).resolve().parent / "creds",
)
ap.add_argument("--http-host", default="127.0.0.1")
ap.add_argument("--http-port", type=int, default=8765)
ap.add_argument("--zkac-host", default="127.0.0.1")
ap.add_argument("--zkac-port", type=int, default=9876)
args = ap.parse_args()
creds_dir: Path = args.creds_dir
if not (creds_dir / "transport.json").is_file():
raise SystemExit(f"Missing {creds_dir}/transport.json — run setup_demo.py first.")
# Epoch must match the member files issued at setup (any member file is enough).
member = json.loads((creds_dir / "member_analyst.json").read_text(encoding="utf-8"))
epoch = int(member["epoch"])
registry = load_registry(creds_dir, epoch)
static_root = Path(__file__).resolve().parent / "static"
if not static_root.is_dir():
raise SystemExit(f"Missing static directory: {static_root}")
http_thread = threading.Thread(
target=run_http,
args=(args.http_host, args.http_port, static_root),
daemon=True,
)
http_thread.start()
print(
f"HTTP http://{args.http_host}:{args.http_port}/ (static demo page)\n"
f"ZKAC {args.zkac_host}:{args.zkac_port} (authenticated /api over TCP)"
)
zkac_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
zkac_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
zkac_sock.bind((args.zkac_host, args.zkac_port))
zkac_sock.listen(8)
while True:
conn, addr = zkac_sock.accept()
threading.Thread(
target=handle_zkac_client,
args=(conn, addr, creds_dir, registry),
daemon=True,
).start()
if __name__ == "__main__":
main()

79
demo/setup_demo.py Normal file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Generate demo credentials under creds/: issuer, server transport key, two member credentials.
Run once before starting the server.
"""
from __future__ import annotations
import argparse
import base64
import json
from pathlib import Path
import zkac
# Human-readable role names; each becomes a 32-byte opaque role_id via zkac.role_id().
# Must stay in sync with server.py (registry + api_body_for_role).
ROLES = ("analyst", "operator")
def main() -> None:
ap = argparse.ArgumentParser(description="Generate ZKAC demo credential files.")
ap.add_argument(
"--output-dir",
type=Path,
default=Path(__file__).resolve().parent / "creds",
help="Directory to write files (default: demo/creds)",
)
args = ap.parse_args()
out: Path = args.output_dir
out.mkdir(parents=True, exist_ok=True)
# BBS+ issuer: signs blind credentials; server only needs the public key in RoleRegistry.
issuer = zkac.BbsIssuer()
issuer_pk = issuer.public_key()
epoch = 1
# Long-term Ristretto identity for the TCP server (X25519 handshake + Schnorr identity proof).
server_kp = zkac.Keypair()
server_pk = server_kp.public_key()
issuer_payload = {
"issuer_secret_key_b64": base64.b64encode(issuer.secret_key_bytes()).decode(),
"issuer_public_key_b64": base64.b64encode(issuer_pk.to_bytes()).decode(),
}
(out / "issuer.json").write_text(json.dumps(issuer_payload, indent=2), encoding="utf-8")
transport_payload = {
"server_secret_key_b64": base64.b64encode(server_kp.secret_key_bytes()).decode(),
"server_public_key_b64": base64.b64encode(server_pk.to_bytes()).decode(),
}
(out / "transport.json").write_text(json.dumps(transport_payload, indent=2), encoding="utf-8")
# One blind issuance per role: issuer never learns member_secret.
for role_name in ROLES:
rid = zkac.role_id(role_name)
req = zkac.prepare_blind_request()
blind_sig = issuer.issue_blind(req.commitment_with_proof(), rid, epoch)
member = {
"role_name": role_name,
"role_id_hex": rid.hex(),
"epoch": epoch,
"blind_sig_b64": base64.b64encode(blind_sig).decode(),
"member_secret_b64": base64.b64encode(req.member_secret()).decode(),
"prover_blind_b64": base64.b64encode(req.prover_blind()).decode(),
"issuer_public_key_b64": base64.b64encode(issuer_pk.to_bytes()).decode(),
}
(out / f"member_{role_name}.json").write_text(
json.dumps(member, indent=2), encoding="utf-8"
)
print(f"Wrote issuer, transport, and member files to {out}")
print(
f"Roles: {', '.join(ROLES)} — use member_{ROLES[0]}.json / member_{ROLES[1]}.json with client_cli.py"
)
if __name__ == "__main__":
main()

33
demo/static/index.html Normal file
View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ZKAC demo</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
code { background: #f0f0f0; padding: 0.1em 0.35em; border-radius: 4px; }
pre { background: #1e1e1e; color: #eee; padding: 1rem; overflow: auto; border-radius: 6px; }
</style>
</head>
<body>
<h1>ZKAC demo</h1>
<p>
This page is served over normal HTTP. Role-based <strong>/api</strong> data is <strong>not</strong> on this port:
it is exposed only after a <strong>ZKAC session</strong> on the separate TCP port (BBS+ credential + encrypted transport).
</p>
<h2>1. Generate credentials</h2>
<pre>python setup_demo.py</pre>
<p>Creates <code>creds/</code> with issuer keys, server transport keys, and two members: <code>analyst</code> and <code>operator</code>.</p>
<h2>2. Start the server</h2>
<pre>python server.py</pre>
<p>HTTP (this page) defaults to <code>127.0.0.1:8765</code>. ZKAC TCP defaults to <code>127.0.0.1:9876</code>.</p>
<h2>3. CLI client</h2>
<pre>python client_cli.py --member creds/member_analyst.json
python client_cli.py --member creds/member_operator.json</pre>
<p>Each command runs a full handshake and requests <code>{"path":"/api"}</code>. The JSON response lists datasets allowed for that role.</p>
</body>
</html>

128
python/zkac/tcp.py Normal file
View File

@ -0,0 +1,128 @@
"""
Length-prefixed TCP framing for ZKAC handshakes and encrypted sessions.
Wire format: each message is ``uint32_le(length) || payload`` with ``length``
counting only ``payload`` bytes. Handshake payloads match the in-memory protocol
(32-byte init; server reply is ``response_msg || identity_proof``; then auth).
"""
from __future__ import annotations
import socket
import struct
from typing import TYPE_CHECKING, Tuple
from zkac import MAX_BBS_AUTH_PROOF_BYTES
if TYPE_CHECKING:
from zkac import Credential, Node, PublicKey, RoleRegistry, Session
# Largest frame: BBS+ auth ciphertext (bound by library) plus handshake/AEAD slack.
MAX_TCP_FRAME_BYTES: int = MAX_BBS_AUTH_PROOF_BYTES + 4096
_HANDSHAKE_MSG_LEN = 32
def _read_exact(sock: socket.socket, n: int) -> bytes:
buf = bytearray()
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("connection closed before read completed")
buf.extend(chunk)
return bytes(buf)
def read_frame(sock: socket.socket) -> bytes:
"""Read one length-prefixed frame from *sock*."""
(length,) = struct.unpack("<I", _read_exact(sock, 4))
if length > MAX_TCP_FRAME_BYTES:
raise ValueError(f"frame length {length} exceeds maximum ({MAX_TCP_FRAME_BYTES})")
if length == 0:
return b""
return _read_exact(sock, length)
def write_frame(sock: socket.socket, payload: bytes) -> None:
"""Write one length-prefixed frame to *sock*."""
if len(payload) > MAX_TCP_FRAME_BYTES:
raise ValueError(f"payload length {len(payload)} exceeds maximum ({MAX_TCP_FRAME_BYTES})")
sock.sendall(struct.pack("<I", len(payload)) + payload)
def client_handshake(
sock: socket.socket,
node: Node,
expected_server_pk: PublicKey,
credential: Credential,
) -> Session:
"""
Run the ZKAC client side over *sock* (TCP connected to the server).
Returns the authenticated :class:`Session` for ``encrypt`` / ``decrypt``.
"""
pending, init_msg = node.connect()
if len(init_msg) != _HANDSHAKE_MSG_LEN:
raise ValueError("internal error: init_msg must be 32 bytes")
write_frame(sock, init_msg)
bundle = read_frame(sock)
if len(bundle) < _HANDSHAKE_MSG_LEN:
raise ValueError("server handshake bundle too short")
response_msg = bundle[:_HANDSHAKE_MSG_LEN]
identity_proof = bundle[_HANDSHAKE_MSG_LEN:]
session, auth_packet = node.complete_connect(
pending, response_msg, identity_proof, expected_server_pk, credential
)
write_frame(sock, auth_packet)
return session
def server_handshake(
sock: socket.socket,
node: Node,
registry: RoleRegistry,
) -> Tuple[Session, bytes]:
"""
Run the ZKAC server side over *sock* (accepted TCP connection).
Returns ``(session, role_id)`` where ``role_id`` is 32 bytes after successful
BBS+ verification.
"""
init_msg = read_frame(sock)
if len(init_msg) != _HANDSHAKE_MSG_LEN:
raise ValueError("init_msg must be 32 bytes")
session, response_msg = node.accept(init_msg)
if len(response_msg) != _HANDSHAKE_MSG_LEN:
raise ValueError("internal error: response_msg must be 32 bytes")
identity_proof = node.prove_identity(session)
bundle = response_msg + identity_proof
write_frame(sock, bundle)
auth_packet = read_frame(sock)
role_id = node.verify_auth(session, auth_packet, registry)
return session, role_id
class FramedSession:
"""
One ZKAC ciphertext per TCP frame: encrypt before send, decrypt after recv.
"""
def __init__(self, sock: socket.socket, session: Session) -> None:
self._sock = sock
self._session = session
@property
def session(self) -> Session:
return self._session
def send(self, plaintext: bytes) -> None:
packet = self._session.encrypt(plaintext)
write_frame(self._sock, packet)
def recv(self) -> bytes:
return self._session.decrypt(read_frame(self._sock))

177
python/zkac/udp.py Normal file
View File

@ -0,0 +1,177 @@
"""
Length-prefixed UDP datagram framing for ZKAC handshakes and encrypted sessions.
Wire format matches :mod:`zkac.tcp`: each datagram is ``uint32_le(length) || payload``
with *length* counting only *payload* bytes. **One datagram = one frame** (do not
split a frame across packets).
**Reliability:** UDP is unordered and lossy. This module does not add ACKs or
retransmits. Use TCP (``zkac.tcp``) if you need a reliable stream without
building your own reliability layer.
**Size:** Large BBS+ auth packets can exceed typical path MTUs (~1500 B). If
``send`` raises ``OSError`` (e.g. ``EMSGSIZE``), use TCP or reduce proof size /
raise MTU on controlled networks.
"""
from __future__ import annotations
import socket
import struct
from typing import TYPE_CHECKING, Optional, Tuple
from zkac import MAX_BBS_AUTH_PROOF_BYTES
if TYPE_CHECKING:
from zkac import Credential, Node, PublicKey, RoleRegistry, Session
# Same logical cap as tcp framing; note UDP + large proofs may hit EMSGSIZE on send.
MAX_UDP_FRAME_BYTES: int = MAX_BBS_AUTH_PROOF_BYTES + 4096
# IPv4 max UDP payload (theoretical); recv buffer size hint.
MAX_UDP_DATAGRAM_BYTES: int = 65507
_HANDSHAKE_MSG_LEN = 32
def _build_framed_datagram(payload: bytes) -> bytes:
if len(payload) > MAX_UDP_FRAME_BYTES:
raise ValueError(
f"payload length {len(payload)} exceeds maximum ({MAX_UDP_FRAME_BYTES})"
)
return struct.pack("<I", len(payload)) + payload
def _parse_framed_datagram(data: bytes) -> bytes:
if len(data) < 4:
raise ValueError("datagram too short for length prefix")
(length,) = struct.unpack("<I", data[:4])
if length > MAX_UDP_FRAME_BYTES:
raise ValueError(f"frame length {length} exceeds maximum ({MAX_UDP_FRAME_BYTES})")
if len(data) != 4 + length:
raise ValueError(
f"datagram size mismatch: expected {4 + length} bytes, got {len(data)}"
)
return data[4:] if length else b""
def write_datagram(sock: socket.socket, payload: bytes, addr: Optional[tuple] = None) -> None:
"""
Send one framed datagram. If *addr* is ``None``, *sock* must be connected
(e.g. after :meth:`socket.socket.connect`).
"""
packet = _build_framed_datagram(payload)
if len(packet) > MAX_UDP_DATAGRAM_BYTES:
raise ValueError("framed datagram exceeds maximum UDP payload size")
if addr is not None:
sock.sendto(packet, addr)
else:
sock.send(packet)
def read_datagram(sock: socket.socket, bufsize: int = MAX_UDP_DATAGRAM_BYTES) -> bytes:
"""
Receive one framed datagram on a **connected** UDP socket (``recv``).
"""
data = sock.recv(bufsize)
if not data:
raise ConnectionError("received empty datagram (peer closed?)")
return _parse_framed_datagram(data)
def read_datagram_from(
sock: socket.socket, bufsize: int = MAX_UDP_DATAGRAM_BYTES
) -> Tuple[bytes, tuple]:
"""
Receive one framed datagram on an **unconnected** UDP socket (``recvfrom``).
Returns ``(payload, addr)``.
"""
data, addr = sock.recvfrom(bufsize)
if not data:
raise ConnectionError("received empty datagram")
return _parse_framed_datagram(data), addr
def client_handshake(
sock: socket.socket,
server_addr: tuple,
node: Node,
expected_server_pk: PublicKey,
credential: Credential,
) -> Session:
"""
Run the ZKAC client side over UDP. Connects *sock* to *server_addr* and
exchanges three framed datagrams (init server bundle auth).
*server_addr* is ``(host, port)`` for :meth:`socket.socket.connect`.
"""
sock.connect(server_addr)
pending, init_msg = node.connect()
if len(init_msg) != _HANDSHAKE_MSG_LEN:
raise ValueError("internal error: init_msg must be 32 bytes")
write_datagram(sock, init_msg)
bundle = read_datagram(sock)
if len(bundle) < _HANDSHAKE_MSG_LEN:
raise ValueError("server handshake bundle too short")
response_msg = bundle[:_HANDSHAKE_MSG_LEN]
identity_proof = bundle[_HANDSHAKE_MSG_LEN:]
session, auth_packet = node.complete_connect(
pending, response_msg, identity_proof, expected_server_pk, credential
)
write_datagram(sock, auth_packet)
return session
def server_handshake(
sock: socket.socket,
node: Node,
registry: RoleRegistry,
) -> Tuple[Session, bytes, tuple]:
"""
Run the ZKAC server side over UDP. Waits for the first datagram, then
:meth:`socket.socket.connect` to that peer so the rest of the handshake
uses the same path.
Returns ``(session, role_id, client_addr)``.
"""
init_msg, client_addr = read_datagram_from(sock)
if len(init_msg) != _HANDSHAKE_MSG_LEN:
raise ValueError("init_msg must be 32 bytes")
sock.connect(client_addr)
session, response_msg = node.accept(init_msg)
if len(response_msg) != _HANDSHAKE_MSG_LEN:
raise ValueError("internal error: response_msg must be 32 bytes")
identity_proof = node.prove_identity(session)
bundle = response_msg + identity_proof
write_datagram(sock, bundle)
auth_packet = read_datagram(sock)
role_id = node.verify_auth(session, auth_packet, registry)
return session, role_id, client_addr
class FramedSession:
"""
One ZKAC ciphertext per UDP datagram; *sock* must be connected.
"""
def __init__(self, sock: socket.socket, session: Session) -> None:
self._sock = sock
self._session = session
@property
def session(self) -> Session:
return self._session
def send(self, plaintext: bytes) -> None:
packet = self._session.encrypt(plaintext)
write_datagram(self._sock, packet)
def recv(self) -> bytes:
return self._session.decrypt(read_datagram(self._sock))

View File

@ -50,6 +50,22 @@ fn challenge(r: &CompressedRistretto, pk: &CompressedRistretto, msg: &[u8]) -> S
impl Keypair {
pub fn generate<R: CryptoRng + RngCore>(rng: &mut R) -> Self {
let scalar = Scalar::random(rng);
Self::from_scalar(scalar)
}
/// 32-byte canonical encoding of the secret scalar (for persistence).
pub fn secret_key_bytes(&self) -> [u8; 32] {
self.secret.scalar.to_bytes()
}
/// Restore from [`secret_key_bytes`](Self::secret_key_bytes).
pub fn from_secret_key_bytes(bytes: &[u8; 32]) -> Result<Self> {
let scalar = Option::from(Scalar::from_canonical_bytes(*bytes))
.ok_or_else(|| Error::DeserializationError("invalid secret key scalar"))?;
Ok(Self::from_scalar(scalar))
}
fn from_scalar(scalar: Scalar) -> Self {
let point = &scalar * RISTRETTO_BASEPOINT_TABLE;
Keypair {
secret: SecretKey { scalar },
@ -186,4 +202,13 @@ mod tests {
let s2 = kp.sign(b"same msg");
assert_eq!(s1.to_bytes(), s2.to_bytes());
}
#[test]
fn keypair_secret_roundtrip() {
let kp = Keypair::generate(&mut OsRng);
let bytes = kp.secret_key_bytes();
let kp2 = Keypair::from_secret_key_bytes(&bytes).unwrap();
assert_eq!(kp.public().to_bytes(), kp2.public().to_bytes());
assert_eq!(kp.sign(b"m").to_bytes(), kp2.sign(b"m").to_bytes());
}
}

View File

@ -46,6 +46,25 @@ impl PyKeypair {
let sig = kp.sign(msg);
Ok(PyBytes::new(py, &sig.to_bytes()))
}
fn secret_key_bytes<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
let kp = self.inner.as_ref().ok_or_else(|| {
PyValueError::new_err("keypair was consumed by Node")
})?;
Ok(PyBytes::new(py, &kp.secret_key_bytes()))
}
#[staticmethod]
fn from_secret_key(bytes: &[u8]) -> PyResult<Self> {
if bytes.len() != 32 {
return Err(PyValueError::new_err("secret key must be 32 bytes"));
}
let arr: [u8; 32] = bytes.try_into().unwrap();
let inner = credential::Keypair::from_secret_key_bytes(&arr).map_err(to_py_err)?;
Ok(PyKeypair {
inner: Some(inner),
})
}
}
// ── Ristretto PublicKey ──────────────────────────────────────────────

View File

@ -22,6 +22,13 @@ class TestKeypairAndPublicKey:
assert r.startswith("PublicKey(")
assert len(r) == len("PublicKey()") + 64
def test_secret_key_roundtrip(self):
kp = zkac.Keypair()
sk = kp.secret_key_bytes()
assert len(sk) == 32
kp2 = zkac.Keypair.from_secret_key(sk)
assert kp.public_key().to_bytes() == kp2.public_key().to_bytes()
def test_different_keypairs_different_pubkeys(self):
pk1 = zkac.Keypair().public_key()
pk2 = zkac.Keypair().public_key()

113
tests/test_zkac_tcp.py Normal file
View File

@ -0,0 +1,113 @@
import socket
import threading
import pytest
import zkac
from zkac.tcp import (
FramedSession,
MAX_TCP_FRAME_BYTES,
client_handshake,
read_frame,
server_handshake,
write_frame,
)
def _make_credential():
issuer = zkac.BbsIssuer()
pk = issuer.public_key()
rid = zkac.role_id("admin")
req = zkac.prepare_blind_request()
sig = issuer.issue_blind(req.commitment_with_proof(), rid, 1)
cred = zkac.Credential.finalize(
sig, req.member_secret(), req.prover_blind(), rid, 1, pk
)
return issuer, pk, rid, cred
class TestFraming:
def test_read_write_roundtrip(self):
a, b = socket.socketpair()
try:
payload = b"hello" * 400
write_frame(a, payload)
assert read_frame(b) == payload
finally:
a.close()
b.close()
def test_oversized_length_rejected(self):
a, b = socket.socketpair()
try:
a.sendall((MAX_TCP_FRAME_BYTES + 1).to_bytes(4, "little"))
with pytest.raises(ValueError, match="exceeds maximum"):
read_frame(b)
finally:
a.close()
b.close()
class TestHandshakeOverTcp:
def test_full_handshake_matching_keys(self):
_, pk, rid, cred = _make_credential()
reg = zkac.RoleRegistry()
reg.register_role(rid, pk, 1)
client_sock, server_sock = socket.socketpair()
server_kp = zkac.Keypair()
server_pk = server_kp.public_key()
def run_server():
try:
srv = zkac.Node(server_kp)
s, verified = server_handshake(server_sock, srv, reg)
assert verified == rid
pkt = s.encrypt(b"admin command")
write_frame(server_sock, pkt)
finally:
server_sock.close()
t = threading.Thread(target=run_server)
t.start()
try:
cli = zkac.Node(zkac.Keypair())
session = client_handshake(client_sock, cli, server_pk, cred)
wire = read_frame(client_sock)
assert session.decrypt(wire) == b"admin command"
finally:
client_sock.close()
t.join(timeout=5)
assert not t.is_alive()
class TestFramedSession:
def test_framed_encrypt_roundtrip(self):
_, pk, rid, cred = _make_credential()
reg = zkac.RoleRegistry()
reg.register_role(rid, pk, 1)
client_sock, server_sock = socket.socketpair()
server_kp = zkac.Keypair()
server_pk = server_kp.public_key()
def run_server():
try:
srv = zkac.Node(server_kp)
session, _ = server_handshake(server_sock, srv, reg)
framed = FramedSession(server_sock, session)
framed.send(b"reply")
finally:
server_sock.close()
t = threading.Thread(target=run_server)
t.start()
try:
cli = zkac.Node(zkac.Keypair())
session = client_handshake(client_sock, cli, server_pk, cred)
framed = FramedSession(client_sock, session)
assert framed.recv() == b"reply"
finally:
client_sock.close()
t.join(timeout=5)
assert not t.is_alive()

83
tests/test_zkac_udp.py Normal file
View File

@ -0,0 +1,83 @@
import socket
import threading
import zkac
from zkac.udp import (
FramedSession,
client_handshake,
read_datagram,
server_handshake,
write_datagram,
)
def _make_credential():
issuer = zkac.BbsIssuer()
pk = issuer.public_key()
rid = zkac.role_id("admin")
req = zkac.prepare_blind_request()
sig = issuer.issue_blind(req.commitment_with_proof(), rid, 1)
cred = zkac.Credential.finalize(
sig, req.member_secret(), req.prover_blind(), rid, 1, pk
)
return pk, rid, cred
class TestFraming:
def test_write_read_connected(self):
a, b = socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
payload = b"udp-framed"
write_datagram(a, payload)
assert read_datagram(b) == payload
finally:
a.close()
b.close()
class TestHandshakeOverUdp:
def test_full_handshake_localhost(self):
pk, rid, cred = _make_credential()
reg = zkac.RoleRegistry()
reg.register_role(rid, pk, 1)
server_kp = zkac.Keypair()
server_pk = server_kp.public_key()
client_kp = zkac.Keypair()
ready = threading.Event()
port_holder: list[int] = []
err: list[BaseException] = []
def run_server():
srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
srv.bind(("127.0.0.1", 0))
port_holder.append(srv.getsockname()[1])
ready.set()
node = zkac.Node(server_kp)
session, verified, _addr = server_handshake(srv, node, reg)
assert verified == rid
framed = FramedSession(srv, session)
framed.send(b"pong: " + framed.recv())
except BaseException as e:
err.append(e)
finally:
srv.close()
t = threading.Thread(target=run_server, daemon=True)
t.start()
ready.wait()
cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sess = client_handshake(
cli, ("127.0.0.1", port_holder[0]), zkac.Node(client_kp), server_pk, cred
)
cf = FramedSession(cli, sess)
cf.send(b"ping")
assert cf.recv() == b"pong: ping"
finally:
cli.close()
t.join(timeout=5.0)
assert not err, err