diff --git a/demo/README.md b/demo/README.md index c46cc51..4509655 100644 --- a/demo/README.md +++ b/demo/README.md @@ -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. diff --git a/demo/__pycache__/cli_web_server.cpython-314.pyc b/demo/__pycache__/cli_web_server.cpython-314.pyc deleted file mode 100644 index 46cba9e..0000000 Binary files a/demo/__pycache__/cli_web_server.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/file_share_client.cpython-314.pyc b/demo/__pycache__/file_share_client.cpython-314.pyc deleted file mode 100644 index 71dc4c1..0000000 Binary files a/demo/__pycache__/file_share_client.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/file_share_credentials.cpython-314.pyc b/demo/__pycache__/file_share_credentials.cpython-314.pyc deleted file mode 100644 index 9d871b0..0000000 Binary files a/demo/__pycache__/file_share_credentials.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/file_share_server.cpython-314.pyc b/demo/__pycache__/file_share_server.cpython-314.pyc deleted file mode 100644 index 3b3a6df..0000000 Binary files a/demo/__pycache__/file_share_server.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/file_share_smoke.cpython-314.pyc b/demo/__pycache__/file_share_smoke.cpython-314.pyc deleted file mode 100644 index 66370b4..0000000 Binary files a/demo/__pycache__/file_share_smoke.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/file_share_tui.cpython-314.pyc b/demo/__pycache__/file_share_tui.cpython-314.pyc deleted file mode 100644 index d120052..0000000 Binary files a/demo/__pycache__/file_share_tui.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/server.cpython-314.pyc b/demo/__pycache__/server.cpython-314.pyc deleted file mode 100644 index e4fa941..0000000 Binary files a/demo/__pycache__/server.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/simple_cli_client.cpython-314.pyc b/demo/__pycache__/simple_cli_client.cpython-314.pyc deleted file mode 100644 index 934d6f0..0000000 Binary files a/demo/__pycache__/simple_cli_client.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/simple_i2p_server.cpython-314.pyc b/demo/__pycache__/simple_i2p_server.cpython-314.pyc deleted file mode 100644 index 1b1c90d..0000000 Binary files a/demo/__pycache__/simple_i2p_server.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/trustless_server.cpython-314.pyc b/demo/__pycache__/trustless_server.cpython-314.pyc deleted file mode 100644 index 631ec4f..0000000 Binary files a/demo/__pycache__/trustless_server.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/zkac_admin_serve.cpython-314.pyc b/demo/__pycache__/zkac_admin_serve.cpython-314.pyc deleted file mode 100644 index bb4836d..0000000 Binary files a/demo/__pycache__/zkac_admin_serve.cpython-314.pyc and /dev/null differ diff --git a/demo/__pycache__/zkac_cli_adapter.cpython-314.pyc b/demo/__pycache__/zkac_cli_adapter.cpython-314.pyc deleted file mode 100644 index a697d97..0000000 Binary files a/demo/__pycache__/zkac_cli_adapter.cpython-314.pyc and /dev/null differ diff --git a/demo/file_share_client.py b/demo/file_share_client.py index 14224cb..9f71228 100644 --- a/demo/file_share_client.py +++ b/demo/file_share_client.py @@ -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: diff --git a/demo/file_share_credentials.py b/demo/file_share_credentials.py index 5ec959d..cba15f8 100644 --- a/demo/file_share_credentials.py +++ b/demo/file_share_credentials.py @@ -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: diff --git a/demo/file_share_server.py b/demo/file_share_server.py index 20d016c..d58e5fb 100644 --- a/demo/file_share_server.py +++ b/demo/file_share_server.py @@ -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(): - try: - p.unlink() - except OSError: - pass + 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)): - raise RuntimeError("permissions updated; request a fresh role grant") - return {"ok": True, **grant} + 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, "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) diff --git a/demo/file_share_smoke.py b/demo/file_share_smoke.py index 1f4ba86..4ff4b8b 100644 --- a/demo/file_share_smoke.py +++ b/demo/file_share_smoke.py @@ -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() diff --git a/demo/file_share_tui.py b/demo/file_share_tui.py index 4db6be9..557459b 100644 --- a/demo/file_share_tui.py +++ b/demo/file_share_tui.py @@ -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() diff --git a/demo/fs_data/registries/519bc59c917f122245d5a6a131ec42f5e3b57ae0b73c8b99a4e0cafc9c3eedab.state b/demo/fs_data/registries/519bc59c917f122245d5a6a131ec42f5e3b57ae0b73c8b99a4e0cafc9c3eedab.state deleted file mode 100644 index 1a99c49..0000000 Binary files a/demo/fs_data/registries/519bc59c917f122245d5a6a131ec42f5e3b57ae0b73c8b99a4e0cafc9c3eedab.state and /dev/null differ diff --git a/demo/fs_data/registries/a0e5ed90dafc9ddfa18197b599a63ea5bc43228a3cf1d5f64a043994951c31ea.cert b/demo/fs_data/registries/a0e5ed90dafc9ddfa18197b599a63ea5bc43228a3cf1d5f64a043994951c31ea.cert deleted file mode 100644 index 0820bfd..0000000 Binary files a/demo/fs_data/registries/a0e5ed90dafc9ddfa18197b599a63ea5bc43228a3cf1d5f64a043994951c31ea.cert and /dev/null differ diff --git a/demo/fs_data/registries/a0e5ed90dafc9ddfa18197b599a63ea5bc43228a3cf1d5f64a043994951c31ea.state b/demo/fs_data/registries/a0e5ed90dafc9ddfa18197b599a63ea5bc43228a3cf1d5f64a043994951c31ea.state deleted file mode 100644 index 0aacd8e..0000000 Binary files a/demo/fs_data/registries/a0e5ed90dafc9ddfa18197b599a63ea5bc43228a3cf1d5f64a043994951c31ea.state and /dev/null differ diff --git a/demo/fs_data/registries/fc2c7a6c4a57247946f5bcf006fb92c0ad7469322dcef8269bc8694ecf262386.cert b/demo/fs_data/registries/fc2c7a6c4a57247946f5bcf006fb92c0ad7469322dcef8269bc8694ecf262386.cert deleted file mode 100644 index 562c779..0000000 Binary files a/demo/fs_data/registries/fc2c7a6c4a57247946f5bcf006fb92c0ad7469322dcef8269bc8694ecf262386.cert and /dev/null differ diff --git a/demo/fs_data/registries/fc2c7a6c4a57247946f5bcf006fb92c0ad7469322dcef8269bc8694ecf262386.state b/demo/fs_data/registries/fc2c7a6c4a57247946f5bcf006fb92c0ad7469322dcef8269bc8694ecf262386.state deleted file mode 100644 index c463d51..0000000 Binary files a/demo/fs_data/registries/fc2c7a6c4a57247946f5bcf006fb92c0ad7469322dcef8269bc8694ecf262386.state and /dev/null differ diff --git a/demo/test_demo_privacy_guardrails.py b/demo/test_demo_privacy_guardrails.py new file mode 100644 index 0000000..20f65ce --- /dev/null +++ b/demo/test_demo_privacy_guardrails.py @@ -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"