v0.4
This commit is contained in:
parent
d01a6ebf85
commit
6e67836e95
122
cli/README.md
122
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/<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 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 <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
|
||||
```
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
87
cli/zkac_cli/pir.py
Normal file
@ -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))
|
||||
@ -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}
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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 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
|
||||
|
||||
|
||||
2
fuzz/Cargo.lock
generated
2
fuzz/Cargo.lock
generated
@ -709,7 +709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zkac"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake2",
|
||||
"chacha20poly1305",
|
||||
|
||||
@ -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
42
tests/test_pir.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user