working PIR
This commit is contained in:
parent
316e3dc0bd
commit
d12a912fa8
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -748,7 +748,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zkac"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"blake2",
|
||||
"chacha20poly1305",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zkac"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
edition = "2021"
|
||||
description = "Zero-Knowledge Access Control: BBS+ anonymous credentials (BLS12-381) with encrypted transport (X25519/ChaCha20-Poly1305)"
|
||||
|
||||
|
||||
@ -55,17 +55,17 @@ zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server localhost:98
|
||||
| `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 …]` | Pending grants: tags + SimplePIR (handle row) + `get_grant_blob` + decrypt |
|
||||
| `collect <id> <spec> [--pool-index N]` | Same retrieval path, then claim |
|
||||
| `credentials list <id> [--server S …]` | Pending grants: tags + SimplePIR (full encrypted row) + decrypt |
|
||||
| `collect <id> <spec> [--pool-index N]` | Same retrieval path, then local credential finalize |
|
||||
| `auth <id> --registry R --role X [--server S]` | Authenticated session |
|
||||
|
||||
## Protocol & threat model
|
||||
|
||||
See [docs/SECURITY.md](../docs/SECURITY.md) in the repo root for the full model, including PIR and detection tags.
|
||||
|
||||
**Custom clients:** Encrypted management JSON supports `pool_tags`, `pir_hints` / `pir_query`, **`get_grant_blob`** (after decoding the PIR handle), and `claim_grant`. Match the CLI’s two-phase mailbox fetch unless you embed ciphertext in the PIR row yourself.
|
||||
**Custom clients:** Encrypted management JSON supports `pool_tags`, `pir_hints`, and `pir_query`. Mailbox retrieval is single-hop: decode a full encrypted row from PIR and decrypt locally.
|
||||
|
||||
**Operational scaling:** The server grant pool is append-only (claimed rows are tombstones), so pool length grows with every grant. Large pools increase discovery traffic, PIR query size, and server work per retrieval (all linear in pool length). Treat unbounded growth as a potential DoS and capacity risk; mitigations are listed under *Known limitations* and *Future work* in `docs/SECURITY.md`. Transport, BBS+ auth, registry state updates, and issuance queues have separate scaling profiles (CPU dominated by BBS+, state size linear in role count, queue memory); see **Scaling and complexity (transport, credentials, registries)** in the same doc.
|
||||
**Operational scaling:** The server grant pool is append-only, so pool length grows with every grant. Large pools increase discovery traffic, PIR query size, and server work per retrieval (all linear in pool length). Treat unbounded growth as a potential DoS and capacity risk; mitigations are listed under *Known limitations* and *Future work* in `docs/SECURITY.md`. Transport, BBS+ auth, registry state updates, and issuance queues have separate scaling profiles (CPU dominated by BBS+, state size linear in role count, queue memory); see **Scaling and complexity (transport, credentials, registries)** in the same doc.
|
||||
|
||||
## Storage layout
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "zkac-node"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["zkac"]
|
||||
|
||||
|
||||
@ -210,26 +210,20 @@ def _fetch_row(
|
||||
)
|
||||
ans_bytes = bytes(buf)
|
||||
raw = bytes(pir_client.decode(ans_bytes, state))
|
||||
handle = json.loads(raw.rstrip(b"\x00").decode("utf-8"))
|
||||
if handle.get("v") != 1:
|
||||
raise RuntimeError("unsupported PIR handle version")
|
||||
grant_id = handle["g"]
|
||||
expect_digest = handle["h"]
|
||||
blob = _ok(
|
||||
_mgmt_cmd(framed, {"cmd": "get_grant_blob", "grant_id": grant_id}),
|
||||
)
|
||||
ct_b64 = blob["ciphertext_b64"]
|
||||
actual = hashlib.sha256(_unb64(ct_b64)).hexdigest()
|
||||
row = json.loads(raw.rstrip(b"\x00").decode("utf-8"))
|
||||
if row.get("v") != 2:
|
||||
raise RuntimeError("unsupported PIR row version")
|
||||
ct_b64 = row.get("ciphertext_b64", "")
|
||||
expect_digest = row.get("ciphertext_sha256", "")
|
||||
actual = hashlib.sha256(_unb64(ct_b64)).hexdigest() if ct_b64 else ""
|
||||
if actual != expect_digest:
|
||||
raise RuntimeError("grant ciphertext does not match PIR handle (tampered blob?)")
|
||||
row = {
|
||||
"grant_id": grant_id,
|
||||
"eph_pk_b64": blob["eph_pk_b64"],
|
||||
raise RuntimeError("PIR row ciphertext digest mismatch")
|
||||
return {
|
||||
"eph_pk_b64": row.get("eph_pk_b64", ""),
|
||||
"ciphertext_b64": ct_b64,
|
||||
"to_tag_b64": blob.get("to_tag_b64", ""),
|
||||
"claimed": blob.get("claimed", False),
|
||||
"to_tag_b64": row.get("to_tag_b64", ""),
|
||||
"claimed": row.get("claimed", False),
|
||||
}
|
||||
return row
|
||||
|
||||
|
||||
# ── Public operations ────────────────────────────────────────────────
|
||||
@ -357,7 +351,7 @@ def grant(userid: str, server: str, registry_id_hex: str, role_name: str,
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
return resp["grant_id"], resp.get("pool_index", -1)
|
||||
return "inline-pir-row", resp.get("pool_index", -1)
|
||||
|
||||
|
||||
def _match_tags(userid: str, tags: list[dict]) -> list[int]:
|
||||
@ -378,7 +372,7 @@ def _match_tags(userid: str, tags: list[dict]) -> list[int]:
|
||||
|
||||
|
||||
def list_pending(userid: str, server: str) -> list[dict]:
|
||||
"""Discover pending grants via detection tags, then PIR-fetch matches."""
|
||||
"""Discover pending grants via detection tags, then PIR-fetch full rows."""
|
||||
identity = store.load_identity(userid)
|
||||
receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"])
|
||||
|
||||
@ -408,14 +402,12 @@ def list_pending(userid: str, server: str) -> list[dict]:
|
||||
ct = _unb64(row["ciphertext_b64"])
|
||||
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct))
|
||||
results.append({
|
||||
"grant_id": row["grant_id"],
|
||||
"pool_index": idx,
|
||||
"registry_id": plaintext.get("registry_id", "?"),
|
||||
"role_name": plaintext.get("role_name", "?"),
|
||||
})
|
||||
except Exception:
|
||||
results.append({
|
||||
"grant_id": row.get("grant_id", "?"),
|
||||
"pool_index": idx,
|
||||
"registry_id": "?",
|
||||
"role_name": "(undecryptable)",
|
||||
@ -490,13 +482,6 @@ def collect(
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
target_grant_id = target_row["grant_id"]
|
||||
|
||||
_mgmt_single(userid, server, {
|
||||
"cmd": "claim_grant",
|
||||
"grant_id": target_grant_id,
|
||||
})
|
||||
|
||||
_ = _mgmt_single(userid, server, {
|
||||
"cmd": "get_registry", "registry_id": registry_id_hex,
|
||||
})
|
||||
|
||||
@ -104,11 +104,11 @@ def _cmd_registry_list(args):
|
||||
# ── grant ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _cmd_grant(args):
|
||||
gid, pool_index = client.grant(
|
||||
_row_mode, 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(" delivery: inline PIR row (no grant-id follow-up fetch)")
|
||||
print(f" pool index: {pool_index}")
|
||||
print(f" recipient can collect with:")
|
||||
print(
|
||||
|
||||
@ -13,8 +13,9 @@ The server stores only cryptographically verified opaque blobs:
|
||||
<data_dir>/mailbox/grants_pool.json anonymous append-only grant pool
|
||||
|
||||
Recipients discover matching grants via a cheap detection-tag index
|
||||
(``pool_tags``). PIR (``pir_query``) returns a **small handle** per row;
|
||||
bulk ciphertext is fetched with ``get_grant_blob`` (split payload).
|
||||
(``pool_tags``). PIR (``pir_query``) returns a full encrypted mailbox row,
|
||||
so no follow-up ``grant_id`` fetch is required. This avoids leaking a stable
|
||||
row identifier during retrieval.
|
||||
Large PIR answers are streamed in slices (same pattern as ``pir_hints``).
|
||||
"""
|
||||
|
||||
@ -23,7 +24,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import traceback
|
||||
@ -45,14 +45,22 @@ def _unb64(s: str) -> bytes:
|
||||
return base64.b64decode(s)
|
||||
|
||||
|
||||
def _pir_handle_bytes(grant_id: str, ciphertext_b64: str) -> bytes:
|
||||
"""Fixed-size PIR row: JSON handle binding ``grant_id`` to ciphertext (SHA-256)."""
|
||||
ct_digest = hashlib.sha256(_unb64(ciphertext_b64)).hexdigest()
|
||||
handle = {"v": 1, "g": grant_id, "h": ct_digest}
|
||||
raw = json.dumps(handle, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
def _pir_row_bytes(entry: dict) -> bytes:
|
||||
"""Fixed-size PIR row: full encrypted mailbox row + ciphertext digest."""
|
||||
ct_b64 = entry.get("ciphertext_b64", "")
|
||||
ct_digest = hashlib.sha256(_unb64(ct_b64)).hexdigest() if ct_b64 else ""
|
||||
row = {
|
||||
"v": 2,
|
||||
"eph_pk_b64": entry.get("eph_pk_b64", ""),
|
||||
"to_tag_b64": entry.get("to_tag_b64", ""),
|
||||
"ciphertext_b64": ct_b64,
|
||||
"ciphertext_sha256": ct_digest,
|
||||
"claimed": bool(entry.get("claimed", False)),
|
||||
}
|
||||
raw = json.dumps(row, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
if len(raw) > zkac.PIR_RECORD_BYTES:
|
||||
raise ValueError(
|
||||
f"PIR handle exceeds PIR_RECORD_BYTES ({zkac.PIR_RECORD_BYTES})"
|
||||
f"PIR row exceeds PIR_RECORD_BYTES ({zkac.PIR_RECORD_BYTES})"
|
||||
)
|
||||
return raw + b"\x00" * (zkac.PIR_RECORD_BYTES - len(raw))
|
||||
|
||||
@ -152,7 +160,7 @@ class _ServerStore:
|
||||
if self._pir_server is not None and not self._pir_dirty:
|
||||
return self._pir_server
|
||||
records = self._load_pool()
|
||||
packed = [_pir_handle_bytes(r["grant_id"], r["ciphertext_b64"]) for r in records]
|
||||
packed = [_pir_row_bytes(r) for r in records]
|
||||
db = zkac.PirDatabase(packed, zkac.PIR_RECORD_BYTES)
|
||||
self._pir_server = zkac.PirServer(db)
|
||||
self._pir_dirty = False
|
||||
@ -160,17 +168,16 @@ class _ServerStore:
|
||||
|
||||
# ── anonymous grant pool ─────────────────────────────────────────
|
||||
|
||||
def post_grant(self, entry: dict) -> tuple[str, int]:
|
||||
grant_id = os.urandom(16).hex()
|
||||
_pir_handle_bytes(grant_id, entry["ciphertext_b64"])
|
||||
row = {"grant_id": grant_id, "claimed": False, **entry}
|
||||
def post_grant(self, entry: dict) -> int:
|
||||
_pir_row_bytes(entry)
|
||||
row = {"claimed": False, **entry}
|
||||
with self._lock:
|
||||
records = self._load_pool()
|
||||
pool_index = len(records)
|
||||
records.append(row)
|
||||
self._save_pool(records)
|
||||
self._pir_dirty = True
|
||||
return grant_id, pool_index
|
||||
return pool_index
|
||||
|
||||
def pool_info(self) -> dict:
|
||||
with self._lock:
|
||||
@ -209,32 +216,6 @@ class _ServerStore:
|
||||
ans = pir.answer(_unb64(query_b64))
|
||||
return bytes(ans)
|
||||
|
||||
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)
|
||||
self._pir_dirty = True
|
||||
return dict(e)
|
||||
return None
|
||||
|
||||
def get_grant_blob(self, grant_id: str) -> dict | None:
|
||||
"""Return public grant fields for second-phase fetch (after PIR handle)."""
|
||||
with self._lock:
|
||||
for e in self._load_pool():
|
||||
if e.get("grant_id") == grant_id:
|
||||
return {
|
||||
"eph_pk_b64": e.get("eph_pk_b64", ""),
|
||||
"ciphertext_b64": e.get("ciphertext_b64", ""),
|
||||
"to_tag_b64": e.get("to_tag_b64", ""),
|
||||
"claimed": bool(e.get("claimed", False)),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
# ── Command dispatch (inside encrypted session) ──────────────────────
|
||||
|
||||
def _dispatch(
|
||||
@ -285,8 +266,8 @@ def _dispatch(
|
||||
"ciphertext_b64": cmd["ciphertext_b64"],
|
||||
"to_tag_b64": cmd.get("to_tag_b64", ""),
|
||||
}
|
||||
gid, pool_index = store.post_grant(entry)
|
||||
return {"ok": True, "grant_id": gid, "pool_index": pool_index}
|
||||
pool_index = store.post_grant(entry)
|
||||
return {"ok": True, "pool_index": pool_index}
|
||||
|
||||
if action == "pool_info":
|
||||
info = store.pool_info()
|
||||
@ -379,21 +360,6 @@ def _dispatch(
|
||||
"done": done,
|
||||
}
|
||||
|
||||
if action == "claim_grant":
|
||||
entry = store.claim_grant(cmd["grant_id"])
|
||||
if entry is None:
|
||||
return {"error": "grant not found"}
|
||||
return {"ok": True, "grant": entry}
|
||||
|
||||
if action == "get_grant_blob":
|
||||
gid = cmd.get("grant_id", "")
|
||||
if not gid:
|
||||
return {"error": "missing grant_id"}
|
||||
blob = store.get_grant_blob(gid)
|
||||
if blob is None:
|
||||
return {"error": "grant not found"}
|
||||
return {"ok": True, **blob}
|
||||
|
||||
return {"error": f"unknown command: {action}"}
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: zkac-node
|
||||
Version: 0.1.0
|
||||
Version: 0.2.1
|
||||
Requires-Python: >=3.10
|
||||
Requires-Dist: zkac
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# ZKAC Python API Reference
|
||||
|
||||
Version 0.5.0. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures), **LWE** (single-server SimplePIR for mailbox handles).
|
||||
Version 0.5.1. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures), **LWE** (single-server SimplePIR for mailbox handles).
|
||||
|
||||
```python
|
||||
import zkac
|
||||
@ -14,7 +14,7 @@ Upper bound on BBS+ proof size in an encrypted auth packet (256 KiB). Larger pro
|
||||
|
||||
### `PIR_RECORD_BYTES`
|
||||
|
||||
Fixed byte length of each PIR plaintext row (mailbox **handle** JSON, padded with zeros). **256** in 0.5.0. Must match the server’s PIR database packing and the `record_bytes` argument to `PirClient(...)`.
|
||||
Fixed byte length of each PIR plaintext row (mailbox **handle** JSON, padded with zeros). **256** in 0.5.1. Must match the server’s PIR database packing and the `record_bytes` argument to `PirClient(...)`.
|
||||
|
||||
## Single-server PIR (SimplePIR)
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Security model and audit notes (ZKAC 0.5.0)
|
||||
# Security model and audit notes (ZKAC 0.5.1)
|
||||
|
||||
This document summarizes the design, residual risks, and recommendations for operators integrating **ZKAC**. It is not a substitute for independent review before high-assurance deployment.
|
||||
|
||||
@ -48,14 +48,14 @@ Grants live in a single **anonymous append-only pool** (no recipient identifier
|
||||
|
||||
**Discovery (cheap, no PIR):** The server exposes a `pool_tags` command returning all `(eph_pk, tag)` pairs. The client computes `X25519(my_issuance_sk, eph_pk_j)` for each entry and derives the expected tag via `BLAKE2b-512("zkac-grant-tag" || shared_secret)[..16]`. Matching entries are the client's grants. This scan is a single round-trip transferring ~48 bytes per pool entry and is computed locally.
|
||||
|
||||
**Retrieval (PIR + split payload):** For each matching pool index, the client runs LWE-based single-server **SimplePIR** (`pir_query`). The PIR database row is only a **small handle** (JSON: version, `grant_id`, SHA-256 of the ciphertext) padded to `PIR_RECORD_BYTES`; the bulk ciphertext is fetched in a second management round-trip (`get_grant_blob`). The client checks that the blob’s hash matches the handle before decrypting. The server learns **which `grant_id`** was requested on the second hop (unlike the PIR index, which stays private). Hints use `H = D · A^T` (seeded public matrix `A`); the client caches hints keyed by `pool_version`.
|
||||
**Retrieval (single-hop PIR row):** For each matching pool index, the client runs LWE-based single-server **SimplePIR** (`pir_query`). The PIR database row contains the full encrypted mailbox row (ephemeral public key, detection tag, ciphertext, ciphertext digest) padded to `PIR_RECORD_BYTES`. The client validates the row digest and decrypts locally. There is no `grant_id` second hop, so retrieval does not reveal a stable row identifier beyond the PIR exchange itself. Hints use `H = D · A^T` (seeded public matrix `A`); the client caches hints keyed by `pool_version`.
|
||||
|
||||
```
|
||||
Admin Server (opaque relay) Recipient
|
||||
|-- post_grant ------->| |
|
||||
| (admin_proof, | appends to pool: |
|
||||
| eph_pk, | {grant_id, eph_pk, |
|
||||
| ciphertext, | ciphertext, to_tag} |
|
||||
| eph_pk, | {eph_pk, ciphertext, |
|
||||
| ciphertext, | to_tag, claimed=false} |
|
||||
| to_tag) | (no recipient address) |
|
||||
| | |
|
||||
| |<-- pool_tags --------------|
|
||||
@ -63,12 +63,8 @@ Admin Server (opaque relay) Recipient
|
||||
| | | local tag match
|
||||
| |<-- pir_query(j) -----------|
|
||||
| |--- answer ----------------->|
|
||||
| | | PIR decode → handle
|
||||
| |<-- get_grant_blob ---------|
|
||||
| |--- blob fields ----------->|
|
||||
| | | verify hash → decrypt
|
||||
| |<-- claim_grant ------------|
|
||||
| | (tombstone / claimed) |
|
||||
| | | PIR decode full row
|
||||
| | | verify digest → decrypt
|
||||
```
|
||||
|
||||
## PIR security (LWE)
|
||||
@ -249,7 +245,7 @@ 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.
|
||||
- **Anonymous grant pool:** Grant entries contain `(eph_pk, ciphertext, to_tag)` plus stable row metadata — no registry ID or role name. Recipients discover their grants via detection tags and retrieve them via LWE PIR. Pool rows use tombstones (`claimed`) so indices stay stable for PIR hints.
|
||||
- **Anonymous grant pool:** Grant entries contain `(eph_pk, ciphertext, to_tag)` plus row metadata — no registry ID or role name. Recipients discover their grants via detection tags and retrieve full encrypted rows via LWE PIR in one step.
|
||||
- **No user IDs on server:** The server has no concept of user accounts. It is a stateless relay authenticated only by cryptographic proofs.
|
||||
- **Single-server PIR (LWE):** Eliminates the two-server non-collusion assumption of the previous XOR PIR design. Query privacy rests on decisional LWE, not operational trust in multiple server operators.
|
||||
- **Detection tags for discovery:** A 16-byte tag derived from X25519 DH allows O(n) local matching from a cheap bulk download, reducing PIR usage from O(n) queries to O(matches) queries per scan.
|
||||
@ -261,14 +257,14 @@ Recommended strategies:
|
||||
- **zkryptium dependency:** Security follows the underlying crate and BLS12-381/BBS+ standards; keep dependencies updated.
|
||||
- **Key distribution:** The library provides the cryptographic mechanism; initial key distribution is an application-layer responsibility.
|
||||
- **Honest-but-curious PIR:** The server can return incorrect PIR answers. Corrupted answers are caught by E2E decryption / BBS+ verification, but censorship (omitting grants) is not detected at the PIR layer. Cross-replica hash comparison or a transparency log can mitigate this.
|
||||
- **Hint size:** PIR hints are approximately `56 + record_bytes × N_LWE × 4` bytes (on the order of **1 MiB** with `record_bytes = 256` and `N_LWE = 1024`). Hints are cached client-side and only refetched when the pool version changes.
|
||||
- **Unbounded grant pool:** Rows are never removed from the pool file; only marked claimed. Pool length `n` therefore grows monotonically with every posted grant. That increases discovery traffic (`pool_tags` is O(n)), PIR query size (O(n) bytes per query), server work per PIR answer (O(n × record_bytes)), and hint **rebuild** cost when the pool changes (O(n × record_bytes × N_LWE)). Operators should plan for bounded pools or archival; the codebase does not yet enforce limits.
|
||||
- **Hint size:** PIR hints are approximately `56 + record_bytes × N_LWE × 4` bytes (on the order of **8 MiB** with `record_bytes = 2048` and `N_LWE = 1024`). Hints are cached client-side and only refetched when the pool version changes.
|
||||
- **Unbounded grant pool:** Rows are append-only and currently not privately claimed/deleted in protocol. Pool length `n` therefore grows monotonically with every posted grant. That increases discovery traffic (`pool_tags` is O(n)), PIR query size (O(n) bytes per query), server work per PIR answer (O(n × record_bytes)), and hint **rebuild** cost when the pool changes (O(n × record_bytes × N_LWE)). Operators should plan for bounded pools or archival; the codebase does not yet enforce limits.
|
||||
|
||||
## Future work
|
||||
|
||||
- **Bounded grant pool and anti-DoS:** Introduce explicit **pool caps**, **rate limits** on `post_grant`, **per-registry quotas**, or **pool generations** (rotate to a fresh empty pool while archiving the old one). Optionally **compact** the on-disk pool by rewriting only unclaimed rows and bumping a generation id so PIR indices stay meaningful without retaining every tombstone forever. Any design must preserve stable addressing for in-flight collects or migrate clients with explicit pool ids.
|
||||
- **Scale beyond large `n`:** Today’s bottleneck is **linear cost in pool length `n`** for each PIR retrieval: client upload ~4n bytes per query, server matrix–vector multiply O(n × record_bytes), and discovery O(n). For very large pools, future work includes **sublinear-communication PIR** (e.g. DoublePIR-style layering), **sharded pools** with client-side routing, **streaming or chunked hints**, or **moving heavy work off the hot path** (precomputed answers, CDN for hints) — trading complexity, trust, or privacy for throughput.
|
||||
- **DoublePIR / layered PIR:** The Rust tree still carries a Figure‑14 DoublePIR reference implementation (`fig14`) for tests and research. Production mailbox PIR is SimplePIR on handle-only rows plus `get_grant_blob` for ciphertext.
|
||||
- **DoublePIR / layered PIR:** Production mailbox PIR remains SimplePIR; layered/sublinear PIR remains future work.
|
||||
- **Verifiable PIR:** Adding a commitment to the pool state (e.g. Merkle tree or KZG) and proof of correct answer computation would defend against malicious server responses beyond what E2E encryption catches.
|
||||
- **Pool commitment / transparency:** Publishing a hash of `(pool_version, hints, tags)` to a public log or allowing cross-replica comparison would detect censorship by a malicious server.
|
||||
|
||||
|
||||
2
fuzz/Cargo.lock
generated
2
fuzz/Cargo.lock
generated
@ -709,7 +709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zkac"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"blake2",
|
||||
"chacha20poly1305",
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "zkac"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
description = "Zero-Knowledge Access Control: BBS+ anonymous credentials with encrypted transport"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@ -4,7 +4,7 @@ ZKAC — Zero-Knowledge Access Control
|
||||
BBS+ anonymous credentials (BLS12-381) with encrypted transport (Ristretto255 / X25519).
|
||||
"""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.5.1"
|
||||
|
||||
from zkac._zkac import (
|
||||
MAX_BBS_AUTH_PROOF_BYTES,
|
||||
|
||||
246
scripts/e2e_two_clients_timing.py
Normal file
246
scripts/e2e_two_clients_timing.py
Normal file
@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E smoke + timing: one server, clients A (admin) and B (recipient).
|
||||
|
||||
1. A creates a registry with 3 roles
|
||||
2. A posts one or more grants to B (same role ``beta``)
|
||||
3. Time B's mailbox fetch and permission-style checks (same phases as
|
||||
``zkac-node credentials list B --server …``: local creds, list_pending, has_credential)
|
||||
|
||||
Default (no args): one grant, asserts one pending ``beta`` grant.
|
||||
|
||||
Scaling: ``--sizes 2,5,25,50`` runs a fresh server + pool for each size (all grants
|
||||
to B), prints a timing table (``list_pending`` = tags + PIR full-row decode per match).
|
||||
|
||||
Run from repo root, e.g.:
|
||||
|
||||
uv run python scripts/e2e_two_clients_timing.py
|
||||
uv run python scripts/e2e_two_clients_timing.py --sizes 2,5,25,50
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "python"))
|
||||
sys.path.insert(0, str(ROOT / "cli"))
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
_, port = s.getsockname()
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
def _run_scaled_sizes(sizes: list[int]) -> int:
|
||||
from zkac_cli import client, store
|
||||
from zkac_cli.server import _ServerStore, serve
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
rows: list[tuple[int, float, float, float, float, float, float]] = []
|
||||
|
||||
for n in sizes:
|
||||
td = tempfile.mkdtemp(prefix="zkac-e2e-")
|
||||
os.environ["ZKAC_HOME"] = td
|
||||
port = _free_port()
|
||||
server = f"127.0.0.1:{port}"
|
||||
server_dd = Path(td) / "srv"
|
||||
server_dd.mkdir(parents=True)
|
||||
|
||||
ss = _ServerStore(server_dd)
|
||||
kp = ss.load_or_create_keypair()
|
||||
pk_b64 = base64.b64encode(kp.public_key().to_bytes()).decode()
|
||||
|
||||
store.create_user("A")
|
||||
store.create_user("B")
|
||||
store.pin_server("A", server, pk_b64)
|
||||
store.pin_server("B", server, pk_b64)
|
||||
|
||||
t_srv = threading.Thread(
|
||||
target=lambda: serve(str(server_dd), "127.0.0.1", port),
|
||||
daemon=True,
|
||||
)
|
||||
t_srv.start()
|
||||
time.sleep(0.25)
|
||||
|
||||
t_setup0 = time.perf_counter()
|
||||
rid = client.create_registry("A", server, ["alpha", "beta", "gamma"])
|
||||
t_after_create = time.perf_counter()
|
||||
b_pk = store.load_identity("B")["issuance_pk"].hex()
|
||||
for _ in range(n):
|
||||
client.grant("A", server, rid, "beta", b_pk)
|
||||
t_gr1 = time.perf_counter()
|
||||
|
||||
t_loc0 = time.perf_counter()
|
||||
local_creds = store.list_credentials("B")
|
||||
t_loc1 = time.perf_counter()
|
||||
|
||||
t_mail0 = time.perf_counter()
|
||||
pending = client.list_pending("B", server)
|
||||
t_mail1 = time.perf_counter()
|
||||
|
||||
t_perm0 = time.perf_counter()
|
||||
for g in pending:
|
||||
r = g.get("registry_id")
|
||||
role = g.get("role_name")
|
||||
if r not in (None, "?") and role not in (None, "?"):
|
||||
store.has_credential("B", r, role)
|
||||
t_perm1 = time.perf_counter()
|
||||
|
||||
create_ms = (t_after_create - t_setup0) * 1000
|
||||
grant_ms = (t_gr1 - t_after_create) * 1000
|
||||
local_ms = (t_loc1 - t_loc0) * 1000
|
||||
mail_ms = (t_mail1 - t_mail0) * 1000
|
||||
perm_ms = (t_perm1 - t_perm0) * 1000
|
||||
total_ms = (t_perm1 - t_loc0) * 1000
|
||||
|
||||
rows.append((n, create_ms, grant_ms, local_ms, mail_ms, perm_ms, total_ms))
|
||||
|
||||
log(
|
||||
f"n={n}: create_registry={create_ms:.0f} ms N×grant={grant_ms:.0f} ms "
|
||||
f"list_pending={mail_ms:.0f} ms pending={len(pending)} ZKAC_HOME={td}"
|
||||
)
|
||||
if len(pending) != n:
|
||||
print(f"ERROR: expected {n} pending, got {len(pending)}", flush=True)
|
||||
return 1
|
||||
for p in pending:
|
||||
if p.get("role_name") != "beta" or p.get("registry_id") != rid:
|
||||
print(f"ERROR: bad pending row {p!r}", flush=True)
|
||||
return 1
|
||||
|
||||
print()
|
||||
print(
|
||||
"pool_n | create_registry (ms) | N×grant (ms) | list_local (ms) | "
|
||||
"list_pending mailbox (ms) | has_cred (ms) | cred_list_total (ms)"
|
||||
)
|
||||
print("-" * 120)
|
||||
for n, create_ms, grant_ms, local_ms, mail_ms, perm_ms, total_ms in rows:
|
||||
print(
|
||||
f"{n:6d} | {create_ms:20.1f} | {grant_ms:12.1f} | {local_ms:15.3f} | "
|
||||
f"{mail_ms:25.1f} | {perm_ms:12.3f} | {total_ms:20.1f}"
|
||||
)
|
||||
print("OK")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="ZKAC e2e timing (mailbox / credentials list)")
|
||||
parser.add_argument(
|
||||
"--sizes",
|
||||
default=None,
|
||||
metavar="N,N,...",
|
||||
help="comma-separated pool sizes (each run: fresh server, N grants to B, then list_pending)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.sizes is not None:
|
||||
sizes = [int(x.strip()) for x in args.sizes.split(",") if x.strip()]
|
||||
if not sizes or any(x < 1 for x in sizes):
|
||||
print("error: --sizes must be positive integers", file=sys.stderr)
|
||||
return 2
|
||||
return _run_scaled_sizes(sizes)
|
||||
|
||||
from zkac_cli import client, store
|
||||
from zkac_cli.server import _ServerStore, serve
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
td = tempfile.mkdtemp(prefix="zkac-e2e-")
|
||||
os.environ["ZKAC_HOME"] = td
|
||||
port = _free_port()
|
||||
server = f"127.0.0.1:{port}"
|
||||
server_dd = Path(td) / "srv"
|
||||
server_dd.mkdir(parents=True)
|
||||
|
||||
ss = _ServerStore(server_dd)
|
||||
kp = ss.load_or_create_keypair()
|
||||
pk_b64 = base64.b64encode(kp.public_key().to_bytes()).decode()
|
||||
|
||||
store.create_user("A")
|
||||
store.create_user("B")
|
||||
store.pin_server("A", server, pk_b64)
|
||||
store.pin_server("B", server, pk_b64)
|
||||
|
||||
t_srv = threading.Thread(
|
||||
target=lambda: serve(str(server_dd), "127.0.0.1", port),
|
||||
daemon=True,
|
||||
)
|
||||
t_srv.start()
|
||||
time.sleep(0.25)
|
||||
log("server thread up")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
rid = client.create_registry("A", server, ["alpha", "beta", "gamma"])
|
||||
t_create = time.perf_counter()
|
||||
log(f"registry created ({(t_create - t0) * 1000:.0f} ms)")
|
||||
|
||||
b_pk = store.load_identity("B")["issuance_pk"].hex()
|
||||
client.grant("A", server, rid, "beta", b_pk)
|
||||
t_grant = time.perf_counter()
|
||||
log(f"grant posted ({(t_grant - t_create) * 1000:.0f} ms)")
|
||||
|
||||
log("B mailbox + permission-style checks (same work as `credentials list`) …")
|
||||
t_loc0 = time.perf_counter()
|
||||
local_creds = store.list_credentials("B")
|
||||
t_loc1 = time.perf_counter()
|
||||
|
||||
t_mail0 = time.perf_counter()
|
||||
pending = client.list_pending("B", server)
|
||||
t_mail1 = time.perf_counter()
|
||||
|
||||
t_perm0 = time.perf_counter()
|
||||
for g in pending:
|
||||
r = g.get("registry_id")
|
||||
role = g.get("role_name")
|
||||
if r not in (None, "?") and role not in (None, "?"):
|
||||
store.has_credential("B", r, role)
|
||||
t_perm1 = time.perf_counter()
|
||||
|
||||
log(f"ZKAC_HOME={td}")
|
||||
log(f"server={server} registry={rid[:24]}…")
|
||||
print(f"create_registry: {(t_create - t0) * 1000:.1f} ms")
|
||||
print(f"grant: {(t_grant - t_create) * 1000:.1f} ms")
|
||||
print(
|
||||
f"list_local_creds(B): {(t_loc1 - t_loc0) * 1000:.3f} ms ({len(local_creds)} on disk)"
|
||||
)
|
||||
print(
|
||||
f"list_pending(mailbox): {(t_mail1 - t_mail0) * 1000:.1f} ms "
|
||||
f"({len(pending)} match(es); tags + PIR row + decrypt)"
|
||||
)
|
||||
print(
|
||||
f"has_credential checks: {(t_perm1 - t_perm0) * 1000:.3f} ms "
|
||||
f"({len(pending)} grant(s))"
|
||||
)
|
||||
print(
|
||||
f"credentials_list_total: {(t_perm1 - t_loc0) * 1000:.1f} ms "
|
||||
"(local + mailbox + permission flags)"
|
||||
)
|
||||
for p in pending:
|
||||
print(
|
||||
f" pending: registry={p.get('registry_id', '?')[:16]}… "
|
||||
f"role={p.get('role_name')} idx={p.get('pool_index')}"
|
||||
)
|
||||
|
||||
assert len(pending) == 1, pending
|
||||
assert pending[0].get("role_name") == "beta"
|
||||
assert pending[0].get("registry_id") == rid
|
||||
print("OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -2,8 +2,8 @@
|
||||
//! first-layer construction over ``Z_{2^{32}}`` with plaintext modulus ``p = 256``.
|
||||
//!
|
||||
//! ``H = D · A^T`` (offline hint), query ``q = A^T s + e + Δ·e_index``, answer ``D·q``; client removes ``H·s`` and rounds.
|
||||
//! The grant **mailbox** uses a **split payload**: each PIR row is only ``RECORD_BYTES`` of **handle** bytes (built
|
||||
//! in the CLI server); the bulk ciphertext is fetched separately via ``get_grant_blob``.
|
||||
//! The grant **mailbox** uses single-hop retrieval: each PIR row contains one full encrypted mailbox row
|
||||
//! (ephemeral key, detection tag, ciphertext, ciphertext digest), padded to ``RECORD_BYTES``.
|
||||
//!
|
||||
//! Security: decisional LWE with ``n = N_LWE``, ``q = 2^32``, ``σ = 6.4``.
|
||||
|
||||
|
||||
544
src/pir/fig14.rs
544
src/pir/fig14.rs
@ -1,544 +0,0 @@
|
||||
//! DoublePIR (Figure 14, Appendix E of ePrint 2022/949 / USENIX Security 2023).
|
||||
//!
|
||||
//! Implements Setup / Query / Answer / Recover over a database in ``Z_p^{ℓ×m}``
|
||||
//! (here ``p = 256``, digits stored as ``u32``), with LWE dimension ``n``,
|
||||
//! ``κ = ⌈32 / log₂(p)⌉ = 4`` base-``p`` digits per ``Z_{2^32}`` limb, and the
|
||||
//! same ``Δ`` / rounding as the rest of this crate.
|
||||
//!
|
||||
//! **Integration note:** one ``Recover`` returns a **single** plaintext limb at
|
||||
//! ``(irow, icol)``. Production wiring (``doublepir``) batches ``ℓ`` inner
|
||||
//! queries per record fetch; keep ``ℓ`` modest so ``4·m + 4·ℓ²`` query size stays
|
||||
//! practical (see ``params::RECORD_BYTES``).
|
||||
|
||||
use rand::rngs::OsRng;
|
||||
use rand::Rng;
|
||||
|
||||
use super::lwe;
|
||||
use super::params::{DELTA, N_LWE, P};
|
||||
|
||||
/// κ digits per ``u32`` wire element (32 bits / 8 bits per digit).
|
||||
pub const KAPPA: usize = 4;
|
||||
|
||||
fn gen_matrix(seed: &[u8; 32], rows: usize, cols: usize) -> Vec<u32> {
|
||||
lwe::gen_matrix(seed, rows, cols)
|
||||
}
|
||||
|
||||
/// ``db`` column-major: ``db[i * m + j]`` = row ``i``, column ``j``, values in ``0..P``.
|
||||
pub fn db_get(db: &[u32], m: usize, irow: usize, icol: usize) -> u32 {
|
||||
db[irow * m + icol]
|
||||
}
|
||||
|
||||
fn mat_mul_at_b(a_t: &[u32], n: usize, m: usize, b: &[u32], m2: usize, ell: usize) -> Vec<u32> {
|
||||
debug_assert_eq!(m, m2);
|
||||
let mut out = vec![0u32; n * ell];
|
||||
for i in 0..n {
|
||||
for j in 0..ell {
|
||||
let mut acc = 0u64;
|
||||
for k in 0..m {
|
||||
acc = acc.wrapping_add(
|
||||
(a_t[i * m + k] as u64).wrapping_mul(b[k * ell + j] as u64),
|
||||
);
|
||||
}
|
||||
out[i * ell + j] = acc as u32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mat_mul_ab(a: &[u32], rows: usize, inner: usize, b: &[u32], inner2: usize, cols: usize) -> Vec<u32> {
|
||||
debug_assert_eq!(inner, inner2);
|
||||
let mut c = vec![0u32; rows * cols];
|
||||
for i in 0..rows {
|
||||
for j in 0..cols {
|
||||
let mut acc = 0u64;
|
||||
for k in 0..inner {
|
||||
acc = acc.wrapping_add(
|
||||
(a[i * inner + k] as u64).wrapping_mul(b[k * cols + j] as u64),
|
||||
);
|
||||
}
|
||||
c[i * cols + j] = acc as u32;
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Decompose each ``u32`` into ``KAPPA`` base-``P`` digits (row-major ``κa × b``).
|
||||
fn decomp_matrix(inp: &[u32], rows: usize, cols: usize) -> Vec<u32> {
|
||||
let mut out = vec![0u32; (rows * KAPPA) * cols];
|
||||
for i in 0..rows {
|
||||
for j in 0..cols {
|
||||
let mut v = inp[i * cols + j];
|
||||
for t in 0..KAPPA {
|
||||
out[(i * KAPPA + t) * cols + j] = (v % P) as u32;
|
||||
v /= P;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`decomp_matrix`].
|
||||
fn recomp_matrix(inp: &[u32], rows: usize, cols: usize) -> Vec<u32> {
|
||||
let mut out = vec![0u32; rows * cols];
|
||||
for i in 0..rows {
|
||||
for j in 0..cols {
|
||||
let mut acc: u32 = 0;
|
||||
let mut pow: u32 = 1;
|
||||
for t in 0..KAPPA {
|
||||
let d = inp[(i * KAPPA + t) * cols + j] % P;
|
||||
acc = acc.wrapping_add(d.wrapping_mul(pow));
|
||||
pow = pow.wrapping_mul(P);
|
||||
}
|
||||
out[i * cols + j] = acc;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Round each limb to the nearest multiple of ``Δ`` (Figure 14 ``RoundΔ``).
|
||||
fn round_delta_vec(v: &[u32]) -> Vec<u32> {
|
||||
let d = DELTA as u64;
|
||||
let half = d / 2;
|
||||
v.iter()
|
||||
.map(|&x| {
|
||||
let vx = x as u64;
|
||||
((vx.wrapping_add(half)) / d).wrapping_mul(d) as u32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Server secret from Setup: ``hint_s`` (``κ n × ℓ``), ``A1`` (``m × n``), ``A2`` (``ℓ × n``).
|
||||
pub struct DoublePirServerState {
|
||||
pub hint_s: Vec<u32>,
|
||||
pub a1: Vec<u32>,
|
||||
pub a2: Vec<u32>,
|
||||
pub ell: usize,
|
||||
pub m: usize,
|
||||
pub n: usize,
|
||||
}
|
||||
|
||||
/// Client offline hint ``hint_c`` (``κ n × n``).
|
||||
pub struct DoublePirClientHint {
|
||||
pub hint_c: Vec<u32>,
|
||||
pub ell: usize,
|
||||
pub m: usize,
|
||||
pub n: usize,
|
||||
}
|
||||
|
||||
pub fn setup(
|
||||
db: &[u32],
|
||||
ell: usize,
|
||||
m: usize,
|
||||
seed1: &[u8; 32],
|
||||
seed2: &[u8; 32],
|
||||
) -> (DoublePirServerState, DoublePirClientHint) {
|
||||
let n = N_LWE;
|
||||
assert!(db.len() >= ell * m);
|
||||
let a1 = gen_matrix(seed1, m, n);
|
||||
let a2 = gen_matrix(seed2, ell, n);
|
||||
let a1_t = transpose(&a1, m, n);
|
||||
let d_t = transpose(db, ell, m);
|
||||
let raw = mat_mul_at_b(&a1_t, n, m, &d_t, m, ell);
|
||||
let hint_s = decomp_matrix(&raw, n, ell);
|
||||
let hint_c = mat_mul_ab(&hint_s, n * KAPPA, ell, &a2, ell, n);
|
||||
(
|
||||
DoublePirServerState {
|
||||
hint_s,
|
||||
a1,
|
||||
a2,
|
||||
ell,
|
||||
m,
|
||||
n,
|
||||
},
|
||||
DoublePirClientHint { hint_c, ell, m, n },
|
||||
)
|
||||
}
|
||||
|
||||
fn transpose(inp: &[u32], rows: usize, cols: usize) -> Vec<u32> {
|
||||
let mut out = vec![0u32; cols * rows];
|
||||
for i in 0..rows {
|
||||
for j in 0..cols {
|
||||
out[j * rows + i] = inp[i * cols + j];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct DoublePirQueryState {
|
||||
pub s1: Vec<u32>,
|
||||
pub s2: Vec<u32>,
|
||||
pub irow: usize,
|
||||
pub icol: usize,
|
||||
}
|
||||
|
||||
pub fn query(
|
||||
irow: usize,
|
||||
icol: usize,
|
||||
ell: usize,
|
||||
m: usize,
|
||||
n: usize,
|
||||
a1: &[u32],
|
||||
a2: &[u32],
|
||||
) -> (DoublePirQueryState, Vec<u32>, Vec<u32>) {
|
||||
let mut rng = OsRng;
|
||||
query_with_rng(&mut rng, irow, icol, ell, m, n, a1, a2)
|
||||
}
|
||||
|
||||
/// Same as [`query`], but uses the given RNG (for deterministic tests).
|
||||
pub fn query_with_rng(
|
||||
rng: &mut impl Rng,
|
||||
irow: usize,
|
||||
icol: usize,
|
||||
ell: usize,
|
||||
m: usize,
|
||||
n: usize,
|
||||
a1: &[u32],
|
||||
a2: &[u32],
|
||||
) -> (DoublePirQueryState, Vec<u32>, Vec<u32>) {
|
||||
assert!(irow < ell && icol < m);
|
||||
let s1 = lwe::sample_uniform_vec(rng, n);
|
||||
let s2 = lwe::sample_uniform_vec(rng, n);
|
||||
let e1 = lwe::sample_error_vec(rng, m);
|
||||
let e2 = lwe::sample_error_vec(rng, ell);
|
||||
|
||||
let mut c1 = mat_mul_ab(a1, m, n, &s1, n, 1);
|
||||
for i in 0..m {
|
||||
c1[i] = c1[i].wrapping_add(e1[i]);
|
||||
}
|
||||
c1[icol] = c1[icol].wrapping_add(DELTA);
|
||||
|
||||
let mut c2 = mat_mul_ab(a2, ell, n, &s2, n, 1);
|
||||
for i in 0..ell {
|
||||
c2[i] = c2[i].wrapping_add(e2[i]);
|
||||
}
|
||||
c2[irow] = c2[irow].wrapping_add(DELTA);
|
||||
|
||||
(
|
||||
DoublePirQueryState {
|
||||
s1,
|
||||
s2,
|
||||
irow,
|
||||
icol,
|
||||
},
|
||||
c1,
|
||||
c2,
|
||||
)
|
||||
}
|
||||
|
||||
pub struct DoublePirAnswer {
|
||||
pub h: Vec<u32>,
|
||||
pub ans_h: Vec<u32>,
|
||||
pub ans2: Vec<u32>,
|
||||
}
|
||||
|
||||
pub fn answer(
|
||||
db: &[u32],
|
||||
st: &DoublePirServerState,
|
||||
c1: &[u32],
|
||||
c2: &[u32],
|
||||
) -> DoublePirAnswer {
|
||||
let ell = st.ell;
|
||||
let m = st.m;
|
||||
let n = st.n;
|
||||
let row_c1_t: Vec<u32> = c1.to_vec();
|
||||
let d_t = transpose(db, ell, m);
|
||||
let one_ell = mat_mul_at_b(&row_c1_t, 1, m, &d_t, m, ell);
|
||||
let ans1 = decomp_matrix(&one_ell, 1, ell);
|
||||
let h = mat_mul_ab(&ans1, KAPPA, ell, &st.a2, ell, n);
|
||||
|
||||
let mut stacked = vec![0u32; (n * KAPPA + KAPPA) * ell];
|
||||
stacked[..n * KAPPA * ell].copy_from_slice(&st.hint_s);
|
||||
stacked[n * KAPPA * ell..].copy_from_slice(&ans1);
|
||||
let ans_h2 = mat_mul_ab(&stacked, n * KAPPA + KAPPA, ell, c2, ell, 1);
|
||||
let ans_h = ans_h2[..n * KAPPA].to_vec();
|
||||
let ans2 = ans_h2[n * KAPPA..].to_vec();
|
||||
DoublePirAnswer { h, ans_h, ans2 }
|
||||
}
|
||||
|
||||
fn mat_vec_mul(mat: &[u32], rows: usize, cols: usize, v: &[u32]) -> Vec<u32> {
|
||||
let mut out = vec![0u32; rows];
|
||||
for r in 0..rows {
|
||||
let mut acc = 0u64;
|
||||
for c in 0..cols {
|
||||
acc = acc.wrapping_add((mat[r * cols + c] as u64).wrapping_mul(v[c] as u64));
|
||||
}
|
||||
out[r] = acc as u32;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of ``u32`` limbs in a serialized [`DoublePirAnswer`] (fixed for ``n = N_LWE``).
|
||||
pub const ANSWER_U32_LEN: usize = 2 * KAPPA * N_LWE + KAPPA;
|
||||
|
||||
/// Figure 14 ``Recover`` (scalar at ``(irow, icol)``).
|
||||
pub fn recover(
|
||||
st: &DoublePirQueryState,
|
||||
hint_c: &[u32],
|
||||
ans: &DoublePirAnswer,
|
||||
ell: usize,
|
||||
m: usize,
|
||||
n: usize,
|
||||
) -> u32 {
|
||||
recover_with_secrets(&st.s1, &st.s2, hint_c, ans, ell, m, n)
|
||||
}
|
||||
|
||||
/// Same as [`recover`] but takes secret vectors explicitly (avoids cloning ``s1`` per row when
|
||||
/// decoding a full column with shared ``s1``).
|
||||
pub fn recover_with_secrets(
|
||||
s1: &[u32],
|
||||
s2: &[u32],
|
||||
hint_c: &[u32],
|
||||
ans: &DoublePirAnswer,
|
||||
ell: usize,
|
||||
m: usize,
|
||||
n: usize,
|
||||
) -> u32 {
|
||||
let _ = (ell, m);
|
||||
let kn = KAPPA * n;
|
||||
debug_assert_eq!(hint_c.len(), kn * n);
|
||||
debug_assert_eq!(s1.len(), n);
|
||||
debug_assert_eq!(s2.len(), n);
|
||||
debug_assert_eq!(ans.h.len(), KAPPA * n);
|
||||
debug_assert_eq!(ans.ans_h.len(), kn);
|
||||
debug_assert_eq!(ans.ans2.len(), KAPPA);
|
||||
|
||||
let mut mmat = vec![0u32; (kn + KAPPA) * n];
|
||||
mmat[..kn * n].copy_from_slice(hint_c);
|
||||
for r in 0..KAPPA {
|
||||
for c in 0..n {
|
||||
mmat[(kn + r) * n + c] = ans.h[r * n + c];
|
||||
}
|
||||
}
|
||||
|
||||
let col = mat_vec_mul(&mmat, kn + KAPPA, n, s2);
|
||||
let mut diff = vec![0u32; kn + KAPPA];
|
||||
for r in 0..kn {
|
||||
diff[r] = ans.ans_h[r].wrapping_sub(col[r]);
|
||||
}
|
||||
for r in 0..KAPPA {
|
||||
diff[kn + r] = ans.ans2[r].wrapping_sub(col[kn + r]);
|
||||
}
|
||||
|
||||
// Figure 14: ``[h1|a1] <- Recomp(RoundDelta([ĥ1|â1] / Delta))``.
|
||||
// In ``Z_{2^{32}}`` with plaintext modulus ``p=256``: first round each limb to
|
||||
// the nearest multiple of ``Δ``, then divide by ``Δ`` to get base-``p`` digits,
|
||||
// then ``Recomp`` groups ``κ`` digits into one ring element (inverse of Setup ``Decomp``).
|
||||
let rd = round_delta_vec(&diff);
|
||||
let digits: Vec<u32> = rd
|
||||
.iter()
|
||||
.map(|&x| ((x as u64).wrapping_div(DELTA as u64) % (P as u64)) as u32)
|
||||
.collect();
|
||||
let h1_a1 = recomp_matrix(&digits, n + 1, 1);
|
||||
|
||||
let mut d_hat = h1_a1[n];
|
||||
for j in 0..n {
|
||||
d_hat = d_hat.wrapping_sub(s1[j].wrapping_mul(h1_a1[j]));
|
||||
}
|
||||
lwe::round_to_plaintext(d_hat) as u32
|
||||
}
|
||||
|
||||
pub fn flatten_answer(ans: &DoublePirAnswer) -> Vec<u32> {
|
||||
debug_assert_eq!(ans.h.len() + ans.ans_h.len() + ans.ans2.len(), ANSWER_U32_LEN);
|
||||
let mut v = Vec::with_capacity(ANSWER_U32_LEN);
|
||||
v.extend_from_slice(&ans.h);
|
||||
v.extend_from_slice(&ans.ans_h);
|
||||
v.extend_from_slice(&ans.ans2);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn unflatten_answer(v: &[u32]) -> Result<DoublePirAnswer, &'static str> {
|
||||
if v.len() != ANSWER_U32_LEN {
|
||||
return Err("DoublePIR answer u32 length mismatch");
|
||||
}
|
||||
let kn = KAPPA * N_LWE;
|
||||
let h = v[..KAPPA * N_LWE].to_vec();
|
||||
let ans_h = v[KAPPA * N_LWE..2 * kn].to_vec();
|
||||
let ans2 = v[2 * kn..].to_vec();
|
||||
Ok(DoublePirAnswer { h, ans_h, ans2 })
|
||||
}
|
||||
|
||||
/// One row of a batched column query: fresh ``s2`` / ``c2`` with bump at ``irow``.
|
||||
pub struct RowQueryPart {
|
||||
pub s2: Vec<u32>,
|
||||
pub irow: usize,
|
||||
}
|
||||
|
||||
/// Client secrets for fetching an entire DB column (one record): shared ``s1`` / ``c1``, one
|
||||
/// second-layer state per row.
|
||||
pub struct ColumnQueryState {
|
||||
pub s1: Vec<u32>,
|
||||
pub icol: usize,
|
||||
pub rows: Vec<RowQueryPart>,
|
||||
}
|
||||
|
||||
/// Build ``c1`` once, then one ``(s2, c2)`` per row ``0..ell``, all targeting the same column
|
||||
/// ``icol`` (record index).
|
||||
pub fn query_column_all_rows_with_rng(
|
||||
rng: &mut impl Rng,
|
||||
icol: usize,
|
||||
ell: usize,
|
||||
m: usize,
|
||||
n: usize,
|
||||
a1: &[u32],
|
||||
a2: &[u32],
|
||||
) -> (ColumnQueryState, Vec<u32>, Vec<Vec<u32>>) {
|
||||
assert!(icol < m && ell > 0);
|
||||
let s1 = lwe::sample_uniform_vec(rng, n);
|
||||
let e1 = lwe::sample_error_vec(rng, m);
|
||||
let mut c1 = mat_mul_ab(a1, m, n, &s1, n, 1);
|
||||
for i in 0..m {
|
||||
c1[i] = c1[i].wrapping_add(e1[i]);
|
||||
}
|
||||
c1[icol] = c1[icol].wrapping_add(DELTA);
|
||||
|
||||
let mut rows = Vec::with_capacity(ell);
|
||||
let mut c2s = Vec::with_capacity(ell);
|
||||
for irow in 0..ell {
|
||||
let s2 = lwe::sample_uniform_vec(rng, n);
|
||||
let e2 = lwe::sample_error_vec(rng, ell);
|
||||
let mut c2 = mat_mul_ab(a2, ell, n, &s2, n, 1);
|
||||
for i in 0..ell {
|
||||
c2[i] = c2[i].wrapping_add(e2[i]);
|
||||
}
|
||||
c2[irow] = c2[irow].wrapping_add(DELTA);
|
||||
rows.push(RowQueryPart { s2, irow });
|
||||
c2s.push(c2);
|
||||
}
|
||||
(
|
||||
ColumnQueryState {
|
||||
s1,
|
||||
icol,
|
||||
rows,
|
||||
},
|
||||
c1,
|
||||
c2s,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
|
||||
fn rand_db(rng: &mut OsRng, ell: usize, m: usize) -> Vec<u32> {
|
||||
(0..ell * m).map(|_| rng.gen_range(0u32..P)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_pir_hint_c_matches_hint_s_a2() {
|
||||
let mut rng = OsRng;
|
||||
let ell = 3usize;
|
||||
let m = 5usize;
|
||||
let n = N_LWE;
|
||||
let db = rand_db(&mut rng, ell, m);
|
||||
let seed1 = rng.gen();
|
||||
let seed2 = rng.gen();
|
||||
let (srv, cli) = setup(&db, ell, m, &seed1, &seed2);
|
||||
let kn = KAPPA * n;
|
||||
let hc = mat_mul_ab(&srv.hint_s, kn, ell, &srv.a2, ell, n);
|
||||
assert_eq!(hc, cli.hint_c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_pir_answer_ans_h_matches_hint_s_times_c2() {
|
||||
let mut rng = OsRng;
|
||||
let ell = 4usize;
|
||||
let m = 6usize;
|
||||
let n = N_LWE;
|
||||
let db = rand_db(&mut rng, ell, m);
|
||||
let seed1 = rng.gen();
|
||||
let seed2 = rng.gen();
|
||||
let (srv, _cli) = setup(&db, ell, m, &seed1, &seed2);
|
||||
let (_qst, c1, c2) = query_noiseless(1, 2, ell, m, n, &srv.a1, &srv.a2);
|
||||
let ans = answer(&db, &srv, &c1, &c2);
|
||||
let kn = KAPPA * n;
|
||||
let expect_top = mat_mul_ab(&srv.hint_s, kn, ell, &c2, ell, 1);
|
||||
assert_eq!(ans.ans_h, expect_top, "ans_h != hint_s @ c2");
|
||||
let d_t = transpose(&db, ell, m);
|
||||
let one_ell = mat_mul_at_b(&c1, 1, m, &d_t, m, ell);
|
||||
let ans1 = decomp_matrix(&one_ell, 1, ell);
|
||||
let expect_h = mat_mul_ab(&ans1, KAPPA, ell, &srv.a2, ell, n);
|
||||
assert_eq!(ans.h, expect_h, "h != ans1 @ A2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_pir_fig14_roundtrip_tiny() {
|
||||
let mut rng = OsRng;
|
||||
let ell = 3usize;
|
||||
let m = 5usize;
|
||||
let n = N_LWE;
|
||||
let db = rand_db(&mut rng, ell, m);
|
||||
let seed1 = rng.gen();
|
||||
let seed2 = rng.gen();
|
||||
let (srv, cli) = setup(&db, ell, m, &seed1, &seed2);
|
||||
for irow in 0..ell {
|
||||
for icol in 0..m {
|
||||
let (qst, c1, c2) = query_noiseless(irow, icol, ell, m, n, &srv.a1, &srv.a2);
|
||||
let ans = answer(&db, &srv, &c1, &c2);
|
||||
let got = recover(&qst, &cli.hint_c, &ans, ell, m, n);
|
||||
let want = db_get(&db, m, irow, icol);
|
||||
assert_eq!(got, want, "mismatch at ({irow},{icol})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Noisy LWE errors (same σ as production); several RNG seeds (each cell × trial).
|
||||
#[test]
|
||||
fn double_pir_fig14_roundtrip_noisy_smoke() {
|
||||
let ell = 3usize;
|
||||
let m = 5usize;
|
||||
let n = N_LWE;
|
||||
let db: Vec<u32> = (0..ell * m).map(|i| (i as u32 * 17 + 41) % P).collect();
|
||||
let seed1 = [7u8; 32];
|
||||
let seed2 = [11u8; 32];
|
||||
let (srv, cli) = setup(&db, ell, m, &seed1, &seed2);
|
||||
for trial in 0u64..6 {
|
||||
let mut rng = StdRng::seed_from_u64(trial);
|
||||
for irow in 0..ell {
|
||||
for icol in 0..m {
|
||||
let (qst, c1, c2) =
|
||||
query_with_rng(&mut rng, irow, icol, ell, m, n, &srv.a1, &srv.a2);
|
||||
let ans = answer(&db, &srv, &c1, &c2);
|
||||
let got = recover(&qst, &cli.hint_c, &ans, ell, m, n);
|
||||
let want = db_get(&db, m, irow, icol);
|
||||
assert_eq!(
|
||||
got, want,
|
||||
"noisy mismatch trial {trial} at ({irow},{icol})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic query with **zero** LWE noise (test / debugging only).
|
||||
fn query_noiseless(
|
||||
irow: usize,
|
||||
icol: usize,
|
||||
ell: usize,
|
||||
m: usize,
|
||||
n: usize,
|
||||
a1: &[u32],
|
||||
a2: &[u32],
|
||||
) -> (DoublePirQueryState, Vec<u32>, Vec<u32>) {
|
||||
assert!(irow < ell && icol < m);
|
||||
let mut rng = OsRng;
|
||||
let s1 = lwe::sample_uniform_vec(&mut rng, n);
|
||||
let s2 = lwe::sample_uniform_vec(&mut rng, n);
|
||||
let mut c1 = mat_mul_ab(a1, m, n, &s1, n, 1);
|
||||
c1[icol] = c1[icol].wrapping_add(DELTA);
|
||||
let mut c2 = mat_mul_ab(a2, ell, n, &s2, n, 1);
|
||||
c2[irow] = c2[irow].wrapping_add(DELTA);
|
||||
(
|
||||
DoublePirQueryState {
|
||||
s1,
|
||||
s2,
|
||||
irow,
|
||||
icol,
|
||||
},
|
||||
c1,
|
||||
c2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
pub mod params;
|
||||
pub mod lwe;
|
||||
pub mod db;
|
||||
/// Production single-server SimplePIR implementation.
|
||||
pub mod doublepir;
|
||||
/// Figure 14 DoublePIR (ePrint 2022/949). Kept for research / correctness tests only; production PIR is SimplePIR in ``doublepir``.
|
||||
pub mod fig14;
|
||||
|
||||
pub use params::RECORD_BYTES;
|
||||
pub use db::Database;
|
||||
|
||||
@ -10,8 +10,9 @@ pub const DELTA: u32 = 1 << 24;
|
||||
/// Discrete Gaussian standard deviation for LWE error sampling.
|
||||
pub const SIGMA: f64 = 6.4;
|
||||
|
||||
/// Fixed record size for PIR (bytes). Must match the CLI **handle** padding (split payload).
|
||||
/// Fixed record size for PIR (bytes). Must fit one full encrypted mailbox row.
|
||||
///
|
||||
/// Each mailbox row’s PIR cell is only a small handle (``grant_id`` + ciphertext digest);
|
||||
/// bulk ciphertext is fetched via ``get_grant_blob``. Hint size scales as ``O(RECORD_BYTES · N_LWE · n)``.
|
||||
pub const RECORD_BYTES: usize = 256;
|
||||
/// Mailbox retrieval is single-hop: the PIR row carries ``(eph_pk, to_tag, ciphertext, digest)``
|
||||
/// so clients do not reveal a stable ``grant_id`` in a second fetch round-trip.
|
||||
/// Hint size scales as ``O(RECORD_BYTES · N_LWE · n)``.
|
||||
pub const RECORD_BYTES: usize = 2048;
|
||||
|
||||
4
uv.lock
generated
4
uv.lock
generated
@ -907,7 +907,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zkac"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ipykernel" },
|
||||
@ -941,7 +941,7 @@ provides-extras = ["cli", "demo", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "zkac-node"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = { editable = "cli" }
|
||||
dependencies = [
|
||||
{ name = "zkac" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user