diff --git a/cli/README.md b/cli/README.md index 95c3865..a287982 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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//) +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 +zkac-node server pin bob localhost:9800 --key -# 3. Pin the server's public key (printed at startup) -zkac-node server pin localhost:9800 --key +# 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 --role analyst --to $BOB_PK_HEX +# (prints pool_index for Bob’s 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 -# 5. Grant recipient the 'analyst' role (only needs their public key) -zkac-node grant --server localhost:9800 \ - --registry --role analyst --to +# 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::analyst \ + --pir-peer localhost:9801 --pool-index -# 7. Recipient collects (host:port:registry_id:role) -zkac-node collect localhost:9800::analyst - -# 8. Recipient authenticates anonymously -zkac-node auth --registry --role analyst --server localhost:9800 +# 7. Bob authenticates +zkac-node auth bob --registry --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 --key ` | Pin a server's public key | -| `registry create --roles r1,r2` | Create a new registry (fresh BBS+ issuer) | -| `registry update --registry R --add-roles r3` | Add roles to a registry you own | -| `registry get --registry R` | Fetch registry state from a server | -| `registry list` | List locally owned registries | -| `grant --server S --registry R --role X --to ` | Issue credential encrypted to recipient's pk | -| `credentials list [--server S ...]` | Show local credentials + pending grants | -| `collect ` | Fetch + finalize one pending credential | -| `auth --registry R --role X [--server S]` | Authenticate via ZKAC handshake | +| `user create ` | Generate issuance keypair under `~/.zkac//` | +| `user list` | List all local user ids | +| `user show ` | Show issuance pk + owned registries + credentials | +| `serve [--data-dir D]` | Run server; default data dir is `~/.zkac//server/` | +| `server pin --key ` | Pin server public key for that user | +| `registry create --roles …` | Create registry on server | +| `registry update --registry R --add-roles …` | Add roles | +| `registry get --registry R` | Fetch registry state | +| `registry list ` | List registries this user owns locally | +| `grant --server S --registry R --role X --to ` | Admin grant (encrypted to recipient pk) | +| `credentials list [--server S …] [--pir-peer P]` | Local credentials; pending grants only with `--pir-peer` (PIR scan) | +| `collect --pir-peer P --pool-index N` | Fetch one grant via two-server XOR PIR | +| `auth --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/.json BBS+ issuer + admin credential per owned registry -credentials/_.json BBS+ credentials granted to this identity -servers/.json pinned server public keys -``` +Per user `~/.zkac//`: -Server (`--data-dir`): ``` -server_key.json Schnorr keypair (long-term server secret) -registries/.state raw RegistryState bytes -registries/.cert raw state cert bytes -mailbox/.json [{grant_id, eph_pk_b64, ciphertext_b64}, ...] +identity.json issuance keypair +admin/.json BBS+ admin material for owned registries +credentials/_.json received credentials +servers/.json pinned server public keys +server/ (only if you run `serve `) server_key.json, registries/, mailbox/grants_pool.json ``` diff --git a/cli/zkac_cli/__init__.py b/cli/zkac_cli/__init__.py index e69de29..8b13789 100644 --- a/cli/zkac_cli/__init__.py +++ b/cli/zkac_cli/__init__.py @@ -0,0 +1 @@ + diff --git a/cli/zkac_cli/client.py b/cli/zkac_cli/client.py index 83a85b4..1100b84 100644 --- a/cli/zkac_cli/client.py +++ b/cli/zkac_cli/client.py @@ -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 " + f"no pinned key for {server}; run: zkac-node server pin {userid} {server} --key " ) 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) diff --git a/cli/zkac_cli/main.py b/cli/zkac_cli/main.py index 953fcc1..eee5abd 100644 --- a/cli/zkac_cli/main.py +++ b/cli/zkac_cli/main.py @@ -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 {args.server}:{args.registry}:{args.role} " + f"--pir-peer --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 ; 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//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() diff --git a/cli/zkac_cli/paths.py b/cli/zkac_cli/paths.py index a161d00..99636e9 100644 --- a/cli/zkac_cli/paths.py +++ b/cli/zkac_cli/paths.py @@ -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())) diff --git a/cli/zkac_cli/pir.py b/cli/zkac_cli/pir.py new file mode 100644 index 0000000..f06f56f --- /dev/null +++ b/cli/zkac_cli/pir.py @@ -0,0 +1,87 @@ +"""Private information retrieval helpers. + +Two-server XOR PIR (Chor–Goldreich–Kushilevitz–Sudan 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 ⊕_{j∈S} D[j] + - Server B returns ⊕_{j∈S⊕{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)) diff --git a/cli/zkac_cli/server.py b/cli/zkac_cli/server.py index c271f92..fb80d08 100644 --- a/cli/zkac_cli/server.py +++ b/cli/zkac_cli/server.py @@ -10,7 +10,12 @@ The server stores only cryptographically verified opaque blobs: /server_key.json Schnorr keypair /registries/.state raw RegistryState bytes /registries/.cert raw state cert bytes - /mailbox/.json [{grant_id, eph_pk_b64, ciphertext_b64}, ...] + /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} diff --git a/cli/zkac_cli/store.py b/cli/zkac_cli/store.py index 7c7c913..aaad9ee 100644 --- a/cli/zkac_cli/store.py +++ b/cli/zkac_cli/store.py @@ -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/.json BBS+ issuer + admin credential per owned registry - ~/.zkac/credentials/_.json BBS+ credentials granted to this identity - ~/.zkac/servers/.json pinned server public keys -""" +"""Persistent storage per local user id under ~/.zkac//.""" 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 = [] diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 105b302..486853e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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 --key `). 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 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) @@ -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-encryption–based 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 diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 6a5c0ea..bc6da3b 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -709,7 +709,7 @@ dependencies = [ [[package]] name = "zkac" -version = "0.1.0" +version = "0.3.0" dependencies = [ "blake2", "chacha20poly1305", diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 7c9a3ab..3c0dd84 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -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]; diff --git a/tests/test_pir.py b/tests/test_pir.py new file mode 100644 index 0000000..da17b9f --- /dev/null +++ b/tests/test_pir.py @@ -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