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/file_share_tui.py`: Textual UI.
|
||||||
- `demo/zkac_cli_adapter.py`: subprocess bridge to `zkac-node`.
|
- `demo/zkac_cli_adapter.py`: subprocess bridge to `zkac-node`.
|
||||||
- `demo/file_share_smoke.py`: end-to-end smoke test.
|
- `demo/file_share_smoke.py`: end-to-end smoke test.
|
||||||
|
- `demo/test_demo_privacy_guardrails.py`: pytest privacy regressions for the demo.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@ -38,4 +39,11 @@ local ZKAC usage.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run python demo/file_share_smoke.py
|
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,
|
* deterministic folder flattening and per-file content-key generation,
|
||||||
* per-role visibility bitmasks over the flattened index,
|
* 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.
|
* role-credential authenticated download + decrypt against the same server.
|
||||||
|
|
||||||
Authenticated sessions piggy-back on the ZKAC TCP framing/handshake from
|
Authenticated sessions piggy-back on the ZKAC TCP framing/handshake from
|
||||||
@ -98,7 +98,7 @@ class BucketManifest:
|
|||||||
registry_id_hex: str
|
registry_id_hex: str
|
||||||
files: list[FileEntry]
|
files: list[FileEntry]
|
||||||
role_masks: dict[str, str] = field(default_factory=dict) # role -> bitmask string ('0'/'1')
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@ -311,10 +311,17 @@ class FileShareSession:
|
|||||||
"ciphertext_b64": _b64(ciphertext),
|
"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(
|
def bucket_put_role_grant(
|
||||||
self,
|
self,
|
||||||
bucket_id: str,
|
bucket_id: str,
|
||||||
recipient_pk_hex: str,
|
|
||||||
role_id_hex: str,
|
role_id_hex: str,
|
||||||
acl_version: int,
|
acl_version: int,
|
||||||
eph_pk_b64: str,
|
eph_pk_b64: str,
|
||||||
@ -323,21 +330,12 @@ class FileShareSession:
|
|||||||
self._call({
|
self._call({
|
||||||
"cmd": "bucket_put_role_grant",
|
"cmd": "bucket_put_role_grant",
|
||||||
"bucket_id": bucket_id,
|
"bucket_id": bucket_id,
|
||||||
"recipient_pk_hex": recipient_pk_hex,
|
|
||||||
"role_id_hex": role_id_hex,
|
"role_id_hex": role_id_hex,
|
||||||
"acl_version": int(acl_version),
|
"acl_version": int(acl_version),
|
||||||
"eph_pk_b64": eph_pk_b64,
|
"eph_pk_b64": eph_pk_b64,
|
||||||
"ciphertext_b64": ciphertext_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:
|
def bucket_get_role_acl(self, bucket_id: str, role_id_hex: str) -> dict:
|
||||||
return self._call({
|
return self._call({
|
||||||
"cmd": "bucket_get_role_acl",
|
"cmd": "bucket_get_role_acl",
|
||||||
@ -377,7 +375,6 @@ def open_session(
|
|||||||
registry_id_hex: str,
|
registry_id_hex: str,
|
||||||
role_id: bytes,
|
role_id: bytes,
|
||||||
credential: "zkac.Credential",
|
credential: "zkac.Credential",
|
||||||
user_issuance_pk_hex: str,
|
|
||||||
connect_timeout_s: float = DEFAULT_CONNECT_TIMEOUT_S,
|
connect_timeout_s: float = DEFAULT_CONNECT_TIMEOUT_S,
|
||||||
) -> FileShareSession:
|
) -> FileShareSession:
|
||||||
"""Connect, anonymous-handshake, then present a BBS+ proof bound to the transcript."""
|
"""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))
|
bbs_auth_proof = bytes(credential.present(transcript_hash))
|
||||||
framed.send(json.dumps({
|
framed.send(json.dumps({
|
||||||
"op": "fs",
|
"op": "fs",
|
||||||
"registry_id": registry_id_hex,
|
|
||||||
"role_id": role_id.hex(),
|
|
||||||
"bbs_auth_b64": _b64(bbs_auth_proof),
|
"bbs_auth_b64": _b64(bbs_auth_proof),
|
||||||
"issuance_pk_hex": user_issuance_pk_hex,
|
|
||||||
}).encode("utf-8"))
|
}).encode("utf-8"))
|
||||||
hello = json.loads(framed.recv())
|
hello = json.loads(framed.recv())
|
||||||
if hello.get("error"):
|
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:
|
def apply_role_masks_to_server(sess: FileShareSession, manifest: BucketManifest) -> None:
|
||||||
"""Push per-role blob ACLs so server enforces mask on fs_get_blob."""
|
"""Push per-role blob ACLs so server enforces mask on fs_get_blob."""
|
||||||
if not manifest.role_masks:
|
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(
|
def download_bucket(
|
||||||
sess: FileShareSession,
|
sess: FileShareSession,
|
||||||
bucket_id: str,
|
bucket_id: str,
|
||||||
*,
|
*,
|
||||||
issuance_secret_hex: str,
|
issuance_secret_hex: str,
|
||||||
|
role_grant_payload: dict | None = None,
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fetch + decrypt every file the auth'd role has access to."""
|
"""Fetch + decrypt files from a role grant, optionally fetched from server."""
|
||||||
grant = sess.fs_get_role_grant(bucket_id)
|
payload = role_grant_payload
|
||||||
payload = decrypt_grant_for_recipient(grant["eph_pk_b64"], grant["ciphertext_b64"], issuance_secret_hex)
|
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:
|
if payload.get("bucket_id") != bucket_id:
|
||||||
raise RuntimeError("role grant bucket_id mismatch")
|
raise RuntimeError("role grant bucket_id mismatch")
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
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}
|
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) ──────────────────────────────
|
# ── local persistence (admin manifests) ──────────────────────────────
|
||||||
|
|
||||||
def state_dir(userid: str) -> Path:
|
def state_dir(userid: str) -> Path:
|
||||||
|
|||||||
@ -29,6 +29,7 @@ from pathlib import Path
|
|||||||
import zkac
|
import zkac
|
||||||
from zkac.tcp import FramedSession, client_handshake_anon
|
from zkac.tcp import FramedSession, client_handshake_anon
|
||||||
|
|
||||||
|
import file_share_client as fsc
|
||||||
import zkac_cli_adapter as cli
|
import zkac_cli_adapter as cli
|
||||||
|
|
||||||
|
|
||||||
@ -106,6 +107,7 @@ def grant_role_p2p(
|
|||||||
role_name: str,
|
role_name: str,
|
||||||
recipient_contact_bundle: str,
|
recipient_contact_bundle: str,
|
||||||
*,
|
*,
|
||||||
|
bucket_role_grant: dict | None = None,
|
||||||
connect_timeout_s: float = 8.0,
|
connect_timeout_s: float = 8.0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Issue a BBS+ role credential and ship it to the recipient over an authenticated TCP session.
|
"""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)),
|
"blind_sig_b64": _b64(bytes(blind_sig)),
|
||||||
"member_secret_b64": _b64(bytes(req.member_secret())),
|
"member_secret_b64": _b64(bytes(req.member_secret())),
|
||||||
"prover_blind_b64": _b64(bytes(req.prover_blind())),
|
"prover_blind_b64": _b64(bytes(req.prover_blind())),
|
||||||
|
"bucket_role_grant": bucket_role_grant or None,
|
||||||
}).encode()
|
}).encode()
|
||||||
|
|
||||||
rec_pk_bytes = bytes.fromhex(recipient_issuance_pk_hex)
|
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")
|
raise RuntimeError("granted credential does not verify")
|
||||||
|
|
||||||
_save_credential_json(userid, registry_id_hex, role_name, payload)
|
_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())
|
framed.send(json.dumps({"ok": True, "status": "stored"}).encode())
|
||||||
return {"registry_id": registry_id_hex, "role": role_name}
|
return {"registry_id": registry_id_hex, "role": role_name}
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@ -8,7 +8,7 @@ Headless TCP service that combines:
|
|||||||
``zkac-node registry create/get/update`` commands work unchanged.
|
``zkac-node registry create/get/update`` commands work unchanged.
|
||||||
* A new file-share channel that, after BBS+ role authentication, exposes
|
* A new file-share channel that, after BBS+ role authentication, exposes
|
||||||
bucket primitives. The server only ever sees opaque ciphertext blobs and
|
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.
|
visibility masks are never visible server-side.
|
||||||
|
|
||||||
Run with::
|
Run with::
|
||||||
@ -23,12 +23,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -72,6 +72,11 @@ def _safe_id(value: str) -> str:
|
|||||||
return value
|
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 ─────────────────────────────────────────────
|
# ── opaque on-disk store ─────────────────────────────────────────────
|
||||||
|
|
||||||
class _FileShareStore:
|
class _FileShareStore:
|
||||||
@ -81,11 +86,39 @@ class _FileShareStore:
|
|||||||
self._dir = data_dir
|
self._dir = data_dir
|
||||||
self._reg_dir = data_dir / "registries"
|
self._reg_dir = data_dir / "registries"
|
||||||
self._buckets_dir = data_dir / "buckets"
|
self._buckets_dir = data_dir / "buckets"
|
||||||
|
self._privacy_key: bytes = b""
|
||||||
for d in (self._dir, self._reg_dir, self._buckets_dir):
|
for d in (self._dir, self._reg_dir, self._buckets_dir):
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
_chmod(d, 0o700)
|
_chmod(d, 0o700)
|
||||||
self._lock = threading.Lock()
|
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
|
# transport key
|
||||||
|
|
||||||
def load_or_create_keypair(self) -> zkac.Keypair:
|
def load_or_create_keypair(self) -> zkac.Keypair:
|
||||||
@ -120,6 +153,14 @@ class _FileShareStore:
|
|||||||
print(f"[fs-server] skip registry {p.stem}: {exc}")
|
print(f"[fs-server] skip registry {p.stem}: {exc}")
|
||||||
return n
|
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
|
# buckets
|
||||||
|
|
||||||
def _bucket_dir(self, bucket_id: str) -> Path:
|
def _bucket_dir(self, bucket_id: str) -> Path:
|
||||||
@ -128,6 +169,22 @@ class _FileShareStore:
|
|||||||
def _bucket_meta_path(self, bucket_id: str) -> Path:
|
def _bucket_meta_path(self, bucket_id: str) -> Path:
|
||||||
return self._bucket_dir(bucket_id) / "meta.json"
|
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:
|
def bucket_create(self, bucket_id: str, owner_registry_id_hex: str) -> None:
|
||||||
bd = self._bucket_dir(bucket_id)
|
bd = self._bucket_dir(bucket_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@ -138,17 +195,21 @@ class _FileShareStore:
|
|||||||
_chmod(bd, 0o700)
|
_chmod(bd, 0o700)
|
||||||
_write_private_json(self._bucket_meta_path(bucket_id), {
|
_write_private_json(self._bucket_meta_path(bucket_id), {
|
||||||
"bucket_id": 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,
|
"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:
|
def bucket_meta(self, bucket_id: str) -> dict:
|
||||||
return json.loads(self._bucket_meta_path(bucket_id).read_text())
|
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:
|
def _bucket_require_owner(self, bucket_id: str, registry_id_hex: str) -> dict:
|
||||||
meta = self.bucket_meta(bucket_id)
|
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")
|
raise RuntimeError("not bucket owner")
|
||||||
return meta
|
return meta
|
||||||
|
|
||||||
@ -165,11 +226,18 @@ class _FileShareStore:
|
|||||||
for sub in ("blobs", "role_grants"):
|
for sub in ("blobs", "role_grants"):
|
||||||
d = bd / sub
|
d = bd / sub
|
||||||
if d.is_dir():
|
if d.is_dir():
|
||||||
for p in d.iterdir():
|
for p in d.rglob("*"):
|
||||||
try:
|
if p.is_file():
|
||||||
p.unlink()
|
try:
|
||||||
except OSError:
|
p.unlink()
|
||||||
pass
|
except OSError:
|
||||||
|
pass
|
||||||
|
for p in sorted(d.rglob("*"), reverse=True):
|
||||||
|
if p.is_dir():
|
||||||
|
try:
|
||||||
|
p.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
d.rmdir()
|
d.rmdir()
|
||||||
except OSError:
|
except OSError:
|
||||||
@ -194,29 +262,6 @@ class _FileShareStore:
|
|||||||
self._bucket_require_owner(bucket_id, registry_id_hex)
|
self._bucket_require_owner(bucket_id, registry_id_hex)
|
||||||
(self._bucket_dir(bucket_id) / "blobs" / _safe_id(blob_id)).write_bytes(ciphertext)
|
(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(
|
def bucket_set_role_acl(
|
||||||
self,
|
self,
|
||||||
bucket_id: str,
|
bucket_id: str,
|
||||||
@ -227,7 +272,7 @@ class _FileShareStore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
meta = self._bucket_require_owner(bucket_id, registry_id_hex)
|
meta = self._bucket_require_owner(bucket_id, registry_id_hex)
|
||||||
role_acl = dict(meta.get("role_acl", {}))
|
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 = role_acl.get(key, {})
|
||||||
prev_version = int(prev.get("version", 0)) if isinstance(prev, dict) else 0
|
prev_version = int(prev.get("version", 0)) if isinstance(prev, dict) else 0
|
||||||
role_acl[key] = {
|
role_acl[key] = {
|
||||||
@ -236,6 +281,7 @@ class _FileShareStore:
|
|||||||
}
|
}
|
||||||
meta["role_acl"] = role_acl
|
meta["role_acl"] = role_acl
|
||||||
_write_private_json(self._bucket_meta_path(bucket_id), meta)
|
_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:
|
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()
|
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:
|
def bucket_blob_allowed_for_role(self, bucket_id: str, role_id_hex: str, blob_id: str) -> bool:
|
||||||
meta = self.bucket_meta(bucket_id)
|
meta = self.bucket_meta(bucket_id)
|
||||||
role_acl = meta.get("role_acl", {})
|
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):
|
if not isinstance(role_meta, dict):
|
||||||
return False
|
return False
|
||||||
allowed = role_meta.get("allowed_blob_ids")
|
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:
|
def bucket_role_acl(self, bucket_id: str, role_id_hex: str) -> dict:
|
||||||
meta = self.bucket_meta(bucket_id)
|
meta = self.bucket_meta(bucket_id)
|
||||||
role_acl = meta.get("role_acl", {})
|
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):
|
if not isinstance(role_meta, dict):
|
||||||
return {"version": 0, "allowed_blob_ids": []}
|
return {"version": 0, "allowed_blob_ids": []}
|
||||||
version = int(role_meta.get("version", 0))
|
version = int(role_meta.get("version", 0))
|
||||||
@ -266,20 +312,75 @@ class _FileShareStore:
|
|||||||
"allowed_blob_ids": [_safe_id(str(v)) for v in allowed],
|
"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:
|
def buckets_for_role_in_registry(self, role_id_hex: str, registry_id_hex: str) -> list[str]:
|
||||||
path = self._bucket_dir(bucket_id) / "role_grants" / f"{_safe_id(recipient_pk_hex)}.json"
|
"""Bucket ids where this authenticated role currently has non-empty ACL."""
|
||||||
return json.loads(path.read_text())
|
role_id_hex = _safe_id(role_id_hex)
|
||||||
|
|
||||||
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)
|
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for bd in sorted(self._buckets_dir.iterdir()):
|
for bd in sorted(self._buckets_dir.iterdir()):
|
||||||
grant = bd / "role_grants" / f"{recipient_pk_hex}.json"
|
bid = bd.name
|
||||||
if grant.is_file():
|
try:
|
||||||
out.append(bd.name)
|
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
|
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]:
|
def buckets_owned_by(self, registry_id_hex: str) -> list[str]:
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for bd in sorted(self._buckets_dir.iterdir()):
|
for bd in sorted(self._buckets_dir.iterdir()):
|
||||||
@ -287,12 +388,25 @@ class _FileShareStore:
|
|||||||
if not meta.is_file():
|
if not meta.is_file():
|
||||||
continue
|
continue
|
||||||
try:
|
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)
|
out.append(bd.name)
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
continue
|
continue
|
||||||
return out
|
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) ──────────────────────
|
# ── command dispatch (inside encrypted session) ──────────────────────
|
||||||
|
|
||||||
@ -378,13 +492,13 @@ def _dispatch_fs(
|
|||||||
store: _FileShareStore,
|
store: _FileShareStore,
|
||||||
ctx: dict,
|
ctx: dict,
|
||||||
) -> 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:
|
try:
|
||||||
action = cmd.get("cmd")
|
action = cmd.get("cmd")
|
||||||
registry_id_hex: str = ctx["registry_id_hex"]
|
registry_id_hex: str = ctx["registry_id_hex"]
|
||||||
role_id: bytes = ctx["role_id"]
|
registry_tag: str = ctx["registry_tag"]
|
||||||
issuance_pk_hex: str = ctx["issuance_pk_hex"]
|
role_id_hex: str = ctx["role_id_hex"]
|
||||||
is_admin = role_id == zkac.admin_role_id()
|
is_admin: bool = bool(ctx["is_admin"])
|
||||||
|
|
||||||
def _require_admin() -> None:
|
def _require_admin() -> None:
|
||||||
if not is_admin:
|
if not is_admin:
|
||||||
@ -393,10 +507,8 @@ def _dispatch_fs(
|
|||||||
if action == "whoami":
|
if action == "whoami":
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"registry_id": registry_id_hex,
|
|
||||||
"role_id": role_id.hex(),
|
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
"issuance_pk_hex": issuance_pk_hex,
|
"auth_scope": "admin" if is_admin else "credential",
|
||||||
}
|
}
|
||||||
|
|
||||||
if action == "bucket_create":
|
if action == "bucket_create":
|
||||||
@ -413,19 +525,6 @@ def _dispatch_fs(
|
|||||||
store.bucket_put_blob(bid, registry_id_hex, blob_id, ciphertext)
|
store.bucket_put_blob(bid, registry_id_hex, blob_id, ciphertext)
|
||||||
return {"ok": True}
|
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":
|
if action == "bucket_set_role_acl":
|
||||||
_require_admin()
|
_require_admin()
|
||||||
store.bucket_set_role_acl(
|
store.bucket_set_role_acl(
|
||||||
@ -436,6 +535,18 @@ def _dispatch_fs(
|
|||||||
)
|
)
|
||||||
return {"ok": True}
|
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":
|
if action == "bucket_get_role_acl":
|
||||||
_require_admin()
|
_require_admin()
|
||||||
role_acl = store.bucket_role_acl(cmd["bucket_id"], cmd["role_id_hex"])
|
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)}
|
return {"ok": True, "bucket_ids": store.buckets_owned_by(registry_id_hex)}
|
||||||
|
|
||||||
if action == "fs_buckets":
|
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":
|
if action == "fs_get_role_grant":
|
||||||
bid = cmd["bucket_id"]
|
bid = cmd["bucket_id"]
|
||||||
grant = store.bucket_get_role_grant(bid, issuance_pk_hex)
|
if store.bucket_owner_registry_tag(bid) != registry_tag:
|
||||||
if not is_admin:
|
raise RuntimeError("bucket does not belong to authenticated registry")
|
||||||
expected_role = _safe_id(role_id.hex())
|
if not store.bucket_is_finalized(bid):
|
||||||
granted_role_raw = grant.get("role_id_hex")
|
raise RuntimeError("bucket is not finalized")
|
||||||
if not isinstance(granted_role_raw, str) or not granted_role_raw:
|
current_acl = store.bucket_role_acl(bid, role_id_hex)
|
||||||
raise RuntimeError("permissions updated; role grant is outdated, request a fresh grant")
|
acl_version = int(current_acl.get("version", -1))
|
||||||
granted_role = _safe_id(granted_role_raw)
|
grants = [
|
||||||
if granted_role != expected_role:
|
g for g in store.bucket_get_role_grants(bid, role_id_hex)
|
||||||
raise RuntimeError("role grant not valid for authenticated role")
|
if isinstance(g.get("acl_version"), int) and g["acl_version"] == acl_version
|
||||||
current_acl = store.bucket_role_acl(bid, expected_role)
|
]
|
||||||
acl_version = grant.get("acl_version")
|
if not grants:
|
||||||
if not isinstance(acl_version, int):
|
raise RuntimeError("permissions updated; request a fresh role grant")
|
||||||
raise RuntimeError("permissions updated; role grant is outdated, request a fresh grant")
|
return {"ok": True, "grants": grants}
|
||||||
if acl_version != int(current_acl.get("version", -2)):
|
|
||||||
raise RuntimeError("permissions updated; request a fresh role grant")
|
|
||||||
return {"ok": True, **grant}
|
|
||||||
|
|
||||||
if action == "fs_get_blob":
|
if action == "fs_get_blob":
|
||||||
bid = cmd["bucket_id"]
|
bid = cmd["bucket_id"]
|
||||||
blob_id = cmd["blob_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")
|
raise RuntimeError("blob access denied by role mask")
|
||||||
data = store.bucket_get_blob(bid, blob_id)
|
data = store.bucket_get_blob(bid, blob_id)
|
||||||
return {"ok": True, "ciphertext_b64": _b64(data)}
|
return {"ok": True, "ciphertext_b64": _b64(data)}
|
||||||
@ -491,6 +609,61 @@ def _dispatch_fs(
|
|||||||
return {"error": str(exc)}
|
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 ────────────────────────────────────────────
|
# ── per-connection handler ────────────────────────────────────────────
|
||||||
|
|
||||||
def _handle_conn(
|
def _handle_conn(
|
||||||
@ -503,7 +676,6 @@ def _handle_conn(
|
|||||||
idle_timeout_s: float,
|
idle_timeout_s: float,
|
||||||
slots: threading.BoundedSemaphore,
|
slots: threading.BoundedSemaphore,
|
||||||
) -> None:
|
) -> None:
|
||||||
peer = f"{addr[0]}:{addr[1]}"
|
|
||||||
try:
|
try:
|
||||||
conn.settimeout(idle_timeout_s)
|
conn.settimeout(idle_timeout_s)
|
||||||
session = server_handshake_anon(conn, node)
|
session = server_handshake_anon(conn, node)
|
||||||
@ -525,27 +697,16 @@ def _handle_conn(
|
|||||||
|
|
||||||
if op == "fs":
|
if op == "fs":
|
||||||
try:
|
try:
|
||||||
registry_id = bytes.fromhex(hello["registry_id"])
|
|
||||||
role_id = bytes.fromhex(hello["role_id"])
|
|
||||||
proof = _unb64(hello["bbs_auth_b64"])
|
proof = _unb64(hello["bbs_auth_b64"])
|
||||||
issuance_pk_hex = hello["issuance_pk_hex"]
|
|
||||||
_safe_id(issuance_pk_hex)
|
|
||||||
except (KeyError, ValueError) as exc:
|
except (KeyError, ValueError) as exc:
|
||||||
framed.send(json.dumps({"error": f"invalid fs hello: {exc}"}).encode())
|
framed.send(json.dumps({"error": f"invalid fs hello: {exc}"}).encode())
|
||||||
return
|
return
|
||||||
if role_id == zkac.admin_role_id():
|
auth = _authenticate_fs_identity(mgr, store, proof, transcript_hash)
|
||||||
ok = mgr.verify_admin(registry_id, proof, transcript_hash)
|
if auth is None:
|
||||||
else:
|
|
||||||
ok = mgr.verify_presentation(registry_id, role_id, proof, transcript_hash)
|
|
||||||
if not ok:
|
|
||||||
framed.send(json.dumps({"error": "auth failed"}).encode())
|
framed.send(json.dumps({"error": "auth failed"}).encode())
|
||||||
return
|
return
|
||||||
framed.send(json.dumps({"ok": True, "status": "authenticated"}).encode())
|
framed.send(json.dumps({"ok": True, "status": "authenticated"}).encode())
|
||||||
ctx = {
|
ctx = auth
|
||||||
"registry_id_hex": registry_id.hex(),
|
|
||||||
"role_id": role_id,
|
|
||||||
"issuance_pk_hex": issuance_pk_hex,
|
|
||||||
}
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
cmd = json.loads(framed.recv())
|
cmd = json.loads(framed.recv())
|
||||||
@ -560,8 +721,8 @@ def _handle_conn(
|
|||||||
except (ConnectionError, BrokenPipeError, OSError):
|
except (ConnectionError, BrokenPipeError, OSError):
|
||||||
pass
|
pass
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[fs-server] {peer} error: {exc}")
|
# Privacy model: never emit client endpoint or request-linked payloads.
|
||||||
traceback.print_exc()
|
print(f"[fs-server] connection error ({_privacy_safe_error_tag(exc)})")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
slots.release()
|
slots.release()
|
||||||
@ -582,6 +743,7 @@ def serve(
|
|||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
store = _FileShareStore(data_dir)
|
store = _FileShareStore(data_dir)
|
||||||
kp = store.load_or_create_keypair()
|
kp = store.load_or_create_keypair()
|
||||||
|
store.set_privacy_key(bytes(kp.secret_key_bytes()))
|
||||||
server_pk_b64 = _b64(kp.public_key().to_bytes())
|
server_pk_b64 = _b64(kp.public_key().to_bytes())
|
||||||
pk_hex = kp.public_key().to_bytes().hex()
|
pk_hex = kp.public_key().to_bytes().hex()
|
||||||
node = zkac.Node(kp)
|
node = zkac.Node(kp)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ Exercises:
|
|||||||
* admin (Alice) creates a registry on the file-share server,
|
* admin (Alice) creates a registry on the file-share server,
|
||||||
* admin uploads a folder as an encrypted bucket with per-role visibility masks,
|
* 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 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,
|
* Bob authenticates to the file-share server with his role credential,
|
||||||
downloads, and decrypts the files his role can see,
|
downloads, and decrypts the files his role can see,
|
||||||
* server opacity: every byte at rest in the bucket directory is ciphertext
|
* 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,
|
registry_id_hex=registry_id,
|
||||||
role_id=zkac.admin_role_id(),
|
role_id=zkac.admin_role_id(),
|
||||||
credential=cred,
|
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,
|
registry_id_hex=registry_id,
|
||||||
role_id=zkac.role_id(role_name),
|
role_id=zkac.role_id(role_name),
|
||||||
credential=cred,
|
credential=cred,
|
||||||
user_issuance_pk_hex=secrets["issuance_pk_hex"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -182,8 +180,33 @@ def main() -> int:
|
|||||||
raise RuntimeError("bob listener did not start")
|
raise RuntimeError("bob listener did not start")
|
||||||
bob_contact = cli.show_user_contact("bob", peer=f"127.0.0.1:{listener_port}")
|
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}")
|
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:
|
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:
|
except RuntimeError as exc:
|
||||||
listener.stop()
|
listener.stop()
|
||||||
print(f"[smoke] grant failed: {exc}\n[smoke] bob listener output:\n{listener.output()}")
|
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 and received["role"] == "viewer"
|
||||||
assert received["registry_id"] == rid
|
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 ------------------------
|
# --- bob downloads using his viewer credential ------------------------
|
||||||
with _open_role_session("bob", server_addr, server_pk_hex, rid, "viewer") as sess:
|
with _open_role_session("bob", server_addr, server_pk_hex, rid, "viewer") as sess:
|
||||||
accessible = sess.fs_buckets()
|
accessible = sess.fs_buckets()
|
||||||
|
|||||||
@ -352,7 +352,6 @@ class FileShareApp(App[None]):
|
|||||||
registry_id_hex=registry_id_hex,
|
registry_id_hex=registry_id_hex,
|
||||||
role_id=role_id,
|
role_id=role_id,
|
||||||
credential=credential,
|
credential=credential,
|
||||||
user_issuance_pk_hex=ident.issuance_pk_hex,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _choose_index(self, title: str, options: list[str], default: int = 1) -> int:
|
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.push_role_grant(sess, manifest, role, recipient_pk_hex)
|
||||||
fsc.save_manifest(ident.userid, manifest)
|
fsc.save_manifest(ident.userid, manifest)
|
||||||
self.current_bucket_id = manifest.bucket_id
|
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:
|
async def menu_listen(self) -> None:
|
||||||
ident = self._require_user()
|
ident = self._require_user()
|
||||||
@ -781,8 +780,9 @@ class FileShareApp(App[None]):
|
|||||||
with self._open_session(cred["registry_id"], cred["role"]) as sess:
|
with self._open_session(cred["registry_id"], cred["role"]) as sess:
|
||||||
who = sess.whoami()
|
who = sess.whoami()
|
||||||
self.write_log(
|
self.write_log(
|
||||||
f"[inbox] authenticated registry={who['registry_id'][:16]}... "
|
"[inbox] authenticated "
|
||||||
f"role_id={who['role_id'][:16]}..."
|
f"scope={who.get('auth_scope', 'unknown')} "
|
||||||
|
f"admin={bool(who.get('is_admin', False))}"
|
||||||
)
|
)
|
||||||
if cred["role"] == "__admin__":
|
if cred["role"] == "__admin__":
|
||||||
bids = sess.bucket_list_owned()
|
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