p2p grants

This commit is contained in:
everbarry 2026-05-06 00:08:46 +02:00
parent d12a912fa8
commit 3b75d3c6f0
22 changed files with 1981 additions and 1824 deletions

View File

@ -5,6 +5,7 @@ Install the `zkac` wheel from the repo root first (`maturin develop` or `pip ins
```bash ```bash
pip install -e ./cli pip install -e ./cli
zkac-node --help zkac-node --help
zkac-node-i2p-server --help
``` ```
## Quick start ## Quick start
@ -14,28 +15,25 @@ zkac-node --help
zkac-node user create alice zkac-node user create alice
zkac-node user create bob zkac-node user create bob
# Bob shares his issuance public key with Alice out-of-band: # Bob shares one contact string with Alice out-of-band:
# zkac-node user show bob → copy issuance pk # zkac-node user show bob
# 2. Alice runs a server; pin its public key for clients # 2. Alice runs a server; pin its public key for clients
zkac-node serve alice --port 9800 & zkac-node serve alice --port 9800 &
zkac-node server pin alice localhost:9800 --key <SERVER_PK_HEX> zkac-node server pin alice localhost:9800 --key <SERVER_PK_HEX>
zkac-node server pin bob localhost:9800 --key <SERVER_PK_HEX> zkac-node server pin bob localhost:9800 --key <SERVER_PK_HEX>
# 3. Alice creates a registry and grants Bob a role (needs Bob's issuance pk hex) # 3. Alice creates a registry and grants Bob a role directly (needs Bob's contact string)
zkac-node registry create alice localhost:9800 --roles analyst,operator zkac-node registry create alice localhost:9800 --roles analyst,operator
zkac-node grant alice --server localhost:9800 \ zkac-node grant alice --server localhost:9800 \
--registry <REGISTRY_ID> --role analyst --to $BOB_PK_HEX --registry <REGISTRY_ID> --role analyst --to-contact "$BOB_CONTACT" \
# (prints pool_index for Bob's collect) --peer 127.0.0.1:9810
# 4. Bob lists local creds + pending grants (single-server PIR, no second replica needed) # 4. Bob listens for direct p2p delivery
zkac-node p2p-listen bob --host 127.0.0.1 --port 9810
# 5. Bob lists local creds
zkac-node credentials list bob zkac-node credentials list bob
zkac-node credentials list bob --server localhost:9800
# 5. Bob collects (auto-discovers via detection tags; --pool-index is optional)
zkac-node collect bob localhost:9800:<REGISTRY_ID>:analyst
# or with explicit index:
zkac-node collect bob localhost:9800:<REGISTRY_ID>:analyst --pool-index <POOL_INDEX>
# 6. Bob authenticates # 6. Bob authenticates
zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server localhost:9800 zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server localhost:9800
@ -49,23 +47,73 @@ zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server localhost:98
| `user list` | List all local user ids | | `user list` | List all local user ids |
| `user show <id>` | Show issuance pk + owned registries + credentials | | `user show <id>` | Show issuance pk + owned registries + credentials |
| `serve <id> [--data-dir D]` | Run server; default data dir is `~/.zkac/<id>/server/` | | `serve <id> [--data-dir D]` | Run server; default data dir is `~/.zkac/<id>/server/` |
| `zkac-node-i2p-server <id> [--host H --port P]` | Same as `serve`, for I2P server-tunnel exposure (see below) |
| `server pin <id> <host:port> --key <hex>` | Pin server public key for that user | | `server pin <id> <host:port> --key <hex>` | Pin server public key for that user |
| `registry create <id> <server> --roles …` | Create registry on server | | `registry create <id> <server> --roles …` | Create registry on server |
| `registry update <id> <server> --registry R --add-roles …` | Add roles | | `registry update <id> <server> --registry R --add-roles …` | Add roles |
| `registry get <id> <server> --registry R` | Fetch registry state | | `registry get <id> <server> --registry R` | Fetch registry state |
| `registry list <id>` | List registries this user owns locally | | `registry list <id>` | List registries this user owns locally |
| `grant <id> --server S --registry R --role X --to <pk>` | Admin grant (encrypted to recipient pk) | | `grant <id> --server S --registry R --role X --to-contact <blob> --peer host:port` | Admin direct p2p grant (single-share recipient contact) |
| `credentials list <id> [--server S …]` | Pending grants: tags + SimplePIR (full encrypted row) + decrypt | | `p2p-listen <id> [--host H --port P]` | Receive one direct p2p grant and store credential |
| `collect <id> <spec> [--pool-index N]` | Same retrieval path, then local credential finalize | | `credentials list <id>` | List local credentials |
| `auth <id> --registry R --role X [--server S]` | Authenticated session | | `auth <id> --registry R --role X [--server S]` | Authenticated session |
## Protocol & threat model ## Protocol & threat model
See [docs/SECURITY.md](../docs/SECURITY.md) in the repo root for the full model, including PIR and detection tags. See [docs/SECURITY.md](../docs/SECURITY.md) in the repo root for the direct p2p grant model.
**Custom clients:** Encrypted management JSON supports `pool_tags`, `pir_hints`, and `pir_query`. Mailbox retrieval is single-hop: decode a full encrypted row from PIR and decrypt locally. ## Admin debug web UI (demo)
**Operational scaling:** The server grant pool is append-only, so pool length grows with every grant. Large pools increase discovery traffic, PIR query size, and server work per retrieval (all linear in pool length). Treat unbounded growth as a potential DoS and capacity risk; mitigations are listed under *Known limitations* and *Future work* in `docs/SECURITY.md`. Transport, BBS+ auth, registry state updates, and issuance queues have separate scaling profiles (CPU dominated by BBS+, state size linear in role count, queue memory); see **Scaling and complexity (transport, credentials, registries)** in the same doc. For a **fully transparent** HTTP dashboard (registry rows, live TCP sessions, data-dir
listing), run **`demo/zkac_admin_serve.py`** from the repo with **`uv sync --extra demo`**.
See [demo/README.md](../demo/README.md).
## Running over I2P
### Inbound: `zkac-node-i2p-server`
Run the node on a loopback TCP port and forward it from an **I2P server tunnel**
(I2P or i2pd). Clients use your published **`*.b32.i2p:port`** as the server
string in all `zkac-node` commands.
```bash
zkac-node-i2p-server alice --host 127.0.0.1 --port 9800
```
They should **pin** that same `host:port` string (the `.b32.i2p` form), not `localhost`.
### Outbound clients
Outbound client connections can be proxied through an I2P SOCKS tunnel.
1. Start I2P/i2pd SOCKS proxy (commonly `127.0.0.1:4447`).
2. Export:
```bash
export ZKAC_SOCKS5_PROXY=127.0.0.1:4447
```
3. Use `.b32.i2p:port` endpoints with normal commands:
```bash
zkac-node registry get alice exampledestination.b32.i2p:9800 --registry <REGISTRY_ID>
zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server exampledestination.b32.i2p:9800
```
For inbound direct grants, run `p2p-listen` on a local port and expose it via an I2P inbound tunnel. Peers dial your published I2P destination/port; your tunnel forwards to local `host:port`.
Connectivity sanity check:
```bash
zkac-node net check exampledestination.b32.i2p:9800
```
With proxy:
```bash
export ZKAC_SOCKS5_PROXY=127.0.0.1:4447
zkac-node net check exampledestination.b32.i2p:9800
```
## Storage layout ## Storage layout
@ -73,10 +121,9 @@ Per user `~/.zkac/<userid>/`:
``` ```
identity.json issuance keypair identity.json issuance keypair
p2p transport keypair
admin/<registry_id>.json BBS+ admin material for owned registries admin/<registry_id>.json BBS+ admin material for owned registries
credentials/<rid>_<role>.json received credentials credentials/<rid>_<role>.json received credentials
servers/<host_port>.json pinned server public keys servers/<host_port>.json pinned server public keys
pir_cache/<server>.json PIR hint metadata (pool_version, n_records) server/ (only if you run `serve <userid>`) server_key.json, registries/
pir_cache/<server>.bin PIR hint data (cached, keyed by pool_version)
server/ (only if you run `serve <userid>`) server_key.json, registries/, mailbox/grants_pool.json
``` ```

View File

@ -6,6 +6,7 @@ dependencies = ["zkac"]
[project.scripts] [project.scripts]
zkac-node = "zkac_cli.main:main" zkac-node = "zkac_cli.main:main"
zkac-node-i2p-server = "zkac_cli.i2p_serve:main"
[build-system] [build-system]
requires = ["setuptools>=68"] requires = ["setuptools>=68"]

View File

@ -1,15 +1,15 @@
"""Client-side operations over a unified encrypted channel (per local user id).""" """Client-side operations for registry management and direct P2P grants."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import json import json
import os
import socket import socket
from pathlib import Path import struct
import zkac import zkac
from zkac.tcp import FramedSession, client_handshake_anon from zkac.tcp import FramedSession, client_handshake_anon, server_handshake_anon
from . import store from . import store
@ -41,6 +41,98 @@ def _parse_server(server: str) -> tuple[str, int]:
return (host or "127.0.0.1"), port return (host or "127.0.0.1"), port
def _parse_host_port(value: str, name: str) -> tuple[str, int]:
host, sep, port_s = value.rpartition(":")
if not sep:
raise ValueError(f"{name} must be host:port")
try:
port = int(port_s, 10)
except ValueError as e:
raise ValueError(f"{name} port must be numeric") from e
if not 1 <= port <= 65535:
raise ValueError(f"{name} port out of range: {port}")
return (host or "127.0.0.1"), port
def _proxy_target() -> tuple[str, int] | None:
raw = os.environ.get("ZKAC_SOCKS5_PROXY", "").strip()
if not raw:
return None
return _parse_host_port(raw, "ZKAC_SOCKS5_PROXY")
def _recv_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 during SOCKS5 handshake")
buf.extend(chunk)
return bytes(buf)
def _socks5_connect(sock: socket.socket, host: str, port: int):
sock.sendall(b"\x05\x01\x00")
hello = _recv_exact(sock, 2)
if hello != b"\x05\x00":
raise RuntimeError("SOCKS5 proxy requires unsupported authentication")
host_b = host.encode("idna")
if len(host_b) > 255:
raise ValueError("destination host too long for SOCKS5")
req = b"\x05\x01\x00\x03" + bytes([len(host_b)]) + host_b + struct.pack(">H", port)
sock.sendall(req)
head = _recv_exact(sock, 4)
if head[0] != 0x05:
raise RuntimeError("invalid SOCKS5 proxy reply")
if head[1] != 0x00:
raise RuntimeError(f"SOCKS5 connect failed (code=0x{head[1]:02x})")
atyp = head[3]
if atyp == 0x01:
_ = _recv_exact(sock, 4 + 2)
elif atyp == 0x03:
ln = _recv_exact(sock, 1)[0]
_ = _recv_exact(sock, ln + 2)
elif atyp == 0x04:
_ = _recv_exact(sock, 16 + 2)
else:
raise RuntimeError("invalid SOCKS5 address type in reply")
def _connect(host: str, port: int) -> socket.socket:
proxy = _proxy_target()
if proxy is None:
return socket.create_connection((host, port))
sock = socket.create_connection(proxy)
try:
_socks5_connect(sock, host, port)
return sock
except Exception:
sock.close()
raise
def net_check(target: str, timeout_s: float = 8.0) -> dict:
"""Connectivity diagnostic for direct/server endpoints, with optional SOCKS5."""
host, port = _parse_server(target)
proxy = _proxy_target()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout_s)
try:
if proxy is None:
sock.connect((host, port))
via = "direct"
else:
sock.connect(proxy)
_socks5_connect(sock, host, port)
via = f"socks5:{proxy[0]}:{proxy[1]}"
return {"ok": True, "target": target, "via": via}
except Exception as exc:
via = "direct" if proxy is None else f"socks5:{proxy[0]}:{proxy[1]}"
return {"ok": False, "target": target, "via": via, "error": str(exc)}
finally:
sock.close()
def parse_spec(spec: str) -> tuple[str, str, str]: def parse_spec(spec: str) -> tuple[str, str, str]:
"""Parse 'host:port:registry_id:role' into (server, registry_id, role).""" """Parse 'host:port:registry_id:role' into (server, registry_id, role)."""
parts = spec.rsplit(":", 2) parts = spec.rsplit(":", 2)
@ -61,11 +153,17 @@ def _resolve_server_pk(userid: str, server: str) -> zkac.PublicKey:
return zkac.PublicKey.from_bytes(_unb64(pin["server_public_key_b64"])) return zkac.PublicKey.from_bytes(_unb64(pin["server_public_key_b64"]))
def _local_node(userid: str) -> zkac.Node:
ident = store.load_identity(userid)
keypair = zkac.Keypair.from_secret_key(ident["transport_sk"])
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]:
host, port = _parse_server(server) host, port = _parse_server(server)
sock = socket.create_connection((host, port)) sock = _connect(host, port)
server_pk = _resolve_server_pk(userid, server) server_pk = _resolve_server_pk(userid, server)
node = zkac.Node(zkac.Keypair()) node = _local_node(userid)
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())
@ -91,141 +189,6 @@ def _ok(resp: dict) -> dict:
return resp return resp
# ── PIR hint cache ───────────────────────────────────────────────────
def _cache_dir(userid: str) -> Path:
d = store.user_dir(userid) / "pir_cache"
d.mkdir(parents=True, exist_ok=True)
return d
def _server_cache_key(server: str) -> str:
return server.replace(":", "_")
def _load_cached_hints(userid: str, server: str, pool_version: str) -> bytes | None:
meta_path = _cache_dir(userid) / f"{_server_cache_key(server)}.json"
bin_path = _cache_dir(userid) / f"{_server_cache_key(server)}.bin"
if not meta_path.exists() or not bin_path.exists():
return None
meta = json.loads(meta_path.read_text())
if meta.get("pool_version") != pool_version:
return None
return bin_path.read_bytes()
def _save_cached_hints(userid: str, server: str, pool_version: str,
n_records: int, record_bytes: int, hints_bytes: bytes):
key = _server_cache_key(server)
meta = {"pool_version": pool_version, "n_records": n_records, "record_bytes": record_bytes}
(_cache_dir(userid) / f"{key}.json").write_text(json.dumps(meta))
(_cache_dir(userid) / f"{key}.bin").write_bytes(hints_bytes)
def _fetch_hints_bytes(framed: FramedSession) -> tuple[bytes, str]:
"""Download PIR hint blob; uses chunked wire protocol when hints exceed one frame."""
resp = _ok(_mgmt_cmd(framed, {"cmd": "pir_hints"}))
if "hints_b64" in resp:
return _unb64(resp["hints_b64"]), resp["pool_version"]
total = int(resp["hints_total"])
pv = resp["pool_version"]
buf = bytearray()
off = 0
while off < total:
resp = _ok(
_mgmt_cmd(
framed,
{"cmd": "pir_hints", "offset": off, "pool_version": pv},
)
)
if resp.get("pool_version") != pv:
raise RuntimeError("PIR hints pool_version changed during download")
piece = _unb64(resp["slice_b64"])
if not piece and off < total:
raise RuntimeError("PIR hints short read")
buf.extend(piece)
off += len(piece)
if len(buf) != total:
raise RuntimeError(
f"PIR hints size mismatch (got {len(buf)}, expected {total})"
)
return bytes(buf), pv
def _pir_client(userid: str, framed: FramedSession, server: str) -> tuple[zkac.PirClient, str]:
"""Fetch pool_info, load or refresh hints, return (PirClient, pool_version)."""
info = _ok(_mgmt_cmd(framed, {"cmd": "pool_info"}))
n = info["n"]
rb = info["record_bytes"]
pv = info["pool_version"]
cached = _load_cached_hints(userid, server, pv)
if cached is not None:
return zkac.PirClient(cached, n, rb), pv
hints_bytes, pv = _fetch_hints_bytes(framed)
_save_cached_hints(userid, server, pv, n, rb, hints_bytes)
return zkac.PirClient(hints_bytes, n, rb), pv
def _fetch_row(
userid: str, framed: FramedSession, server: str,
pir_client: zkac.PirClient, pool_version: str, pool_index: int,
) -> dict:
q, state = pir_client.query(pool_index)
q_b64 = _b64(q)
resp = _ok(
_mgmt_cmd(
framed,
{"cmd": "pir_query", "query_b64": q_b64, "pool_version": pool_version},
)
)
if "answer_b64" in resp:
ans_bytes = _unb64(resp["answer_b64"])
else:
total = int(resp["answer_total"])
buf = bytearray()
off = 0
while off < total:
chunk = _ok(
_mgmt_cmd(
framed,
{
"cmd": "pir_query",
"query_b64": q_b64,
"pool_version": pool_version,
"offset": off,
},
)
)
piece = _unb64(chunk["slice_b64"])
if not piece and off < total:
raise RuntimeError("PIR answer short read")
buf.extend(piece)
off += len(piece)
if len(buf) != total:
raise RuntimeError(
f"PIR answer size mismatch (got {len(buf)}, expected {total})"
)
ans_bytes = bytes(buf)
raw = bytes(pir_client.decode(ans_bytes, state))
row = json.loads(raw.rstrip(b"\x00").decode("utf-8"))
if row.get("v") != 2:
raise RuntimeError("unsupported PIR row version")
ct_b64 = row.get("ciphertext_b64", "")
expect_digest = row.get("ciphertext_sha256", "")
actual = hashlib.sha256(_unb64(ct_b64)).hexdigest() if ct_b64 else ""
if actual != expect_digest:
raise RuntimeError("PIR row ciphertext digest mismatch")
return {
"eph_pk_b64": row.get("eph_pk_b64", ""),
"ciphertext_b64": ct_b64,
"to_tag_b64": row.get("to_tag_b64", ""),
"claimed": row.get("claimed", False),
}
# ── Public operations ──────────────────────────────────────────────── # ── Public operations ────────────────────────────────────────────────
def create_registry(userid: str, server: str, role_names: list[str]) -> str: def create_registry(userid: str, server: str, role_names: list[str]) -> str:
@ -307,8 +270,15 @@ def list_own_registries(userid: str) -> list[dict]:
return result return result
def grant(userid: str, server: str, registry_id_hex: str, role_name: str, def grant_p2p(
recipient_pk_hex: str) -> tuple[str, int]: userid: str,
server: str,
registry_id_hex: str,
role_name: str,
recipient_pk_hex: str,
peer: str,
peer_transport_pk_hex: str,
) -> dict:
admin_data = store.load_admin(userid, registry_id_hex) admin_data = store.load_admin(userid, registry_id_hex)
roles = admin_data.get("roles", []) roles = admin_data.get("roles", [])
if role_name not in roles: if role_name not in roles:
@ -334,172 +304,79 @@ def grant(userid: str, server: str, registry_id_hex: str, role_name: str,
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)
to_tag = zkac.grant_detection_tag(eph_kp.secret_bytes(), recipient_pk) reg = _mgmt_single(userid, server, {"cmd": "get_registry", "registry_id": registry_id_hex})
host, port = _parse_server(peer)
sock, framed = _mgmt_connect(userid, server) peer_transport_pk = zkac.PublicKey.from_bytes(bytes.fromhex(peer_transport_pk_hex))
sock = _connect(host, port)
try: try:
transcript_hash = bytes(framed.session.transcript_hash()) session = client_handshake_anon(sock, _local_node(userid), peer_transport_pk)
framed = FramedSession(sock, session)
transcript_hash = bytes(session.transcript_hash())
admin_proof = admin_cred.present(transcript_hash) admin_proof = admin_cred.present(transcript_hash)
resp = _ok(_mgmt_cmd(framed, { resp = _ok(json.loads(framed.recv()))
"cmd": "post_grant", if resp.get("op") != "ready_for_grant":
raise RuntimeError("peer did not accept grant session")
framed.send(json.dumps({
"op": "p2p_grant",
"registry_id": registry_id_hex, "registry_id": registry_id_hex,
"registry_state_bytes_b64": reg["state_bytes_b64"],
"registry_state_cert_b64": reg["state_cert_b64"],
"role_name": role_name,
"eph_pk_b64": _b64(eph_kp.public_key_bytes()), "eph_pk_b64": _b64(eph_kp.public_key_bytes()),
"ciphertext_b64": _b64(ciphertext), "ciphertext_b64": _b64(ciphertext),
"to_tag_b64": _b64(to_tag),
"admin_proof_b64": _b64(admin_proof), "admin_proof_b64": _b64(admin_proof),
})) }).encode())
ack = _ok(json.loads(framed.recv()))
finally: finally:
sock.close() sock.close()
return {"status": ack.get("status", "ok"), "peer": peer}
return "inline-pir-row", resp.get("pool_index", -1)
def _match_tags(userid: str, tags: list[dict]) -> list[int]: def receive_p2p(userid: str, host: str, port: int) -> dict:
"""Return pool indices whose detection tag matches our issuance key.""" ident = store.load_identity(userid)
identity = store.load_identity(userid) receiver_kp = zkac.IssuanceKeypair.from_secret(ident["issuance_sk"])
receiver_sk = identity["issuance_sk"] listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
matches = [] listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
for idx, entry in enumerate(tags): listener.bind((host, port))
eph_pk_b64 = entry.get("eph_pk_b64", "") listener.listen(1)
to_tag_b64 = entry.get("to_tag_b64", "") conn, _addr = listener.accept()
if not eph_pk_b64 or not to_tag_b64:
continue
eph_pk = _unb64(eph_pk_b64)
expected = zkac.grant_detection_tag(receiver_sk, eph_pk)
if _unb64(to_tag_b64) == bytes(expected):
matches.append(idx)
return matches
def list_pending(userid: str, server: str) -> list[dict]:
"""Discover pending grants via detection tags, then PIR-fetch full rows."""
identity = store.load_identity(userid)
receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"])
info = _mgmt_single(userid, server, {"cmd": "server_info"})
store.pin_server(userid, server, info["server_public_key_b64"])
sock, framed = _mgmt_connect(userid, server)
try: try:
tags_resp = _ok(_mgmt_cmd(framed, {"cmd": "pool_tags"})) session = server_handshake_anon(conn, _local_node(userid))
tags = tags_resp["tags"] framed = FramedSession(conn, session)
matches = _match_tags(userid, tags) framed.send(json.dumps({"ok": True, "op": "ready_for_grant"}).encode())
msg = _ok(json.loads(framed.recv()))
if not matches: if msg.get("op") != "p2p_grant":
return [] raise RuntimeError("unexpected p2p message")
registry_id_hex = msg["registry_id"]
pir_cl, pv = _pir_client(userid, framed, server) state_bytes = _unb64(msg["registry_state_bytes_b64"])
results = [] state_cert = _unb64(msg["registry_state_cert_b64"])
for idx in matches: mgr = zkac.RegistryManager()
try: mgr.restore(state_bytes, state_cert)
row = _fetch_row(userid, framed, server, pir_cl, pv, idx) if not mgr.verify_admin(
except Exception: bytes.fromhex(registry_id_hex),
continue _unb64(msg["admin_proof_b64"]),
if row.get("claimed"): bytes(session.transcript_hash()),
continue ):
try: raise RuntimeError("sender admin proof failed")
eph_pk = _unb64(row["eph_pk_b64"]) payload = json.loads(
ct = _unb64(row["ciphertext_b64"]) receiver_kp.decrypt(_unb64(msg["eph_pk_b64"]), _unb64(msg["ciphertext_b64"]))
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct)) )
results.append({ cred_data = {
"pool_index": idx, "blind_sig_b64": payload["blind_sig_b64"],
"registry_id": plaintext.get("registry_id", "?"), "member_secret_b64": payload["member_secret_b64"],
"role_name": plaintext.get("role_name", "?"), "prover_blind_b64": payload["prover_blind_b64"],
}) "role_name": payload["role_name"],
except Exception: "epoch": payload["epoch"],
results.append({ "issuer_pk_b64": payload["issuer_pk_b64"],
"pool_index": idx, }
"registry_id": "?", cred = store.reconstruct_credential(cred_data)
"role_name": "(undecryptable)", cred.present(b"self-test")
}) store.save_credential(userid, payload["registry_id"], payload["role_name"], cred_data)
return results framed.send(json.dumps({"ok": True, "status": "stored"}).encode())
return {"registry_id": payload["registry_id"], "role": payload["role_name"]}
finally: finally:
sock.close() conn.close()
listener.close()
def collect(
userid: str,
spec: str,
*,
pool_index: int | None = None,
) -> dict:
server, registry_id_hex, role_name = parse_spec(spec)
identity = store.load_identity(userid)
receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"])
info = _mgmt_single(userid, server, {"cmd": "server_info"})
store.pin_server(userid, server, info["server_public_key_b64"])
sock, framed = _mgmt_connect(userid, server)
try:
if pool_index is None:
tags_resp = _ok(_mgmt_cmd(framed, {"cmd": "pool_tags"}))
tags = tags_resp["tags"]
matches = _match_tags(userid, tags)
if not matches:
raise RuntimeError("no matching grants found in pool")
pir_cl, pv = _pir_client(userid, framed, server)
found = None
for idx in matches:
try:
row = _fetch_row(userid, framed, server, pir_cl, pv, idx)
except Exception:
continue
if row.get("claimed"):
continue
try:
eph_pk = _unb64(row["eph_pk_b64"])
ct = _unb64(row["ciphertext_b64"])
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct))
except Exception:
continue
if (plaintext.get("registry_id") == registry_id_hex and
plaintext.get("role_name") == role_name):
found = (idx, row, plaintext)
break
if found is None:
raise RuntimeError(
f"no unclaimed grant for {registry_id_hex}:{role_name} in pool"
)
pool_index, target_row, target_payload = found
else:
pir_cl, pv = _pir_client(userid, framed, server)
target_row = _fetch_row(userid, framed, server, pir_cl, pv, pool_index)
if target_row.get("claimed"):
raise RuntimeError("grant row is already claimed")
try:
eph_pk = _unb64(target_row["eph_pk_b64"])
ct = _unb64(target_row["ciphertext_b64"])
target_payload = json.loads(receiver_kp.decrypt(eph_pk, ct))
except Exception as exc:
raise RuntimeError("PIR row did not decrypt for this user") from exc
if (target_payload.get("registry_id") != registry_id_hex or
target_payload.get("role_name") != role_name):
raise RuntimeError(
"PIR row does not match this collect spec"
)
finally:
sock.close()
_ = _mgmt_single(userid, server, {
"cmd": "get_registry", "registry_id": registry_id_hex,
})
cred_data = {
"blind_sig_b64": target_payload["blind_sig_b64"],
"member_secret_b64": target_payload["member_secret_b64"],
"prover_blind_b64": target_payload["prover_blind_b64"],
"role_name": role_name,
"epoch": target_payload["epoch"],
"issuer_pk_b64": target_payload["issuer_pk_b64"],
}
cred = store.reconstruct_credential(cred_data)
cred.present(b"self-test")
store.save_credential(userid, registry_id_hex, role_name, cred_data)
return {"registry_id": registry_id_hex, "role": role_name, "server": server}
def authenticate(userid: str, registry_id_hex: str, role_name: str, def authenticate(userid: str, registry_id_hex: str, role_name: str,
@ -523,7 +400,7 @@ def authenticate(userid: str, registry_id_hex: str, role_name: str,
node = zkac.Node(zkac.Keypair()) node = zkac.Node(zkac.Keypair())
host, port = _parse_server(server) host, port = _parse_server(server)
sock = socket.create_connection((host, port)) sock = _connect(host, port)
try: try:
session = client_handshake_anon(sock, node, server_pk) session = client_handshake_anon(sock, node, server_pk)
framed = FramedSession(sock, session) framed = FramedSession(sock, session)

78
cli/zkac_cli/i2p_serve.py Normal file
View File

@ -0,0 +1,78 @@
"""Entry point for ``zkac-node-i2p-server``: same TCP node as ``zkac-node serve``, for I2P tunnels."""
from __future__ import annotations
import argparse
import sys
from .paths import user_dir
from .server import serve
def main() -> None:
epilog = """
I2P setup (typical):
1. Run this command so the node listens on the loopback address shown below.
2. In I2P or i2pd, create a server tunnel (TCP) that forwards inbound I2P
connections to that host:port (e.g. 127.0.0.1:9800).
3. Note the published destination (b32.i2p) and the tunnels virtual port.
Clients use: <destination>.b32.i2p:<port> as the server argument to
zkac-node (registry, grant, auth, net check, etc.).
4. Clients must reach I2P (e.g. export ZKAC_SOCKS5_PROXY=127.0.0.1:4447).
Pins are stored per host:port string clients should pin the same address they
use to connect (the .b32.i2p:port form, not localhost).
"""
p = argparse.ArgumentParser(
prog="zkac-node-i2p-server",
description=(
"Run the ZKAC node (registry + anonymous handshake) on TCP for exposure "
"through an I2P server tunnel. This is zkac-node serve with I2P-oriented "
"defaults and setup hints."
),
epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument(
"userid",
help="user whose ~/.zkac/<userid>/server/ holds server state (unless --data-dir)",
)
p.add_argument("--data-dir", default=None, help="override server data directory")
p.add_argument(
"--host",
default="127.0.0.1",
help="bind address (default: 127.0.0.1 — forward here from your I2P tunnel)",
)
p.add_argument(
"--port",
type=int,
default=9800,
help="TCP port the node listens on (default: 9800)",
)
p.add_argument(
"--no-i2p-banner",
action="store_true",
help="suppress I2P tunnel reminder text",
)
args = p.parse_args()
data_dir = args.data_dir
if data_dir is None:
data_dir = str(user_dir(args.userid) / "server")
if not args.no_i2p_banner:
print(
"zkac-node-i2p-server: listening for plain TCP (expose this with an I2P server tunnel).\n"
f" bind: {args.host}:{args.port}\n"
f" data: {data_dir}\n"
"Clients on I2P: set ZKAC_SOCKS5_PROXY if needed, then use host:port like "
"mydest.b32.i2p:9800 with zkac-node commands.\n",
file=sys.stderr,
)
serve(data_dir, args.host, args.port)
if __name__ == "__main__":
main()

View File

@ -18,6 +18,7 @@ def _cmd_user_create(args):
ident = store.load_identity(args.userid) ident = store.load_identity(args.userid)
print(f"created user {args.userid!r}") print(f"created user {args.userid!r}")
print(f" issuance public key: {ident['issuance_pk'].hex()}") print(f" issuance public key: {ident['issuance_pk'].hex()}")
print(f" p2p transport public key: {ident['transport_pk'].hex()}")
print(f" (share this key out-of-band to receive credentials)") print(f" (share this key out-of-band to receive credentials)")
print(f" stored in {path}") print(f" stored in {path}")
@ -33,8 +34,12 @@ def _cmd_user_list(_args):
def _cmd_user_show(args): def _cmd_user_show(args):
ident = store.load_identity(args.userid) ident = store.load_identity(args.userid)
contact = store.export_contact_bundle(args.userid)
print(f"user: {args.userid}") print(f"user: {args.userid}")
print(f" issuance pk: {ident['issuance_pk'].hex()}") print(f" issuance pk: {ident['issuance_pk'].hex()}")
print(f" p2p transport pk: {ident['transport_pk'].hex()}")
print(" share contact:")
print(f" {contact}")
owned = [ owned = [
{"registry_id": r, **store.load_admin(args.userid, r)} {"registry_id": r, **store.load_admin(args.userid, r)}
@ -104,21 +109,22 @@ def _cmd_registry_list(args):
# ── grant ───────────────────────────────────────────────────────────── # ── grant ─────────────────────────────────────────────────────────────
def _cmd_grant(args): def _cmd_grant(args):
_row_mode, pool_index = client.grant( to = args.to
args.userid, args.server, args.registry, args.role, args.to, peer_key = args.peer_key
) if args.to_contact:
print(f"granted {args.role!r} to {args.to[:16]}") if args.to or args.peer_key:
print(" delivery: inline PIR row (no grant-id follow-up fetch)") raise RuntimeError("use either --to-contact OR (--to and --peer-key), not both")
print(f" pool index: {pool_index}") parsed = store.parse_contact_bundle(args.to_contact)
print(f" recipient can collect with:") to = parsed["issuance_pk_hex"]
print( peer_key = parsed["transport_pk_hex"]
f" zkac-node collect <userid> {args.server}:{args.registry}:{args.role}" elif not args.to or not args.peer_key:
) raise RuntimeError("grant requires --to and --peer-key (or --to-contact)")
print(f" or with explicit index:")
print( result = client.grant_p2p(
f" zkac-node collect <userid> {args.server}:{args.registry}:{args.role} " args.userid, args.server, args.registry, args.role, to, args.peer, peer_key,
f"--pool-index {pool_index}"
) )
print(f"granted {args.role!r} to {to[:16]}")
print(f" delivery: direct p2p ({result['peer']})")
# ── credentials / collect ─────────────────────────────────────────── # ── credentials / collect ───────────────────────────────────────────
@ -131,48 +137,15 @@ def _cmd_credentials_list(args):
for reg_hex, role in local: for reg_hex, role in local:
print(f" {reg_hex}:{role}") print(f" {reg_hex}:{role}")
servers = list(args.server or []) print("\npending grants: removed (use direct p2p listener)")
for s in store.known_servers(args.userid):
if s not in servers:
servers.append(s)
if not servers:
print("\n(no servers to query; pass --server host:port to check for pending)")
return
print("\npending grants:")
any_pending = False
for srv in servers:
try:
grants = client.list_pending(args.userid, srv)
except Exception as exc:
print(f" [{srv}] error: {exc}")
continue
for g in grants:
any_pending = True
rid = g.get("registry_id", "?")
role = g.get("role_name", "?")
pidx = g.get("pool_index")
idx_s = f" idx={pidx}" if pidx is not None else ""
if rid != "?" and role != "?" and store.has_credential(args.userid, rid, role):
note = " (already collected locally)"
else:
note = ""
print(f" {srv}:{rid}:{role}{idx_s}{note}")
if not any_pending:
print(" (none)")
def _cmd_collect(args): def _cmd_p2p_listen(args):
result = client.collect( print(f"listening for p2p grant on {args.host}:{args.port}")
args.userid, result = client.receive_p2p(args.userid, args.host, args.port)
args.spec, print("received credential")
pool_index=args.pool_index,
)
print("collected credential")
print(f" registry: {result['registry_id']}") print(f" registry: {result['registry_id']}")
print(f" role: {result['role']}") print(f" role: {result['role']}")
print(f" server: {result['server']}")
# ── auth ────────────────────────────────────────────────────────────── # ── auth ──────────────────────────────────────────────────────────────
@ -184,6 +157,20 @@ def _cmd_auth(args):
print(json.dumps(resp, indent=2)) print(json.dumps(resp, indent=2))
def _cmd_net_check(args):
resp = client.net_check(args.target, timeout_s=args.timeout)
if resp.get("ok"):
print("network check: ok")
print(f" target: {resp['target']}")
print(f" via: {resp['via']}")
return
print("network check: failed")
print(f" target: {resp['target']}")
print(f" via: {resp['via']}")
print(f" error: {resp.get('error', 'unknown')}")
sys.exit(2)
# ── argparse ───────────────────────────────────────────────────────── # ── argparse ─────────────────────────────────────────────────────────
def main(): def main():
@ -256,7 +243,10 @@ def main():
c.add_argument("--server", required=True, help="host:port") c.add_argument("--server", required=True, help="host:port")
c.add_argument("--registry", required=True) c.add_argument("--registry", required=True)
c.add_argument("--role", required=True) c.add_argument("--role", required=True)
c.add_argument("--to", required=True, help="recipient issuance public key (hex)") c.add_argument("--to", default=None, help="recipient issuance public key (hex)")
c.add_argument("--to-contact", default=None, help="recipient one-string contact bundle")
c.add_argument("--peer", required=True, help="recipient p2p host:port")
c.add_argument("--peer-key", default=None, help="recipient p2p transport public key (hex)")
c.set_defaults(func=_cmd_grant) c.set_defaults(func=_cmd_grant)
# credentials # credentials
@ -264,20 +254,14 @@ def main():
cred_sub = cred_p.add_subparsers(dest="action", required=True) cred_sub = cred_p.add_subparsers(dest="action", required=True)
c = cred_sub.add_parser("list", help="show local + pending credentials") c = cred_sub.add_parser("list", help="show local + pending credentials")
c.add_argument("userid") c.add_argument("userid")
c.add_argument("--server", action="append", help="server to query (host:port); repeatable")
c.set_defaults(func=_cmd_credentials_list) c.set_defaults(func=_cmd_credentials_list)
# collect # direct p2p receive
c = sub.add_parser("collect", help="fetch and finalize a pending credential") c = sub.add_parser("p2p-listen", help="listen for one direct p2p credential grant")
c.add_argument("userid") c.add_argument("userid")
c.add_argument("spec", help="host:port:registry_id:role") c.add_argument("--host", default="127.0.0.1")
c.add_argument( c.add_argument("--port", type=int, default=9810)
"--pool-index", c.set_defaults(func=_cmd_p2p_listen)
type=int,
default=None,
help="grant row index (optional; auto-discovered via detection tags if omitted)",
)
c.set_defaults(func=_cmd_collect)
# auth # auth
c = sub.add_parser("auth", help="authenticate with a credential") c = sub.add_parser("auth", help="authenticate with a credential")
@ -287,6 +271,14 @@ def main():
c.add_argument("--server", default=None, help="host:port (optional if known from registry)") c.add_argument("--server", default=None, help="host:port (optional if known from registry)")
c.set_defaults(func=_cmd_auth) c.set_defaults(func=_cmd_auth)
# network diagnostics
net_p = sub.add_parser("net", help="network diagnostics")
net_sub = net_p.add_subparsers(dest="action", required=True)
c = net_sub.add_parser("check", help="test TCP reachability (direct or SOCKS5 proxy)")
c.add_argument("target", help="host:port (supports .b32.i2p via SOCKS5)")
c.add_argument("--timeout", type=float, default=8.0, help="connect timeout in seconds")
c.set_defaults(func=_cmd_net_check)
args = p.parse_args() args = p.parse_args()
if not hasattr(args, "func"): if not hasattr(args, "func"):
p.print_help() p.print_help()

View File

@ -1,28 +1,8 @@
"""ZKAC server: all traffic over a single encrypted, server-authenticated channel. """ZKAC server for registry management and role authentication."""
Every connection performs an anonymous handshake (X25519 + Schnorr server
identity proof). The first encrypted frame selects the mode:
{"op": "mgmt"} management commands (create_registry, post_grant, ...)
{"op": "auth", "registry_id": hex, "bbs_auth_b64": ...} role authentication
The server stores only cryptographically verified opaque blobs:
<data_dir>/server_key.json Schnorr keypair
<data_dir>/registries/<rid>.state raw RegistryState bytes
<data_dir>/registries/<rid>.cert raw state cert bytes
<data_dir>/mailbox/grants_pool.json anonymous append-only grant pool
Recipients discover matching grants via a cheap detection-tag index
(``pool_tags``). PIR (``pir_query``) returns a full encrypted mailbox row,
so no follow-up ``grant_id`` fetch is required. This avoids leaking a stable
row identifier during retrieval.
Large PIR answers are streamed in slices (same pattern as ``pir_hints``).
"""
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import json import json
import socket import socket
import threading import threading
@ -30,11 +10,9 @@ import traceback
from pathlib import Path from pathlib import Path
import zkac import zkac
from zkac.tcp import MAX_TCP_FRAME_BYTES, FramedSession, server_handshake_anon from zkac.tcp import FramedSession, server_handshake_anon
# Serialized PIR hints can be huge (64 KiB records × LWE width). Each mgmt reply must from .server_debug import ServerDebugState
# stay under :data:`MAX_TCP_FRAME_BYTES` after encryption; base64 expands ~4/3.
_PIR_HINT_CHUNK = min(131_072, max(16_384, (MAX_TCP_FRAME_BYTES * 3) // 5))
def _b64(data: bytes) -> str: def _b64(data: bytes) -> str:
@ -45,42 +23,16 @@ def _unb64(s: str) -> bytes:
return base64.b64decode(s) return base64.b64decode(s)
def _pir_row_bytes(entry: dict) -> bytes:
"""Fixed-size PIR row: full encrypted mailbox row + ciphertext digest."""
ct_b64 = entry.get("ciphertext_b64", "")
ct_digest = hashlib.sha256(_unb64(ct_b64)).hexdigest() if ct_b64 else ""
row = {
"v": 2,
"eph_pk_b64": entry.get("eph_pk_b64", ""),
"to_tag_b64": entry.get("to_tag_b64", ""),
"ciphertext_b64": ct_b64,
"ciphertext_sha256": ct_digest,
"claimed": bool(entry.get("claimed", False)),
}
raw = json.dumps(row, separators=(",", ":"), sort_keys=True).encode("utf-8")
if len(raw) > zkac.PIR_RECORD_BYTES:
raise ValueError(
f"PIR row exceeds PIR_RECORD_BYTES ({zkac.PIR_RECORD_BYTES})"
)
return raw + b"\x00" * (zkac.PIR_RECORD_BYTES - len(raw))
# ── Opaque server storage ───────────────────────────────────────────── # ── Opaque server storage ─────────────────────────────────────────────
class _ServerStore: class _ServerStore:
"""Thread-safe, opaque persistence for registries and anonymous grant pool.""" """Thread-safe, opaque persistence for registry snapshots."""
def __init__(self, data_dir: Path): def __init__(self, data_dir: Path):
self._dir = data_dir self._dir = data_dir
self._reg_dir = data_dir / "registries" self._reg_dir = data_dir / "registries"
self._mbox_dir = data_dir / "mailbox"
self._pool_path = self._mbox_dir / "grants_pool.json"
self._reg_dir.mkdir(parents=True, exist_ok=True) self._reg_dir.mkdir(parents=True, exist_ok=True)
self._mbox_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock() self._lock = threading.Lock()
self._pir_server: zkac.PirServer | None = None
self._pir_dirty = True
self._migrate_legacy_mailbox()
# ── server key ──────────────────────────────────────────────────── # ── server key ────────────────────────────────────────────────────
@ -117,105 +69,6 @@ class _ServerStore:
print(f"[server] skip registry {rid_hex}: {exc}") print(f"[server] skip registry {rid_hex}: {exc}")
return count return count
# ── legacy per-recipient mailbox → anonymous pool ─────────────────
def _migrate_legacy_mailbox(self):
if self._pool_path.exists():
return
records: list[dict] = []
for p in sorted(self._mbox_dir.glob("*.json")):
if p.name == "grants_pool.json":
continue
try:
entries = json.loads(p.read_text())
for e in entries:
if "claimed" not in e:
e["claimed"] = False
records.append(e)
except Exception as exc:
print(f"[server] skip legacy mailbox {p.name}: {exc}")
try:
p.unlink()
except OSError:
pass
if records:
self._pool_path.write_text(
json.dumps({"version": 1, "records": records}, indent=2)
)
def _load_pool(self) -> list[dict]:
if not self._pool_path.exists():
return []
data = json.loads(self._pool_path.read_text())
return data.get("records", [])
def _save_pool(self, records: list[dict]):
self._pool_path.write_text(
json.dumps({"version": 1, "records": records}, indent=2)
)
# ── PIR server rebuild ────────────────────────────────────────────
def _ensure_pir(self) -> zkac.PirServer:
if self._pir_server is not None and not self._pir_dirty:
return self._pir_server
records = self._load_pool()
packed = [_pir_row_bytes(r) for r in records]
db = zkac.PirDatabase(packed, zkac.PIR_RECORD_BYTES)
self._pir_server = zkac.PirServer(db)
self._pir_dirty = False
return self._pir_server
# ── anonymous grant pool ─────────────────────────────────────────
def post_grant(self, entry: dict) -> int:
_pir_row_bytes(entry)
row = {"claimed": False, **entry}
with self._lock:
records = self._load_pool()
pool_index = len(records)
records.append(row)
self._save_pool(records)
self._pir_dirty = True
return pool_index
def pool_info(self) -> dict:
with self._lock:
pir = self._ensure_pir()
return {
"n": pir.n_records,
"record_bytes": pir.record_bytes,
"pool_version": bytes(pir.version()).hex(),
}
def pool_tags(self) -> tuple[list[tuple[str, str]], str]:
with self._lock:
records = self._load_pool()
pir = self._ensure_pir()
version = bytes(pir.version()).hex()
tags = []
for r in records:
tags.append((
r.get("eph_pk_b64", ""),
r.get("to_tag_b64", ""),
))
return tags, version
def pir_hints(self) -> tuple[bytes, str]:
with self._lock:
pir = self._ensure_pir()
return bytes(pir.hints()), bytes(pir.version()).hex()
def pir_answer_bytes(self, query_b64: str, pool_version: str) -> bytes | None:
"""Return raw PIR answer bytes, or ``None`` if ``pool_version`` is stale."""
with self._lock:
pir = self._ensure_pir()
current = bytes(pir.version()).hex()
if current != pool_version:
return None
ans = pir.answer(_unb64(query_b64))
return bytes(ans)
# ── Command dispatch (inside encrypted session) ────────────────────── # ── Command dispatch (inside encrypted session) ──────────────────────
def _dispatch( def _dispatch(
@ -256,110 +109,6 @@ def _dispatch(
store.save_registry(cmd["registry_id"], state_bytes, state_cert) store.save_registry(cmd["registry_id"], state_bytes, state_cert)
return {"ok": True} return {"ok": True}
if action == "post_grant":
rid = bytes.fromhex(cmd["registry_id"])
proof = _unb64(cmd["admin_proof_b64"])
if not mgr.verify_admin(rid, proof, transcript_hash):
return {"error": "admin proof failed"}
entry = {
"eph_pk_b64": cmd["eph_pk_b64"],
"ciphertext_b64": cmd["ciphertext_b64"],
"to_tag_b64": cmd.get("to_tag_b64", ""),
}
pool_index = store.post_grant(entry)
return {"ok": True, "pool_index": pool_index}
if action == "pool_info":
info = store.pool_info()
return {"ok": True, **info}
if action == "pool_tags":
tags, version = store.pool_tags()
entries = [
{"eph_pk_b64": epk, "to_tag_b64": tag}
for epk, tag in tags
]
return {"ok": True, "tags": entries, "pool_version": version}
if action == "pir_hints":
raw, version = store.pir_hints()
offset = cmd.get("offset")
if offset is not None:
if cmd.get("pool_version") != version:
return {"error": "stale_version"}
try:
off = int(offset)
except (TypeError, ValueError):
return {"error": "bad offset"}
if off < 0 or off > len(raw):
return {"error": "bad offset"}
piece = raw[off : off + _PIR_HINT_CHUNK]
nxt = off + len(piece)
return {
"ok": True,
"pool_version": version,
"slice_b64": _b64(piece),
"offset": off,
"returned": len(piece),
"done": nxt >= len(raw),
}
if len(raw) <= _PIR_HINT_CHUNK:
return {"ok": True, "hints_b64": _b64(raw), "pool_version": version}
return {
"ok": True,
"pool_version": version,
"hints_total": len(raw),
"chunk": _PIR_HINT_CHUNK,
}
if action == "pir_query":
q_b64 = cmd.get("query_b64", "")
pv = cmd.get("pool_version", "")
offset = cmd.get("offset")
if offset is None:
ans_bytes = store.pir_answer_bytes(q_b64, pv)
if ans_bytes is None:
return {"error": "stale_version"}
conn_ctx.pop("pir_answer_buffer", None)
conn_ctx.pop("pir_answer_pv", None)
if len(ans_bytes) <= _PIR_HINT_CHUNK:
return {"ok": True, "answer_b64": _b64(ans_bytes), "pool_version": pv}
conn_ctx["pir_answer_buffer"] = ans_bytes
conn_ctx["pir_answer_pv"] = pv
return {
"ok": True,
"pool_version": pv,
"answer_total": len(ans_bytes),
"chunk": _PIR_HINT_CHUNK,
}
if conn_ctx.get("pir_answer_pv") != pv:
return {"error": "stale_version"}
buf = conn_ctx.get("pir_answer_buffer")
if buf is None:
return {"error": "no PIR answer in progress"}
try:
off = int(offset)
except (TypeError, ValueError):
return {"error": "bad offset"}
if off < 0 or off > len(buf):
return {"error": "bad offset"}
piece = buf[off : off + _PIR_HINT_CHUNK]
nxt = off + len(piece)
done = nxt >= len(buf)
if done:
conn_ctx.pop("pir_answer_buffer", None)
conn_ctx.pop("pir_answer_pv", None)
return {
"ok": True,
"pool_version": pv,
"slice_b64": _b64(piece),
"offset": off,
"returned": len(piece),
"done": done,
}
return {"error": f"unknown command: {action}"} return {"error": f"unknown command: {action}"}
except Exception as exc: except Exception as exc:
@ -368,26 +117,48 @@ def _dispatch(
# ── Connection handler ──────────────────────────────────────────────── # ── Connection handler ────────────────────────────────────────────────
def _handle_conn(conn: socket.socket, addr: tuple, node: zkac.Node, def _handle_conn(
mgr: zkac.RegistryManager, store: _ServerStore, conn: socket.socket,
server_pk_b64: str): addr: tuple,
node: zkac.Node,
mgr: zkac.RegistryManager,
store: _ServerStore,
server_pk_b64: str,
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
err: str | None = None
try: try:
if debug and cid:
debug.update_connection(cid, phase="handshake")
session = server_handshake_anon(conn, node) session = server_handshake_anon(conn, node)
framed = FramedSession(conn, session) framed = FramedSession(conn, session)
transcript_hash = bytes(session.transcript_hash()) transcript_hash = bytes(session.transcript_hash())
if debug and cid:
debug.update_connection(
cid,
phase="post_handshake",
transcript_hash_hex=transcript_hash.hex(),
)
hello = json.loads(framed.recv()) hello = json.loads(framed.recv())
op = hello.get("op") op = hello.get("op")
if debug and cid:
debug.update_connection(cid, phase=f"hello:{op}", hello_op=op)
if op == "mgmt": if op == "mgmt":
conn_ctx: dict = {} conn_ctx: dict = {}
if debug and cid:
debug.update_connection(cid, phase="mgmt_loop")
while True: while True:
try: try:
data = framed.recv() data = framed.recv()
except (ConnectionError, OSError): except (ConnectionError, OSError):
break break
cmd = json.loads(data) cmd = json.loads(data)
if debug and cid:
debug.note_mgmt_command(cid, cmd)
resp = _dispatch(cmd, mgr, store, server_pk_b64, transcript_hash, conn_ctx) resp = _dispatch(cmd, mgr, store, server_pk_b64, transcript_hash, conn_ctx)
framed.send(json.dumps(resp).encode()) framed.send(json.dumps(resp).encode())
@ -395,14 +166,25 @@ def _handle_conn(conn: socket.socket, addr: tuple, node: zkac.Node,
registry_id = bytes.fromhex(hello["registry_id"]) registry_id = bytes.fromhex(hello["registry_id"])
role_id = bytes.fromhex(hello["role_id"]) role_id = bytes.fromhex(hello["role_id"])
proof_bytes = _unb64(hello["bbs_auth_b64"]) proof_bytes = _unb64(hello["bbs_auth_b64"])
if debug and cid:
debug.update_connection(
cid,
phase="auth_verify",
auth_registry_hex=registry_id.hex(),
auth_role_hex=role_id.hex(),
)
ok = mgr.verify_presentation( ok = mgr.verify_presentation(
registry_id, role_id, proof_bytes, transcript_hash, registry_id, role_id, proof_bytes, transcript_hash,
) )
if not ok: if not ok:
if debug and cid:
debug.update_connection(cid, phase="auth_failed", auth_ok=False)
framed.send(json.dumps({"error": "auth failed"}).encode()) framed.send(json.dumps({"error": "auth failed"}).encode())
return return
if debug and cid:
debug.update_connection(cid, phase="auth_ok", auth_ok=True)
resp = { resp = {
"status": "authenticated", "status": "authenticated",
"registry_id": registry_id.hex(), "registry_id": registry_id.hex(),
@ -410,39 +192,61 @@ def _handle_conn(conn: socket.socket, addr: tuple, node: zkac.Node,
} }
framed.send(json.dumps(resp).encode()) framed.send(json.dumps(resp).encode())
if debug and cid:
debug.update_connection(cid, phase="auth_echo_loop")
while True: while True:
try: try:
data = framed.recv() data = framed.recv()
except (ConnectionError, OSError): except (ConnectionError, OSError):
break break
if debug and cid:
debug.note_echo_chunk(cid, len(data))
framed.send(data) framed.send(data)
else: else:
if debug and cid:
debug.update_connection(cid, phase="unknown_op", error=f"op={op!r}")
framed.send(json.dumps({"error": f"unknown op: {op}"}).encode()) framed.send(json.dumps({"error": f"unknown op: {op}"}).encode())
except (ConnectionError, BrokenPipeError, OSError): except (ConnectionError, BrokenPipeError, OSError):
pass pass
except Exception as exc: except Exception as exc:
err = str(exc)
if debug and cid:
debug.update_connection(cid, phase="error", error=err)
print(f"[server] {peer} error: {exc}") print(f"[server] {peer} error: {exc}")
traceback.print_exc() traceback.print_exc()
finally: finally:
if debug and cid:
debug.close_connection(cid, error=err)
conn.close() conn.close()
# ── Public entry point ──────────────────────────────────────────────── # ── Public entry point ────────────────────────────────────────────────
def serve(data_dir: str, host: str = "127.0.0.1", port: int = 9800): def serve(
data_dir: str,
host: str = "127.0.0.1",
port: int = 9800,
*,
debug: ServerDebugState | None = None,
):
dd = Path(data_dir) dd = Path(data_dir)
dd.mkdir(parents=True, exist_ok=True) dd.mkdir(parents=True, exist_ok=True)
store = _ServerStore(dd) store = _ServerStore(dd)
kp = store.load_or_create_keypair() kp = store.load_or_create_keypair()
server_pk_b64 = _b64(kp.public_key().to_bytes()) server_pk_b64 = _b64(kp.public_key().to_bytes())
pk_hex = _unb64(server_pk_b64).hex()
node = zkac.Node(kp) node = zkac.Node(kp)
mgr = zkac.RegistryManager() mgr = zkac.RegistryManager()
n = store.load_all_registries(mgr) n = store.load_all_registries(mgr)
print(f"server public key: {_unb64(server_pk_b64).hex()}") if debug is not None:
debug.set_listen(host, port)
debug.set_boot_info(server_pk_hex=pk_hex, registries_loaded=n)
print(f"server public key: {pk_hex}")
print(f"loaded {n} registries") print(f"loaded {n} registries")
print(f"listening on {host}:{port}") print(f"listening on {host}:{port}")
@ -455,7 +259,7 @@ def serve(data_dir: str, host: str = "127.0.0.1", port: int = 9800):
conn, addr = sock.accept() conn, addr = sock.accept()
threading.Thread( threading.Thread(
target=_handle_conn, target=_handle_conn,
args=(conn, addr, node, mgr, store, server_pk_b64), args=(conn, addr, node, mgr, store, server_pk_b64, debug),
daemon=True, daemon=True,
).start() ).start()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -0,0 +1,186 @@
"""Mutable debug snapshot for ``zkac-node serve`` (optional, thread-safe)."""
from __future__ import annotations
import base64
import json
import threading
import time
import uuid
from collections import deque
from pathlib import Path
from typing import Any
import zkac
class ServerDebugState:
"""
Live server introspection for an admin dashboard.
Connection records are updated from worker threads; readers take a consistent
snapshot via :meth:`snapshot`.
"""
def __init__(self, *, userid: str, data_dir: str) -> None:
self.userid = userid
self.data_dir = str(Path(data_dir).resolve())
self._lock = threading.Lock()
self._started_wall = time.time()
self._started_mono = time.monotonic()
self._listen: tuple[str, int] = ("", 0)
self._server_pk_hex: str | None = None
self._registries_loaded = 0
self._active: dict[str, dict[str, Any]] = {}
self._history: deque[dict[str, Any]] = deque(maxlen=64)
def set_listen(self, host: str, port: int) -> None:
with self._lock:
self._listen = (host, port)
def set_boot_info(self, *, server_pk_hex: str, registries_loaded: int) -> None:
with self._lock:
self._server_pk_hex = server_pk_hex
self._registries_loaded = registries_loaded
def open_connection(self, peer: str) -> str:
cid = uuid.uuid4().hex[:10]
rec: dict[str, Any] = {
"id": cid,
"peer": peer,
"opened_wall": time.time(),
"opened_mono": time.monotonic(),
"phase": "accepted",
"hello_op": None,
"transcript_hash_hex": None,
"auth_registry_hex": None,
"auth_role_hex": None,
"auth_ok": None,
"last_mgmt_cmd": None,
"mgmt_commands": 0,
"bytes_echoed": 0,
"error": None,
}
with self._lock:
self._active[cid] = rec
return cid
def update_connection(self, cid: str, **fields: Any) -> None:
with self._lock:
rec = self._active.get(cid)
if rec is None:
return
rec.update(fields)
def note_mgmt_command(self, cid: str, cmd: dict) -> None:
name = cmd.get("cmd")
with self._lock:
rec = self._active.get(cid)
if rec is None:
return
rec["last_mgmt_cmd"] = name
rec["mgmt_commands"] = int(rec.get("mgmt_commands") or 0) + 1
def note_echo_chunk(self, cid: str, n: int) -> None:
with self._lock:
rec = self._active.get(cid)
if rec is None:
return
rec["bytes_echoed"] = int(rec.get("bytes_echoed") or 0) + n
def close_connection(self, cid: str, *, error: str | None = None) -> None:
with self._lock:
rec = self._active.pop(cid, None)
if rec is None:
return
rec = {**rec, "closed_wall": time.time(), "closed_mono": time.monotonic()}
if error:
rec["error"] = error
self._history.appendleft(rec)
def snapshot(self) -> dict[str, Any]:
with self._lock:
uptime = time.monotonic() - self._started_mono
return {
"userid": self.userid,
"data_dir": self.data_dir,
"started_wall": self._started_wall,
"uptime_s": round(uptime, 3),
"listen": {"host": self._listen[0], "port": self._listen[1]},
"server_public_key_hex": self._server_pk_hex,
"registries_loaded_boot": self._registries_loaded,
"active_connections": list(self._active.values()),
"recent_connections": list(self._history),
"active_connection_count": len(self._active),
}
def server_key_meta(data_dir: Path) -> dict[str, Any]:
"""Non-secret metadata from ``server_key.json``."""
path = data_dir / "server_key.json"
if not path.is_file():
return {"present": False, "path": str(path)}
try:
data = json.loads(path.read_text())
pub = base64.b64decode(data["public_b64"])
sec = base64.b64decode(data["secret_b64"])
return {
"present": True,
"path": str(path),
"public_key_hex": pub.hex(),
"secret_key_stored_bytes": len(sec),
}
except Exception as exc:
return {"present": True, "path": str(path), "error": str(exc)}
def collect_registry_debug(data_dir: Path) -> list[dict[str, Any]]:
"""Per-registry rows from on-disk state (deserialized for version / ids)."""
reg_dir = data_dir / "registries"
if not reg_dir.is_dir():
return []
rows: list[dict[str, Any]] = []
for p in sorted(reg_dir.glob("*.state")):
rid_file = p.stem
cert_path = reg_dir / f"{rid_file}.cert"
raw = p.read_bytes()
row: dict[str, Any] = {
"file_registry_id_hex": rid_file,
"state_path": str(p),
"state_bytes": len(raw),
"cert_path": str(cert_path) if cert_path.exists() else None,
"cert_bytes": cert_path.stat().st_size if cert_path.exists() else 0,
}
try:
st = zkac.RegistryState.deserialize(raw)
row["parsed_ok"] = True
row["version"] = st.version()
row["registry_id_hex"] = st.registry_id().hex()
row["state_hash_hex"] = st.state_hash().hex()
except Exception as exc:
row["parsed_ok"] = False
row["parse_error"] = str(exc)
rows.append(row)
return rows
def data_dir_tree(data_dir: Path, *, max_files: int = 200) -> list[dict[str, Any]]:
"""Small file listing for transparency (names + sizes only)."""
out: list[dict[str, Any]] = []
root = data_dir.resolve()
if not root.is_dir():
return out
n = 0
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
if n >= max_files:
out.append({"truncated": True, "note": f"listing capped at {max_files} files"})
break
rel = path.relative_to(root)
try:
out.append({"path": str(rel).replace("\\", "/"), "bytes": path.stat().st_size})
n += 1
except OSError:
continue
return out

View File

@ -32,9 +32,12 @@ def create_user(userid: str) -> Path:
raise FileExistsError(f"user {userid!r} already exists at {d}") raise FileExistsError(f"user {userid!r} already exists at {d}")
issuance_kp = zkac.IssuanceKeypair() issuance_kp = zkac.IssuanceKeypair()
transport_kp = zkac.Keypair()
identity = { identity = {
"issuance_secret_b64": _b64(issuance_kp.secret_bytes()), "issuance_secret_b64": _b64(issuance_kp.secret_bytes()),
"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_public_b64": _b64(transport_kp.public_key().to_bytes()),
} }
p.write_text(json.dumps(identity, indent=2)) p.write_text(json.dumps(identity, indent=2))
for sub in ("admin", "credentials", "servers"): for sub in ("admin", "credentials", "servers"):
@ -43,10 +46,61 @@ def create_user(userid: str) -> Path:
def load_identity(userid: str) -> dict: def load_identity(userid: str) -> dict:
data = json.loads((_ud(userid) / "identity.json").read_text()) p = _ud(userid) / "identity.json"
data = json.loads(p.read_text())
if "transport_secret_b64" not in data or "transport_public_b64" not in data:
transport_kp = zkac.Keypair()
data["transport_secret_b64"] = _b64(transport_kp.secret_key_bytes())
data["transport_public_b64"] = _b64(transport_kp.public_key().to_bytes())
p.write_text(json.dumps(data, indent=2))
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_pk": _unb64(data["transport_public_b64"]),
}
def export_contact_bundle(userid: str) -> str:
"""One-string public contact bundle for out-of-band sharing."""
ident = load_identity(userid)
payload = {
"v": 1,
"issuance_pk_hex": ident["issuance_pk"].hex(),
"transport_pk_hex": ident["transport_pk"].hex(),
}
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def parse_contact_bundle(bundle: str) -> dict:
"""Parse and validate a contact bundle into key hex fields."""
try:
s = bundle.strip()
padding = "=" * (-len(s) % 4)
raw = base64.urlsafe_b64decode((s + padding).encode())
data = json.loads(raw.decode("utf-8"))
except Exception as exc:
raise ValueError("invalid contact bundle encoding") from exc
if data.get("v") != 1:
raise ValueError("unsupported contact bundle version")
issuance_hex = data.get("issuance_pk_hex", "")
transport_hex = data.get("transport_pk_hex", "")
if not isinstance(issuance_hex, str) or not isinstance(transport_hex, str):
raise ValueError("invalid contact bundle fields")
try:
issuance = bytes.fromhex(issuance_hex)
transport = bytes.fromhex(transport_hex)
except ValueError as exc:
raise ValueError("contact bundle keys must be hex") from exc
if len(issuance) != 32:
raise ValueError("issuance public key must be 32 bytes")
if len(transport) != 32:
raise ValueError("transport public key must be 32 bytes")
return {
"issuance_pk_hex": issuance_hex,
"transport_pk_hex": transport_hex,
} }

View File

@ -1,6 +1,6 @@
# ZKAC Python API Reference # ZKAC Python API Reference
Version 0.5.1. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures), **LWE** (single-server SimplePIR for mailbox handles). Version 0.5.1. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures).
```python ```python
import zkac import zkac
@ -12,33 +12,6 @@ import zkac
Upper bound on BBS+ proof size in an encrypted auth packet (256 KiB). Larger proofs are rejected. Upper bound on BBS+ proof size in an encrypted auth packet (256 KiB). Larger proofs are rejected.
### `PIR_RECORD_BYTES`
Fixed byte length of each PIR plaintext row (mailbox **handle** JSON, padded with zeros). **256** in 0.5.1. Must match the servers PIR database packing and the `record_bytes` argument to `PirClient(...)`.
## Single-server PIR (SimplePIR)
Used by the CLI/WASM mailbox path: the servers hint blob starts with magic ``ZKACSP1`` (not compatible with 0.4.x DoublePIR hints).
### `PirDatabase(records, record_bytes)`
`records` is a list of `bytes`, each **exactly** `record_bytes` long (padded plaintext cells, one byte per LWE plaintext slot).
### `PirServer(db)`
`hints() -> bytes`, `version() -> bytes` (32-byte pool/hint digest), `answer(query: bytes) -> bytes` (little-endian `u32` limbs: **one `u32` per record byte**, i.e. `record_bytes` limbs for SimplePIR).
### `PirClient(hints_bytes, n_records, record_bytes)`
`hints_bytes` must deserialize with matching `n_records` and `record_bytes`.
- `query(index) -> (query_bytes, PirClientState)``query_bytes` length **4 × n_records** (one `u32` per database column).
- `decode(answer_bytes, state) -> bytes``answer_bytes` must hold **`record_bytes` × 4** bytes (`record_bytes` little-endian `u32`s). Consumes `PirClientState`.
### `grant_detection_tag(secret_key: bytes, public_key: bytes) -> bytes`
16-byte tag for grant discovery (`secret_key` / `public_key` are 32-byte X25519 keys).
## Transport identity (Ristretto255) ## Transport identity (Ristretto255)
### `Keypair()` ### `Keypair()`

View File

@ -1,273 +1,61 @@
# Security model and audit notes (ZKAC 0.5.1) # Security model (ZKAC 0.5.1)
This document summarizes the design, residual risks, and recommendations for operators integrating **ZKAC**. It is not a substitute for independent review before high-assurance deployment. This document summarizes the direct peer-to-peer grant model, with transcript-bound BBS+ authorization (Option C).
## Goals ## Goals
- **Authentication:** Only holders of a valid BBS+ credential for a registered role can complete `verify_auth` for that role. - MITM-safe transport using transcript-bound authentication.
- **Server identity:** The server proves its long-term identity to the client via a Schnorr signature over the session transcript; clients verify against a pinned public key. This prevents MITM attacks without requiring TLS. - Anonymous-authorized grant sender: recipient verifies sender is a valid admin for the target registry, without requiring a stable sender identifier.
- **Confidentiality & integrity:** All traffic (management and authenticated sessions) is authenticated-encrypted (ChaCha20-Poly1305) with keys derived from an ephemeral X25519 handshake. - End-to-end credential confidentiality from sender to recipient.
- **Replay resistance:** Duplicate ciphertexts in a direction are rejected (sliding window + monotonic counter). - Replay and downgrade resistance on the encrypted transport channel.
- **Unlinkability (credential layer):** BBS+ presentations are unlinkable across sessions when the presentation header (the session transcript hash) differs; the verifier learns only the disclosed attributes (opaque `role_id`, epoch) and validity. Client anonymity is preserved: the client never reveals its long-term key during the handshake.
- **Server cannot forge credentials:** The server stores only the issuer **public** key per role; forging requires the issuer secret key.
- **Opaque server:** The server stores only cryptographically verified state blobs and opaque grant ciphertexts. No user identities, role names, or credential material are stored or visible to the server.
## Cryptographic components ## Cryptographic components
| Layer | Primitive | Purpose | - Transport: X25519 ephemeral DH, HKDF-SHA256, ChaCha20-Poly1305.
|-------|-----------|---------| - Identity proofing: Schnorr signatures on Ristretto255 over session transcript.
| Transport | X25519 ephemeral DH, HKDF-SHA256, ChaCha20-Poly1305 | Session keys, AEAD | - Authorization proofs: BBS+ presentations over BLS12-381.
| Identity | Schnorr on Ristretto255, BLAKE2b-512 challenge | Server identity binding | - Registry integrity: certified `RegistryState` with BBS+ admin proof checks.
| Credentials | BBS+ on BLS12-381 (zkryptium), SHAKE256 ciphersuite | Blind issuance, ZK presentations | - Grant payload encryption: X25519 + HKDF-SHA256 + ChaCha20-Poly1305 issuance envelope.
| Role IDs | BLAKE2b-512 (truncated to 32 bytes) | Opaque role identifiers |
| Grant delivery | X25519 static/ephemeral DH, HKDF-SHA256, ChaCha20-Poly1305 | E2E-encrypted credential grants | ## Direct p2p grant flow
| Grant discovery | X25519 DH + BLAKE2b-512 truncated to 16 bytes | Detection tags for anonymous matching |
| PIR | LWE (n=1024, q=2^32, p=256, σ=6.4) | Single-server private record retrieval | 1. Sender and recipient establish an encrypted peer session.
2. Sender prepares a blind-issued credential payload for recipient and encrypts it with recipient issuance key material.
## Protocol flow 3. Sender transmits:
- encrypted payload,
### Unified channel (all connections) - certified registry state snapshot,
- transcript-bound admin BBS+ proof.
``` 4. Recipient verifies:
Client Server - registry state certificate,
|--- init_msg (eph_pk) ------------>| - admin proof bound to current session transcript.
| | accept() 5. Recipient decrypts payload, validates credential data, and stores the credential locally.
| | prove_identity() → sign(transcript)
|<-- response_msg + identity_pkt ---| ## Threat model
| complete DH |
| decrypt + verify server sig | - Passive network attacker:
|===== encrypted session ==========>| - Cannot recover plaintext or keys without breaking X25519/ChaCha20-Poly1305 assumptions.
|--- {op: "mgmt"} or {op: "auth"}->| - Active MITM:
``` - Cannot relay proofs across sessions because authorization proof nonce is session transcript hash.
- Cannot impersonate peer without required long-term key/proof material.
Management commands (`create_registry`, `post_grant`, etc.) and BBS+ role authentication both run inside the same encrypted, server-authenticated channel. There is no unencrypted management path. - Malicious sender without admin credential:
- Rejected during transcript-bound BBS+ admin verification.
### Grant delivery (admin → recipient, through server) - Malicious recipient:
- Can only decrypt payloads addressed to its issuance key.
Grants live in a single **anonymous append-only pool** (no recipient identifier on the server). Each grant entry carries an ephemeral public key, the E2E-encrypted credential payload, and a 16-byte **detection tag**. - Replay:
- Rejected by session replay protections (monotonic counter + replay window).
**Discovery (cheap, no PIR):** The server exposes a `pool_tags` command returning all `(eph_pk, tag)` pairs. The client computes `X25519(my_issuance_sk, eph_pk_j)` for each entry and derives the expected tag via `BLAKE2b-512("zkac-grant-tag" || shared_secret)[..16]`. Matching entries are the client's grants. This scan is a single round-trip transferring ~48 bytes per pool entry and is computed locally.
## Security properties and limits
**Retrieval (single-hop PIR row):** For each matching pool index, the client runs LWE-based single-server **SimplePIR** (`pir_query`). The PIR database row contains the full encrypted mailbox row (ephemeral public key, detection tag, ciphertext, ciphertext digest) padded to `PIR_RECORD_BYTES`. The client validates the row digest and decrypts locally. There is no `grant_id` second hop, so retrieval does not reveal a stable row identifier beyond the PIR exchange itself. Hints use `H = D · A^T` (seeded public matrix `A`); the client caches hints keyed by `pool_version`.
- `anonymous_authorized` mode:
``` - Recipient learns sender is authorized for the registry, not a mandatory stable real-world identity.
Admin Server (opaque relay) Recipient - Network metadata (IP/endpoint timing) is still visible to direct peers.
|-- post_grant ------->| | - Registry freshness:
| (admin_proof, | appends to pool: | - Recipient verifies cryptographic validity of received state; deployment policy should define freshness expectations.
| eph_pk, | {eph_pk, ciphertext, | - Key management remains operationally critical:
| ciphertext, | to_tag, claimed=false} | - Protect admin/BBS+ secret material and transport keys.
| to_tag) | (no recipient address) |
| | | ## Operational guidance
| |<-- pool_tags --------------|
| |--- [(eph_pk, tag), …] ---->| - Pin or verify peer transport keys out-of-band before first direct grant exchange.
| | | local tag match - Rotate compromised keys and re-issue credentials with epoch/state updates.
| |<-- pir_query(j) -----------| - Do not log credential secrets, issuance secret keys, or raw proof material.
| |--- answer ----------------->|
| | | PIR decode full row
| | | verify digest → decrypt
```
## PIR security (LWE)
Private information retrieval uses the **SimplePIR** construction (HenzingerHongCorrigan-GibbsMeiklejohnVaikuntanathan, USENIX Security '23). Security rests on the **decisional Learning With Errors (LWE)** assumption:
- **Parameters:** LWE dimension n=1024, ciphertext modulus q=2^32, plaintext modulus p=256, discrete Gaussian noise σ=6.4.
- **Classical security:** ~128 bits (based on lattice estimator analysis at these parameters).
- **Post-quantum:** LWE is believed hard for quantum computers; no known quantum algorithm breaks it in polynomial time.
- **Single-server:** No non-collusion assumption. Privacy holds against an honest-but-curious server that inspects all queries and answers.
The PIR scheme is **honest-but-curious only**: a malicious server can return incorrect answers. This is acceptable because grant payloads are E2E-encrypted (ChaCha20-Poly1305) and credential finalization validates BBS+ blind signatures — a corrupted PIR answer causes decryption or BBS+ verification to fail, not credential forgery.
## Detection tags
Each grant carries a 16-byte detection tag: `BLAKE2b-512("zkac-grant-tag" || X25519(eph_sk, recipient_pk))[..16]`.
**Privacy properties:**
- The tag is a deterministic function of the shared secret, which requires knowledge of either the ephemeral secret key or the recipient's issuance secret key to compute. An observer (including the server) who knows neither key cannot link a tag to a recipient.
- The `pool_tags` list is equivalent to what the server already sees at grant insertion time — broadcasting it to querying clients reveals no new information.
- A client downloading `pool_tags` reveals that it is checking for pending grants, but not which entries matched. Matching is a local computation.
- Tags have 128-bit collision resistance (16 bytes); false positives are negligible.
## Scaling and complexity (transport, credentials, registries)
This section complements the **grant pool / PIR** analysis above. Asymptotics use: **R** = number of roles in one registry state, **G** = number of registries hosted in memory, **L** = byte length of an application payload (JSON management command or auth packet body after decryption).
### Transport and session crypto
| Operation | Time | Bandwidth / memory |
|-----------|------|----------------------|
| Handshake (`connect` / `accept`) | **O(1)** | Fixed 32-byte handshake messages; one X25519 DH, HKDF, ChaCha open. |
| Server identity proof | **O(1)** | Schnorr verify on Ristretto255 over a short transcript-derived message. |
| `Session::encrypt` / `decrypt` per frame | **O(L)** | ChaCha20-Poly1305 is linear in payload size; replay window checks are **O(1)** per direction. |
**Bottlenecks:** negligible compared to BBS+ unless payloads are pushed toward frame limits. Python framing caps TCP payloads at `MAX_BBS_AUTH_PROOF_BYTES + 4 KiB` (~260 KiB), bounding worst-case allocations per read.
### BBS+ credentials (issuance and verification)
| Operation | Time | Notes |
|-----------|------|-------|
| Blind `issue_blind` / `finalize` (issuer / member) | **O(1)** in R and G | Dominated by BLS12-381 and BBS+ proof math in zkryptium (pairings, multi-scalar muls); not sensitive to registry count or pool size. |
| `present` (proof generation) | **O(1)** | Produces a presentation bound to a nonce (e.g. transcript hash). |
| `verify_presentation` | **O(1)** | One proof check against one issuer public key. |
| Proof size on the wire | **≤ 256 KiB** | `MAX_BBS_AUTH_PROOF_BYTES`; caps attacker-controlled allocation for auth packets. |
**Bottlenecks:** **BBS+ verify and present** dominate CPU on authenticated paths (role auth, admin proofs for `post_grant`, registry state certification). Cost is **per event**, not per grant in pool, but high QPS auth still needs horizontal scaling or hardware tuned for pairing-heavy crypto.
### Registry state (client-managed blob on server)
| Operation | Time | Size |
|-----------|------|------|
| `RegistryState::serialize` / `deserialize` | **O(R)** | Linear in number of role entries (each: fixed `role_id`, variable-length issuer pk bytes, epoch). |
| `state_hash` | **O(|state_bytes|)****O(R)** | One BLAKE2b-512 over the serialized state. |
| `certify` / `verify_cert` | Same as BBS+ present / verify | One presentation over `state_hash`. |
| `RegistryManager::update` | **O(R)** for cache rebuild | Deserializes old + new state, verifies cert and version chain, rebuilds `RoleRegistry` cache by iterating all roles (`build_role_cache`). |
**Bandwidth:** `get_registry` / `create_registry` / `update_registry` move the **full serialized state** and **state certificate** each time — **O(R)** bytes per round-trip. Very large role lists mean large management frames and more CPU on every update.
**Bottlenecks:** **Large R** (many roles in one registry) inflates state blob size, hash work, and cache rebuild. **Frequent updates** multiply BBS+ certify/verify cost.
### RegistryManager (multi-registry server)
| Operation | Time | Notes |
|-----------|------|-------|
| `create` / `get` / `update` / `verify_*` | **O(1)** expected in G | Hash map on `registry_id`; work is on **one** stored registry at a time. |
| In-memory footprint | **O(G × (|state| + |cert| + queues))** | Each registry holds state bytes, cert bytes, `RoleRegistry` cache, and issuance **queues** (below). |
**Bottlenecks:** **G** grows with every distinct registry the server accepts — mostly a **RAM** and operational concern. Per-request CPU is still dominated by BBS+ and (for managed flows) issuance queue handling.
### Issuance request queues (`RegistryManager`)
| Structure | Growth | Risk |
|-----------|--------|------|
| `pending_requests` / `granted` maps | **Unbounded** per registry unless the application drains them | A client could queue many `queue_issuance_request` entries; server memory grows with pending items. Not the same as the grant pool file, but a similar **resource exhaustion** class. |
**Bottlenecks:** **Queue depth** per registry; mitigations are rate limits, caps, or TTL policies at the application layer (not enforced in core today).
### Issuance encryption (X25519 + ChaCha)
| Operation | Time |
|-----------|------|
| `encrypt` / `decrypt` (grant payloads, admin replies) | **O(L)** for payload length L |
Negligible vs BBS+ for typical small JSON blobs.
### Summary: dominant costs outside the grant pool
1. **BBS+ present/verify** on every auth, admin proof, and registry certificate path — **pairing-heavy**, fixed per operation, proof capped at 256 KiB.
2. **Registry state size and `update`****O(R)** serialization, hashing, and full cache rebuild.
3. **Issuance queues****unbounded** pending entries per registry if abused.
4. **Transport****O(L)** per frame; handshake **O(1)**.
The **grant pool** remains the subsystem whose **per-operation** cost scales with **pool length n** (discovery, PIR query, PIR answer compute); the rest of the protocol scales mainly with **roles per registry**, **registry count**, and **proof operations per session**, not with anonymous pool size.
## Threats considered
### Network attacker (passive)
- Observes ciphertexts; cannot break ChaCha20-Poly1305 or derive session keys without breaking X25519 / HKDF under standard assumptions.
- Management traffic is indistinguishable from auth traffic at the wire level (same handshake, same framing).
### Network attacker (active / MITM)
- **Server impersonation:** The server signs the session transcript hash with its long-term Ristretto255 key (`prove_identity`). The client verifies this signature against the **pinned** server public key. A MITM running a separate DH exchange produces a different transcript; it cannot forge the server's signature. The client aborts on mismatch.
- **Client impersonation:** The BBS+ presentation is bound to the session transcript hash. A MITM cannot relay a presentation from one session to another (different transcripts) or forge one (requires a valid credential from the issuer).
- **Relay attack:** A MITM that relays the real server's identity proof to a client fails because the proof is encrypted under the MITM-to-server session keys (not the client-to-MITM keys), and the signature is over the wrong transcript.
- **Management channel:** All management commands (registry creation, grants) are protected by the same encrypted channel, eliminating the previous plaintext management path.
### Malicious server
- Can **learn** opaque `role_id`, current epoch, and that *some* valid member authenticated.
- Sees `registry_id` values (needed for routing) but not role names or registry contents beyond opaque state bytes.
- Sees `eph_pk`, `to_tag`, and ciphertext per grant in the anonymous pool, and pool size / timing of syncs, but cannot decrypt grant payloads or link tags to recipients.
- Sees PIR queries, which are LWE-encrypted under the decisional LWE assumption — cannot determine which pool index the client is retrieving (single-server, no collusion needed).
- **Cannot** forge BBS+ credentials without the issuer secret key.
- **Cannot** learn `member_secret` from presentations under the BBS+ security assumptions.
- **Cannot** distinguish which specific member authenticated among valid credential holders (unlinkability holds against the verifier for distinct presentation headers).
- **Cannot** learn the client's long-term public key — it is never transmitted during handshake or auth.
- **Cannot** perform admin operations (registry updates, grant posting) without a valid admin BBS+ credential.
- **Cannot** correlate a recipient's mailbox identity with their authenticated sessions (different keys, unlinkable proofs).
- **Can** censor grants by omitting tags from `pool_tags` or returning corrupted PIR answers. Corrupted answers are caught by E2E decryption / BBS+ verification failures. Censorship is a residual operational risk; cross-checking pool hashes across replicas mitigates it.
### Malicious client
- Cannot decrypt others' traffic without session keys.
- Cannot produce valid auth for a role without a valid credential + correct epoch + registry entry.
### Denial of service
- **Auth packet size:** Proof length is capped (`MAX_BBS_AUTH_PROOF_BYTES`, 256 KiB) to bound allocations.
- **Handshake:** Fixed 32-byte messages; no variable-length handshake parsing.
- **Grant pool growth:** The anonymous pool is append-only with tombstoned rows (`claimed`), so **pool length `n` never shrinks** on disk. A malicious or careless admin can grow `n` without bound: larger `pool_tags` downloads, longer PIR hint **recomputation** when the pool version bumps, and **per-query** PIR cost linear in `n` (see Known limitations). This is a **storage and workload amplification** vector, not credential forgery. Mitigation belongs in future work (pool caps, compaction, generations).
- General packet limits should still be enforced at the application layer (total message size, rate limits).
## Key distribution
The server's long-term `PublicKey` (32-byte Ristretto255 point) functions as a **self-authenticating identity** — no certificate authority is required. The client must obtain and pin this key before connecting.
Recommended strategies:
1. **Static configuration** (default): embed the server public key in client config or CLI pin command (`zkac-node server pin <userid> <host:port> --key <hex>`). Equivalent to WireGuard's `[Peer] PublicKey = ...`.
2. **Trust On First Use (TOFU):** accept the server's key on first connection, pin it for subsequent sessions. Risk: first connection is vulnerable.
3. **Out-of-band verification:** compare public key fingerprints over a trusted side channel (phone, in-person, encrypted messaging).
4. **Key registry / directory:** a trusted service maps names to public keys. Shifts trust to the registry and its authentication channel.
## Operational requirements
1. **Issuer secret key:** Protect `BbsIssuer` secret material (HSM, KMS, or encrypted at rest). Compromise = ability to issue arbitrary credentials for that role.
2. **Server long-term key:** Protect the server's `server_key.json`. Compromise = ability to impersonate the server. Rotate the key and distribute the new public key to clients if compromised.
3. **Member storage:** `member_secret` and finalized `Credential` material must be protected; loss = re-enrollment required.
4. **Epoch revocation:** On compromise or policy change, call `set_epoch` and re-issue credentials only to legitimate members; old credentials become invalid at verification time.
5. **Registry integrity:** Registry state is integrity-protected by BBS+ state certificates (admin must sign updates). The server verifies these certificates before accepting changes.
6. **Role ID privacy:** `role_id` is a hash of the role name only if you use `role_id("myrole")`; treat role names as secrets if enumeration is a concern, or derive role IDs with an additional secret salt known to members.
7. **Recipient addressing:** Admins encrypt grants to the recipient's issuance public key off-server; that key is not used as a server-side mailbox index. Recipients are identified to the issuer out-of-band only.
## Implementation notes (audit checklist)
- [x] BBS+ proof verification uses the same header and presentation binding as proof generation (`verify_presentation` in Rust).
- [x] Session transcript is included in the presentation via `present(transcript_hash)`.
- [x] Server identity proof: Schnorr signature over `transcript_hash`, verified against pinned public key before any traffic.
- [x] Schnorr nonce is deterministic (`H(sk || msg)`) — no dependence on RNG quality at signing time.
- [x] Replay protection is symmetric per direction in `Session`.
- [x] Constant-time comparisons are used where critical in transport/replay paths (`subtle` crate).
- [x] Client long-term key is never transmitted, preserving BBS+ unlinkability.
- [x] Management and auth channels use the same encrypted handshake (no plaintext management path).
- [x] Admin proofs in `post_grant` are bound to the session transcript hash (no separate nonce); the CLI uses **one TCP session per grant** so each proof uses a fresh transcript.
- [x] After collect, the client persists the server public key from `server_info` (never a placeholder key).
- [x] Server stores only opaque state bytes, state certs, and encrypted grant blobs (no role names, no user IDs).
- [x] PIR queries are LWE-encrypted; the server cannot determine the queried index.
- [x] Detection tags are derived from X25519 shared secrets and cannot be linked to recipients by the server.
- [ ] **External:** Python bindings surface raw bytes; callers must not log secrets (`secret_key_bytes`, `member_secret`, `prover_blind`).
- [ ] **External:** Use secure randomness from the OS (library uses OS RNG for key generation paths exposed in Rust).
## Design decisions
- **Unified encrypted channel:** All traffic (management and auth) uses the same anonymous handshake. This eliminates the attack surface of an unencrypted management path and simplifies the protocol to a single mode.
- **Anonymous handshake (`complete_connect_anon`):** The client verifies the server's identity but does not authenticate itself during the handshake. BBS+ auth is sent as an application-layer message inside the encrypted session, not as part of the handshake. This allows the same channel for both anonymous management and authenticated role access.
- **Server-only identity proof:** Only the server signs the transcript. Adding client long-term signing would break BBS+ unlinkability (the server could correlate sessions by client public key). Client authentication is handled entirely by the anonymous BBS+ credential.
- **Deterministic Schnorr nonces:** The signing nonce is derived as `H("zkac-schnorr-nonce" || sk || msg)`, eliminating a class of RNG-failure attacks (cf. PS3 ECDSA, Sony 2010). Same key + same message = same signature.
- **Anonymous grant pool:** Grant entries contain `(eph_pk, ciphertext, to_tag)` plus row metadata — no registry ID or role name. Recipients discover their grants via detection tags and retrieve full encrypted rows via LWE PIR in one step.
- **No user IDs on server:** The server has no concept of user accounts. It is a stateless relay authenticated only by cryptographic proofs.
- **Single-server PIR (LWE):** Eliminates the two-server non-collusion assumption of the previous XOR PIR design. Query privacy rests on decisional LWE, not operational trust in multiple server operators.
- **Detection tags for discovery:** A 16-byte tag derived from X25519 DH allows O(n) local matching from a cheap bulk download, reducing PIR usage from O(n) queries to O(matches) queries per scan.
- **One session per admin grant (CLI):** Each `post_grant` runs in its own connection so `verify_admin` nonces are not reused across grants in a single session.
## Known limitations
- **Epoch granularity:** Revocation is coarse (epoch bump); plan issuance and rotation policy accordingly.
- **zkryptium dependency:** Security follows the underlying crate and BLS12-381/BBS+ standards; keep dependencies updated.
- **Key distribution:** The library provides the cryptographic mechanism; initial key distribution is an application-layer responsibility.
- **Honest-but-curious PIR:** The server can return incorrect PIR answers. Corrupted answers are caught by E2E decryption / BBS+ verification, but censorship (omitting grants) is not detected at the PIR layer. Cross-replica hash comparison or a transparency log can mitigate this.
- **Hint size:** PIR hints are approximately `56 + record_bytes × N_LWE × 4` bytes (on the order of **8 MiB** with `record_bytes = 2048` and `N_LWE = 1024`). Hints are cached client-side and only refetched when the pool version changes.
- **Unbounded grant pool:** Rows are append-only and currently not privately claimed/deleted in protocol. Pool length `n` therefore grows monotonically with every posted grant. That increases discovery traffic (`pool_tags` is O(n)), PIR query size (O(n) bytes per query), server work per PIR answer (O(n × record_bytes)), and hint **rebuild** cost when the pool changes (O(n × record_bytes × N_LWE)). Operators should plan for bounded pools or archival; the codebase does not yet enforce limits.
## Future work
- **Bounded grant pool and anti-DoS:** Introduce explicit **pool caps**, **rate limits** on `post_grant`, **per-registry quotas**, or **pool generations** (rotate to a fresh empty pool while archiving the old one). Optionally **compact** the on-disk pool by rewriting only unclaimed rows and bumping a generation id so PIR indices stay meaningful without retaining every tombstone forever. Any design must preserve stable addressing for in-flight collects or migrate clients with explicit pool ids.
- **Scale beyond large `n`:** Todays bottleneck is **linear cost in pool length `n`** for each PIR retrieval: client upload ~4n bytes per query, server matrixvector multiply O(n × record_bytes), and discovery O(n). For very large pools, future work includes **sublinear-communication PIR** (e.g. DoublePIR-style layering), **sharded pools** with client-side routing, **streaming or chunked hints**, or **moving heavy work off the hot path** (precomputed answers, CDN for hints) — trading complexity, trust, or privacy for throughput.
- **DoublePIR / layered PIR:** Production mailbox PIR remains SimplePIR; layered/sublinear PIR remains future work.
- **Verifiable PIR:** Adding a commitment to the pool state (e.g. Merkle tree or KZG) and proof of correct answer computation would defend against malicious server responses beyond what E2E encryption catches.
- **Pool commitment / transparency:** Publishing a hash of `(pool_version, hints, tags)` to a public log or allowing cross-replica comparison would detect censorship by a malicious server.
## Reporting issues
Report security-sensitive findings through your project's private disclosure channel (configure `SECURITY.md` contact or GitHub security advisories when the repository is public).

213
docs/WHITEPAPER.md Normal file
View File

@ -0,0 +1,213 @@
# Trustless Federated Authorization
## Abstract
Internet services traditionally rely on trusted servers for authentication, authorization, and routing. Blockchains reduce server trust through global consensus, but impose substantial latency, replication, and operational cost. This whitepaper presents a third model: **trustless federation**. In this model, nodes still host registries and provide service endpoints, yet cryptographic protocol design limits what a malicious node can learn or forge. Authorization is proven with transcript-bound zero-knowledge credentials, credential transfer is end-to-end encrypted, and policy state is verifiable by clients. The result is a practical system positioned between classical TCP/IP client-server trust and blockchain-style distributed consensus.
## 1. Idea and Motivation
### 1.1 The gap between two worlds
Modern networking stacks solve transport and connectivity well, but application trust usually collapses to "trust the server operator." At the other extreme, blockchains attempt to remove this assumption by making consensus globally replicated and tamper-resistant.
Both models work, but both have trade-offs:
- **Client-server model:** efficient, low latency, but high operator trust.
- **Blockchain model:** low operator trust, but expensive consensus and reduced throughput.
The central idea of this protocol family is to provide an **in-between layer**:
- Federated nodes exist (for hosting registries and serving traffic).
- Those nodes are treated as potentially malicious.
- Cryptography prevents nodes from learning protected credential data or forging authorization.
### 1.2 Privacy-first motivation
For privacy-sensitive services, users want to prove permissions without exposing stable identity. If this protocol runs over anonymity overlays such as Tor or I2P, network-level identifiers become less meaningful, and the protocol can support anonymous service access with minimized trust in servers.
## 2. Design Goals
The system targets the following properties:
- **Trustless authorization:** a node should not be able to forge client permissions.
- **MITM resistance:** authentication material must be bound to the actual session.
- **Anonymous authorization:** verify "is authorized" without requiring real-world identity.
- **End-to-end credential confidentiality:** transfer should be unreadable to intermediaries.
- **Federated practicality:** avoid heavy global consensus for each interaction.
- **Composability with existing Internet infrastructure:** deploy over TCP today, anonymity overlays tomorrow.
## 3. Positioning Against Existing Protocol Families
### 3.1 Versus traditional Internet service protocols
In common web architectures, TLS protects transport, but servers still decide and observe most authentication/authorization details. This protocol reduces that trust requirement by moving proof validity into cryptographic verification that endpoints can perform independently.
### 3.2 Versus blockchain systems
Blockchains provide strong tamper resistance via global consensus and replication. This protocol intentionally does not replicate all state globally. It keeps consensus/governance lightweight and local to federated registry hosting, while ensuring malicious operators cannot violate core authorization integrity.
### 3.3 Intended operating point
This protocol is best viewed as:
- More trust-minimizing than standard centralized services.
- Lighter and more responsive than blockchain consensus systems.
- Suitable for private, federated service networks.
## 4. System Model
### 4.1 Roles
- **Federated nodes:** host registries and provide service endpoints.
- **Issuers/Admins:** create and authorize credential grants.
- **Clients:** receive credentials and authenticate to services.
- **Recipients/Peers:** participate in direct credential exchange.
### 4.2 Assumptions
- Nodes may be malicious, curious, censoring, or unreliable.
- Endpoints protect secret keys.
- Cryptographic primitives are assumed sound.
- Availability is not guaranteed by cryptography alone.
## 5. System Architecture
### 5.1 High-level architecture
The architecture separates concerns:
- **Registry layer:** federated nodes host verifiable authorization state.
- **Session layer:** peers establish encrypted channels with transcript material.
- **Authorization layer:** BBS+ presentations prove role/authority.
- **Credential transport layer:** credential payloads are transferred directly peer-to-peer under end-to-end encryption.
### 5.2 Direct credential grant flow
1. Sender and recipient establish a secure session.
2. Sender generates a transcript-bound BBS+ authorization proof.
3. Sender sends encrypted credential payload plus registry-state evidence.
4. Recipient verifies proof against registry state.
5. Recipient decrypts payload and stores credential locally.
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.
## 6. Cryptographic Backbone
### 6.1 Session and channel security
- **Key exchange:** ephemeral X25519 Diffie-Hellman for forward-secret session establishment.
- **Key schedule:** HKDF-SHA256 domain-separated derivation of directional send/receive keys and transcript hash material.
- **Record protection:** ChaCha20-Poly1305 AEAD with associated data bound to message counters.
- **Replay handling:** monotonic counters plus replay-window checks for per-direction packet acceptance.
- **Identity proofing:** Schnorr signatures over transcript-derived context to authenticate peer/server identity keys when required by deployment policy.
### 6.2 Transcript binding
A transcript hash from the handshake/session is used as proof challenge material. This binds authorization proofs to the exact session and prevents replay across different channels.
### 6.3 Authorization proofs
BBS+ presentations provide zero-knowledge role/authority proofs with selective disclosure properties. Recipients verify proof validity against issuer/registry material without learning hidden witness data.
Operationally, proof verification is done against certified registry state (issuer keys, role identifiers, epoch/version constraints). This allows a verifier to accept an authorization claim without trusting the transport node's interpretation of policy.
### 6.4 Credential confidentiality
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.
### 6.5 Primitive suite (current profile)
- X25519 for ECDH key agreement.
- HKDF-SHA256 for key derivation and transcript-bound expansion.
- ChaCha20-Poly1305 for authenticated encryption.
- BBS+ (BLS12-381 ciphersuite) for anonymous authorization presentations.
- Schnorr signatures on Ristretto255 for transport identity proofs.
- BLAKE2-family hashing for role/identifier derivation and protocol-domain separation.
## 7. Security and Privacy Properties
### 7.1 What malicious nodes should not be able to do
- Forge valid authorization proofs.
- Decrypt end-to-end encrypted credential payloads.
- Rebind valid proofs to unrelated sessions.
- Extract hidden secrets from zero-knowledge presentations.
### 7.2 Residual risks and limits
- **Censorship/DoS:** nodes can refuse service or delay traffic.
- **Metadata leakage:** timing and routing metadata may persist unless anonymized transports are used.
- **Operational key hygiene:** endpoint compromise remains critical.
## 8. Threat Model and Attacker Games
This section states explicit adversarial games suitable for academic evaluation. Let `A` denote a PPT adversary controlling network scheduling and any subset of federated nodes unless otherwise stated.
### 8.1 Adversary classes
- **Network adversary:** full active MITM capabilities (intercept, modify, replay, inject).
- **Malicious federation-node adversary:** controls registry-hosting/service nodes.
- **Endpoint compromise adversary:** compromises sender or recipient endpoint keys (out of primary guarantee scope; used for limit analysis).
### 8.2 Security games
#### Game G1: Authorization Unforgeability
**Goal:** `A` convinces an honest verifier to accept an authorization proof for role/registry context without access to valid issuer/admin credential material.
**Win condition:** verifier accepts with non-negligible probability.
**Target claim:** advantage is negligible under BBS+ proof unforgeability and sound registry-state verification.
#### Game G2: Transcript Rebinding / MITM Relay
**Goal:** `A` relays or reuses a proof generated in session `s1` to authenticate in distinct session `s2`.
**Win condition:** verifier in `s2` accepts proof bound to transcript hash from `s1`.
**Target claim:** advantage is negligible when proof challenge includes session transcript hash and transcript computation is collision-resistant/domain-separated.
#### Game G3: Credential Payload Confidentiality
**Goal:** `A` distinguishes which of two equal-length credential payloads was transmitted to recipient.
**Experiment:** challenger encrypts one of two adversary-chosen messages under recipient-targeted envelope; `A` gets ciphertext plus all node-observable metadata.
**Win condition:** `A` guesses chosen message with non-negligible advantage over 1/2.
**Target claim:** IND-CPA/AEAD security inherited from ECDH + HKDF + ChaCha20-Poly1305 under standard assumptions.
#### Game G4: Payload Integrity Under Active Modification
**Goal:** `A` modifies in-transit encrypted payload so recipient accepts altered plaintext as valid credential material.
**Win condition:** recipient accepts altered semantics without detection.
**Target claim:** negligible advantage due to AEAD integrity plus credential-structure verification/finalization checks.
#### Game G5: Malicious Node Knowledge Gain
**Goal:** malicious node learns hidden credential witness data beyond explicitly disclosed proof attributes and unavoidable metadata.
**Win condition:** extraction of non-disclosed witness information from observed protocol transcripts.
**Target claim:** negligible advantage under zero-knowledge properties of BBS+ presentations and secure transport assumptions.
### 8.3 Out-of-scope and partial-scope properties
- **Availability/censorship resistance:** not guaranteed by core cryptography.
- **Traffic-analysis resistance:** improved only when deployed over anonymity networks (Tor/I2P); timing correlation may still exist.
- **Endpoint key compromise:** breaks guarantees for affected principal by definition.
## 9. Why This Matters for Private Internet Services
This model enables service architectures where users do not have to trust server operators with authorization truth. Combined with Tor/I2P-style deployment, it enables anonymous, trust-minimized service access with practical latency and operational cost.
In short: this is a lightweight trustless layer for federated Internet services, positioned between classic server trust and heavyweight blockchain consensus.
## 10. Conclusion
Trustless federation is a practical design point for privacy-preserving Internet systems. It preserves the deployability and performance of federated services while shifting security from operator trust to cryptographic verification. This architecture is not a replacement for blockchains or traditional protocols; it is a complementary middle layer for applications that need stronger privacy and weaker server trust without paying the cost of global consensus.

View File

@ -33,3 +33,9 @@ zkac-node = { path = "cli", editable = true }
features = ["python"] features = ["python"]
module-name = "zkac._zkac" module-name = "zkac._zkac"
python-source = "python" python-source = "python"
[dependency-groups]
dev = [
"matplotlib>=3.10.9",
"nbconvert>=7.17.1",
]

View File

@ -8,7 +8,6 @@ __version__ = "0.5.1"
from zkac._zkac import ( from zkac._zkac import (
MAX_BBS_AUTH_PROOF_BYTES, MAX_BBS_AUTH_PROOF_BYTES,
PIR_RECORD_BYTES,
Keypair, Keypair,
PublicKey, PublicKey,
BbsIssuer, BbsIssuer,
@ -25,11 +24,6 @@ from zkac._zkac import (
IssuanceKeypair, IssuanceKeypair,
encrypt_for_admin, encrypt_for_admin,
decrypt_from_admin, decrypt_from_admin,
PirDatabase,
PirServer,
PirClient,
PirClientState,
grant_detection_tag,
Session, Session,
Node, Node,
PendingConnect, PendingConnect,
@ -38,7 +32,6 @@ from zkac._zkac import (
__all__ = [ __all__ = [
"__version__", "__version__",
"MAX_BBS_AUTH_PROOF_BYTES", "MAX_BBS_AUTH_PROOF_BYTES",
"PIR_RECORD_BYTES",
"Keypair", "Keypair",
"PublicKey", "PublicKey",
"BbsIssuer", "BbsIssuer",
@ -55,11 +48,6 @@ __all__ = [
"IssuanceKeypair", "IssuanceKeypair",
"encrypt_for_admin", "encrypt_for_admin",
"decrypt_from_admin", "decrypt_from_admin",
"PirDatabase",
"PirServer",
"PirClient",
"PirClientState",
"grant_detection_tag",
"Session", "Session",
"Node", "Node",
"PendingConnect", "PendingConnect",

View File

@ -2,7 +2,6 @@ pub mod credential;
pub mod error; pub mod error;
pub mod issuance; pub mod issuance;
pub mod node; pub mod node;
pub mod pir;
pub mod registry_manager; pub mod registry_manager;
pub mod transport; pub mod transport;

View File

@ -1,46 +0,0 @@
use blake2::Blake2b512;
use digest::Digest;
/// Database of fixed-length records, packed as a `cells_per_record × n_records`
/// column-major matrix of mod-p cells (p = 256, so one byte = one cell).
pub struct Database {
/// Row-major: data[i * n_records + j] = record j's byte i.
data: Vec<u32>,
n_records: usize,
record_bytes: usize,
}
impl Database {
/// Pack `records` (each padded/truncated to `record_bytes`) into a query-ready matrix.
pub fn new(records: &[&[u8]], record_bytes: usize) -> Self {
let n_records = records.len();
let cells = record_bytes;
let mut data = vec![0u32; cells * n_records];
for (j, rec) in records.iter().enumerate() {
let len = rec.len().min(record_bytes);
for i in 0..len {
data[i * n_records + j] = rec[i] as u32;
}
}
Database { data, n_records, record_bytes }
}
pub fn data(&self) -> &[u32] { &self.data }
pub fn n_records(&self) -> usize { self.n_records }
pub fn record_bytes(&self) -> usize { self.record_bytes }
pub fn cells_per_record(&self) -> usize { self.record_bytes }
/// BLAKE2b-256 commitment over (n_records, record_bytes, all packed cells).
pub fn version(&self) -> [u8; 32] {
let mut h = Blake2b512::new();
h.update((self.n_records as u64).to_le_bytes());
h.update((self.record_bytes as u64).to_le_bytes());
for &val in &self.data {
h.update(val.to_le_bytes());
}
let digest = h.finalize();
let mut v = [0u8; 32];
v.copy_from_slice(&digest[..32]);
v
}
}

View File

@ -1,372 +0,0 @@
//! SimplePIR-style single-server PIR (HenzingerHongCorrigan-GibbsMeiklejohnVaikuntanathan, USENIX Security 2023),
//! first-layer construction over ``Z_{2^{32}}`` with plaintext modulus ``p = 256``.
//!
//! ``H = D · A^T`` (offline hint), query ``q = A^T s + e + Δ·e_index``, answer ``D·q``; client removes ``H·s`` and rounds.
//! The grant **mailbox** uses single-hop retrieval: each PIR row contains one full encrypted mailbox row
//! (ephemeral key, detection tag, ciphertext, ciphertext digest), padded to ``RECORD_BYTES``.
//!
//! Security: decisional LWE with ``n = N_LWE``, ``q = 2^32``, ``σ = 6.4``.
use blake2::Blake2b512;
use digest::Digest;
use rand::rngs::OsRng;
use rand::Rng;
use super::db::Database;
use super::lwe;
use super::params::*;
const HINT_MAGIC: &[u8; 8] = b"ZKACSP1\0";
const CLIENT_STATE_MAGIC: &[u8; 8] = b"ZKACSPst";
// ── Hints (public matrix seed + precomputed ``H = D · A^T``) ─────────
pub struct Hints {
seed: [u8; 32],
n_records: usize,
cells_per_record: usize,
/// Row-major ``cells_per_record × N_LWE`` matrix (mod ``2^32``).
hint: Vec<u32>,
}
impl Hints {
pub fn n_records(&self) -> usize {
self.n_records
}
pub fn cells_per_record(&self) -> usize {
self.cells_per_record
}
pub fn seed(&self) -> &[u8; 32] {
&self.seed
}
pub fn hint_matrix(&self) -> &[u32] {
&self.hint
}
pub fn serialize(&self) -> Vec<u8> {
let n = self.cells_per_record * N_LWE;
debug_assert_eq!(self.hint.len(), n);
let mut buf = Vec::with_capacity(8 + 32 + 8 + 8 + n * 4);
buf.extend_from_slice(HINT_MAGIC);
buf.extend_from_slice(&self.seed);
buf.extend_from_slice(&(self.n_records as u64).to_le_bytes());
buf.extend_from_slice(&(self.cells_per_record as u64).to_le_bytes());
for &v in &self.hint {
buf.extend_from_slice(&v.to_le_bytes());
}
buf
}
pub fn deserialize(data: &[u8]) -> Result<Self, &'static str> {
if data.len() < 8 + 32 + 8 + 8 {
return Err("hint data too short");
}
if &data[0..8] != HINT_MAGIC {
return Err("unknown PIR hint format (expected SimplePIR v1)");
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&data[8..40]);
let n_records = u64::from_le_bytes(data[40..48].try_into().unwrap()) as usize;
let cells_per_record = u64::from_le_bytes(data[48..56].try_into().unwrap()) as usize;
let n = cells_per_record
.checked_mul(N_LWE)
.ok_or("overflow")?;
let need = 56 + n * 4;
if data.len() != need {
return Err("hint data length mismatch");
}
let hint: Vec<u32> = data[56..]
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect();
Ok(Hints {
seed,
n_records,
cells_per_record,
hint,
})
}
pub fn version(&self) -> [u8; 32] {
let mut h = Blake2b512::new();
h.update(b"zkac-pir-hints-sp1-v1");
h.update(self.seed);
h.update((self.n_records as u64).to_le_bytes());
h.update((self.cells_per_record as u64).to_le_bytes());
for &v in &self.hint {
h.update(v.to_le_bytes());
}
let digest = h.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&digest[..32]);
out
}
}
// ── Server ──────────────────────────────────────────────────────────
pub struct Server {
db: Database,
hints: Hints,
}
impl Server {
pub fn new(db: Database) -> Self {
let ell = db.cells_per_record();
let m = db.n_records();
let mut seed = [0u8; 32];
OsRng.fill(&mut seed);
let hint = if m == 0 {
Vec::new()
} else {
let a = lwe::gen_matrix(&seed, N_LWE, m);
lwe::mat_mul_bt(db.data(), &a, ell, m, N_LWE)
};
let hints = Hints {
seed,
n_records: m,
cells_per_record: ell,
hint,
};
Server { db, hints }
}
pub fn hints(&self) -> &Hints {
&self.hints
}
pub fn version(&self) -> [u8; 32] {
self.hints.version()
}
/// ``answer = D · q`` (mod ``2^32``); ``q`` is ``m`` ``u32`` limbs serialized as little-endian bytes.
pub fn answer(&self, query: &[u8]) -> Vec<u32> {
let m = self.hints.n_records;
let ell = self.hints.cells_per_record;
if m == 0 {
return Vec::new();
}
let q: Vec<u32> = query
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect();
if q.len() != m {
return Vec::new();
}
lwe::mat_vec_mul(self.db.data(), &q, ell, m)
}
pub fn n_records(&self) -> usize {
self.hints.n_records
}
pub fn record_bytes(&self) -> usize {
self.hints.cells_per_record
}
}
// ── Client ──────────────────────────────────────────────────────────
pub struct ClientState {
pub secret: Vec<u32>,
}
pub struct Client {
hints: Hints,
}
impl Client {
pub fn new(hints: Hints) -> Self {
Client { hints }
}
pub fn version(&self) -> [u8; 32] {
self.hints.version()
}
pub fn n_records(&self) -> usize {
self.hints.n_records
}
pub fn record_bytes(&self) -> usize {
self.hints.cells_per_record
}
pub fn query(&self, index: usize) -> (Vec<u8>, ClientState) {
assert!(index < self.hints.n_records, "PIR query index out of range");
let mut rng = OsRng;
let s = lwe::sample_uniform_vec(&mut rng, N_LWE);
let e = lwe::sample_error_vec(&mut rng, self.hints.n_records);
let a = lwe::gen_matrix(self.hints.seed(), N_LWE, self.hints.n_records);
let mut q = lwe::mat_t_vec_mul(&a, &s, N_LWE, self.hints.n_records);
for j in 0..self.hints.n_records {
q[j] = q[j].wrapping_add(e[j]);
}
q[index] = q[index].wrapping_add(DELTA);
(serialize_vec(&q), ClientState { secret: s })
}
pub fn decode(&self, answer: &[u32], state: &ClientState) -> Vec<u8> {
let h_s = lwe::mat_vec_mul(
self.hints.hint_matrix(),
&state.secret,
self.hints.cells_per_record,
N_LWE,
);
(0..self.hints.cells_per_record)
.map(|i| lwe::round_to_plaintext(answer[i].wrapping_sub(h_s[i])))
.collect()
}
}
pub fn serialize_vec(v: &[u32]) -> Vec<u8> {
let mut buf = Vec::with_capacity(v.len() * 4);
for &val in v {
buf.extend_from_slice(&val.to_le_bytes());
}
buf
}
pub fn deserialize_vec(data: &[u8]) -> Vec<u32> {
data.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect()
}
/// Persist [`ClientState`] for WASM / out-of-process flows.
pub fn serialize_client_state(st: &ClientState) -> Vec<u8> {
let mut buf = Vec::with_capacity(8 + st.secret.len() * 4);
buf.extend_from_slice(CLIENT_STATE_MAGIC);
for &x in &st.secret {
buf.extend_from_slice(&x.to_le_bytes());
}
buf
}
pub fn deserialize_client_state(data: &[u8]) -> Result<ClientState, &'static str> {
if data.len() < 8 + N_LWE * 4 {
return Err("client state too short");
}
if &data[0..8] != CLIENT_STATE_MAGIC {
return Err("bad client state magic");
}
let mut secret = vec![0u32; N_LWE];
let mut off = 8;
for i in 0..N_LWE {
secret[i] = u32::from_le_bytes(data[off..off + 4].try_into().map_err(|_| "parse")?);
off += 4;
}
if off != data.len() {
return Err("client state trailing garbage");
}
Ok(ClientState { secret })
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_records(n: usize, rec_bytes: usize) -> Vec<Vec<u8>> {
(0..n)
.map(|i| {
let mut rec = vec![0u8; rec_bytes];
rec[0] = i as u8;
rec[1] = (i.wrapping_mul(37) & 0xFF) as u8;
if rec_bytes > 2 {
rec[rec_bytes - 1] = 0xAA;
}
rec
})
.collect()
}
#[test]
fn roundtrip_small() {
let records = make_test_records(8, 24);
let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect();
let db = Database::new(&refs, 24);
let server = Server::new(db);
let hints_bytes = server.hints().serialize();
let client = Client::new(Hints::deserialize(&hints_bytes).unwrap());
for target in 0..8 {
let (query, state) = client.query(target);
let answer = server.answer(&query);
let decoded = client.decode(&answer, &state);
assert_eq!(decoded, records[target]);
}
}
#[test]
fn roundtrip_serialized_query_answer() {
let records = make_test_records(4, 64);
let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect();
let db = Database::new(&refs, 64);
let server = Server::new(db);
let client = Client::new(Hints::deserialize(&server.hints().serialize()).unwrap());
let (q, state) = client.query(2);
let ans = server.answer(&q);
let decoded = client.decode(&ans, &state);
assert_eq!(decoded, records[2]);
}
#[test]
fn version_changes_on_rebuild() {
let records = make_test_records(4, 64);
let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect();
let db1 = Database::new(&refs, 64);
let s1 = Server::new(db1);
let v1 = s1.version();
let db2 = Database::new(&refs, 64);
let s2 = Server::new(db2);
let v2 = s2.version();
assert_ne!(v1, v2);
}
#[test]
fn hints_roundtrip() {
let records = make_test_records(4, 64);
let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect();
let db = Database::new(&refs, 64);
let server = Server::new(db);
let original = server.hints();
let bytes = original.serialize();
let restored = Hints::deserialize(&bytes).unwrap();
assert_eq!(original.seed(), restored.seed());
assert_eq!(original.n_records(), restored.n_records());
assert_eq!(original.cells_per_record(), restored.cells_per_record());
assert_eq!(original.hint_matrix(), restored.hint_matrix());
assert_eq!(original.version(), restored.version());
}
#[test]
fn client_state_serialize_roundtrip() {
let records = make_test_records(3, 32);
let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect();
let db = Database::new(&refs, 32);
let server = Server::new(db);
let client = Client::new(Hints::deserialize(&server.hints().serialize()).unwrap());
let (q, st) = client.query(1);
let bytes = serialize_client_state(&st);
let st2 = deserialize_client_state(&bytes).unwrap();
let ans = server.answer(&q);
let d1 = client.decode(&ans, &st);
let d2 = client.decode(&ans, &st2);
assert_eq!(d1, d2);
}
#[test]
fn wire_sizes_match_simple_pir_formulas() {
let m: u64 = 100;
let ell = RECORD_BYTES as u64;
let n = N_LWE as u64;
let hint = 56u64 + ell * n * 4u64;
let query = m * 4u64;
let answer = ell * 4u64;
assert_eq!(hint, 56 + (RECORD_BYTES as u64) * (N_LWE as u64) * 4);
assert!(hint > query && hint > answer);
let _ = (hint, query, answer);
}
}

View File

@ -1,103 +0,0 @@
use rand::{Rng, SeedableRng};
use rand::rngs::StdRng;
use super::params::*;
/// Deterministically generate a `rows × cols` matrix of uniform u32 values from `seed`.
pub fn gen_matrix(seed: &[u8; 32], rows: usize, cols: usize) -> Vec<u32> {
let mut rng = StdRng::from_seed(*seed);
(0..rows * cols).map(|_| rng.gen()).collect()
}
/// Sample one value from a discrete Gaussian with stddev SIGMA (Box-Muller, rounded).
fn sample_gaussian(rng: &mut impl Rng) -> i32 {
let u1: f64 = rng.gen_range(1e-10f64..1.0);
let u2: f64 = rng.gen();
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
(z * SIGMA).round() as i32
}
/// Sample a vector of `len` small error values (discrete Gaussian, stored as u32 mod 2^32).
pub fn sample_error_vec(rng: &mut impl Rng, len: usize) -> Vec<u32> {
(0..len).map(|_| sample_gaussian(rng) as u32).collect()
}
pub fn sample_uniform_vec(rng: &mut impl Rng, len: usize) -> Vec<u32> {
(0..len).map(|_| rng.gen()).collect()
}
/// C = A · B^T (mod 2^32).
/// A is `rows_a × inner` (row-major), B is `rows_b × inner` (row-major).
/// Result C is `rows_a × rows_b`.
pub fn mat_mul_bt(a: &[u32], b: &[u32], rows_a: usize, inner: usize, rows_b: usize) -> Vec<u32> {
let mut c = vec![0u32; rows_a * rows_b];
for i in 0..rows_a {
let a_off = i * inner;
for k in 0..rows_b {
let b_off = k * inner;
let mut acc = 0u64;
for j in 0..inner {
acc = acc.wrapping_add(
(a[a_off + j] as u64).wrapping_mul(b[b_off + j] as u64),
);
}
c[i * rows_b + k] = acc as u32;
}
}
c
}
/// c = A · v (mod 2^32). A is `rows × cols`, v is `cols`-length.
pub fn mat_vec_mul(a: &[u32], v: &[u32], rows: usize, cols: usize) -> Vec<u32> {
let mut c = vec![0u32; rows];
for i in 0..rows {
let off = i * cols;
let mut acc = 0u64;
for j in 0..cols {
acc = acc.wrapping_add((a[off + j] as u64).wrapping_mul(v[j] as u64));
}
c[i] = acc as u32;
}
c
}
/// c = A^T · v (mod 2^32). A is `rows × cols`, v is `rows`-length. Result is `cols`-length.
pub fn mat_t_vec_mul(a: &[u32], v: &[u32], rows: usize, cols: usize) -> Vec<u32> {
let mut c = vec![0u64; cols];
for k in 0..rows {
let v_k = v[k] as u64;
let off = k * cols;
for j in 0..cols {
c[j] = c[j].wrapping_add(v_k.wrapping_mul(a[off + j] as u64));
}
}
c.iter().map(|&x| x as u32).collect()
}
/// Round from Z_{2^32} to Z_p. Recovers the plaintext byte from Δ·m + noise.
pub fn round_to_plaintext(val: u32) -> u8 {
(val.wrapping_add(DELTA / 2) >> 24) as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_exact() {
for m in 0u32..=255 {
let encoded = m.wrapping_mul(DELTA);
assert_eq!(round_to_plaintext(encoded), m as u8);
}
}
#[test]
fn round_with_small_noise() {
for m in 0u32..=255 {
for noise in [-100i32, -1, 0, 1, 100] {
let encoded = m.wrapping_mul(DELTA).wrapping_add(noise as u32);
assert_eq!(round_to_plaintext(encoded), m as u8);
}
}
}
}

View File

@ -1,69 +0,0 @@
pub mod params;
pub mod lwe;
pub mod db;
/// Production single-server SimplePIR implementation.
pub mod doublepir;
pub use params::RECORD_BYTES;
pub use db::Database;
pub use doublepir::{
deserialize_client_state, deserialize_vec, serialize_client_state, serialize_vec, Client,
ClientState, Hints, Server,
};
use blake2::Blake2b512;
use digest::Digest;
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
/// Compute a 16-byte detection tag for grant discovery.
///
/// tag = BLAKE2b-512("zkac-grant-tag" || X25519(sk, pk))[..16]
///
/// Both sides of the DH produce the same tag, so the sender (admin) can
/// publish it alongside the grant ciphertext, and the recipient can match
/// it locally without PIR.
pub fn detection_tag(secret_key: &[u8; 32], public_key: &[u8; 32]) -> [u8; 16] {
let sk = StaticSecret::from(*secret_key);
let pk = X25519Public::from(*public_key);
let shared = sk.diffie_hellman(&pk);
let mut h = Blake2b512::new();
h.update(b"zkac-grant-tag");
h.update(shared.as_bytes());
let digest = h.finalize();
let mut tag = [0u8; 16];
tag.copy_from_slice(&digest[..16]);
tag
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::OsRng;
#[test]
fn detection_tag_symmetric() {
let sk_a = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng);
let pk_a = X25519Public::from(&sk_a);
let sk_b = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng);
let pk_b = X25519Public::from(&sk_b);
let tag_ab = detection_tag(&sk_a.to_bytes(), pk_b.as_bytes());
let tag_ba = detection_tag(&sk_b.to_bytes(), pk_a.as_bytes());
assert_eq!(tag_ab, tag_ba);
}
#[test]
fn detection_tag_different_keys() {
let sk_a = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng);
let sk_b = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng);
let sk_c = x25519_dalek::StaticSecret::random_from_rng(&mut OsRng);
let pk_b = X25519Public::from(&sk_b);
let pk_c = X25519Public::from(&sk_c);
let tag1 = detection_tag(&sk_a.to_bytes(), pk_b.as_bytes());
let tag2 = detection_tag(&sk_a.to_bytes(), pk_c.as_bytes());
assert_ne!(tag1, tag2);
}
}

View File

@ -1,18 +0,0 @@
/// LWE dimension — controls security level.
pub const N_LWE: usize = 1024;
/// Plaintext modulus: each cell holds one byte (p = 256).
pub const P: u32 = 256;
/// Scaling factor Δ = 2^32 / p = 2^24. Maps plaintext [0,255] into Z_{2^32}.
pub const DELTA: u32 = 1 << 24;
/// Discrete Gaussian standard deviation for LWE error sampling.
pub const SIGMA: f64 = 6.4;
/// Fixed record size for PIR (bytes). Must fit one full encrypted mailbox row.
///
/// Mailbox retrieval is single-hop: the PIR row carries ``(eph_pk, to_tag, ciphertext, digest)``
/// so clients do not reveal a stable ``grant_id`` in a second fetch round-trip.
/// Hint size scales as ``O(RECORD_BYTES · N_LWE · n)``.
pub const RECORD_BYTES: usize = 2048;

View File

@ -833,187 +833,11 @@ impl PyNode {
} }
} }
// ── PIR (SimplePIR, LWE-based) ─────────────────────────────────────
#[pyclass(name = "PirDatabase")]
pub struct PyPirDatabase {
inner: crate::pir::Database,
}
#[pymethods]
impl PyPirDatabase {
#[new]
fn new(records: Vec<Vec<u8>>, record_bytes: usize) -> PyResult<Self> {
if record_bytes == 0 {
return Err(PyValueError::new_err("record_bytes must be > 0"));
}
let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect();
Ok(PyPirDatabase {
inner: crate::pir::Database::new(&refs, record_bytes),
})
}
fn version<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> {
PyBytes::new(py, &self.inner.version())
}
#[getter]
fn record_bytes(&self) -> usize {
self.inner.record_bytes()
}
#[getter]
fn n_records(&self) -> usize {
self.inner.n_records()
}
}
#[pyclass(name = "PirServer")]
pub struct PyPirServer {
inner: crate::pir::Server,
}
#[pymethods]
impl PyPirServer {
#[new]
fn new(db: &PyPirDatabase) -> Self {
// Database is packed into u32 cells; we need to reconstruct from the
// inner data. Since Database doesn't implement Clone, rebuild it from
// the raw cell data.
//
// Actually, Server::new takes ownership of Database. We reconstruct
// by re-packing from the stored cell matrix. This is slightly wasteful
// but keeps the API simple.
let n = db.inner.n_records();
let rb = db.inner.record_bytes();
let cells = db.inner.cells_per_record();
let mut records: Vec<Vec<u8>> = Vec::with_capacity(n);
for j in 0..n {
let mut rec = vec![0u8; rb];
for i in 0..cells {
rec[i] = db.inner.data()[i * n + j] as u8;
}
records.push(rec);
}
let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect();
let new_db = crate::pir::Database::new(&refs, rb);
PyPirServer {
inner: crate::pir::Server::new(new_db),
}
}
fn hints<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> {
PyBytes::new(py, &self.inner.hints().serialize())
}
fn version<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> {
PyBytes::new(py, &self.inner.version())
}
fn answer<'py>(&self, py: Python<'py>, query: &[u8]) -> PyResult<Bound<'py, PyBytes>> {
let ans = self.inner.answer(query);
Ok(PyBytes::new(py, &crate::pir::serialize_vec(&ans)))
}
#[getter]
fn n_records(&self) -> usize {
self.inner.n_records()
}
#[getter]
fn record_bytes(&self) -> usize {
self.inner.record_bytes()
}
}
#[pyclass(name = "PirClientState")]
pub struct PyPirClientState {
inner: Option<crate::pir::ClientState>,
}
#[pyclass(name = "PirClient")]
pub struct PyPirClient {
inner: crate::pir::Client,
}
#[pymethods]
impl PyPirClient {
#[new]
fn new(hints: &[u8], n_records: usize, record_bytes: usize) -> PyResult<Self> {
let h = crate::pir::Hints::deserialize(hints)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
if h.n_records() != n_records {
return Err(PyValueError::new_err("n_records mismatch with hints"));
}
if h.cells_per_record() != record_bytes {
return Err(PyValueError::new_err("record_bytes mismatch with hints"));
}
Ok(PyPirClient {
inner: crate::pir::Client::new(h),
})
}
fn version<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> {
PyBytes::new(py, &self.inner.version())
}
fn query<'py>(
&self,
py: Python<'py>,
index: usize,
) -> PyResult<(Bound<'py, PyBytes>, PyPirClientState)> {
if index >= self.inner.n_records() {
return Err(PyValueError::new_err("index out of range"));
}
let (q, state) = self.inner.query(index);
Ok((
PyBytes::new(py, &q),
PyPirClientState { inner: Some(state) },
))
}
fn decode<'py>(
&self,
py: Python<'py>,
answer: &[u8],
state: &mut PyPirClientState,
) -> PyResult<Bound<'py, PyBytes>> {
let st = state.inner.take().ok_or_else(|| {
PyValueError::new_err("PirClientState already consumed")
})?;
let ans = crate::pir::deserialize_vec(answer);
let expect = self.inner.record_bytes();
if ans.len() != expect {
return Err(PyValueError::new_err(format!(
"answer length mismatch: got {} u32, want {} (one limb per record byte)",
ans.len(),
expect
)));
}
let decoded = self.inner.decode(&ans, &st);
Ok(PyBytes::new(py, &decoded))
}
}
#[pyfunction]
fn grant_detection_tag<'py>(
py: Python<'py>,
secret_key: &[u8],
public_key: &[u8],
) -> PyResult<Bound<'py, PyBytes>> {
let sk = to_32(secret_key, "secret_key")?;
let pk = to_32(public_key, "public_key")?;
let tag = crate::pir::detection_tag(&sk, &pk);
Ok(PyBytes::new(py, &tag))
}
// ── Module ─────────────────────────────────────────────────────────── // ── Module ───────────────────────────────────────────────────────────
#[pymodule] #[pymodule]
fn _zkac(m: &Bound<'_, PyModule>) -> PyResult<()> { fn _zkac(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("MAX_BBS_AUTH_PROOF_BYTES", crate::node::MAX_BBS_AUTH_PROOF_BYTES)?; m.add("MAX_BBS_AUTH_PROOF_BYTES", crate::node::MAX_BBS_AUTH_PROOF_BYTES)?;
m.add("PIR_RECORD_BYTES", crate::pir::RECORD_BYTES)?;
// Transport identity (ristretto255) // Transport identity (ristretto255)
m.add_class::<PyKeypair>()?; m.add_class::<PyKeypair>()?;
m.add_class::<PyPublicKey>()?; m.add_class::<PyPublicKey>()?;
@ -1035,12 +859,6 @@ fn _zkac(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyIssuanceKeypair>()?; m.add_class::<PyIssuanceKeypair>()?;
m.add_function(wrap_pyfunction!(encrypt_for_admin, m)?)?; m.add_function(wrap_pyfunction!(encrypt_for_admin, m)?)?;
m.add_function(wrap_pyfunction!(decrypt_from_admin, m)?)?; m.add_function(wrap_pyfunction!(decrypt_from_admin, m)?)?;
// PIR (LWE-based, single-server)
m.add_class::<PyPirDatabase>()?;
m.add_class::<PyPirServer>()?;
m.add_class::<PyPirClient>()?;
m.add_class::<PyPirClientState>()?;
m.add_function(wrap_pyfunction!(grant_detection_tag, m)?)?;
// Transport // Transport
m.add_class::<PySession>()?; m.add_class::<PySession>()?;
m.add_class::<PyNode>()?; m.add_class::<PyNode>()?;

View File

@ -1,82 +0,0 @@
"""Tests for LWE-based SimplePIR via the Python bindings."""
import json
import os
import zkac
def _pad(entry: dict) -> bytes:
raw = json.dumps(entry, separators=(",", ":"), sort_keys=True).encode()
return raw + b"\x00" * (zkac.PIR_RECORD_BYTES - len(raw))
def test_roundtrip_small():
"""Build a small DB, query every index, verify each decode is correct."""
records = [
_pad({"id": i, "data": f"record-{i}"})
for i in range(8)
]
db = zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES)
server = zkac.PirServer(db)
hints = bytes(server.hints())
client = zkac.PirClient(hints, 8, zkac.PIR_RECORD_BYTES)
for target in range(8):
q, state = client.query(target)
answer = server.answer(q)
decoded = bytes(client.decode(answer, state))
assert decoded == records[target], f"mismatch at index {target}"
def test_roundtrip_medium():
"""64 records padded to ``PIR_RECORD_BYTES`` (must fit LWE-encoded plaintext)."""
records = []
for i in range(64):
entry = {"id": i, "payload": os.urandom(32).hex()}
records.append(_pad(entry))
db = zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES)
server = zkac.PirServer(db)
hints = bytes(server.hints())
client = zkac.PirClient(hints, 64, zkac.PIR_RECORD_BYTES)
for target in [0, 1, 31, 32, 63]:
q, state = client.query(target)
answer = server.answer(q)
decoded = bytes(client.decode(answer, state))
assert decoded == records[target]
def test_version_changes_on_rebuild():
"""Rebuilding from the same data with a new seed gives a different version."""
records = [_pad({"i": i}) for i in range(4)]
s1 = zkac.PirServer(zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES))
s2 = zkac.PirServer(zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES))
assert bytes(s1.version()) != bytes(s2.version())
def test_hints_serialize_roundtrip():
records = [_pad({"i": i}) for i in range(4)]
server = zkac.PirServer(zkac.PirDatabase(records, zkac.PIR_RECORD_BYTES))
hints1 = bytes(server.hints())
client = zkac.PirClient(hints1, 4, zkac.PIR_RECORD_BYTES)
assert bytes(client.version()) == bytes(server.version())
def test_detection_tag_symmetric():
"""grant_detection_tag(a_sk, b_pk) == grant_detection_tag(b_sk, a_pk)."""
kp_a = zkac.IssuanceKeypair()
kp_b = zkac.IssuanceKeypair()
tag_ab = bytes(zkac.grant_detection_tag(kp_a.secret_bytes(), kp_b.public_key_bytes()))
tag_ba = bytes(zkac.grant_detection_tag(kp_b.secret_bytes(), kp_a.public_key_bytes()))
assert tag_ab == tag_ba
assert len(tag_ab) == 16
def test_detection_tag_distinct():
kp_a = zkac.IssuanceKeypair()
kp_b = zkac.IssuanceKeypair()
kp_c = zkac.IssuanceKeypair()
t1 = bytes(zkac.grant_detection_tag(kp_a.secret_bytes(), kp_b.public_key_bytes()))
t2 = bytes(zkac.grant_detection_tag(kp_a.secret_bytes(), kp_c.public_key_bytes()))
assert t1 != t2

1023
uv.lock generated

File diff suppressed because it is too large Load Diff