This commit is contained in:
everbarry 2026-04-18 01:06:12 +02:00
parent d01a6ebf85
commit 6e67836e95
12 changed files with 685 additions and 403 deletions

View File

@ -10,99 +10,71 @@ zkac-node --help
## Quick start
```bash
# 1. Create identities (one per machine / actor)
zkac-node identity init # on admin machine
zkac-node identity init # on recipient machine (separate ~/.zkac)
# 1. Create identities (one directory per user under ~/.zkac/<userid>/)
zkac-node user create alice
zkac-node user create bob
# Recipient shares their issuance public key out-of-band:
zkac-node identity show # prints issuance pk (hex)
# Bob shares his issuance public key with Alice out-of-band:
# zkac-node user show bob → copy issuance pk
# 2. Start the server (separate machine or same, different data-dir)
zkac-node serve --data-dir /var/lib/zkac --port 9800 &
# 2. Alice runs a server; pin its public key for clients
zkac-node serve alice --port 9800 &
zkac-node server pin alice localhost:9800 --key <SERVER_PK_HEX>
zkac-node server pin bob localhost:9800 --key <SERVER_PK_HEX>
# 3. Pin the server's public key (printed at startup)
zkac-node server pin localhost:9800 --key <SERVER_PK_HEX>
# 3. Alice creates a registry and grants Bob a role (needs Bob's issuance pk hex)
zkac-node registry create alice localhost:9800 --roles analyst,operator
zkac-node grant alice --server localhost:9800 \
--registry <REGISTRY_ID> --role analyst --to $BOB_PK_HEX
# (prints pool_index for Bobs collect)
# 4. Create a registry (admin side)
zkac-node registry create localhost:9800 --roles analyst,operator
# 4. Two-server XOR PIR needs a second replica with the same server_key + grants pool.
# Example: rsync ~/.zkac/alice/server/ to a temp dir after the grant, then:
# zkac-node serve alice --port 9801 --data-dir /tmp/zkac-replica &
# zkac-node server pin bob localhost:9801 --key <same SERVER_PK_HEX as step 2>
# 5. Grant recipient the 'analyst' role (only needs their public key)
zkac-node grant --server localhost:9800 \
--registry <REGISTRY_ID> --role analyst --to <RECIPIENT_PK_HEX>
# 5. Bob lists local creds; optional pending scan (O(n) PIR queries per server)
zkac-node credentials list bob
zkac-node credentials list bob --server localhost:9800 --pir-peer localhost:9801
# 6. Recipient lists pending credentials
zkac-node credentials list --server localhost:9800
# 6. Bob collects (primary host in spec, second replica as --pir-peer)
zkac-node collect bob localhost:9800:<REGISTRY_ID>:analyst \
--pir-peer localhost:9801 --pool-index <POOL_INDEX>
# 7. Recipient collects (host:port:registry_id:role)
zkac-node collect localhost:9800:<REGISTRY_ID>:analyst
# 8. Recipient authenticates anonymously
zkac-node auth --registry <REGISTRY_ID> --role analyst --server localhost:9800
# 7. Bob authenticates
zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server localhost:9800
```
## Commands
| Command | Description |
|---------|-------------|
| `identity init` | Generate issuance keypair under `~/.zkac/` |
| `identity show` | Show issuance pk + owned registries + credentials |
| `serve --data-dir D` | Run as a ZKAC server storing data in D |
| `server pin <host:port> --key <hex>` | Pin a server's public key |
| `registry create <server> --roles r1,r2` | Create a new registry (fresh BBS+ issuer) |
| `registry update <server> --registry R --add-roles r3` | Add roles to a registry you own |
| `registry get <server> --registry R` | Fetch registry state from a server |
| `registry list` | List locally owned registries |
| `grant --server S --registry R --role X --to <pk>` | Issue credential encrypted to recipient's pk |
| `credentials list [--server S ...]` | Show local credentials + pending grants |
| `collect <host:port:registry:role>` | Fetch + finalize one pending credential |
| `auth --registry R --role X [--server S]` | Authenticate via ZKAC handshake |
| `user create <id>` | Generate issuance keypair under `~/.zkac/<id>/` |
| `user list` | List all local user ids |
| `user show <id>` | Show issuance pk + owned registries + credentials |
| `serve <id> [--data-dir D]` | Run server; default data dir is `~/.zkac/<id>/server/` |
| `server pin <id> <host:port> --key <hex>` | Pin server public key for that user |
| `registry create <id> <server> --roles …` | Create registry on server |
| `registry update <id> <server> --registry R --add-roles …` | Add roles |
| `registry get <id> <server> --registry R` | Fetch registry state |
| `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) |
| `credentials list <id> [--server S …] [--pir-peer P]` | Local credentials; pending grants only with `--pir-peer` (PIR scan) |
| `collect <id> <spec> --pir-peer P --pool-index N` | Fetch one grant via two-server XOR PIR |
| `auth <id> --registry R --role X [--server S]` | Authenticated session |
## Protocol
## Protocol & threat model
All connections use a single encrypted channel:
1. **Anonymous handshake** (X25519 ephemeral DH + Schnorr server identity proof
verified against a pinned public key) establishes an encrypted session.
2. The first encrypted frame selects the mode:
- `{"op": "mgmt"}` — management commands (JSON request/reply loop)
- `{"op": "auth", ...}` — BBS+ role authentication
Admin-only commands (`post_grant`) require a BBS+ presentation of the registry's
`__admin__` credential bound to the session transcript hash — unlinkable across
grants by construction.
## Threat model
The server is a **trustless, zero-information** relay:
- All traffic is encrypted and server-authenticated (same handshake as role auth).
- Registry state is opaque bytes with a BBS+ state certificate. The server cannot
mutate state or impersonate the admin without the BBS+ issuer secret.
- Grants are end-to-end encrypted from admin to recipient using ephemeral X25519
ECDH. The server stores only `(recipient_pk, eph_pk, ciphertext)` per grant —
no registry ID, role name, or credential material.
- Admin identity is unlinkable across `grant` calls (BBS+ presentations are
rerandomized per call).
- Recipient's authenticated session uses a fresh ephemeral transport key + an
unlinkable BBS+ presentation, so the server cannot correlate sessions with
the mailbox pk used at collect time.
- No user IDs exist on the server. Clients are identified only by ephemeral
keys (handshake) or issuance public keys (mailbox addressing).
See [docs/SECURITY.md](../docs/SECURITY.md) in the repo root.
## Storage layout
Client (`~/.zkac/`):
```
identity.json issuance keypair (long-term secret)
admin/<registry_id>.json BBS+ issuer + admin credential per owned registry
credentials/<rid>_<role>.json BBS+ credentials granted to this identity
servers/<host_port>.json pinned server public keys
```
Per user `~/.zkac/<userid>/`:
Server (`--data-dir`):
```
server_key.json Schnorr keypair (long-term server secret)
registries/<rid>.state raw RegistryState bytes
registries/<rid>.cert raw state cert bytes
mailbox/<recipient_pk_hex>.json [{grant_id, eph_pk_b64, ciphertext_b64}, ...]
identity.json issuance keypair
admin/<registry_id>.json BBS+ admin material for owned registries
credentials/<rid>_<role>.json received credentials
servers/<host_port>.json pinned server public keys
server/ (only if you run `serve <userid>`) server_key.json, registries/, mailbox/grants_pool.json
```

View File

@ -0,0 +1 @@

View File

@ -1,18 +1,4 @@
"""Client-side operations over a unified encrypted channel.
All management and grant traffic uses the same anonymous handshake
(X25519 + Schnorr server identity proof) as authenticated sessions.
Grant flow (admin-initiated, server is trustless):
1. Admin knows recipient's X25519 issuance public key (out-of-band).
2. Admin locally prepares a blind request and issues the blind signature.
3. Admin encrypts the full credential material to the recipient's pk.
4. Admin posts the opaque ciphertext to the server's mailbox, authenticated
by a BBS+ admin presentation bound to the session transcript.
5. Recipient trial-decrypts each pending entry to find matching grants.
The server sees only: recipient_pk, eph_pk, ciphertext, grant_id.
"""
"""Client-side operations over a unified encrypted channel (per local user id)."""
from __future__ import annotations
@ -21,9 +7,9 @@ import json
import socket
import zkac
from zkac.tcp import FramedSession, client_handshake_anon, client_handshake_managed
from zkac.tcp import FramedSession, client_handshake_anon
from . import store
from . import pir, store
def _b64(data: bytes) -> str:
@ -47,23 +33,19 @@ def parse_spec(spec: str) -> tuple[str, str, str]:
return parts[0], parts[1], parts[2]
# ── Encrypted management session ─────────────────────────────────────
def _resolve_server_pk(server: str) -> zkac.PublicKey:
"""Load pinned server public key or fail."""
pin = store.load_server_pin(server)
def _resolve_server_pk(userid: str, server: str) -> zkac.PublicKey:
pin = store.load_server_pin(userid, server)
if pin is None:
raise RuntimeError(
f"no pinned key for {server}; run: zkac-node server pin {server} --key <hex>"
f"no pinned key for {server}; run: zkac-node server pin {userid} {server} --key <hex>"
)
return zkac.PublicKey.from_bytes(_unb64(pin["server_public_key_b64"]))
def _mgmt_connect(server: str) -> tuple[socket.socket, FramedSession]:
"""Open an encrypted management session to the server."""
def _mgmt_connect(userid: str, server: str) -> tuple[socket.socket, FramedSession]:
host, port = _parse_server(server)
sock = socket.create_connection((host, port))
server_pk = _resolve_server_pk(server)
server_pk = _resolve_server_pk(userid, server)
node = zkac.Node(zkac.Keypair())
session = client_handshake_anon(sock, node, server_pk)
framed = FramedSession(sock, session)
@ -76,16 +58,22 @@ def _mgmt_cmd(framed: FramedSession, cmd: dict) -> dict:
return json.loads(framed.recv())
def _mgmt_single(userid: str, server: str, cmd: dict) -> dict:
sock, framed = _mgmt_connect(userid, server)
try:
return _ok(_mgmt_cmd(framed, cmd))
finally:
sock.close()
def _ok(resp: dict) -> dict:
if resp.get("error"):
raise RuntimeError(resp["error"])
return resp
# ── Registry operations ──────────────────────────────────────────────
def create_registry(server: str, role_names: list[str]) -> str:
identity = store.load_identity()
def create_registry(userid: str, server: str, role_names: list[str]) -> str:
identity = store.load_identity(userid)
admin_mat = store.new_admin_material()
bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_mat)
@ -97,18 +85,14 @@ def create_registry(server: str, role_names: list[str]) -> str:
state_cert = state.certify(admin_cred)
registry_id = state.registry_id()
sock, framed = _mgmt_connect(server)
try:
resp = _ok(_mgmt_cmd(framed, {
"cmd": "create_registry",
"state_bytes_b64": _b64(state_bytes),
"state_cert_b64": _b64(bytes(state_cert)),
}))
finally:
sock.close()
resp = _mgmt_single(userid, server, {
"cmd": "create_registry",
"state_bytes_b64": _b64(state_bytes),
"state_cert_b64": _b64(bytes(state_cert)),
})
rid_hex = resp["registry_id"]
store.save_admin(rid_hex, {
store.save_admin(userid, rid_hex, {
"server": server,
"roles": role_names,
**admin_mat,
@ -116,57 +100,49 @@ def create_registry(server: str, role_names: list[str]) -> str:
return rid_hex
def update_registry(server: str, registry_id_hex: str, add_roles: list[str]):
admin_data = store.load_admin(registry_id_hex)
def update_registry(userid: str, server: str, registry_id_hex: str, add_roles: list[str]):
admin_data = store.load_admin(userid, registry_id_hex)
bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data)
identity = store.load_identity()
identity = store.load_identity(userid)
sock, framed = _mgmt_connect(server)
try:
cur = _ok(_mgmt_cmd(framed, {
"cmd": "get_registry", "registry_id": registry_id_hex,
}))
cur = _mgmt_single(userid, server, {
"cmd": "get_registry", "registry_id": registry_id_hex,
})
old_state = zkac.RegistryState.deserialize(_unb64(cur["state_bytes_b64"]))
prev_hash = old_state.state_hash()
new_version = old_state.version() + 1
old_state = zkac.RegistryState.deserialize(_unb64(cur["state_bytes_b64"]))
prev_hash = old_state.state_hash()
new_version = old_state.version() + 1
old_roles = admin_data.get("roles", [])
all_roles = list(old_roles) + [r for r in add_roles if r not in old_roles]
role_entries = [(zkac.role_id(name), bbs_pk, 1) for name in all_roles]
old_roles = admin_data.get("roles", [])
all_roles = list(old_roles) + [r for r in add_roles if r not in old_roles]
role_entries = [(zkac.role_id(name), bbs_pk, 1) for name in all_roles]
new_state = zkac.RegistryState.build(
bbs_pk, identity["issuance_pk"], new_version, bytes(prev_hash), role_entries,
)
new_cert = new_state.certify(admin_cred)
new_state = zkac.RegistryState.build(
bbs_pk, identity["issuance_pk"], new_version, bytes(prev_hash), role_entries,
)
new_cert = new_state.certify(admin_cred)
_ok(_mgmt_cmd(framed, {
"cmd": "update_registry",
"registry_id": registry_id_hex,
"state_bytes_b64": _b64(new_state.serialize()),
"state_cert_b64": _b64(bytes(new_cert)),
}))
finally:
sock.close()
_mgmt_single(userid, server, {
"cmd": "update_registry",
"registry_id": registry_id_hex,
"state_bytes_b64": _b64(new_state.serialize()),
"state_cert_b64": _b64(bytes(new_cert)),
})
admin_data["roles"] = all_roles
store.save_admin(registry_id_hex, admin_data)
store.save_admin(userid, registry_id_hex, admin_data)
def get_registry(server: str, registry_id_hex: str) -> dict:
sock, framed = _mgmt_connect(server)
try:
return _ok(_mgmt_cmd(framed, {
"cmd": "get_registry", "registry_id": registry_id_hex,
}))
finally:
sock.close()
def get_registry(userid: str, server: str, registry_id_hex: str) -> dict:
return _mgmt_single(userid, server, {
"cmd": "get_registry", "registry_id": registry_id_hex,
})
def list_own_registries() -> list[dict]:
def list_own_registries(userid: str) -> list[dict]:
result = []
for rid in store.list_admin_registries():
data = store.load_admin(rid)
for rid in store.list_admin_registries(userid):
data = store.load_admin(userid, rid)
result.append({
"registry_id": rid,
"server": data.get("server", "?"),
@ -175,12 +151,9 @@ def list_own_registries() -> list[dict]:
return result
# ── Admin-initiated grant ────────────────────────────────────────────
def grant(server: str, registry_id_hex: str, role_name: str,
recipient_pk_hex: str) -> str:
"""Admin issues a credential and posts it (encrypted) to the recipient's mailbox."""
admin_data = store.load_admin(registry_id_hex)
def grant(userid: str, server: str, registry_id_hex: str, role_name: str,
recipient_pk_hex: str) -> tuple[str, int]:
admin_data = store.load_admin(userid, registry_id_hex)
roles = admin_data.get("roles", [])
if role_name not in roles:
raise RuntimeError(f"role {role_name!r} not in registry (have: {roles})")
@ -206,14 +179,13 @@ def grant(server: str, registry_id_hex: str, role_name: str,
eph_kp = zkac.IssuanceKeypair()
ciphertext = eph_kp.encrypt(recipient_pk, payload)
sock, framed = _mgmt_connect(server)
sock, framed = _mgmt_connect(userid, server)
try:
transcript_hash = bytes(framed.session.transcript_hash())
admin_proof = admin_cred.present(transcript_hash)
resp = _ok(_mgmt_cmd(framed, {
"cmd": "post_grant",
"registry_id": registry_id_hex,
"recipient_pk_hex": recipient_pk_hex,
"eph_pk_b64": _b64(eph_kp.public_key_bytes()),
"ciphertext_b64": _b64(ciphertext),
"admin_proof_b64": _b64(admin_proof),
@ -221,84 +193,130 @@ def grant(server: str, registry_id_hex: str, role_name: str,
finally:
sock.close()
return resp["grant_id"]
return resp["grant_id"], resp.get("pool_index", -1)
# ── Receiver: list + collect (trial-decrypt) ─────────────────────────
def _pir_recover_row(
framed_a: FramedSession,
framed_b: FramedSession,
n: int,
pool_index: int,
) -> dict:
idx_a, idx_b = pir.pir_query_indices(n, pool_index)
xa = _ok(_mgmt_cmd(framed_a, {"cmd": "pir_fold", "indices": idx_a}))
xb = _ok(_mgmt_cmd(framed_b, {"cmd": "pir_fold", "indices": idx_b}))
raw = pir.pir_recover(_unb64(xa["xor_b64"]), _unb64(xb["xor_b64"]))
return pir.unpad_grant_record(raw)
def list_pending(server: str) -> list[dict]:
identity = store.load_identity()
pk_hex = identity["issuance_pk"].hex()
sock, framed = _mgmt_connect(server)
def _fetch_grant_entry_pir(
userid: str, server_a: str, server_b: str, pool_index: int,
) -> dict:
sock_a, fa = _mgmt_connect(userid, server_a)
sock_b, fb = _mgmt_connect(userid, server_b)
try:
resp = _ok(_mgmt_cmd(framed, {"cmd": "list_grants", "recipient_pk_hex": pk_hex}))
a = _ok(_mgmt_cmd(fa, {"cmd": "mail_pool_len"}))
b = _ok(_mgmt_cmd(fb, {"cmd": "mail_pool_len"}))
if a["n"] != b["n"] or a["record_bytes"] != b["record_bytes"]:
raise RuntimeError("PIR peers disagree on pool length or record size")
n = a["n"]
if not (0 <= pool_index < n):
raise RuntimeError("pool_index out of range for current pool")
return _pir_recover_row(fa, fb, n, pool_index)
finally:
sock.close()
receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"])
results = []
for entry in resp["grants"]:
try:
eph_pk = _unb64(entry["eph_pk_b64"])
ct = _unb64(entry["ciphertext_b64"])
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct))
results.append({
"grant_id": entry["grant_id"],
"registry_id": plaintext.get("registry_id", "?"),
"role_name": plaintext.get("role_name", "?"),
})
except Exception:
results.append({
"grant_id": entry["grant_id"],
"registry_id": "?",
"role_name": "(undecryptable)",
})
return results
sock_a.close()
sock_b.close()
def collect(spec: str) -> dict:
"""Fetch and finalize a pending credential described by 'host:port:registry_id:role'."""
server, registry_id_hex, role_name = parse_spec(spec)
identity = store.load_identity()
pk_hex = identity["issuance_pk"].hex()
def list_pending(userid: str, server: str, pir_peer: str) -> list[dict]:
"""Scan the grant pool via two-server XOR PIR (one Chor query per row)."""
identity = store.load_identity(userid)
receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"])
sock, framed = _mgmt_connect(server)
info = _mgmt_single(userid, server, {"cmd": "server_info"})
store.pin_server(userid, server, info["server_public_key_b64"])
info_b = _mgmt_single(userid, pir_peer, {"cmd": "server_info"})
store.pin_server(userid, pir_peer, info_b["server_public_key_b64"])
sock_a, fa = _mgmt_connect(userid, server)
sock_b, fb = _mgmt_connect(userid, pir_peer)
try:
pending = _ok(_mgmt_cmd(framed, {
"cmd": "list_grants", "recipient_pk_hex": pk_hex,
}))["grants"]
# Trial-decrypt to find matching grant
target_grant_id = None
target_payload = None
for entry in pending:
try:
eph_pk = _unb64(entry["eph_pk_b64"])
ct = _unb64(entry["ciphertext_b64"])
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct))
if (plaintext.get("registry_id") == registry_id_hex and
plaintext.get("role_name") == role_name):
target_grant_id = entry["grant_id"]
target_payload = plaintext
break
except Exception:
a = _ok(_mgmt_cmd(fa, {"cmd": "mail_pool_len"}))
b = _ok(_mgmt_cmd(fb, {"cmd": "mail_pool_len"}))
if a["n"] != b["n"] or a["record_bytes"] != b["record_bytes"]:
raise RuntimeError("PIR peers disagree on pool length or record size")
n = a["n"]
results = []
for i in range(n):
row = _pir_recover_row(fa, fb, n, i)
if row.get("claimed"):
continue
if target_grant_id is None:
raise RuntimeError(f"no pending grant for {spec}")
_ok(_mgmt_cmd(framed, {
"cmd": "claim_grant",
"recipient_pk_hex": pk_hex,
"grant_id": target_grant_id,
}))
reg_info = _ok(_mgmt_cmd(framed, {
"cmd": "get_registry", "registry_id": registry_id_hex,
}))
try:
eph_pk = _unb64(row["eph_pk_b64"])
ct = _unb64(row["ciphertext_b64"])
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct))
results.append({
"grant_id": row["grant_id"],
"pool_index": i,
"registry_id": plaintext.get("registry_id", "?"),
"role_name": plaintext.get("role_name", "?"),
})
except Exception:
results.append({
"grant_id": row.get("grant_id", "?"),
"pool_index": i,
"registry_id": "?",
"role_name": "(undecryptable)",
})
return results
finally:
sock.close()
sock_a.close()
sock_b.close()
def collect(
userid: str,
spec: str,
*,
pir_peer: str,
pool_index: int,
) -> 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"])
info_b = _mgmt_single(userid, pir_peer, {"cmd": "server_info"})
store.pin_server(userid, pir_peer, info_b["server_public_key_b64"])
row = _fetch_grant_entry_pir(userid, server, pir_peer, pool_index)
if row.get("claimed"):
raise RuntimeError("grant row is already claimed")
try:
eph_pk = _unb64(row["eph_pk_b64"])
ct = _unb64(row["ciphertext_b64"])
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct))
except Exception as exc:
raise RuntimeError(
"PIR row did not decrypt for this user (wrong pool_index or desynced peers)"
) from exc
if (plaintext.get("registry_id") != registry_id_hex or
plaintext.get("role_name") != role_name):
raise RuntimeError(
"PIR row does not match this collect spec (check pool_index and peers)"
)
target_grant_id = row["grant_id"]
target_payload = plaintext
_mgmt_single(userid, server, {
"cmd": "claim_grant",
"grant_id": target_grant_id,
})
_ = _mgmt_single(userid, server, {
"cmd": "get_registry", "registry_id": registry_id_hex,
})
cred_data = {
"blind_sig_b64": target_payload["blind_sig_b64"],
@ -311,26 +329,16 @@ def collect(spec: str) -> dict:
cred = store.reconstruct_credential(cred_data)
cred.present(b"self-test")
store.save_credential(registry_id_hex, role_name, cred_data)
# Pin server + stash registry metadata locally
pin = store.load_server_pin(server)
if pin:
server_pk_b64 = pin["server_public_key_b64"]
else:
server_pk_b64 = _b64(b"\x00" * 32)
store.pin_server(server, server_pk_b64)
store.save_credential(userid, registry_id_hex, role_name, cred_data)
return {"registry_id": registry_id_hex, "role": role_name, "server": server}
# ── Authenticated session ─────────────────────────────────────────────
def authenticate(registry_id_hex: str, role_name: str,
def authenticate(userid: str, registry_id_hex: str, role_name: str,
server: str | None = None) -> dict:
admin_data = None
try:
admin_data = store.load_admin(registry_id_hex)
admin_data = store.load_admin(userid, registry_id_hex)
except FileNotFoundError:
pass
@ -340,10 +348,10 @@ def authenticate(registry_id_hex: str, role_name: str,
else:
raise RuntimeError("server address required (--server host:port)")
cred_data = store.load_credential_data(registry_id_hex, role_name)
cred_data = store.load_credential_data(userid, registry_id_hex, role_name)
cred = store.reconstruct_credential(cred_data)
server_pk = _resolve_server_pk(server)
server_pk = _resolve_server_pk(userid, server)
node = zkac.Node(zkac.Keypair())
host, port = _parse_server(server)

View File

@ -3,84 +3,97 @@
from __future__ import annotations
import argparse
import base64
import json
import sys
from . import client, store
from .paths import user_dir
# ── identity ──────────────────────────────────────────────────────────
# ── user ──────────────────────────────────────────────────────────────
def _cmd_identity_init(_args):
if store.identity_exists():
print("identity already exists")
ident = store.load_identity()
print(f" issuance public key: {ident['issuance_pk'].hex()}")
return
path = store.create_identity()
ident = store.load_identity()
print("created identity")
def _cmd_user_create(args):
path = store.create_user(args.userid)
ident = store.load_identity(args.userid)
print(f"created user {args.userid!r}")
print(f" issuance public key: {ident['issuance_pk'].hex()}")
print(f" (share this key out-of-band to receive credentials)")
print(f" stored in {path}")
def _cmd_identity_show(_args):
ident = store.load_identity()
def _cmd_user_list(_args):
users = store.list_users()
if not users:
print("no users found")
return
for u in users:
print(u)
def _cmd_user_show(args):
ident = store.load_identity(args.userid)
print(f"user: {args.userid}")
print(f" issuance pk: {ident['issuance_pk'].hex()}")
regs = client.list_own_registries()
if regs:
print(f" registries owned: {len(regs)}")
for r in regs:
print(f" {r['registry_id']} @ {r['server']} roles={r['roles']}")
owned = [
{"registry_id": r, **store.load_admin(args.userid, r)}
for r in store.list_admin_registries(args.userid)
]
if owned:
print(f" registries owned: {len(owned)}")
for r in owned:
print(f" {r['registry_id']} @ {r.get('server', '?')} roles={r.get('roles', [])}")
creds = store.list_credentials()
creds = store.list_credentials(args.userid)
if creds:
print(f" credentials: {len(creds)}")
for reg_hex, role in creds:
print(f" {reg_hex[:16]}… / {role}")
# ── server ────────────────────────────────────────────────────────────
# ── serve ────────────────────────────────────────────────────────────
def _cmd_serve(args):
from .server import serve
serve(args.data_dir, args.host, args.port)
data_dir = args.data_dir
if data_dir is None:
data_dir = str(user_dir(args.userid) / "server")
serve(data_dir, args.host, args.port)
# ── server pin ───────────────────────────────────────────────────────
def _cmd_server_pin(args):
pk_hex = args.key
import base64
pk_bytes = bytes.fromhex(pk_hex)
store.pin_server(args.server, base64.b64encode(pk_bytes).decode())
print(f"pinned {args.server} -> {pk_hex[:16]}")
pk_bytes = bytes.fromhex(args.key)
store.pin_server(args.userid, args.server, base64.b64encode(pk_bytes).decode())
print(f"pinned {args.server} for {args.userid} -> {args.key[:16]}")
# ── registry ──────────────────────────────────────────────────────────
def _cmd_registry_create(args):
roles = [r.strip() for r in args.roles.split(",")]
rid = client.create_registry(args.server, roles)
rid = client.create_registry(args.userid, args.server, roles)
print(f"registry created: {rid}")
print(f" roles: {', '.join(roles)}")
def _cmd_registry_update(args):
add = [r.strip() for r in args.add_roles.split(",")]
client.update_registry(args.server, args.registry, add)
client.update_registry(args.userid, args.server, args.registry, add)
print(f"registry updated: {args.registry[:16]}")
print(f" added roles: {', '.join(add)}")
def _cmd_registry_get(args):
info = client.get_registry(args.server, args.registry)
info = client.get_registry(args.userid, args.server, args.registry)
print(f"registry: {args.registry}")
print(f" state bytes: {len(info.get('state_bytes_b64', ''))} chars (b64)")
def _cmd_registry_list(_args):
regs = client.list_own_registries()
def _cmd_registry_list(args):
regs = client.list_own_registries(args.userid)
if not regs:
print("no registries")
return
@ -88,20 +101,26 @@ def _cmd_registry_list(_args):
print(f" {r['registry_id']} @ {r['server']} roles={r['roles']}")
# ── grant (admin-initiated) ──────────────────────────────────────────
# ── grant ─────────────────────────────────────────────────────────────
def _cmd_grant(args):
gid = client.grant(args.server, args.registry, args.role, args.to)
gid, pool_index = client.grant(
args.userid, args.server, args.registry, args.role, args.to,
)
print(f"granted {args.role!r} to {args.to[:16]}")
print(f" grant id: {gid}")
print(f" pool index: {pool_index}")
print(f" recipient can collect with:")
print(f" zkac-node collect {args.server}:{args.registry}:{args.role}")
print(
f" zkac-node collect <userid> {args.server}:{args.registry}:{args.role} "
f"--pir-peer <SECOND_REPLICA> --pool-index {pool_index}"
)
# ── credentials list / collect ────────────────────────────────────────
# ── credentials / collect ───────────────────────────────────────────
def _cmd_credentials_list(args):
local = store.list_credentials()
local = store.list_credentials(args.userid)
print("local credentials:")
if not local:
print(" (none)")
@ -109,7 +128,7 @@ def _cmd_credentials_list(args):
print(f" {reg_hex}:{role}")
servers = list(args.server or [])
for s in store.list_pinned_servers():
for s in store.known_servers(args.userid):
if s not in servers:
servers.append(s)
@ -117,11 +136,18 @@ def _cmd_credentials_list(args):
print("\n(no servers to query; pass --server host:port to check for pending)")
return
print("\npending grants:")
if args.pir_peer is None:
print(
"\n(skipping pending grants: two-server XOR PIR requires "
"--pir-peer <second-replica>; local credentials are listed above)"
)
return
print("\npending grants (PIR scan, O(n) per server):")
any_pending = False
for srv in servers:
try:
grants = client.list_pending(srv)
grants = client.list_pending(args.userid, srv, args.pir_peer)
except Exception as exc:
print(f" [{srv}] error: {exc}")
continue
@ -129,17 +155,24 @@ def _cmd_credentials_list(args):
any_pending = True
rid = g.get("registry_id", "?")
role = g.get("role_name", "?")
if rid != "?" and role != "?" and store.has_credential(rid, role):
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}{note}")
print(f" {srv}:{rid}:{role}{idx_s}{note}")
if not any_pending:
print(" (none)")
def _cmd_collect(args):
result = client.collect(args.spec)
result = client.collect(
args.userid,
args.spec,
pir_peer=args.pir_peer,
pool_index=args.pool_index,
)
print("collected credential")
print(f" registry: {result['registry_id']}")
print(f" role: {result['role']}")
@ -149,29 +182,37 @@ def _cmd_collect(args):
# ── auth ──────────────────────────────────────────────────────────────
def _cmd_auth(args):
resp = client.authenticate(args.registry, args.role, server=args.server)
resp = client.authenticate(
args.userid, args.registry, args.role, server=args.server,
)
print(json.dumps(resp, indent=2))
# ── argparse wiring ───────────────────────────────────────────────────
# ── argparse ─────────────────────────────────────────────────────────
def main():
p = argparse.ArgumentParser(prog="zkac-node")
sub = p.add_subparsers(dest="group", required=True)
# identity
id_p = sub.add_parser("identity", help="manage local identity")
id_sub = id_p.add_subparsers(dest="action", required=True)
# user
user_p = sub.add_parser("user", help="manage local user identities")
user_sub = user_p.add_subparsers(dest="action", required=True)
c = id_sub.add_parser("init", help="generate a new identity")
c.set_defaults(func=_cmd_identity_init)
c = user_sub.add_parser("create", help="generate a new user identity")
c.add_argument("userid")
c.set_defaults(func=_cmd_user_create)
c = id_sub.add_parser("show", help="show identity + registries + credentials")
c.set_defaults(func=_cmd_identity_show)
c = user_sub.add_parser("list", help="list all local users")
c.set_defaults(func=_cmd_user_list)
c = user_sub.add_parser("show", help="show user keys + registries + credentials")
c.add_argument("userid")
c.set_defaults(func=_cmd_user_show)
# serve
c = sub.add_parser("serve", help="run as a ZKAC server node")
c.add_argument("--data-dir", required=True, help="server data directory")
c.add_argument("userid", help="user whose ~/.zkac/<userid>/server/ holds server state (unless --data-dir)")
c.add_argument("--data-dir", default=None, help="override server data directory")
c.add_argument("--host", default="127.0.0.1")
c.add_argument("--port", type=int, default=9800)
c.set_defaults(func=_cmd_serve)
@ -180,7 +221,8 @@ def main():
srv_p = sub.add_parser("server", help="manage server pins")
srv_sub = srv_p.add_subparsers(dest="action", required=True)
c = srv_sub.add_parser("pin", help="pin a server's public key")
c = srv_sub.add_parser("pin", help="pin a server's public key for a user")
c.add_argument("userid")
c.add_argument("server", help="host:port")
c.add_argument("--key", required=True, help="server public key (hex)")
c.set_defaults(func=_cmd_server_pin)
@ -190,51 +232,75 @@ def main():
reg_sub = reg_p.add_subparsers(dest="action", required=True)
c = reg_sub.add_parser("create", help="create a new registry on a server")
c.add_argument("userid")
c.add_argument("server", help="host:port")
c.add_argument("--roles", required=True, help="comma-separated role names")
c.set_defaults(func=_cmd_registry_create)
c = reg_sub.add_parser("update", help="add roles to a registry you own")
c.add_argument("userid")
c.add_argument("server", help="host:port")
c.add_argument("--registry", required=True)
c.add_argument("--add-roles", required=True, help="comma-separated new roles")
c.set_defaults(func=_cmd_registry_update)
c = reg_sub.add_parser("get", help="fetch registry info from server")
c = reg_sub.add_parser("get", help="fetch registry state from server")
c.add_argument("userid")
c.add_argument("server", help="host:port")
c.add_argument("--registry", required=True)
c.set_defaults(func=_cmd_registry_get)
c = reg_sub.add_parser("list", help="list local registries (owned)")
c = reg_sub.add_parser("list", help="list locally owned registries")
c.add_argument("userid")
c.set_defaults(func=_cmd_registry_list)
# grant
c = sub.add_parser("grant", help="issue a credential to a recipient (admin)")
c.add_argument("userid")
c.add_argument("--server", required=True, help="host:port")
c.add_argument("--registry", required=True)
c.add_argument("--role", required=True)
c.add_argument("--to", required=True,
help="recipient's issuance public key (hex)")
c.add_argument("--to", required=True, help="recipient issuance public key (hex)")
c.set_defaults(func=_cmd_grant)
# credentials list
# credentials
cred_p = sub.add_parser("credentials", help="credentials (local + pending)")
cred_sub = cred_p.add_subparsers(dest="action", required=True)
c = cred_sub.add_parser("list", help="show local + pending credentials")
c.add_argument("--server", action="append",
help="server to query (host:port); may be repeated")
c.add_argument("userid")
c.add_argument("--server", action="append", help="server to query (host:port); repeatable")
c.add_argument(
"--pir-peer",
default=None,
metavar="HOST:PORT",
help="second replica with the same grant pool; required to list pending via XOR PIR",
)
c.set_defaults(func=_cmd_credentials_list)
# collect
c = sub.add_parser("collect", help="fetch and finalize a pending credential")
c.add_argument("userid")
c.add_argument("spec", help="host:port:registry_id:role")
c.add_argument(
"--pir-peer",
required=True,
metavar="HOST:PORT",
help="second replica with an identical grant pool (two-server XOR PIR)",
)
c.add_argument(
"--pool-index",
type=int,
required=True,
help="grant row index from admin (printed on grant)",
)
c.set_defaults(func=_cmd_collect)
# auth
c = sub.add_parser("auth", help="authenticate to a server with a credential")
c = sub.add_parser("auth", help="authenticate with a credential")
c.add_argument("userid")
c.add_argument("--registry", required=True)
c.add_argument("--role", required=True)
c.add_argument("--server", default=None, help="host:port (optional if known)")
c.add_argument("--server", default=None, help="host:port (optional if known from registry)")
c.set_defaults(func=_cmd_auth)
args = p.parse_args()

View File

@ -8,6 +8,16 @@ def zkac_home() -> Path:
return Path(os.environ.get("ZKAC_HOME", Path.home() / ".zkac"))
def user_dir(userid: str) -> Path:
return zkac_home() / userid
def ensure_user(userid: str) -> Path:
d = user_dir(userid)
d.mkdir(parents=True, exist_ok=True)
return d
def data_dir() -> Path:
"""Server data directory (overridable via ZKAC_DATA_DIR)."""
"""Default server data root (overridable via ZKAC_DATA_DIR)."""
return Path(os.environ.get("ZKAC_DATA_DIR", zkac_home()))

87
cli/zkac_cli/pir.py Normal file
View File

@ -0,0 +1,87 @@
"""Private information retrieval helpers.
Two-server XOR PIR (ChorGoldreichKushilevitzSudan style):
For a database of n fixed-length records D[0],...,D[n-1], to retrieve D[i]:
- Pick a uniformly random subset S {0,...,n-1}.
- Server A returns _{jS} D[j]
- Server B returns _{jS{i}} D[j]
- Client XORs the two replies D[i].
Privacy holds if the two servers do **not** collude. A single host running both
servers learns both queries and can recover i use two independent operators
or hosts for real privacy.
Recipients fetch rows via **two-server XOR PIR** (``pir_fold`` on each replica).
There is no full-database download endpoint; listing all decryptable grants for
a user requires **O(n) PIR queries** (one Chor query per pool index) when
scanning the pool.
"""
from __future__ import annotations
import json
import secrets
from typing import Iterable
# Fixed record size for XOR PIR (padded JSON grant entry).
PIR_RECORD_BYTES = 64 * 1024
def pad_grant_record(entry: dict) -> bytes:
"""Serialize grant entry to fixed-length bytes for XOR folding."""
raw = json.dumps(entry, separators=(",", ":"), sort_keys=True).encode("utf-8")
if len(raw) > PIR_RECORD_BYTES:
raise ValueError(f"grant record exceeds PIR_RECORD_BYTES ({PIR_RECORD_BYTES})")
return raw + b"\x00" * (PIR_RECORD_BYTES - len(raw))
def unpad_grant_record(buf: bytes) -> dict:
return json.loads(buf.rstrip(b"\x00").decode("utf-8"))
def xor_bytes_many(chunks: Iterable[bytes]) -> bytes:
it = iter(chunks)
first = next(it, None)
if first is None:
return b"\x00" * PIR_RECORD_BYTES
acc = bytearray(first)
for c in it:
if len(c) != len(acc):
raise ValueError("length mismatch in xor_bytes_many")
for j in range(len(acc)):
acc[j] ^= c[j]
return bytes(acc)
def random_subset(n: int) -> set[int]:
"""Uniform random subset of {0,...,n-1}."""
if n <= 0:
return set()
return {j for j in range(n) if secrets.randbelow(2)}
def symdiff_one(s: set[int], i: int) -> set[int]:
"""S ⊕ {i} as symmetric difference."""
t = set(s)
if i in t:
t.remove(i)
else:
t.add(i)
return t
def pir_query_indices(n: int, i: int) -> tuple[list[int], list[int]]:
"""Build two index lists for servers A and B to XOR-fold records."""
if not (0 <= i < n):
raise ValueError("index out of range")
s = random_subset(n)
sa = sorted(s)
sb = sorted(symdiff_one(s, i))
return sa, sb
def pir_recover(xor_a: bytes, xor_b: bytes) -> bytes:
if len(xor_a) != len(xor_b):
raise ValueError("xor length mismatch")
return bytes(a ^ b for a, b in zip(xor_a, xor_b))

View File

@ -10,7 +10,12 @@ 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/<pk_hex>.json [{grant_id, eph_pk_b64, ciphertext_b64}, ...]
<data_dir>/mailbox/grants_pool.json anonymous append-only grant pool (PIR-friendly)
Grants are **not** keyed by recipient public key on the server; delivery uses a
shared pool with **two-server XOR PIR** only: clients issue ``pir_fold`` queries
to two replicas with identical pools (``mail_pool_len`` + ``pir_fold``). There
is no bulk grant export command.
"""
from __future__ import annotations
@ -26,6 +31,8 @@ from pathlib import Path
import zkac
from zkac.tcp import FramedSession, server_handshake_anon
from . import pir
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode()
@ -38,15 +45,17 @@ def _unb64(s: str) -> bytes:
# ── Opaque server storage ─────────────────────────────────────────────
class _ServerStore:
"""Thread-safe, opaque persistence for registries and mailbox."""
"""Thread-safe, opaque persistence for registries and anonymous grant pool."""
def __init__(self, data_dir: Path):
self._dir = data_dir
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._mbox_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._migrate_legacy_mailbox()
# ── server key ────────────────────────────────────────────────────
@ -83,42 +92,85 @@ class _ServerStore:
print(f"[server] skip registry {rid_hex}: {exc}")
return count
# ── mailbox ───────────────────────────────────────────────────────
# ── legacy per-recipient mailbox → anonymous pool ─────────────────
def _mbox_path(self, pk_hex: str) -> Path:
return self._mbox_dir / f"{pk_hex}.json"
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 post_grant(self, recipient_pk_hex: str, entry: dict) -> str:
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)
)
# ── anonymous grant pool ─────────────────────────────────────────
def post_grant(self, entry: dict) -> tuple[str, int]:
grant_id = os.urandom(16).hex()
entry = {"grant_id": grant_id, **entry}
row = {"grant_id": grant_id, "claimed": False, **entry}
try:
pir.pad_grant_record(row)
except ValueError as exc:
raise ValueError(
f"grant entry too large for PIR record size ({pir.PIR_RECORD_BYTES} bytes): {exc}"
) from exc
with self._lock:
p = self._mbox_path(recipient_pk_hex)
entries = json.loads(p.read_text()) if p.exists() else []
entries.append(entry)
p.write_text(json.dumps(entries, indent=2))
return grant_id
records = self._load_pool()
pool_index = len(records)
records.append(row)
self._save_pool(records)
return grant_id, pool_index
def list_grants(self, recipient_pk_hex: str) -> list[dict]:
def pool_len(self) -> tuple[int, int]:
with self._lock:
p = self._mbox_path(recipient_pk_hex)
if not p.exists():
return []
return json.loads(p.read_text())
n = len(self._load_pool())
return n, pir.PIR_RECORD_BYTES
def claim_grant(self, recipient_pk_hex: str, grant_id: str) -> dict | None:
def pir_fold(self, indices: list[int]) -> bytes:
with self._lock:
p = self._mbox_path(recipient_pk_hex)
if not p.exists():
return None
entries = json.loads(p.read_text())
for i, e in enumerate(entries):
if e["grant_id"] == grant_id:
del entries[i]
if entries:
p.write_text(json.dumps(entries, indent=2))
else:
p.unlink(missing_ok=True)
return e
records = self._load_pool()
uniq = sorted(set(indices))
for i in uniq:
if not (0 <= i < len(records)):
raise ValueError("pir_fold index out of range")
chunks = [pir.pad_grant_record(records[i]) for i in uniq]
return pir.xor_bytes_many(chunks)
def claim_grant(self, grant_id: str) -> dict | None:
with self._lock:
records = self._load_pool()
for i, e in enumerate(records):
if e["grant_id"] == grant_id and not e.get("claimed", False):
e["claimed"] = True
records[i] = e
self._save_pool(records)
return dict(e)
return None
@ -165,15 +217,19 @@ def _dispatch(cmd: dict, mgr: zkac.RegistryManager, store: _ServerStore,
"eph_pk_b64": cmd["eph_pk_b64"],
"ciphertext_b64": cmd["ciphertext_b64"],
}
gid = store.post_grant(cmd["recipient_pk_hex"], entry)
return {"ok": True, "grant_id": gid}
gid, pool_index = store.post_grant(entry)
return {"ok": True, "grant_id": gid, "pool_index": pool_index}
if action == "list_grants":
entries = store.list_grants(cmd["recipient_pk_hex"])
return {"ok": True, "grants": entries}
if action == "mail_pool_len":
n, rec_b = store.pool_len()
return {"ok": True, "n": n, "record_bytes": rec_b}
if action == "pir_fold":
raw = store.pir_fold(list(cmd.get("indices", [])))
return {"ok": True, "xor_b64": _b64(raw)}
if action == "claim_grant":
entry = store.claim_grant(cmd["recipient_pk_hex"], cmd["grant_id"])
entry = store.claim_grant(cmd["grant_id"])
if entry is None:
return {"error": "grant not found"}
return {"ok": True, "grant": entry}

View File

@ -1,11 +1,4 @@
"""Persistent storage for ZKAC identities, admin bundles, credentials, and server pins.
Layout (single identity, no userid):
~/.zkac/identity.json issuance keypair (long-term client secret)
~/.zkac/admin/<registry_id>.json BBS+ issuer + admin credential per owned registry
~/.zkac/credentials/<rid>_<role>.json BBS+ credentials granted to this identity
~/.zkac/servers/<host_port>.json pinned server public keys
"""
"""Persistent storage per local user id under ~/.zkac/<userid>/."""
from __future__ import annotations
@ -15,7 +8,7 @@ from pathlib import Path
import zkac
from .paths import zkac_home
from .paths import ensure_user, user_dir, zkac_home
def _b64(data: bytes) -> str:
@ -26,19 +19,17 @@ def _unb64(s: str) -> bytes:
return base64.b64decode(s)
def _home() -> Path:
d = zkac_home()
d.mkdir(parents=True, exist_ok=True)
return d
def _ud(userid: str) -> Path:
return user_dir(userid)
# ── Identity (single, no userid) ─────────────────────────────────────
# ── User identity ────────────────────────────────────────────────────
def create_identity() -> Path:
d = _home()
def create_user(userid: str) -> Path:
d = ensure_user(userid)
p = d / "identity.json"
if p.exists():
raise FileExistsError(f"identity already exists at {p}")
raise FileExistsError(f"user {userid!r} already exists at {d}")
issuance_kp = zkac.IssuanceKeypair()
identity = {
@ -51,47 +42,66 @@ def create_identity() -> Path:
return d
def load_identity() -> dict:
data = json.loads((_home() / "identity.json").read_text())
def load_identity(userid: str) -> dict:
data = json.loads((_ud(userid) / "identity.json").read_text())
return {
"issuance_sk": _unb64(data["issuance_secret_b64"]),
"issuance_pk": _unb64(data["issuance_public_b64"]),
}
def identity_exists() -> bool:
return (_home() / "identity.json").exists()
def list_users() -> list[str]:
home = zkac_home()
if not home.exists():
return []
return sorted(
d.name for d in home.iterdir()
if d.is_dir() and (d / "identity.json").exists()
)
# ── Server pins ───────────────────────────────────────────────────────
# ── Server pins (per user) ───────────────────────────────────────────
def _server_key(server: str) -> str:
return server.replace(":", "_")
def pin_server(server: str, server_pk_b64: str):
d = _home() / "servers"
d.mkdir(exist_ok=True)
def pin_server(userid: str, server: str, server_pk_b64: str):
d = _ud(userid) / "servers"
d.mkdir(parents=True, exist_ok=True)
(d / f"{_server_key(server)}.json").write_text(
json.dumps({"server_public_key_b64": server_pk_b64}, indent=2)
)
def load_server_pin(server: str) -> dict | None:
p = _home() / "servers" / f"{_server_key(server)}.json"
def load_server_pin(userid: str, server: str) -> dict | None:
p = _ud(userid) / "servers" / f"{_server_key(server)}.json"
if not p.exists():
return None
return json.loads(p.read_text())
def list_pinned_servers() -> list[str]:
d = _home() / "servers"
if not d.exists():
return []
return sorted(p.stem.replace("_", ":") for p in d.glob("*.json"))
def known_servers(userid: str) -> list[str]:
"""Unique server addresses from pinned registries + pin files."""
seen: list[str] = []
d = _ud(userid) / "servers"
if d.exists():
for p in d.glob("*.json"):
host_port = p.stem.replace("_", ":")
if host_port not in seen:
seen.append(host_port)
for rid in list_admin_registries(userid):
try:
reg = load_admin(userid, rid)
srv = reg.get("server")
if srv and srv not in seen:
seen.append(srv)
except FileNotFoundError:
continue
return seen
# ── Admin bundles (per registry) ──────────────────────────────────────
# ── Admin bundles (per registry, per user) ────────────────────────────
def new_admin_material() -> dict:
bbs_issuer = zkac.BbsIssuer()
@ -122,18 +132,18 @@ def reconstruct_admin(data: dict) -> tuple:
return bbs_issuer, bbs_pk, admin_cred
def save_admin(registry_id_hex: str, info: dict):
d = _home() / "admin"
d.mkdir(exist_ok=True)
def save_admin(userid: str, registry_id_hex: str, info: dict):
d = _ud(userid) / "admin"
d.mkdir(parents=True, exist_ok=True)
(d / f"{registry_id_hex}.json").write_text(json.dumps(info, indent=2))
def load_admin(registry_id_hex: str) -> dict:
return json.loads((_home() / "admin" / f"{registry_id_hex}.json").read_text())
def load_admin(userid: str, registry_id_hex: str) -> dict:
return json.loads((_ud(userid) / "admin" / f"{registry_id_hex}.json").read_text())
def list_admin_registries() -> list[str]:
d = _home() / "admin"
def list_admin_registries(userid: str) -> list[str]:
d = _ud(userid) / "admin"
if not d.exists():
return []
return sorted(p.stem for p in d.glob("*.json"))
@ -141,20 +151,20 @@ def list_admin_registries() -> list[str]:
# ── Credentials ───────────────────────────────────────────────────────
def save_credential(registry_id_hex: str, role_name: str, cred_data: dict):
d = _home() / "credentials"
d.mkdir(exist_ok=True)
def save_credential(userid: str, registry_id_hex: str, role_name: str, cred_data: dict):
d = _ud(userid) / "credentials"
d.mkdir(parents=True, exist_ok=True)
(d / f"{registry_id_hex}_{role_name}.json").write_text(json.dumps(cred_data, indent=2))
def load_credential_data(registry_id_hex: str, role_name: str) -> dict:
def load_credential_data(userid: str, registry_id_hex: str, role_name: str) -> dict:
return json.loads(
(_home() / "credentials" / f"{registry_id_hex}_{role_name}.json").read_text()
(_ud(userid) / "credentials" / f"{registry_id_hex}_{role_name}.json").read_text()
)
def has_credential(registry_id_hex: str, role_name: str) -> bool:
return (_home() / "credentials" / f"{registry_id_hex}_{role_name}.json").exists()
def has_credential(userid: str, registry_id_hex: str, role_name: str) -> bool:
return (_ud(userid) / "credentials" / f"{registry_id_hex}_{role_name}.json").exists()
def reconstruct_credential(cred_data: dict) -> zkac.Credential:
@ -169,8 +179,8 @@ def reconstruct_credential(cred_data: dict) -> zkac.Credential:
)
def list_credentials() -> list[tuple[str, str]]:
d = _home() / "credentials"
def list_credentials(userid: str) -> list[tuple[str, str]]:
d = _ud(userid) / "credentials"
if not d.exists():
return []
result = []

View File

@ -42,18 +42,20 @@ Management commands (`create_registry`, `post_grant`, etc.) and BBS+ role authen
### Grant delivery (admin → recipient, through server)
Grants live in a single **anonymous append-only pool** (no `recipient_pk` on the server). Recipients fetch rows only via **two-server XOR PIR** (`mail_pool_len` + `pir_fold` on two replicas with identical pools). Each query reveals only a random subset-XOR to each server; *which* logical index is recovered is hidden if the replicas do not collude. There is **no** full-pool download API. Scanning all rows for “pending” uses **O(n) PIR round-trips** (one Chor-style query per index).
```
Admin Server (opaque relay) Recipient
|-- post_grant ------->| |
| (admin_proof, | stores only: |
| recipient_pk, | {eph_pk, ciphertext} |
| eph_pk, | keyed by recipient_pk |
| ciphertext) | |
| |<-- list_grants ------------|
| |--- [{eph_pk, ct}, ...] --->|
| (admin_proof, | appends to pool: |
| eph_pk, | {grant_id, eph_pk, ct} |
| ciphertext) | (no recipient address) |
| |<-- pir_fold (replica A/B) --|
| |--- XOR of subset rows ----->|
| | | combine → one row
| | | trial-decrypt
| |<-- claim_grant ------------|
| | (removes entry) |
| | (tombstone / claimed) |
```
## Threats considered
@ -74,7 +76,7 @@ Admin Server (opaque relay) Recipient
- 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 `recipient_pk` for mailbox addressing, plus `eph_pk` and ciphertext per grant, but cannot decrypt grant payloads.
- Sees `eph_pk` and ciphertext per grant in the anonymous pool, and pool size / timing of syncs, but cannot decrypt grant payloads. It does **not** see a per-recipient mailbox key for addressing.
- **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).
@ -99,7 +101,7 @@ The server's long-term `PublicKey` (32-byte Ristretto255 point) functions as a *
Recommended strategies:
1. **Static configuration** (default): embed the server public key in client config or CLI pin command (`zkac-node server pin`). Equivalent to WireGuard's `[Peer] PublicKey = ...`.
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.
@ -112,7 +114,7 @@ Recommended strategies:
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. **Client identity:** The only persistent client identifier is the issuance public key used for grant mailbox addressing. This key is shared out-of-band with admins; it is not linked to any transport key or BBS+ presentation.
7. **Recipient addressing:** Admins encrypt grants to the recipients 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)
@ -124,7 +126,8 @@ Recommended strategies:
- [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 management commands are bound to the session transcript hash (no separate nonce).
- [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).
- [ ] **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).
@ -135,8 +138,9 @@ Recommended strategies:
- **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.
- **Opaque mailbox:** Grant entries on the server contain only `(eph_pk, ciphertext)` — no registry ID or role name. Recipients find their grants by trial-decrypting. This prevents the server from learning which registry or role a grant is for.
- **Anonymous grant pool:** Grant entries contain only `(eph_pk, ciphertext)` plus stable row metadata — no registry ID or role name. Recipients find their grants by trial-decrypting after two-server XOR PIR (or an O(n) PIR scan over the pool). Pool rows use tombstones (`claimed`) so indices stay stable for replicated PIR.
- **No user IDs on server:** The server has no concept of user accounts. It is a stateless relay authenticated only by cryptographic proofs.
- **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. Registry updates use separate connections for `get_registry` and `update_registry`. Collect uses separate connections for `server_info`, pool fetch / PIR, `claim_grant`, and `get_registry` so those operations are not tied to one transcript.
## Known limitations
@ -144,7 +148,11 @@ Recommended strategies:
- **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.
- **Mailbox metadata:** The server sees `recipient_pk` as a mailbox address and the number/size/timing of grants. This is inherent to the delivery mechanism.
- **Pool metadata:** Each replica sees `pir_fold` subset queries (random-looking index sets) and timing. Two-server XOR PIR hides the target index from each server if they do not collude; running both replicas under one operator does not provide that privacy. A full-pool scan issues **n** PIR queries and has high cost; the issuer should send **`pool_index` out-of-band** so the recipient runs **one** PIR retrieval for collect.
## Future work
- **Single-server sublinear PIR:** The CLI uses **two-server XOR PIR** (Chor-style) only. **Single-server** private information retrieval with **sublinear** client communication (e.g. **SealPIR**, **DoublePIR**, or other lattice / homomorphic-encryptionbased schemes) is **not** implemented; adding it would require new dependencies, fixed database encoding, and a distinct query/response protocol. That would allow a lone replica without a non-colluding peer, at the cost of heavier crypto and implementation complexity.
## Reporting issues

2
fuzz/Cargo.lock generated
View File

@ -709,7 +709,7 @@ dependencies = [
[[package]]
name = "zkac"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"blake2",
"chacha20poly1305",

View File

@ -111,6 +111,25 @@ fn bbs_setup() -> &'static (IssuerPublicKey, [u8; 32]) {
})
}
/// zkryptium `BBSplusPoKSignature::from_bytes` packs `m_cap` scalars plus a final challenge after a
/// 240-byte fixed header. `blind_proof_verify` (with zkac's `L = 2` and two disclosed indexes)
/// uses `U = m_cap.len()` in `U - 1 - L` and **panics** when `U == 0` (debug) — e.g. 272-byte
/// proofs with only a challenge scalar. LibFuzzer builds often use `panic = abort`, so
/// `catch_unwind` in `verify_presentation` does not save the fuzzer. Skip those inputs here.
fn bbs_proof_has_non_empty_m_cap(proof_bytes: &[u8]) -> bool {
const HEAD: usize = 240;
const SCALAR: usize = 32;
if proof_bytes.len() < HEAD + 2 * SCALAR {
return false;
}
let tail = proof_bytes.len() - HEAD;
if tail % SCALAR != 0 {
return false;
}
let n = tail / SCALAR;
n >= 2
}
/// Fuzz BBS+ proof parsing and verification (pairing-heavy; keep corpora small).
pub fn bbs_verify_presentation(data: &[u8]) {
let data = if data.len() > MAX_BBS_INPUT {
@ -118,6 +137,9 @@ pub fn bbs_verify_presentation(data: &[u8]) {
} else {
data
};
if !bbs_proof_has_non_empty_m_cap(data) {
return;
}
let (pk, rid) = bbs_setup();
let pres = Presentation::from_bytes(data.to_vec());
let nonce = [0xabu8; 32];

42
tests/test_pir.py Normal file
View File

@ -0,0 +1,42 @@
"""Unit tests for XOR PIR helpers (Chor-style two-server PIR)."""
from zkac_cli import pir
def test_pad_roundtrip():
d = {
"grant_id": "abc",
"claimed": False,
"eph_pk_b64": "eA==",
"ciphertext_b64": "cA==",
}
p = pir.pad_grant_record(d)
assert len(p) == pir.PIR_RECORD_BYTES
assert pir.unpad_grant_record(p) == d
def test_xor_pir_row_recovery():
rows = [
{
"grant_id": f"id{i}",
"claimed": False,
"eph_pk_b64": f"e{i}==",
"ciphertext_b64": f"c{i}==",
}
for i in range(5)
]
want_i = 3
sa, sb = pir.pir_query_indices(len(rows), want_i)
def fold(idxs):
pads = [pir.pad_grant_record(rows[j]) for j in sorted(set(idxs))]
return pir.xor_bytes_many(pads)
xa = fold(sa)
xb = fold(sb)
got = pir.pir_recover(xa, xb)
assert got == pir.pad_grant_record(rows[want_i])
def test_pir_fold_empty_is_zero_block():
assert pir.xor_bytes_many([]) == b"\x00" * pir.PIR_RECORD_BYTES