demo: privacy-harden file share and add guardrail tests

Harden fs auth and storage for a trustless-server model: proof-only hello,
opaque tagged bucket metadata, safer connection logging, and inbox UI without
raw ids. Add demo/test_demo_privacy_guardrails.py and README notes. Stop
tracking demo __pycache__ and fs_data artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
everbarry 2026-05-07 22:31:37 +02:00
parent d5ae07973a
commit fe68752cc7
24 changed files with 612 additions and 182 deletions

View File

@ -10,6 +10,7 @@ This folder contains only the self-contained Textual file-share demo.
- `demo/file_share_tui.py`: Textual UI.
- `demo/zkac_cli_adapter.py`: subprocess bridge to `zkac-node`.
- `demo/file_share_smoke.py`: end-to-end smoke test.
- `demo/test_demo_privacy_guardrails.py`: pytest privacy regressions for the demo.
## Run
@ -38,4 +39,11 @@ local ZKAC usage.
```bash
uv run python demo/file_share_smoke.py
pytest demo/test_demo_privacy_guardrails.py
```
## Future Work
- Further reduce at-rest metadata by removing persisted raw role-id indexes used
for proof candidate discovery after restart, while preserving reliable auth
recovery semantics.

View File

@ -6,7 +6,7 @@ harness. The library performs:
* deterministic folder flattening and per-file content-key generation,
* per-role visibility bitmasks over the flattened index,
* opaque blob and per-recipient role-grant uploads to ``file_share_server.py``,
* opaque blob uploads to ``file_share_server.py``,
* role-credential authenticated download + decrypt against the same server.
Authenticated sessions piggy-back on the ZKAC TCP framing/handshake from
@ -98,7 +98,7 @@ class BucketManifest:
registry_id_hex: str
files: list[FileEntry]
role_masks: dict[str, str] = field(default_factory=dict) # role -> bitmask string ('0'/'1')
recipients: list[dict] = field(default_factory=list) # [{role_name, issuance_pk_hex}]
recipients: list[dict] = field(default_factory=list) # local audit log only (no server identity binding)
def to_dict(self) -> dict:
return {
@ -311,10 +311,17 @@ class FileShareSession:
"ciphertext_b64": _b64(ciphertext),
})
def bucket_set_role_acl(self, bucket_id: str, role_id_hex: str, allowed_blob_ids: list[str]) -> None:
self._call({
"cmd": "bucket_set_role_acl",
"bucket_id": bucket_id,
"role_id_hex": role_id_hex,
"allowed_blob_ids": allowed_blob_ids,
})
def bucket_put_role_grant(
self,
bucket_id: str,
recipient_pk_hex: str,
role_id_hex: str,
acl_version: int,
eph_pk_b64: str,
@ -323,21 +330,12 @@ class FileShareSession:
self._call({
"cmd": "bucket_put_role_grant",
"bucket_id": bucket_id,
"recipient_pk_hex": recipient_pk_hex,
"role_id_hex": role_id_hex,
"acl_version": int(acl_version),
"eph_pk_b64": eph_pk_b64,
"ciphertext_b64": ciphertext_b64,
})
def bucket_set_role_acl(self, bucket_id: str, role_id_hex: str, allowed_blob_ids: list[str]) -> None:
self._call({
"cmd": "bucket_set_role_acl",
"bucket_id": bucket_id,
"role_id_hex": role_id_hex,
"allowed_blob_ids": allowed_blob_ids,
})
def bucket_get_role_acl(self, bucket_id: str, role_id_hex: str) -> dict:
return self._call({
"cmd": "bucket_get_role_acl",
@ -377,7 +375,6 @@ def open_session(
registry_id_hex: str,
role_id: bytes,
credential: "zkac.Credential",
user_issuance_pk_hex: str,
connect_timeout_s: float = DEFAULT_CONNECT_TIMEOUT_S,
) -> FileShareSession:
"""Connect, anonymous-handshake, then present a BBS+ proof bound to the transcript."""
@ -393,10 +390,7 @@ def open_session(
bbs_auth_proof = bytes(credential.present(transcript_hash))
framed.send(json.dumps({
"op": "fs",
"registry_id": registry_id_hex,
"role_id": role_id.hex(),
"bbs_auth_b64": _b64(bbs_auth_proof),
"issuance_pk_hex": user_issuance_pk_hex,
}).encode("utf-8"))
hello = json.loads(framed.recv())
if hello.get("error"):
@ -436,40 +430,6 @@ def upload_bucket(
)
def push_role_grant(
sess: FileShareSession,
manifest: BucketManifest,
role_name: str,
recipient_issuance_pk_hex: str,
) -> None:
"""Encrypt the per-role visible-file subset to ``recipient`` and store on server."""
if role_name not in manifest.role_masks:
raise RuntimeError(f"role {role_name!r} has no visibility mask in this bucket")
mask = normalize_mask(manifest.role_masks[role_name], len(manifest.files))
visible = files_visible_to_mask(manifest.files, mask)
role_id_hex = zkac.role_id(role_name).hex()
acl_meta = sess.bucket_get_role_acl(manifest.bucket_id, role_id_hex)
acl_version = int(acl_meta.get("version", 0))
if acl_version <= 0:
raise RuntimeError("server ACL missing for role; apply permissions before sharing")
payload = encode_grant_payload(
manifest.bucket_id, role_name, manifest.server, manifest.registry_id_hex, visible,
)
eph_pk_b64, ct_b64 = encrypt_grant_to_recipient(payload, recipient_issuance_pk_hex)
sess.bucket_put_role_grant(
manifest.bucket_id,
recipient_issuance_pk_hex,
role_id_hex,
acl_version,
eph_pk_b64,
ct_b64,
)
manifest.recipients.append({
"role_name": role_name,
"issuance_pk_hex": recipient_issuance_pk_hex,
})
def apply_role_masks_to_server(sess: FileShareSession, manifest: BucketManifest) -> None:
"""Push per-role blob ACLs so server enforces mask on fs_get_blob."""
if not manifest.role_masks:
@ -485,16 +445,63 @@ def apply_role_masks_to_server(sess: FileShareSession, manifest: BucketManifest)
)
def push_role_grant(
sess: FileShareSession,
manifest: BucketManifest,
role_name: str,
recipient_issuance_pk_hex: str,
) -> None:
"""Upload anonymous encrypted role grant envelope (no recipient identifier stored)."""
if role_name not in manifest.role_masks:
raise RuntimeError(f"role {role_name!r} has no visibility mask in this bucket")
mask = normalize_mask(manifest.role_masks[role_name], len(manifest.files))
visible = files_visible_to_mask(manifest.files, mask)
role_id_hex = zkac.role_id(role_name).hex()
acl_meta = sess.bucket_get_role_acl(manifest.bucket_id, role_id_hex)
acl_version = int(acl_meta.get("version", 0))
if acl_version <= 0:
raise RuntimeError("server ACL missing for role; apply permissions before sharing")
payload = encode_grant_payload(
manifest.bucket_id, role_name, manifest.server, manifest.registry_id_hex, visible,
)
eph_pk_b64, ct_b64 = encrypt_grant_to_recipient(payload, recipient_issuance_pk_hex)
sess.bucket_put_role_grant(
manifest.bucket_id,
role_id_hex,
acl_version,
eph_pk_b64,
ct_b64,
)
manifest.recipients.append({
"role_name": role_name,
"recipient_hint": recipient_issuance_pk_hex[:16],
})
def download_bucket(
sess: FileShareSession,
bucket_id: str,
*,
issuance_secret_hex: str,
role_grant_payload: dict | None = None,
output_dir: Path,
) -> dict:
"""Fetch + decrypt every file the auth'd role has access to."""
grant = sess.fs_get_role_grant(bucket_id)
payload = decrypt_grant_for_recipient(grant["eph_pk_b64"], grant["ciphertext_b64"], issuance_secret_hex)
"""Fetch + decrypt files from a role grant, optionally fetched from server."""
payload = role_grant_payload
if payload is None:
grants = sess.fs_get_role_grant(bucket_id).get("grants", [])
for grant in grants:
try:
payload = decrypt_grant_for_recipient(
grant["eph_pk_b64"],
grant["ciphertext_b64"],
issuance_secret_hex,
)
break
except Exception:
continue
if payload is None:
raise RuntimeError("no decryptable role grant for this credential")
if payload.get("bucket_id") != bucket_id:
raise RuntimeError("role grant bucket_id mismatch")
output_dir.mkdir(parents=True, exist_ok=True)
@ -510,6 +517,46 @@ def download_bucket(
return {"role_name": payload.get("role_name"), "files_written": written}
def _received_grant_path(userid: str, registry_id_hex: str, role_name: str, bucket_id: str) -> Path:
return state_dir(userid) / "received_grants" / registry_id_hex / role_name / f"{bucket_id}.json"
def save_received_role_grant(
userid: str,
registry_id_hex: str,
role_name: str,
bucket_id: str,
*,
eph_pk_b64: str,
ciphertext_b64: str,
) -> Path:
path = _received_grant_path(userid, registry_id_hex, role_name, bucket_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({
"registry_id_hex": registry_id_hex,
"role_name": role_name,
"bucket_id": bucket_id,
"eph_pk_b64": eph_pk_b64,
"ciphertext_b64": ciphertext_b64,
}, indent=2))
try:
os.chmod(path, 0o600)
except OSError:
pass
return path
def list_received_role_grants(userid: str, registry_id_hex: str, role_name: str) -> list[str]:
base = state_dir(userid) / "received_grants" / registry_id_hex / role_name
if not base.is_dir():
return []
return sorted(p.stem for p in base.glob("*.json"))
def load_received_role_grant(userid: str, registry_id_hex: str, role_name: str, bucket_id: str) -> dict:
return json.loads(_received_grant_path(userid, registry_id_hex, role_name, bucket_id).read_text())
# ── local persistence (admin manifests) ──────────────────────────────
def state_dir(userid: str) -> Path:

View File

@ -29,6 +29,7 @@ from pathlib import Path
import zkac
from zkac.tcp import FramedSession, client_handshake_anon
import file_share_client as fsc
import zkac_cli_adapter as cli
@ -106,6 +107,7 @@ def grant_role_p2p(
role_name: str,
recipient_contact_bundle: str,
*,
bucket_role_grant: dict | None = None,
connect_timeout_s: float = 8.0,
) -> dict:
"""Issue a BBS+ role credential and ship it to the recipient over an authenticated TCP session.
@ -148,6 +150,7 @@ def grant_role_p2p(
"blind_sig_b64": _b64(bytes(blind_sig)),
"member_secret_b64": _b64(bytes(req.member_secret())),
"prover_blind_b64": _b64(bytes(req.prover_blind())),
"bucket_role_grant": bucket_role_grant or None,
}).encode()
rec_pk_bytes = bytes.fromhex(recipient_issuance_pk_hex)
@ -313,6 +316,20 @@ def listen_for_role_credential(
raise RuntimeError("granted credential does not verify")
_save_credential_json(userid, registry_id_hex, role_name, payload)
bucket_grant = payload.get("bucket_role_grant")
if isinstance(bucket_grant, dict):
bucket_id = bucket_grant.get("bucket_id")
eph_pk_b64 = bucket_grant.get("eph_pk_b64")
ciphertext_b64 = bucket_grant.get("ciphertext_b64")
if isinstance(bucket_id, str) and isinstance(eph_pk_b64, str) and isinstance(ciphertext_b64, str):
fsc.save_received_role_grant(
userid,
registry_id_hex,
role_name,
bucket_id,
eph_pk_b64=eph_pk_b64,
ciphertext_b64=ciphertext_b64,
)
framed.send(json.dumps({"ok": True, "status": "stored"}).encode())
return {"registry_id": registry_id_hex, "role": role_name}
finally:

View File

@ -8,7 +8,7 @@ Headless TCP service that combines:
``zkac-node registry create/get/update`` commands work unchanged.
* A new file-share channel that, after BBS+ role authentication, exposes
bucket primitives. The server only ever sees opaque ciphertext blobs and
per-recipient encrypted role grants; file names, contents and per-role
encrypted role grants; file names, contents and per-role
visibility masks are never visible server-side.
Run with::
@ -23,12 +23,12 @@ from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import socket
import sys
import threading
import traceback
import uuid
from pathlib import Path
from typing import Any
@ -72,6 +72,11 @@ def _safe_id(value: str) -> str:
return value
def _privacy_safe_error_tag(exc: BaseException) -> str:
"""Return a coarse error label suitable for public service logs."""
return type(exc).__name__
# ── opaque on-disk store ─────────────────────────────────────────────
class _FileShareStore:
@ -81,11 +86,39 @@ class _FileShareStore:
self._dir = data_dir
self._reg_dir = data_dir / "registries"
self._buckets_dir = data_dir / "buckets"
self._privacy_key: bytes = b""
for d in (self._dir, self._reg_dir, self._buckets_dir):
d.mkdir(parents=True, exist_ok=True)
_chmod(d, 0o700)
self._lock = threading.Lock()
def set_privacy_key(self, key: bytes) -> None:
if not key:
raise RuntimeError("privacy key must not be empty")
self._privacy_key = bytes(key)
def _require_privacy_key(self) -> bytes:
if not self._privacy_key:
raise RuntimeError("privacy key not initialized")
return self._privacy_key
def _tag(self, kind: str, raw_id: str) -> str:
key = self._require_privacy_key()
raw = _safe_id(raw_id).encode("utf-8")
h = hashlib.sha256()
h.update(kind.encode("utf-8"))
h.update(b"\x00")
h.update(key)
h.update(b"\x00")
h.update(raw)
return h.hexdigest()
def _registry_tag(self, registry_id_hex: str) -> str:
return self._tag("registry", registry_id_hex)
def _role_tag(self, role_id_hex: str) -> str:
return self._tag("role", role_id_hex)
# transport key
def load_or_create_keypair(self) -> zkac.Keypair:
@ -120,6 +153,14 @@ class _FileShareStore:
print(f"[fs-server] skip registry {p.stem}: {exc}")
return n
def list_registry_ids(self) -> list[str]:
out: list[str] = []
for p in sorted(self._reg_dir.glob("*.state")):
cert = self._reg_dir / f"{p.stem}.cert"
if cert.exists():
out.append(p.stem)
return out
# buckets
def _bucket_dir(self, bucket_id: str) -> Path:
@ -128,6 +169,22 @@ class _FileShareStore:
def _bucket_meta_path(self, bucket_id: str) -> Path:
return self._bucket_dir(bucket_id) / "meta.json"
def _roles_index_path(self, registry_id_hex: str) -> Path:
return self._reg_dir / f"{_safe_id(registry_id_hex)}.roles.json"
def _remember_role_id(self, registry_id_hex: str, role_id_hex: str) -> None:
p = self._roles_index_path(registry_id_hex)
roles: set[str] = set()
if p.is_file():
try:
data = json.loads(p.read_text())
if isinstance(data, list):
roles = {_safe_id(str(v)) for v in data}
except Exception:
roles = set()
roles.add(_safe_id(role_id_hex))
_write_private_json(p, sorted(roles))
def bucket_create(self, bucket_id: str, owner_registry_id_hex: str) -> None:
bd = self._bucket_dir(bucket_id)
with self._lock:
@ -138,17 +195,21 @@ class _FileShareStore:
_chmod(bd, 0o700)
_write_private_json(self._bucket_meta_path(bucket_id), {
"bucket_id": bucket_id,
"owner_registry_id": owner_registry_id_hex,
"owner_registry_tag": self._registry_tag(owner_registry_id_hex),
"finalized": False,
"role_acl": {}, # role_id_hex -> {"version": int, "allowed_blob_ids": [blob_id...]}
"role_acl": {}, # role_tag -> {"version": int, "allowed_blob_ids": [blob_id...]}
})
def bucket_meta(self, bucket_id: str) -> dict:
return json.loads(self._bucket_meta_path(bucket_id).read_text())
def bucket_is_finalized(self, bucket_id: str) -> bool:
return bool(self.bucket_meta(bucket_id).get("finalized", False))
def _bucket_require_owner(self, bucket_id: str, registry_id_hex: str) -> dict:
meta = self.bucket_meta(bucket_id)
if meta.get("owner_registry_id") != registry_id_hex:
expected_owner_tag = self._registry_tag(registry_id_hex)
if meta.get("owner_registry_tag") != expected_owner_tag:
raise RuntimeError("not bucket owner")
return meta
@ -165,11 +226,18 @@ class _FileShareStore:
for sub in ("blobs", "role_grants"):
d = bd / sub
if d.is_dir():
for p in d.iterdir():
for p in d.rglob("*"):
if p.is_file():
try:
p.unlink()
except OSError:
pass
for p in sorted(d.rglob("*"), reverse=True):
if p.is_dir():
try:
p.rmdir()
except OSError:
pass
try:
d.rmdir()
except OSError:
@ -194,29 +262,6 @@ class _FileShareStore:
self._bucket_require_owner(bucket_id, registry_id_hex)
(self._bucket_dir(bucket_id) / "blobs" / _safe_id(blob_id)).write_bytes(ciphertext)
def bucket_put_role_grant(
self,
bucket_id: str,
registry_id_hex: str,
recipient_pk_hex: str,
role_id_hex: str,
acl_version: int,
eph_pk_b64: str,
ciphertext_b64: str,
) -> None:
with self._lock:
self._bucket_require_owner(bucket_id, registry_id_hex)
_safe_id(recipient_pk_hex)
payload = {
"bucket_id": bucket_id,
"role_id_hex": _safe_id(role_id_hex),
"acl_version": int(acl_version),
"eph_pk_b64": eph_pk_b64,
"ciphertext_b64": ciphertext_b64,
}
target = self._bucket_dir(bucket_id) / "role_grants" / f"{recipient_pk_hex}.json"
_write_private_json(target, payload)
def bucket_set_role_acl(
self,
bucket_id: str,
@ -227,7 +272,7 @@ class _FileShareStore:
with self._lock:
meta = self._bucket_require_owner(bucket_id, registry_id_hex)
role_acl = dict(meta.get("role_acl", {}))
key = _safe_id(role_id_hex)
key = self._role_tag(role_id_hex)
prev = role_acl.get(key, {})
prev_version = int(prev.get("version", 0)) if isinstance(prev, dict) else 0
role_acl[key] = {
@ -236,6 +281,7 @@ class _FileShareStore:
}
meta["role_acl"] = role_acl
_write_private_json(self._bucket_meta_path(bucket_id), meta)
self._remember_role_id(registry_id_hex, role_id_hex)
def bucket_get_blob(self, bucket_id: str, blob_id: str) -> bytes:
return (self._bucket_dir(bucket_id) / "blobs" / _safe_id(blob_id)).read_bytes()
@ -243,7 +289,7 @@ class _FileShareStore:
def bucket_blob_allowed_for_role(self, bucket_id: str, role_id_hex: str, blob_id: str) -> bool:
meta = self.bucket_meta(bucket_id)
role_acl = meta.get("role_acl", {})
role_meta = role_acl.get(_safe_id(role_id_hex))
role_meta = role_acl.get(self._role_tag(role_id_hex))
if not isinstance(role_meta, dict):
return False
allowed = role_meta.get("allowed_blob_ids")
@ -254,7 +300,7 @@ class _FileShareStore:
def bucket_role_acl(self, bucket_id: str, role_id_hex: str) -> dict:
meta = self.bucket_meta(bucket_id)
role_acl = meta.get("role_acl", {})
role_meta = role_acl.get(_safe_id(role_id_hex), {})
role_meta = role_acl.get(self._role_tag(role_id_hex), {})
if not isinstance(role_meta, dict):
return {"version": 0, "allowed_blob_ids": []}
version = int(role_meta.get("version", 0))
@ -266,20 +312,75 @@ class _FileShareStore:
"allowed_blob_ids": [_safe_id(str(v)) for v in allowed],
}
def bucket_get_role_grant(self, bucket_id: str, recipient_pk_hex: str) -> dict:
path = self._bucket_dir(bucket_id) / "role_grants" / f"{_safe_id(recipient_pk_hex)}.json"
return json.loads(path.read_text())
def buckets_for_recipient(self, recipient_pk_hex: str) -> list[str]:
"""Bucket ids that have an encrypted role grant addressed to ``recipient_pk_hex``."""
_safe_id(recipient_pk_hex)
def buckets_for_role_in_registry(self, role_id_hex: str, registry_id_hex: str) -> list[str]:
"""Bucket ids where this authenticated role currently has non-empty ACL."""
role_id_hex = _safe_id(role_id_hex)
out: list[str] = []
for bd in sorted(self._buckets_dir.iterdir()):
grant = bd / "role_grants" / f"{recipient_pk_hex}.json"
if grant.is_file():
out.append(bd.name)
bid = bd.name
try:
if not self.bucket_is_finalized(bid):
continue
if self.bucket_owner_registry_tag(bid) != self._registry_tag(registry_id_hex):
continue
acl = self.bucket_role_acl(bid, role_id_hex)
allowed = acl.get("allowed_blob_ids", [])
grants = self.bucket_get_role_grants(bid, role_id_hex)
current_acl_version = int(acl.get("version", -1))
has_fresh_grant = any(
isinstance(g.get("acl_version"), int) and g["acl_version"] == current_acl_version
for g in grants
)
if isinstance(allowed, list) and len(allowed) > 0 and has_fresh_grant:
out.append(bid)
except Exception:
continue
return out
def bucket_put_role_grant(
self,
bucket_id: str,
registry_id_hex: str,
role_id_hex: str,
acl_version: int,
eph_pk_b64: str,
ciphertext_b64: str,
) -> None:
with self._lock:
self._bucket_require_owner(bucket_id, registry_id_hex)
role_id_hex = _safe_id(role_id_hex)
role_tag = self._role_tag(role_id_hex)
payload = {
"bucket_id": bucket_id,
"role_tag": role_tag,
"acl_version": int(acl_version),
"eph_pk_b64": eph_pk_b64,
"ciphertext_b64": ciphertext_b64,
}
role_dir = self._bucket_dir(bucket_id) / "role_grants" / role_tag
role_dir.mkdir(parents=True, exist_ok=True)
target = role_dir / f"{uuid.uuid4().hex}.json"
_write_private_json(target, payload)
self._remember_role_id(registry_id_hex, role_id_hex)
def bucket_get_role_grants(self, bucket_id: str, role_id_hex: str) -> list[dict]:
role_dir = self._bucket_dir(bucket_id) / "role_grants" / self._role_tag(role_id_hex)
if not role_dir.is_dir():
return []
out: list[dict] = []
for p in sorted(role_dir.glob("*.json")):
try:
out.append(json.loads(p.read_text()))
except Exception:
continue
return out
def bucket_owner_registry_tag(self, bucket_id: str) -> str:
owner = self.bucket_meta(bucket_id).get("owner_registry_tag")
if not isinstance(owner, str) or not owner:
raise RuntimeError("bucket metadata missing owner_registry_tag")
return owner
def buckets_owned_by(self, registry_id_hex: str) -> list[str]:
out: list[str] = []
for bd in sorted(self._buckets_dir.iterdir()):
@ -287,12 +388,25 @@ class _FileShareStore:
if not meta.is_file():
continue
try:
if json.loads(meta.read_text()).get("owner_registry_id") == registry_id_hex:
owner_tag = self._registry_tag(registry_id_hex)
if json.loads(meta.read_text()).get("owner_registry_tag") == owner_tag:
out.append(bd.name)
except (OSError, json.JSONDecodeError):
continue
return out
def role_ids_for_registry(self, registry_id_hex: str) -> list[str]:
p = self._roles_index_path(registry_id_hex)
if not p.is_file():
return []
try:
data = json.loads(p.read_text())
if not isinstance(data, list):
return []
return sorted({_safe_id(str(v)) for v in data})
except Exception:
return []
# ── command dispatch (inside encrypted session) ──────────────────────
@ -378,13 +492,13 @@ def _dispatch_fs(
store: _FileShareStore,
ctx: dict,
) -> dict:
"""File-share commands authenticated via ``ctx`` (registry_id, role_id, issuance_pk_hex)."""
"""File-share commands authenticated via opaque per-session authorization context."""
try:
action = cmd.get("cmd")
registry_id_hex: str = ctx["registry_id_hex"]
role_id: bytes = ctx["role_id"]
issuance_pk_hex: str = ctx["issuance_pk_hex"]
is_admin = role_id == zkac.admin_role_id()
registry_tag: str = ctx["registry_tag"]
role_id_hex: str = ctx["role_id_hex"]
is_admin: bool = bool(ctx["is_admin"])
def _require_admin() -> None:
if not is_admin:
@ -393,10 +507,8 @@ def _dispatch_fs(
if action == "whoami":
return {
"ok": True,
"registry_id": registry_id_hex,
"role_id": role_id.hex(),
"is_admin": is_admin,
"issuance_pk_hex": issuance_pk_hex,
"auth_scope": "admin" if is_admin else "credential",
}
if action == "bucket_create":
@ -413,19 +525,6 @@ def _dispatch_fs(
store.bucket_put_blob(bid, registry_id_hex, blob_id, ciphertext)
return {"ok": True}
if action == "bucket_put_role_grant":
_require_admin()
store.bucket_put_role_grant(
cmd["bucket_id"],
registry_id_hex,
cmd["recipient_pk_hex"],
cmd["role_id_hex"],
int(cmd["acl_version"]),
cmd["eph_pk_b64"],
cmd["ciphertext_b64"],
)
return {"ok": True}
if action == "bucket_set_role_acl":
_require_admin()
store.bucket_set_role_acl(
@ -436,6 +535,18 @@ def _dispatch_fs(
)
return {"ok": True}
if action == "bucket_put_role_grant":
_require_admin()
store.bucket_put_role_grant(
cmd["bucket_id"],
registry_id_hex,
cmd["role_id_hex"],
int(cmd["acl_version"]),
cmd["eph_pk_b64"],
cmd["ciphertext_b64"],
)
return {"ok": True}
if action == "bucket_get_role_acl":
_require_admin()
role_acl = store.bucket_role_acl(cmd["bucket_id"], cmd["role_id_hex"])
@ -456,31 +567,38 @@ def _dispatch_fs(
return {"ok": True, "bucket_ids": store.buckets_owned_by(registry_id_hex)}
if action == "fs_buckets":
return {"ok": True, "bucket_ids": store.buckets_for_recipient(issuance_pk_hex)}
return {
"ok": True,
"bucket_ids": store.buckets_for_role_in_registry(
role_id_hex,
registry_id_hex,
),
}
if action == "fs_get_role_grant":
bid = cmd["bucket_id"]
grant = store.bucket_get_role_grant(bid, issuance_pk_hex)
if not is_admin:
expected_role = _safe_id(role_id.hex())
granted_role_raw = grant.get("role_id_hex")
if not isinstance(granted_role_raw, str) or not granted_role_raw:
raise RuntimeError("permissions updated; role grant is outdated, request a fresh grant")
granted_role = _safe_id(granted_role_raw)
if granted_role != expected_role:
raise RuntimeError("role grant not valid for authenticated role")
current_acl = store.bucket_role_acl(bid, expected_role)
acl_version = grant.get("acl_version")
if not isinstance(acl_version, int):
raise RuntimeError("permissions updated; role grant is outdated, request a fresh grant")
if acl_version != int(current_acl.get("version", -2)):
if store.bucket_owner_registry_tag(bid) != registry_tag:
raise RuntimeError("bucket does not belong to authenticated registry")
if not store.bucket_is_finalized(bid):
raise RuntimeError("bucket is not finalized")
current_acl = store.bucket_role_acl(bid, role_id_hex)
acl_version = int(current_acl.get("version", -1))
grants = [
g for g in store.bucket_get_role_grants(bid, role_id_hex)
if isinstance(g.get("acl_version"), int) and g["acl_version"] == acl_version
]
if not grants:
raise RuntimeError("permissions updated; request a fresh role grant")
return {"ok": True, **grant}
return {"ok": True, "grants": grants}
if action == "fs_get_blob":
bid = cmd["bucket_id"]
blob_id = cmd["blob_id"]
if not is_admin and not store.bucket_blob_allowed_for_role(bid, role_id.hex(), blob_id):
if store.bucket_owner_registry_tag(bid) != registry_tag:
raise RuntimeError("bucket does not belong to authenticated registry")
if not store.bucket_is_finalized(bid):
raise RuntimeError("bucket is not finalized")
if not is_admin and not store.bucket_blob_allowed_for_role(bid, role_id_hex, blob_id):
raise RuntimeError("blob access denied by role mask")
data = store.bucket_get_blob(bid, blob_id)
return {"ok": True, "ciphertext_b64": _b64(data)}
@ -491,6 +609,61 @@ def _dispatch_fs(
return {"error": str(exc)}
def _authenticate_fs_identity(
mgr: zkac.RegistryManager,
store: _FileShareStore,
proof: bytes,
transcript_hash: bytes,
) -> dict[str, object] | None:
"""Resolve opaque auth context from proof without client-supplied identifiers."""
registry_ids = store.list_registry_ids()
admin_matches: list[str] = []
role_matches: list[tuple[str, str]] = []
for rid_hex in registry_ids:
try:
rid = bytes.fromhex(rid_hex)
except ValueError:
continue
try:
if mgr.verify_admin(rid, proof, transcript_hash):
admin_matches.append(rid_hex)
except Exception:
pass
for role_id_hex in store.role_ids_for_registry(rid_hex):
try:
role_id = bytes.fromhex(role_id_hex)
except ValueError:
continue
if role_id == zkac.admin_role_id():
continue
try:
if mgr.verify_presentation(rid, role_id, proof, transcript_hash):
role_matches.append((rid_hex, role_id_hex))
except Exception:
continue
if len(admin_matches) == 1 and not role_matches:
rid_hex = admin_matches[0]
role_hex = zkac.admin_role_id().hex()
return {
"registry_id_hex": rid_hex,
"registry_tag": store._registry_tag(rid_hex),
"role_id_hex": role_hex,
"role_tag": store._role_tag(role_hex),
"is_admin": True,
}
if not admin_matches and len(role_matches) == 1:
rid_hex, role_hex = role_matches[0]
return {
"registry_id_hex": rid_hex,
"registry_tag": store._registry_tag(rid_hex),
"role_id_hex": role_hex,
"role_tag": store._role_tag(role_hex),
"is_admin": False,
}
# Ambiguous or invalid proof.
return None
# ── per-connection handler ────────────────────────────────────────────
def _handle_conn(
@ -503,7 +676,6 @@ def _handle_conn(
idle_timeout_s: float,
slots: threading.BoundedSemaphore,
) -> None:
peer = f"{addr[0]}:{addr[1]}"
try:
conn.settimeout(idle_timeout_s)
session = server_handshake_anon(conn, node)
@ -525,27 +697,16 @@ def _handle_conn(
if op == "fs":
try:
registry_id = bytes.fromhex(hello["registry_id"])
role_id = bytes.fromhex(hello["role_id"])
proof = _unb64(hello["bbs_auth_b64"])
issuance_pk_hex = hello["issuance_pk_hex"]
_safe_id(issuance_pk_hex)
except (KeyError, ValueError) as exc:
framed.send(json.dumps({"error": f"invalid fs hello: {exc}"}).encode())
return
if role_id == zkac.admin_role_id():
ok = mgr.verify_admin(registry_id, proof, transcript_hash)
else:
ok = mgr.verify_presentation(registry_id, role_id, proof, transcript_hash)
if not ok:
auth = _authenticate_fs_identity(mgr, store, proof, transcript_hash)
if auth is None:
framed.send(json.dumps({"error": "auth failed"}).encode())
return
framed.send(json.dumps({"ok": True, "status": "authenticated"}).encode())
ctx = {
"registry_id_hex": registry_id.hex(),
"role_id": role_id,
"issuance_pk_hex": issuance_pk_hex,
}
ctx = auth
while True:
try:
cmd = json.loads(framed.recv())
@ -560,8 +721,8 @@ def _handle_conn(
except (ConnectionError, BrokenPipeError, OSError):
pass
except Exception as exc:
print(f"[fs-server] {peer} error: {exc}")
traceback.print_exc()
# Privacy model: never emit client endpoint or request-linked payloads.
print(f"[fs-server] connection error ({_privacy_safe_error_tag(exc)})")
finally:
conn.close()
slots.release()
@ -582,6 +743,7 @@ def serve(
data_dir.mkdir(parents=True, exist_ok=True)
store = _FileShareStore(data_dir)
kp = store.load_or_create_keypair()
store.set_privacy_key(bytes(kp.secret_key_bytes()))
server_pk_b64 = _b64(kp.public_key().to_bytes())
pk_hex = kp.public_key().to_bytes().hex()
node = zkac.Node(kp)

View File

@ -7,7 +7,7 @@ Exercises:
* admin (Alice) creates a registry on the file-share server,
* admin uploads a folder as an encrypted bucket with per-role visibility masks,
* Alice issues a ZKAC role credential to Bob via direct P2P grant,
* Alice pushes a per-recipient bucket role grant to Bob,
* Alice uploads an anonymous role-grant envelope to the server,
* Bob authenticates to the file-share server with his role credential,
downloads, and decrypts the files his role can see,
* server opacity: every byte at rest in the bucket directory is ciphertext
@ -108,7 +108,6 @@ def _open_admin_session(userid: str, server: str, server_pk_hex: str, registry_i
registry_id_hex=registry_id,
role_id=zkac.admin_role_id(),
credential=cred,
user_issuance_pk_hex=secrets["issuance_pk_hex"],
)
@ -122,7 +121,6 @@ def _open_role_session(userid: str, server: str, server_pk_hex: str, registry_id
registry_id_hex=registry_id,
role_id=zkac.role_id(role_name),
credential=cred,
user_issuance_pk_hex=secrets["issuance_pk_hex"],
)
@ -182,8 +180,33 @@ def main() -> int:
raise RuntimeError("bob listener did not start")
bob_contact = cli.show_user_contact("bob", peer=f"127.0.0.1:{listener_port}")
print(f"[smoke] bob listening on 127.0.0.1:{listener_port}")
# --- alice uploads bucket + sets masks --------------------------------
files_in_order = fsc.flatten_folder(src)
rel_paths = [str(p.relative_to(src.resolve())) for p in files_in_order]
print(f"[smoke] flattened: {rel_paths}")
# mask: viewer sees only first file (alpha.txt), editor sees everything.
viewer_mask = "1" + "0" * (len(files_in_order) - 1)
editor_mask = "1" * len(files_in_order)
bob_secrets = cli.load_identity_secrets("bob")
with _open_admin_session("alice", server_addr, server_pk_hex, rid) as sess:
manifest = fsc.upload_bucket(
sess, src, server=server_addr, registry_id_hex=rid,
)
manifest.role_masks = {"viewer": viewer_mask, "editor": editor_mask}
fsc.apply_role_masks_to_server(sess, manifest)
fsc.push_role_grant(sess, manifest, "viewer", bob_secrets["issuance_pk_hex"])
fsc.save_manifest("alice", manifest)
print(f"[smoke] uploaded bucket {manifest.bucket_id} with role ACLs + anonymous grant envelope")
try:
fscred.grant_role_p2p("alice", server_addr, rid, "viewer", bob_contact)
fscred.grant_role_p2p(
"alice",
server_addr,
rid,
"viewer",
bob_contact,
)
except RuntimeError as exc:
listener.stop()
print(f"[smoke] grant failed: {exc}\n[smoke] bob listener output:\n{listener.output()}")
@ -196,25 +219,6 @@ def main() -> int:
assert received and received["role"] == "viewer"
assert received["registry_id"] == rid
# --- alice uploads bucket + sets masks --------------------------------
files_in_order = fsc.flatten_folder(src)
rel_paths = [str(p.relative_to(src.resolve())) for p in files_in_order]
print(f"[smoke] flattened: {rel_paths}")
# mask: viewer sees only first file (alpha.txt), editor sees everything.
viewer_mask = "1" + "0" * (len(files_in_order) - 1)
editor_mask = "1" * len(files_in_order)
with _open_admin_session("alice", server_addr, server_pk_hex, rid) as sess:
manifest = fsc.upload_bucket(
sess, src, server=server_addr, registry_id_hex=rid,
)
manifest.role_masks = {"viewer": viewer_mask, "editor": editor_mask}
# bob's issuance pk -> push viewer role grant
bob_secrets = cli.load_identity_secrets("bob")
fsc.push_role_grant(sess, manifest, "viewer", bob_secrets["issuance_pk_hex"])
fsc.save_manifest("alice", manifest)
print(f"[smoke] uploaded bucket {manifest.bucket_id} with role grants")
# --- bob downloads using his viewer credential ------------------------
with _open_role_session("bob", server_addr, server_pk_hex, rid, "viewer") as sess:
accessible = sess.fs_buckets()

View File

@ -352,7 +352,6 @@ class FileShareApp(App[None]):
registry_id_hex=registry_id_hex,
role_id=role_id,
credential=credential,
user_issuance_pk_hex=ident.issuance_pk_hex,
)
async def _choose_index(self, title: str, options: list[str], default: int = 1) -> int:
@ -713,7 +712,7 @@ class FileShareApp(App[None]):
fsc.push_role_grant(sess, manifest, role, recipient_pk_hex)
fsc.save_manifest(ident.userid, manifest)
self.current_bucket_id = manifest.bucket_id
self.write_log(f"[share] pushed bucket grant to {recipient_pk_hex[:16]}...")
self.write_log("[share] uploaded anonymous role-grant envelope (no recipient identifier on server)")
async def menu_listen(self) -> None:
ident = self._require_user()
@ -781,8 +780,9 @@ class FileShareApp(App[None]):
with self._open_session(cred["registry_id"], cred["role"]) as sess:
who = sess.whoami()
self.write_log(
f"[inbox] authenticated registry={who['registry_id'][:16]}... "
f"role_id={who['role_id'][:16]}..."
"[inbox] authenticated "
f"scope={who.get('auth_scope', 'unknown')} "
f"admin={bool(who.get('is_admin', False))}"
)
if cred["role"] == "__admin__":
bids = sess.bucket_list_owned()

View File

@ -0,0 +1,192 @@
import json
import sys
import threading
from pathlib import Path
import zkac
DEMO_DIR = Path(__file__).resolve().parent
if str(DEMO_DIR) not in sys.path:
sys.path.insert(0, str(DEMO_DIR))
import file_share_client as fsc # noqa: E402
import file_share_server as fss # noqa: E402
def _make_credential() -> tuple[bytes, zkac.Credential]:
issuer = zkac.BbsIssuer()
pk = issuer.public_key()
role_id = zkac.role_id("viewer")
req = zkac.prepare_blind_request()
blind_sig = issuer.issue_blind(req.commitment_with_proof(), role_id, 1)
cred = zkac.Credential.finalize(
blind_sig,
req.member_secret(),
req.prover_blind(),
role_id,
1,
pk,
)
return role_id, cred
def test_open_session_fs_hello_contains_only_proof(monkeypatch):
role_id, credential = _make_credential()
sent_payloads: list[dict] = []
class _FakeSocket:
def settimeout(self, _value):
return None
def close(self):
return None
class _FakeHandshakeSession:
def transcript_hash(self) -> bytes:
return b"\x11" * 32
class _FakeFramedSession:
def __init__(self, _sock, _session):
pass
def send(self, payload: bytes) -> None:
sent_payloads.append(json.loads(payload.decode("utf-8")))
def recv(self) -> bytes:
return b'{"ok": true, "status": "authenticated"}'
monkeypatch.setattr(fsc.socket, "create_connection", lambda *_args, **_kwargs: _FakeSocket())
monkeypatch.setattr(fsc, "client_handshake_anon", lambda *_args, **_kwargs: _FakeHandshakeSession())
monkeypatch.setattr(fsc, "FramedSession", _FakeFramedSession)
sess = fsc.open_session(
"127.0.0.1:9879",
server_pk_hex=zkac.Keypair().public_key().to_bytes().hex(),
user_transport_secret=bytes(zkac.Keypair().secret_key_bytes()),
registry_id_hex="00" * 32,
role_id=role_id,
credential=credential,
)
sess.close()
assert len(sent_payloads) == 1
hello = sent_payloads[0]
assert set(hello.keys()) == {"op", "bbs_auth_b64"}
assert hello["op"] == "fs"
assert isinstance(hello["bbs_auth_b64"], str) and hello["bbs_auth_b64"]
def test_handle_conn_error_log_never_includes_peer_endpoint(monkeypatch, tmp_path, capsys):
class _DummyConn:
def settimeout(self, _value):
return None
def close(self):
return None
def _raise_handshake(_conn, _node):
raise RuntimeError("boom")
monkeypatch.setattr(fss, "server_handshake_anon", _raise_handshake)
slots = threading.BoundedSemaphore(1)
assert slots.acquire(blocking=False)
fss._handle_conn(
_DummyConn(),
("203.0.113.44", 4242),
zkac.Node(zkac.Keypair()),
zkac.RegistryManager(),
fss._FileShareStore(tmp_path),
"",
5.0,
slots,
)
out = capsys.readouterr().out
assert "[fs-server] connection error (RuntimeError)" in out
assert "203.0.113.44" not in out
assert ":4242" not in out
def test_bucket_metadata_uses_opaque_tags(tmp_path):
store = fss._FileShareStore(tmp_path)
store.set_privacy_key(b"privacy-key-32-bytes-minimum-seed")
registry_id = "aa" * 32
role_id = "bb" * 32
bucket_id = "cc" * 16
blob_id = "dd" * 16
store.bucket_create(bucket_id, registry_id)
meta = store.bucket_meta(bucket_id)
assert "owner_registry_id" not in meta
assert isinstance(meta.get("owner_registry_tag"), str)
assert len(meta["owner_registry_tag"]) == 64
store.bucket_set_role_acl(bucket_id, registry_id, role_id, [blob_id])
meta2 = store.bucket_meta(bucket_id)
acl_keys = list(meta2.get("role_acl", {}).keys())
assert acl_keys and acl_keys[0] != role_id
assert all(len(k) == 64 for k in acl_keys)
store.bucket_put_role_grant(bucket_id, registry_id, role_id, 1, "eph", "ct")
grants_root = tmp_path / "buckets" / bucket_id / "role_grants"
assert (grants_root / role_id).exists() is False
role_dirs = [p.name for p in grants_root.iterdir() if p.is_dir()]
assert role_dirs and all(len(d) == 64 for d in role_dirs)
def test_auth_scan_does_not_return_early():
class _Mgr:
def __init__(self):
self.admin_checks: list[str] = []
self.role_checks: list[tuple[str, str]] = []
def verify_admin(self, rid: bytes, _proof: bytes, _th: bytes) -> bool:
self.admin_checks.append(rid.hex())
return rid.hex() == ("11" * 32)
def verify_presentation(self, rid: bytes, role_id: bytes, _proof: bytes, _th: bytes) -> bool:
self.role_checks.append((rid.hex(), role_id.hex()))
return False
class _Store:
def list_registry_ids(self):
return ["11" * 32, "22" * 32]
def role_ids_for_registry(self, rid_hex: str):
if rid_hex == "11" * 32:
return ["33" * 32]
return ["44" * 32]
def _registry_tag(self, rid_hex: str):
return "r-" + rid_hex[:8]
def _role_tag(self, role_hex: str):
return "k-" + role_hex[:8]
mgr = _Mgr()
auth = fss._authenticate_fs_identity(mgr, _Store(), b"proof", b"nonce")
assert auth is not None and auth["is_admin"] is True
# Both registries must be checked even though first one matched admin.
assert mgr.admin_checks == ["11" * 32, "22" * 32]
# Role checks also run for all known role candidates.
assert mgr.role_checks == [("11" * 32, "33" * 32), ("22" * 32, "44" * 32)]
def test_dispatch_whoami_does_not_expose_registry_or_role():
resp = fss._dispatch_fs(
{"cmd": "whoami"},
store=None, # not used by whoami branch
ctx={
"registry_id_hex": "11" * 32,
"registry_tag": "r-tag",
"role_id_hex": "22" * 32,
"role_tag": "k-tag",
"is_admin": False,
},
)
assert resp["ok"] is True
assert "registry_id" not in resp
assert "role_id" not in resp
assert resp["auth_scope"] == "credential"