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:
parent
d5ae07973a
commit
fe68752cc7
@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
192
demo/test_demo_privacy_guardrails.py
Normal file
192
demo/test_demo_privacy_guardrails.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user