"""MLS-backed gate confidentiality path. Gate encryption now routes exclusively through privacy-core. This module keeps the gate -> MLS mapping and confidentiality state in Python while Rust owns the actual MLS group state. """ from __future__ import annotations import base64 import hashlib import hmac import json import logging import math import secrets import struct import threading import time from collections import OrderedDict from dataclasses import dataclass, field from pathlib import Path from typing import Any from services.mesh.mesh_local_custody import ( read_sensitive_domain_json, write_sensitive_domain_json, ) from services.mesh.mesh_secure_storage import ( read_secure_json, ) from services.mesh.mesh_privacy_logging import privacy_log_label from services.mesh.mesh_wormhole_persona import ( bootstrap_wormhole_persona_state, get_active_gate_identity, read_wormhole_persona_state, sign_gate_persona_blob, sign_gate_session_blob, sign_gate_wormhole_event, verify_gate_persona_blob, verify_gate_session_blob, ) from services.privacy_core_client import PrivacyCoreClient, PrivacyCoreError logger = logging.getLogger(__name__) import os as _os from cryptography.hazmat.primitives.ciphers.aead import AESGCM as _AESGCM DATA_DIR = Path(__file__).resolve().parents[2] / "data" STATE_FILE = DATA_DIR / "wormhole_gate_mls.json" STATE_FILENAME = "wormhole_gate_mls.json" STATE_DOMAIN = "gate_persona" RUST_GATE_STATE_DOMAIN = "gate_rust" MLS_GATE_FORMAT = "mls1" STATE_CUSTODY_SCOPE = "gate_mls_binding_store" class GateSecretUnavailableError(Exception): """Raised when gate-secret resolution fails or returns empty. New envelope encryption must not silently fall back to the Phase-1 gate-name-only key derivation. Callers should catch this and either skip the durable envelope (MLS-only) or surface a structured failure. """ def _gate_envelope_key_shared(gate_id: str, gate_secret: str) -> bytes: """Derive a 256-bit AES key for gate envelope encryption. Sprint 1 / Rec #6: the legacy gate-name-only derivation has been removed. A non-empty ``gate_secret`` is required; passing an empty secret is a programming error and raises GateSecretUnavailableError. """ from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives import hashes if not gate_secret: raise GateSecretUnavailableError( f"gate secret required for {privacy_log_label(gate_id, label='gate')} — " "legacy gate-name-only envelope key has been removed" ) gate_key = gate_id.strip().lower() ikm = gate_secret.encode("utf-8") info = f"gate_envelope_aes256gcm|{gate_key}".encode("utf-8") return HKDF( algorithm=hashes.SHA256(), length=32, salt=b"shadowbroker-gate-envelope-v1", info=info, ).derive(ikm) def _gate_envelope_key_scoped(gate_id: str, gate_secret: str, *, message_nonce: str) -> bytes: """Derive a 256-bit AES key scoped to one gate message envelope.""" from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives import hashes if not gate_secret: raise GateSecretUnavailableError( f"gate secret required for {privacy_log_label(gate_id, label='gate')} — " "legacy gate-name-only envelope key has been removed" ) nonce_value = str(message_nonce or "").strip() if not nonce_value: raise GateSecretUnavailableError( f"message nonce required for {privacy_log_label(gate_id, label='gate')} envelope scoping" ) gate_key = gate_id.strip().lower() ikm = gate_secret.encode("utf-8") info = f"gate_envelope_aes256gcm|{gate_key}|{nonce_value}".encode("utf-8") return HKDF( algorithm=hashes.SHA256(), length=32, salt=b"shadowbroker-gate-envelope-v2", info=info, ).derive(ikm) def _resolve_gate_secret(gate_id: str) -> str: """Look up the per-gate content key from the gate manager. Returns the secret string (may be empty if the gate has no secret configured). Raises GateSecretUnavailableError if the gate manager lookup itself fails. """ try: from services.mesh.mesh_reputation import gate_manager secret = gate_manager.get_gate_secret(gate_id) if not secret: secret = gate_manager.ensure_gate_secret(gate_id) return secret except Exception as exc: raise GateSecretUnavailableError( f"gate_manager lookup failed for gate {privacy_log_label(gate_id, label='gate')}" ) from exc def _resolve_gate_secret_archive(gate_id: str) -> dict[str, Any]: try: from services.mesh.mesh_reputation import gate_manager return dict(gate_manager.get_gate_secret_archive(gate_id) or {}) except Exception: return {} def _resolve_gate_envelope_policy(gate_id: str) -> str: """Return the gate envelope policy. The per-gate ``envelope_policy`` is the source of truth. If the operator (or the seed catalog) has configured a gate for ``envelope_always`` or ``envelope_recovery``, that IS the acknowledgment — a gate-level opt-in to durable recovery envelopes. A second global runtime gate would be redundant and silently downgrades working configurations to ``envelope_disabled`` without surfacing any error; that's the exact "hostile silent downgrade" pattern this codebase used to perform. """ try: from services.mesh.mesh_reputation import gate_manager return str(gate_manager.get_envelope_policy(gate_id) or "envelope_disabled") except Exception: return "envelope_disabled" def _gate_envelope_encrypt(gate_id: str, plaintext: str, *, message_nonce: str = "") -> str: """Encrypt plaintext under the gate secret, scoped to one message when possible. Raises GateSecretUnavailableError if the gate secret cannot be resolved or is empty — new envelopes must never silently use the Phase-1 gate-name-only derivation. """ gate_secret = _resolve_gate_secret(gate_id) # raises on lookup failure if not gate_secret: raise GateSecretUnavailableError( f"gate secret is empty for {privacy_log_label(gate_id, label='gate')} — " "refusing Phase-1 fallback for new encryption" ) nonce_value = str(message_nonce or "").strip() if nonce_value: key = _gate_envelope_key_scoped(gate_id, gate_secret, message_nonce=nonce_value) aad = f"gate_envelope|{gate_id}|{nonce_value}".encode("utf-8") else: key = _gate_envelope_key_shared(gate_id, gate_secret) aad = f"gate_envelope|{gate_id}".encode("utf-8") nonce = _os.urandom(12) ct = _AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), aad) return base64.b64encode(nonce + ct).decode("ascii") def _gate_envelope_hash(token: str) -> str: """Return the canonical signed binding for a gate envelope token.""" token_value = str(token or "").strip() if not token_value: return "" try: token_bytes = token_value.encode("ascii") except UnicodeEncodeError: return "" return hashlib.sha256(token_bytes).hexdigest() def _try_gate_envelope_decrypt( gate_id: str, gate_secret: str, nonce: bytes, ct: bytes, *, message_nonce: str = "", ) -> str | None: try: nonce_value = str(message_nonce or "").strip() if nonce_value: scoped_aad = f"gate_envelope|{gate_id}|{nonce_value}".encode("utf-8") scoped_key = _gate_envelope_key_scoped(gate_id, gate_secret, message_nonce=nonce_value) return _AESGCM(scoped_key).decrypt(nonce, ct, scoped_aad).decode("utf-8") except Exception: pass try: aad = f"gate_envelope|{gate_id}".encode("utf-8") return _AESGCM(_gate_envelope_key_shared(gate_id, gate_secret)).decrypt(nonce, ct, aad).decode("utf-8") except Exception: return None def _archived_gate_secret_allowed( archive: dict[str, Any], *, message_epoch: int = 0, event_id: str = "", ) -> bool: if not str((archive or {}).get("previous_secret", "") or "").strip(): return False ceiling_epoch = int((archive or {}).get("previous_valid_through_epoch", 0) or 0) if message_epoch > 0 and ceiling_epoch > 0: return message_epoch <= ceiling_epoch ceiling_event_id = str((archive or {}).get("previous_valid_through_event_id", "") or "").strip() target_event_id = str(event_id or "").strip() return bool(ceiling_event_id and target_event_id and target_event_id == ceiling_event_id) def _gate_envelope_decrypt( gate_id: str, token: str, *, message_nonce: str = "", message_epoch: int = 0, event_id: str = "", ) -> str | None: """Decrypt a gate envelope token using the current scoped derivation first. New envelopes are keyed from the gate secret plus the signed message nonce so one long-lived gate key no longer directly wraps every recovery envelope for the gate. Old per-gate envelopes still decrypt via the shared-key fallback so stored recovery material survives upgrade. """ try: raw = base64.b64decode(token) if len(raw) < 13: return None nonce, ct = raw[:12], raw[12:] try: gate_secret = _resolve_gate_secret(gate_id) except GateSecretUnavailableError: return None if not gate_secret: return None plaintext = _try_gate_envelope_decrypt( gate_id, gate_secret, nonce, ct, message_nonce=message_nonce, ) if plaintext is not None: return plaintext archive = _resolve_gate_secret_archive(gate_id) if _archived_gate_secret_allowed( archive, message_epoch=int(message_epoch or 0), event_id=str(event_id or ""), ): previous_secret = str(archive.get("previous_secret", "") or "") if previous_secret: return _try_gate_envelope_decrypt( gate_id, previous_secret, nonce, ct, message_nonce=message_nonce, ) return None except Exception: return None def _stored_legacy_unbound_envelope_allowed( gate_id: str, event_id: str, gate_envelope: str, ) -> bool: """Allow old local history whose envelope predates signed envelope_hash. This is deliberately limited to an exact event already present in the local private gate store. New writes and network ingest still require the signed envelope_hash binding before side effects. """ event_key = str(event_id or "").strip() envelope_value = str(gate_envelope or "").strip() if not event_key or not envelope_value: return False try: from services.mesh.mesh_hashchain import gate_store stored = gate_store.get_event(event_key) payload = stored.get("payload") if isinstance(stored, dict) else None if not isinstance(payload, dict): return False stored_gate = _stable_gate_ref(str(payload.get("gate", "") or "")) if stored_gate != _stable_gate_ref(gate_id): return False if str(payload.get("gate_envelope", "") or "").strip() != envelope_value: return False return not str(payload.get("envelope_hash", "") or "").strip() except Exception: return False # Self-echo plaintext cache: MLS cannot decrypt messages authored by the same # member, so we cache plaintext locally after compose. The TTL must comfortably # exceed the frontend poll + batch-decrypt round-trip (often 2-5 s under load). # 300 s keeps self-authored messages readable for the whole session while still # bounding memory exposure. Long-term durability is intentionally off by # default; ordinary reads keep plaintext local/in-memory only unless the caller # is performing an explicit recovery read or the operator deliberately opted # into durable plaintext retention. LOCAL_CIPHERTEXT_CACHE_MAX = 128 LOCAL_CIPHERTEXT_CACHE_TTL_S = 300 _CT_BUCKETS = (192, 384, 768, 1536, 3072, 6144) class _ComposeResult(dict[str, Any]): """Dict response with hidden legacy epoch access for in-process callers/tests.""" def __init__(self, *args: Any, legacy_epoch: int = 0, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._legacy_epoch = int(legacy_epoch or 0) def __getitem__(self, key: str) -> Any: if key == "epoch": return self._legacy_epoch return super().__getitem__(key) def get(self, key: str, default: Any = None) -> Any: if key == "epoch": return self._legacy_epoch return super().get(key, default) def _b64(data: bytes) -> str: return base64.b64encode(data).decode("ascii") def _unb64(data: str | bytes | None) -> bytes: if not data: return b"" if isinstance(data, bytes): return base64.b64decode(data) return base64.b64decode(data.encode("ascii")) def _pad_ciphertext_raw(raw_ct: bytes) -> bytes: """Length-prefix + pad raw ciphertext to the next bucket size.""" prefixed = struct.pack(">H", len(raw_ct)) + raw_ct prefixed_len = len(prefixed) for bucket in _CT_BUCKETS: if prefixed_len <= bucket: return prefixed + (b"\x00" * (bucket - prefixed_len)) target = (((prefixed_len - 1) // _CT_BUCKETS[-1]) + 1) * _CT_BUCKETS[-1] return prefixed + (b"\x00" * (target - prefixed_len)) def _unpad_ciphertext_raw(padded: bytes) -> bytes: """Read length prefix and extract original ciphertext.""" if len(padded) < 2: return padded original_len = struct.unpack(">H", padded[:2])[0] if original_len == 0 or 2 + original_len > len(padded): return padded return padded[2 : 2 + original_len] def _stable_gate_ref(gate_id: str) -> str: return str(gate_id or "").strip().lower() def _sender_ref_seed(identity: dict[str, Any]) -> str: return str(identity.get("persona_id", "") or identity.get("node_id", "") or "").strip() def _sender_ref(persona_id: str, msg_id: str) -> str: persona_key = str(persona_id or "").strip() message_id = str(msg_id or "").strip() if not persona_key or not message_id: return "" return hmac.new( persona_key.encode("utf-8"), message_id.encode("utf-8"), hashlib.sha256, ).hexdigest()[:16] def _gate_plaintext_persist_enabled() -> bool: try: from services.config import gate_plaintext_persist_effective return bool(gate_plaintext_persist_effective()) except Exception: return False @dataclass class _GateMemberBinding: persona_id: str node_id: str label: str identity_scope: str identity_handle: int group_handle: int member_ref: int is_creator: bool = False key_package_handle: int | None = None public_bundle: bytes = b"" binding_signature: str = "" @dataclass class _GateBinding: gate_id: str epoch: int root_persona_id: str root_group_handle: int next_member_ref: int = 1 members: dict[str, _GateMemberBinding] = field(default_factory=dict) _STATE_LOCK = threading.RLock() _PRIVACY_CLIENT: PrivacyCoreClient | None = None # Rust group state is exported/imported via the privacy-core bridge so gate # bindings can survive restart. Python-side metadata (bindings, epochs, # personas) is still persisted via domain storage, and restored bindings fail # closed if the Rust state cannot be reloaded safely. _GATE_BINDINGS: dict[str, _GateBinding] = {} _LOCAL_CIPHERTEXT_CACHE: OrderedDict[ tuple[str, str, str], tuple[str, str, float], ] = OrderedDict() _HIGH_WATER_EPOCHS: dict[str, int] = {} def _default_binding_store() -> dict[str, Any]: return { "version": 1, "updated_at": 0, "gates": {}, "high_water_epochs": {}, "gate_format_locks": {}, } def _privacy_client() -> PrivacyCoreClient: global _PRIVACY_CLIENT if _PRIVACY_CLIENT is None: _PRIVACY_CLIENT = PrivacyCoreClient.load() return _PRIVACY_CLIENT def reset_gate_mls_state(*, clear_persistence: bool = True) -> None: """Clear in-memory gate -> MLS bindings and optionally persisted Rust state.""" global _PRIVACY_CLIENT with _STATE_LOCK: if _PRIVACY_CLIENT is not None: try: _PRIVACY_CLIENT.reset_all_state() except Exception: logger.exception("privacy-core reset failed while clearing gate MLS state") _GATE_BINDINGS.clear() _LOCAL_CIPHERTEXT_CACHE.clear() _HIGH_WATER_EPOCHS.clear() if clear_persistence: _clear_gate_rust_state() def _gate_personas(gate_id: str) -> list[dict[str, Any]]: gate_key = _stable_gate_ref(gate_id) bootstrap_wormhole_persona_state() state = read_wormhole_persona_state() return [dict(item or {}) for item in list(state.get("gate_personas", {}).get(gate_key) or [])] def _gate_session_identity(gate_id: str) -> dict[str, Any] | None: gate_key = _stable_gate_ref(gate_id) bootstrap_wormhole_persona_state() state = read_wormhole_persona_state() session = dict(state.get("gate_sessions", {}).get(gate_key) or {}) if not session.get("private_key"): return None return session def _gate_member_identity_id(identity: dict[str, Any]) -> str: persona_id = str(identity.get("persona_id", "") or "").strip() if persona_id: return persona_id node_id = str(identity.get("node_id", "") or "").strip() if not node_id: raise PrivacyCoreError("gate member identity requires node_id") return f"session:{node_id}" def _gate_member_identity_scope(identity: dict[str, Any]) -> str: scope = str(identity.get("scope", "") or "").strip().lower() if scope == "gate_persona": return "persona" return "anonymous" def _active_gate_member(gate_id: str) -> tuple[dict[str, Any] | None, str]: active = get_active_gate_identity(gate_id) if not active.get("ok"): return None, "" return dict(active.get("identity") or {}), str(active.get("source", "") or "") def _active_gate_persona(gate_id: str) -> dict[str, Any] | None: active = get_active_gate_identity(gate_id) if not active.get("ok") or str(active.get("source", "") or "") != "persona": return None return dict(active.get("identity") or {}) def _prune_local_plaintext_cache(now: float) -> None: expired_keys = [ key for key, (_plaintext, _reply_to, inserted_at) in _LOCAL_CIPHERTEXT_CACHE.items() if now - inserted_at > LOCAL_CIPHERTEXT_CACHE_TTL_S ] for key in expired_keys: _LOCAL_CIPHERTEXT_CACHE.pop(key, None) def _cache_local_plaintext( gate_id: str, ciphertext: str, sender_ref: str, plaintext: str, reply_to: str = "", ) -> None: now = time.time() cache_key = (gate_id, ciphertext, sender_ref) with _STATE_LOCK: _prune_local_plaintext_cache(now) if cache_key not in _LOCAL_CIPHERTEXT_CACHE and len(_LOCAL_CIPHERTEXT_CACHE) >= LOCAL_CIPHERTEXT_CACHE_MAX: _LOCAL_CIPHERTEXT_CACHE.popitem(last=False) _LOCAL_CIPHERTEXT_CACHE[cache_key] = (plaintext, str(reply_to or "").strip(), now) _LOCAL_CIPHERTEXT_CACHE.move_to_end(cache_key) def _consume_cached_plaintext( gate_id: str, ciphertext: str, sender_ref: str, ) -> tuple[str, str] | None: """Non-destructive read so repeated decrypt polls still find the entry.""" now = time.time() cache_key = (gate_id, ciphertext, sender_ref) with _STATE_LOCK: _prune_local_plaintext_cache(now) entry = _LOCAL_CIPHERTEXT_CACHE.get(cache_key) if entry is None: return None plaintext, reply_to, inserted_at = entry if now - inserted_at > LOCAL_CIPHERTEXT_CACHE_TTL_S: _LOCAL_CIPHERTEXT_CACHE.pop(cache_key, None) return None _LOCAL_CIPHERTEXT_CACHE.move_to_end(cache_key) return plaintext, reply_to def _peek_cached_plaintext( gate_id: str, ciphertext: str, sender_ref: str, ) -> tuple[str, str] | None: now = time.time() cache_key = (gate_id, ciphertext, sender_ref) with _STATE_LOCK: _prune_local_plaintext_cache(now) entry = _LOCAL_CIPHERTEXT_CACHE.get(cache_key) if entry is None: return None plaintext, reply_to, inserted_at = entry if now - inserted_at > LOCAL_CIPHERTEXT_CACHE_TTL_S: _LOCAL_CIPHERTEXT_CACHE.pop(cache_key, None) return None _LOCAL_CIPHERTEXT_CACHE.move_to_end(cache_key) return plaintext, reply_to def _encode_gate_plaintext_envelope(plaintext: str, epoch: int, reply_to: str = "") -> str: payload: dict[str, Any] = { "m": str(plaintext or ""), "e": int(epoch or 0), } reply_to_val = str(reply_to or "").strip() if reply_to_val: payload["r"] = reply_to_val return json.dumps(payload, separators=(",", ":"), ensure_ascii=False) def _decode_gate_plaintext_envelope(raw: str, fallback_epoch: int) -> tuple[str, int, str]: try: envelope = json.loads(raw) if isinstance(envelope, dict): plaintext = str(envelope.get("m", raw)) epoch = int(envelope.get("e", fallback_epoch) or fallback_epoch) reply_to = str(envelope.get("r", "") or "").strip() return plaintext, epoch, reply_to except (json.JSONDecodeError, ValueError, TypeError): pass return raw, int(fallback_epoch or 0), "" def _load_binding_store() -> dict[str, Any]: # KNOWN LIMITATION: Persistence integrity depends on the gate_persona domain key. # Cross-domain compromise no longer follows from a single derived root key, # but any process that can read this domain's key envelope can still forge this file. domain_path = DATA_DIR / STATE_DOMAIN / STATE_FILENAME if not domain_path.exists() and STATE_FILE.exists(): try: legacy = read_secure_json(STATE_FILE, _default_binding_store) write_sensitive_domain_json( STATE_DOMAIN, STATE_FILENAME, legacy, custody_scope=STATE_CUSTODY_SCOPE, ) STATE_FILE.unlink(missing_ok=True) except Exception: logger.warning( "Legacy gate MLS binding store could not be decrypted — " "discarding stale file and starting fresh" ) STATE_FILE.unlink(missing_ok=True) raw = read_sensitive_domain_json( STATE_DOMAIN, STATE_FILENAME, _default_binding_store, custody_scope=STATE_CUSTODY_SCOPE, ) state = _default_binding_store() if isinstance(raw, dict): state.update(raw) state["version"] = int(state.get("version", 1) or 1) state["updated_at"] = int(state.get("updated_at", 0) or 0) state["gates"] = { _stable_gate_ref(gate_id): dict(item or {}) for gate_id, item in dict(state.get("gates") or {}).items() } state["high_water_epochs"] = { _stable_gate_ref(gate_id): int(epoch or 0) for gate_id, epoch in dict(state.get("high_water_epochs") or {}).items() } state["gate_format_locks"] = { _stable_gate_ref(gate_id): str(payload_format or "").strip().lower() for gate_id, payload_format in dict(state.get("gate_format_locks") or {}).items() if str(payload_format or "").strip().lower() } return state def _save_binding_store(state: dict[str, Any]) -> None: # KNOWN LIMITATION: Persistence integrity depends on the gate_persona domain key. # Cross-domain compromise no longer follows from a single derived root key, # but any process that can read this domain's key envelope can still forge this file. payload = dict(state) payload["updated_at"] = int(time.time()) write_sensitive_domain_json( STATE_DOMAIN, STATE_FILENAME, payload, custody_scope=STATE_CUSTODY_SCOPE, ) STATE_FILE.unlink(missing_ok=True) def _rust_gate_state_filename(gate_id: str) -> str: gate_key = _stable_gate_ref(gate_id) safe_id = hashlib.sha256(gate_key.encode("utf-8")).hexdigest()[:16] return f"gate_rust_{safe_id}.bin" def _read_gate_rust_state_snapshot(gate_id: str) -> dict[str, Any] | None: gate_key = _stable_gate_ref(gate_id) return read_sensitive_domain_json( RUST_GATE_STATE_DOMAIN, _rust_gate_state_filename(gate_key), lambda: None, custody_scope=f"gate_mls_rust_state::{gate_key}", ) def _write_gate_rust_state_snapshot(gate_id: str, payload: dict[str, Any] | None) -> None: gate_key = _stable_gate_ref(gate_id) if payload is None: _clear_gate_rust_state(gate_key) return write_sensitive_domain_json( RUST_GATE_STATE_DOMAIN, _rust_gate_state_filename(gate_key), payload, custody_scope=f"gate_mls_rust_state::{gate_key}", ) def _save_gate_rust_state(binding: _GateBinding) -> None: """Export Rust gate state blob for a single gate and persist via domain storage.""" try: identity_handles = [] group_handles = [] seen_ids = set() for member in binding.members.values(): if member.identity_handle not in seen_ids: identity_handles.append(member.identity_handle) seen_ids.add(member.identity_handle) if member.group_handle > 0: group_handles.append(member.group_handle) if binding.root_group_handle > 0 and binding.root_group_handle not in group_handles: group_handles.append(binding.root_group_handle) if not identity_handles or not group_handles: return blob = _privacy_client().export_gate_state(identity_handles, group_handles) if blob: write_sensitive_domain_json( RUST_GATE_STATE_DOMAIN, _rust_gate_state_filename(binding.gate_id), {"version": 1, "blob_b64": _b64(blob)}, custody_scope=f"gate_mls_rust_state::{_stable_gate_ref(binding.gate_id)}", ) except Exception: logger.warning( "failed to export Rust gate state for %s", privacy_log_label(binding.gate_id, label="gate"), exc_info=True, ) def _load_gate_rust_state(gate_id: str, binding: _GateBinding) -> bool: """Import persisted Rust gate state and remap Python binding handles. Returns True if Rust state was successfully imported and handles remapped. Returns False if no Rust state was found (legacy/fresh install). Raises on corruption or version mismatch (caller must handle). """ gate_key = _stable_gate_ref(gate_id) filename = _rust_gate_state_filename(gate_key) raw = read_sensitive_domain_json( RUST_GATE_STATE_DOMAIN, filename, lambda: None, custody_scope=f"gate_mls_rust_state::{gate_key}", ) if raw is None: return False if not isinstance(raw, dict) or raw.get("version") != 1 or not raw.get("blob_b64"): raise PrivacyCoreError("persisted Rust gate state has invalid format or version") blob = _unb64(raw["blob_b64"]) mapping = _privacy_client().import_gate_state(blob) id_map = {int(k): int(v) for k, v in (mapping.get("identities") or {}).items()} group_map = {int(k): int(v) for k, v in (mapping.get("groups") or {}).items()} # Remap root_group_handle. if binding.root_group_handle in group_map: binding.root_group_handle = group_map[binding.root_group_handle] # Remap per-member handles. for member in binding.members.values(): if member.identity_handle in id_map: member.identity_handle = id_map[member.identity_handle] if member.group_handle in group_map: member.group_handle = group_map[member.group_handle] return True def _clear_gate_rust_state(gate_id: str | None = None) -> None: """Delete persisted Rust gate state blob(s). If gate_id is provided, delete only that gate's blob. If gate_id is None, delete all gate Rust state blobs. """ try: domain_dir = DATA_DIR / RUST_GATE_STATE_DOMAIN if not domain_dir.exists(): return if gate_id: (domain_dir / _rust_gate_state_filename(gate_id)).unlink(missing_ok=True) else: for f in domain_dir.glob("gate_rust_*.bin"): f.unlink(missing_ok=True) except Exception: logger.debug("failed to clear persisted Rust gate state", exc_info=True) def _serialize_member_binding(member: _GateMemberBinding) -> dict[str, Any]: return { "persona_id": member.persona_id, "node_id": member.node_id, "label": member.label, "identity_scope": member.identity_scope, "member_ref": int(member.member_ref), "is_creator": bool(member.is_creator), "public_bundle": _b64(member.public_bundle), "binding_signature": member.binding_signature, "identity_handle": int(member.identity_handle), "group_handle": int(member.group_handle), } def _persist_binding(binding: _GateBinding) -> None: for persona_id, member in binding.members.items(): if member.identity_scope == "anonymous": ok, reason = verify_gate_session_blob( binding.gate_id, member.node_id, member.public_bundle, member.binding_signature, ) else: ok, reason = verify_gate_persona_blob( binding.gate_id, persona_id, member.public_bundle, member.binding_signature, ) if not ok: logger.warning( "Skipping MLS binding persistence for %s member %s: binding proof invalid", privacy_log_label(binding.gate_id, label="gate"), privacy_log_label(member.node_id if member.identity_scope == "anonymous" else persona_id, label="member"), ) return state = _load_binding_store() state.setdefault("gates", {})[binding.gate_id] = { "gate_id": binding.gate_id, "epoch": int(binding.epoch), "root_persona_id": binding.root_persona_id, "root_group_handle": int(binding.root_group_handle), "next_member_ref": int(binding.next_member_ref), "members": { persona_id: _serialize_member_binding(member) for persona_id, member in binding.members.items() }, } high_water = max( int(binding.epoch), int(_HIGH_WATER_EPOCHS.get(binding.gate_id, 0) or 0), ) _HIGH_WATER_EPOCHS[binding.gate_id] = high_water state.setdefault("high_water_epochs", {})[binding.gate_id] = high_water _save_binding_store(state) _save_gate_rust_state(binding) def _persist_delete_binding(gate_id: str) -> None: state = _load_binding_store() gate_key = _stable_gate_ref(gate_id) state.setdefault("gates", {}).pop(gate_key, None) state.setdefault("high_water_epochs", {}).pop(gate_key, None) _HIGH_WATER_EPOCHS.pop(gate_key, None) _save_binding_store(state) _clear_gate_rust_state(gate_key) def inspect_local_gate_state(gate_id: str, *, expected_epoch: int = 0) -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) if not gate_key: return { "ok": False, "gate_id": "", "repair_state": "gate_state_stale", "detail": "gate_id required", "repairable": False, "has_metadata": False, "has_rust_state": False, "has_local_access": False, "current_epoch": 0, "identity_scope": "", } metadata = _persisted_gate_metadata(gate_key) or {} rust_state = _read_gate_rust_state_snapshot(gate_key) active_identity, active_source = _active_gate_member(gate_key) identity_scope = "anonymous" if active_source == "anonymous" else "persona" current_epoch = int(metadata.get("epoch", 0) or 0) has_metadata = bool(metadata) has_rust_state = isinstance(rust_state, dict) and bool(rust_state.get("blob_b64")) has_local_access = False member_identity_id = "" if active_identity: member_identity_id = _gate_member_identity_id(active_identity) with _STATE_LOCK: binding = _GATE_BINDINGS.get(gate_key) if binding is not None: has_local_access = member_identity_id in binding.members current_epoch = max(current_epoch, int(binding.epoch or 0)) if not has_local_access and has_metadata: members_meta = dict(metadata.get("members") or {}) has_local_access = member_identity_id in members_meta result = { "ok": True, "gate_id": gate_key, "repair_state": "gate_state_ok", "detail": "gate access ready", "repairable": False, "has_metadata": has_metadata, "has_rust_state": has_rust_state, "has_local_access": has_local_access, "current_epoch": current_epoch, "expected_epoch": int(expected_epoch or 0), "identity_scope": identity_scope, } if not active_identity: result.update( { "ok": False, "repair_state": "gate_state_recovery_only", "detail": "no active gate identity", "repairable": False, "has_local_access": False, "identity_scope": "", } ) return result if int(expected_epoch or 0) > 0 and current_epoch > 0 and int(expected_epoch or 0) != current_epoch: result.update( { "ok": False, "repair_state": "gate_state_stale", "detail": "gate state epoch mismatch", "repairable": True, } ) return result if not has_metadata: result.update( { "ok": False, "repair_state": "gate_state_stale", "detail": "local gate state is missing", "repairable": True, "has_local_access": False, } ) return result if not has_rust_state: result.update( { "ok": False, "repair_state": "gate_state_stale", "detail": "persisted gate state is incomplete", "repairable": True, } ) return result if not has_local_access: result.update( { "ok": False, "repair_state": "gate_state_stale", "detail": "active gate identity is not mapped into the MLS group", "repairable": True, } ) return result return result def resync_local_gate_state(gate_id: str, *, reason: str = "automatic_resync") -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) if not gate_key: return {"ok": False, "gate_id": "", "detail": "gate_id required", "reason": str(reason or "automatic_resync")} store_backup = _load_binding_store() rust_backup = _read_gate_rust_state_snapshot(gate_key) client = _privacy_client() with _STATE_LOCK: existing = _GATE_BINDINGS.pop(gate_key, None) if existing is not None: try: _release_binding(client, existing) except Exception: logger.exception( "Failed to release in-memory gate binding before resync for %s", privacy_log_label(gate_key, label="gate"), ) _persist_delete_binding(gate_key) try: binding = _sync_binding(gate_key) return { "ok": True, "gate_id": gate_key, "epoch": int(binding.epoch), "detail": "gate MLS state synchronized", "reason": str(reason or "automatic_resync"), } except Exception as exc: logger.warning( "Gate MLS resync failed for %s; restoring last-known-good state", privacy_log_label(gate_key, label="gate"), exc_info=True, ) with _STATE_LOCK: failed_binding = _GATE_BINDINGS.pop(gate_key, None) if failed_binding is not None: try: _release_binding(client, failed_binding) except Exception: logger.exception( "Failed to release failed gate binding during rollback for %s", privacy_log_label(gate_key, label="gate"), ) _save_binding_store(store_backup) _write_gate_rust_state_snapshot(gate_key, rust_backup) return { "ok": False, "gate_id": gate_key, "detail": "gate_state_resync_failed", "reason": str(reason or "automatic_resync"), "error_detail": str(exc) or type(exc).__name__, } def _force_rebuild_binding(gate_id: str) -> None: """Tear down the in-memory and persisted MLS binding for a gate. The next call to ``_sync_binding`` will create a fresh MLS group with the current set of identities. The _reader identity is also rotated so that each MLS epoch gets a fresh reader key, limiting key-custody exposure (Rec #9 remediation). """ gate_key = _stable_gate_ref(gate_id) client = _privacy_client() with _STATE_LOCK: binding = _GATE_BINDINGS.pop(gate_key, None) if binding is not None: _release_binding(client, binding) _persist_delete_binding(gate_key) # Rotate the _reader identity so the new epoch gets a fresh key try: _ensure_reader_identity(gate_key, rotate=True) except Exception: pass # non-fatal — _sync_binding will create one if missing logger.info( "Forced MLS binding rebuild for %s", privacy_log_label(gate_key, label="gate"), ) def _persisted_gate_metadata(gate_id: str) -> dict[str, Any] | None: state = _load_binding_store() metadata = dict(state.get("gates", {}).get(_stable_gate_ref(gate_id)) or {}) return metadata or None def _lock_gate_format(gate_id: str, payload_format: str) -> None: state = _load_binding_store() gate_key = _stable_gate_ref(gate_id) state.setdefault("gate_format_locks", {})[gate_key] = str(payload_format or "").strip().lower() _save_binding_store(state) def is_gate_locked_to_format(gate_id: str, payload_format: str) -> bool: gate_key = _stable_gate_ref(gate_id) locked_format = str( _load_binding_store().get("gate_format_locks", {}).get(gate_key, "") or "" ).strip().lower() return bool(locked_format) and locked_format == str(payload_format or "").strip().lower() def is_gate_locked_to_mls(gate_id: str) -> bool: gate_key = _stable_gate_ref(gate_id) if not gate_key: return False locked_format = str( _load_binding_store().get("gate_format_locks", {}).get(gate_key, MLS_GATE_FORMAT) or MLS_GATE_FORMAT ).strip().lower() return locked_format == MLS_GATE_FORMAT def get_local_gate_key_status(gate_id: str) -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) if not gate_key: return {"ok": False, "detail": "gate_id required"} active = get_active_gate_identity(gate_key) if not active.get("ok"): return { "ok": False, "gate_id": gate_key, "detail": str(active.get("detail") or "no active gate identity"), } source = str(active.get("source", "") or "") identity = dict(active.get("identity") or {}) metadata = _persisted_gate_metadata(gate_key) or {} member_key = _gate_member_identity_id(identity) has_local_access = False try: binding = _sync_binding(gate_key) has_local_access = binding.members.get(member_key) is not None except Exception: has_local_access = False if not has_local_access: # Identity may have rotated — force rebuild and retry once. try: _force_rebuild_binding(gate_key) binding = _sync_binding(gate_key) pid = _gate_member_identity_id(identity) has_local_access = pid in binding.members if not has_local_access: logger.warning( "Gate status: identity %s not in binding members %s", pid, list(binding.members.keys()), ) except Exception as exc: logger.warning("Gate status rebuild failed: %s", exc) has_local_access = False return { "ok": True, "gate_id": gate_key, "current_epoch": int(metadata.get("epoch", 1) or 1), "has_local_access": has_local_access, "identity_scope": "anonymous" if source == "anonymous" else "persona", "identity_node_id": str(identity.get("node_id", "") or ""), "identity_persona_id": str(identity.get("persona_id", "") or ""), "detail": "gate access ready" if has_local_access else "active gate identity is not mapped into the MLS group", "format": MLS_GATE_FORMAT, } def export_gate_state_snapshot(gate_id: str) -> dict[str, Any]: """Export opaque gate MLS state for native client-side gate operations. The response includes only the Rust MLS state blob plus the legacy handles needed to remap imported group handles on the native client. It does not return plaintext, durable envelopes, or gate secrets. """ gate_key = _stable_gate_ref(gate_id) if not gate_key: return {"ok": False, "detail": "gate_id required"} try: binding = _sync_binding(gate_key) active_identity, active_source = _active_gate_member(gate_key) identity_handles: list[int] = [] group_handles: list[int] = [] seen_identity_handles: set[int] = set() members: list[dict[str, Any]] = [] for member in binding.members.values(): if member.identity_handle not in seen_identity_handles: identity_handles.append(member.identity_handle) seen_identity_handles.add(member.identity_handle) if member.group_handle > 0: group_handles.append(member.group_handle) members.append( { "persona_id": member.persona_id, "node_id": member.node_id, "identity_scope": member.identity_scope, "group_handle": int(member.group_handle), } ) if binding.root_group_handle > 0 and binding.root_group_handle not in group_handles: group_handles.append(binding.root_group_handle) if not identity_handles or not group_handles: return {"ok": False, "detail": "gate_state_export_empty"} blob = _privacy_client().export_gate_state(identity_handles, group_handles) return { "ok": True, "gate_id": gate_key, "epoch": int(binding.epoch), "rust_state_blob_b64": _b64(blob), "members": members, "active_identity_scope": "anonymous" if active_source == "anonymous" else "persona", "active_persona_id": str((active_identity or {}).get("persona_id", "") or ""), "active_node_id": str((active_identity or {}).get("node_id", "") or ""), } except Exception: logger.exception( "MLS gate state export failed for %s", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_state_export_failed"} def ensure_gate_member_access( *, gate_id: str, recipient_node_id: str, recipient_dh_pub: str, recipient_scope: str = "member", ) -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) recipient_node_id = str(recipient_node_id or "").strip() if not gate_key or not recipient_node_id: return {"ok": False, "detail": "gate_id and recipient_node_id required"} personas = _gate_personas(gate_key) recipient = next( ( persona for persona in personas if str(persona.get("node_id", "") or "").strip() == recipient_node_id ), None, ) if recipient is None: return {"ok": False, "detail": "recipient identity is not a known gate member"} binding = _sync_binding(gate_key) return { "ok": True, "gate_id": gate_key, "epoch": int(binding.epoch), "recipient_node_id": recipient_node_id, "recipient_scope": str(recipient_scope or "member"), "format": MLS_GATE_FORMAT, "detail": "MLS gate membership is synchronized through privacy-core; no wrapped key required", } def mark_gate_rekey_recommended(gate_id: str, *, reason: str = "manual_review") -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) if not gate_key: return {"ok": False, "detail": "gate_id required"} return { "ok": True, "gate_id": gate_key, "format": MLS_GATE_FORMAT, "detail": "MLS gate sessions rekey through membership commits; manual review recorded", "reason": str(reason or "manual_review"), } def rotate_gate_epoch(gate_id: str, *, reason: str = "manual_rotate") -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) if not gate_key: return {"ok": False, "detail": "gate_id required"} with _STATE_LOCK: _GATE_BINDINGS.pop(gate_key, None) binding = _sync_binding(gate_key) return { "ok": True, "gate_id": gate_key, "epoch": int(binding.epoch), "format": MLS_GATE_FORMAT, "detail": "gate MLS state synchronized", "reason": str(reason or "manual_rotate"), } def _validate_persisted_member( gate_id: str, member_meta: dict[str, Any], identity: dict[str, Any] | None, ) -> tuple[bool, str]: persona_id = str(member_meta.get("persona_id", "") or "") identity_scope = str(member_meta.get("identity_scope", "") or "persona").strip().lower() if identity is None: return False, f"persisted MLS member identity is unknown: {persona_id}" if str(identity.get("node_id", "") or "") != str(member_meta.get("node_id", "") or ""): return False, f"persisted MLS member node mismatch: {persona_id}" try: bundle_bytes = _unb64(member_meta.get("public_bundle")) except Exception as exc: return False, f"persisted MLS bundle decode failed for {persona_id}: {exc}" if identity_scope == "anonymous" or persona_id.startswith("session:"): ok, reason = verify_gate_session_blob( gate_id, str(member_meta.get("node_id", "") or ""), bundle_bytes, str(member_meta.get("binding_signature", "") or ""), ) else: if str(identity.get("persona_id", "") or "") != persona_id: return False, f"persisted MLS member persona mismatch: {persona_id}" ok, reason = verify_gate_persona_blob( gate_id, persona_id, bundle_bytes, str(member_meta.get("binding_signature", "") or ""), ) if not ok: return False, f"persisted MLS binding proof invalid for {persona_id}: {reason}" return True, "ok" def _try_rust_gate_restore( gate_key: str, metadata: dict[str, Any], ordered_members: list[dict[str, Any]], identities_by_id: dict[str, dict[str, Any]], ) -> _GateBinding | None: """Attempt to restore a gate binding from persisted Rust state. Reconstructs a _GateBinding with fresh Rust handles remapped from persisted metadata. Returns None if no Rust state exists or if import fails (caller should fall back to the rebuild path). """ root_group_handle = int(metadata.get("root_group_handle", 0) or 0) if root_group_handle <= 0: return None # no persisted handles — legacy metadata # Build a preliminary binding with old handles from metadata. root_persona_id = str(metadata.get("root_persona_id", "") or "") binding = _GateBinding( gate_id=gate_key, epoch=max(1, int(metadata.get("epoch", 1) or 1)), root_persona_id=root_persona_id, root_group_handle=root_group_handle, next_member_ref=int(metadata.get("next_member_ref", 1) or 1), ) for member_meta in ordered_members: persona_id = str(member_meta.get("persona_id", "") or "") identity_handle = int(member_meta.get("identity_handle", 0) or 0) group_handle = int(member_meta.get("group_handle", 0) or 0) if identity_handle <= 0: return None # member has no persisted handle — can't restore binding.members[persona_id] = _GateMemberBinding( persona_id=persona_id, node_id=str(member_meta.get("node_id", "") or ""), label=str(member_meta.get("label", "") or ""), identity_scope=str(member_meta.get("identity_scope", "persona") or "persona"), identity_handle=identity_handle, group_handle=group_handle, member_ref=int(member_meta.get("member_ref", 0) or 0), is_creator=bool(member_meta.get("is_creator")), public_bundle=_unb64(member_meta.get("public_bundle")), binding_signature=str(member_meta.get("binding_signature", "") or ""), ) try: loaded = _load_gate_rust_state(gate_key, binding) if not loaded: return None # no Rust blob found — fall back to rebuild logger.info( "Rust gate state restored for %s", privacy_log_label(gate_key, label="gate"), ) return binding except Exception: logger.warning( "Persisted Rust gate state is corrupt or incompatible for %s — " "invalidating and falling back to rebuild", privacy_log_label(gate_key, label="gate"), exc_info=True, ) _clear_gate_rust_state(gate_key) return None def _restore_binding_from_metadata( gate_id: str, identities_by_id: dict[str, dict[str, Any]], metadata: dict[str, Any], ) -> _GateBinding | None: gate_key = _stable_gate_ref(gate_id) members_meta = dict(metadata.get("members") or {}) if not members_meta: return None restored_epoch = max(1, int(metadata.get("epoch", 1) or 1)) persisted_high_water = int( _load_binding_store().get("high_water_epochs", {}).get(gate_key, _HIGH_WATER_EPOCHS.get(gate_key, 0)) or 0 ) _HIGH_WATER_EPOCHS[gate_key] = max(int(_HIGH_WATER_EPOCHS.get(gate_key, 0) or 0), persisted_high_water) if restored_epoch < int(_HIGH_WATER_EPOCHS.get(gate_key, 0) or 0): logger.warning( "Persisted MLS epoch regressed for %s: restored=%s high_water=%s — rebuilding", privacy_log_label(gate_key, label="gate"), restored_epoch, _HIGH_WATER_EPOCHS.get(gate_key, 0), ) return None ordered = sorted( members_meta.values(), key=lambda item: ( 0 if bool(item.get("is_creator")) else 1, int(item.get("member_ref", 0) or 0), str(item.get("persona_id", "") or ""), ), ) identities: list[dict[str, Any]] = [] for member_meta in ordered: persona_id = str(member_meta.get("persona_id", "") or "") identity = identities_by_id.get(persona_id) ok, reason = _validate_persisted_member(gate_id, member_meta, identity) if not ok: logger.warning( "Corrupted binding for %s member %s: %s — rebuilding", privacy_log_label(gate_key, label="gate"), privacy_log_label(persona_id, label="persona"), type(reason).__name__ if not isinstance(reason, str) else "binding_invalid", ) state = _load_binding_store() gate_entry = dict(state.get("gates", {}).get(gate_key) or {}) members = dict(gate_entry.get("members") or {}) members.pop(persona_id, None) gate_entry["members"] = members if members: state.setdefault("gates", {})[gate_key] = gate_entry else: state.setdefault("gates", {}).pop(gate_key, None) _save_binding_store(state) return None identities.append(dict(identity or {})) # Try Rust state restore before falling back to rebuild. rust_restored = _try_rust_gate_restore(gate_key, metadata, ordered, identities_by_id) if rust_restored is not None: _HIGH_WATER_EPOCHS[gate_key] = max( int(rust_restored.epoch), int(_HIGH_WATER_EPOCHS.get(gate_key, 0) or 0), ) return rust_restored rebuilt = _build_binding(gate_id, identities) rebuilt.epoch = max(1, int(metadata.get("epoch", rebuilt.epoch) or rebuilt.epoch)) rebuilt.next_member_ref = max( int(metadata.get("next_member_ref", rebuilt.next_member_ref) or rebuilt.next_member_ref), max((int(item.get("member_ref", 0) or 0) for item in ordered), default=0) + 1, ) for member_meta in ordered: persona_id = str(member_meta.get("persona_id", "") or "") member = rebuilt.members.get(persona_id) if member is None: continue member.member_ref = int(member_meta.get("member_ref", member.member_ref) or member.member_ref) member.is_creator = bool(member_meta.get("is_creator")) _HIGH_WATER_EPOCHS[gate_key] = max( int(rebuilt.epoch), int(_HIGH_WATER_EPOCHS.get(gate_key, 0) or 0), ) _persist_binding(rebuilt) return rebuilt def _release_member(client: PrivacyCoreClient, member: _GateMemberBinding) -> None: if member.group_handle > 0: try: client.release_group(member.group_handle) except Exception: logger.exception( "Failed to release MLS group handle for %s", privacy_log_label(member.persona_id, label="persona"), ) if member.key_package_handle is not None: try: client.release_key_package(member.key_package_handle) except Exception: logger.exception( "Failed to release MLS key package handle for %s", privacy_log_label(member.persona_id, label="persona"), ) try: client.release_identity(member.identity_handle) except Exception: logger.exception( "Failed to release MLS identity handle for %s", privacy_log_label(member.persona_id, label="persona"), ) def _release_binding(client: PrivacyCoreClient, binding: _GateBinding) -> None: for member in list(binding.members.values()): _release_member(client, member) def _create_member_binding( client: PrivacyCoreClient, *, gate_id: str, identity: dict[str, Any], member_ref: int, is_creator: bool, group_handle: int | None = None, ) -> _GateMemberBinding: identity_handle = client.create_identity() public_bundle = client.export_public_bundle(identity_handle) identity_scope = _gate_member_identity_scope(identity) binding_identity_id = _gate_member_identity_id(identity) if identity_scope == "anonymous": proof = sign_gate_session_blob( gate_id, str(identity.get("node_id", "") or ""), public_bundle, ) else: proof = sign_gate_persona_blob( gate_id, str(identity.get("persona_id", "") or ""), public_bundle, ) if not proof.get("ok"): try: client.release_identity(identity_handle) except Exception: logger.exception("Failed to release MLS identity after binding proof failure") raise PrivacyCoreError(str(proof.get("detail") or "persona MLS binding proof failed")) key_package_handle: int | None = None resolved_group_handle = group_handle if not is_creator: key_package_bytes = client.export_key_package(identity_handle) key_package_handle = client.import_key_package(key_package_bytes) resolved_group_handle = 0 elif resolved_group_handle is None: resolved_group_handle = client.create_group(identity_handle) assert resolved_group_handle is not None return _GateMemberBinding( persona_id=binding_identity_id, node_id=str(identity.get("node_id", "") or ""), label=str(identity.get("label", "") or ""), identity_scope=identity_scope, identity_handle=identity_handle, group_handle=resolved_group_handle, member_ref=member_ref, is_creator=is_creator, key_package_handle=key_package_handle, public_bundle=public_bundle, binding_signature=str(proof.get("signature", "") or ""), ) def _build_binding(gate_id: str, identities: list[dict[str, Any]]) -> _GateBinding: if not identities: raise PrivacyCoreError("no gate identities are available for MLS mapping") client = _privacy_client() creator = identities[0] creator_binding = _create_member_binding( client, gate_id=gate_id, identity=creator, member_ref=0, is_creator=True, ) binding = _GateBinding( gate_id=_stable_gate_ref(gate_id), epoch=1, root_persona_id=creator_binding.persona_id, root_group_handle=creator_binding.group_handle, members={creator_binding.persona_id: creator_binding}, ) for identity in identities[1:]: member_binding: _GateMemberBinding | None = None commit_handle = 0 try: member_binding = _create_member_binding( client, gate_id=gate_id, identity=identity, member_ref=binding.next_member_ref, is_creator=False, ) commit_handle = client.add_member(binding.root_group_handle, member_binding.key_package_handle or 0) member_binding.group_handle = client.commit_joined_group_handle(commit_handle, 0) binding.members[member_binding.persona_id] = member_binding binding.next_member_ref += 1 except Exception: if member_binding is not None: _release_member(client, member_binding) raise finally: if commit_handle: try: client.release_commit(commit_handle) except Exception: pass return binding def _ensure_reader_identity(gate_key: str, *, rotate: bool = False) -> dict[str, Any]: """Create or rotate a dedicated reader identity for cross-member MLS decrypt. MLS does not let the sender decrypt their own ciphertext. On a single-operator node every message is "from self". By ensuring the MLS group always has at least two members, the non-sender member can always decrypt what the sender encrypted — giving every gate member (including the author) read access. The reader is stored as a gate persona so existing signing infrastructure (``sign_gate_persona_blob``) can bind it into the MLS group, but it is **never** activated as the event-signing persona and is excluded from ``sign_gate_wormhole_event``. When ``rotate=True`` (e.g. on binding rebuild / epoch advance), the old reader is retired and a fresh one is minted — limiting the key-custody window per Rec #9 remediation. """ from services.mesh.mesh_wormhole_persona import ( _identity_record, # type: ignore[attr-defined] read_wormhole_persona_state, _write_wormhole_persona_state, bootstrap_wormhole_persona_state, ) bootstrap_wormhole_persona_state() state = read_wormhole_persona_state() personas = list(state.get("gate_personas", {}).get(gate_key) or []) if not rotate: for p in personas: if str(p.get("label", "") or "") == "_reader": return p # Retire any existing _reader identities for this gate remaining = [p for p in personas if str(p.get("label", "") or "") != "_reader"] import secrets as _secrets reader_persona_id = f"_reader_{_secrets.token_hex(4)}" reader = _identity_record( scope="gate_persona", gate_id=gate_key, persona_id=reader_persona_id, label="_reader", ) remaining.append(reader) state.setdefault("gate_personas", {})[gate_key] = remaining # Ensure _reader is never left as the active persona active_pid = str(state.get("active_gate_personas", {}).get(gate_key, "") or "") if active_pid.startswith("_reader"): state.setdefault("active_gate_personas", {}).pop(gate_key, None) _write_wormhole_persona_state(state) return reader def _current_gate_identities(gate_key: str) -> list[dict[str, Any]]: personas = _gate_personas(gate_key) session_identity = _gate_session_identity(gate_key) identities: list[dict[str, Any]] = list(personas) if session_identity: identities.append(session_identity) if len(identities) < 2: reader = _ensure_reader_identity(gate_key) reader_id = _gate_member_identity_id(reader) if not any(_gate_member_identity_id(i) == reader_id for i in identities): identities.append(reader) return identities def _sync_binding(gate_id: str) -> _GateBinding: gate_key = _stable_gate_ref(gate_id) identities = _current_gate_identities(gate_key) # Ensure we always have ≥2 members so cross-member MLS decrypt works. # MLS does not allow a sender to decrypt their own message — on a # single-operator node, every member is "self". The reader identity # is a dedicated second member that exists solely for this purpose. if not identities: _persist_delete_binding(gate_key) raise PrivacyCoreError("no gate identities exist for this gate") identities_by_id = { _gate_member_identity_id(identity): identity for identity in identities } client = _privacy_client() active_identity, _active_source = _active_gate_member(gate_key) active_identity_id = _gate_member_identity_id(active_identity) if active_identity else "" with _STATE_LOCK: binding = _GATE_BINDINGS.get(gate_key) if binding is None or binding.root_persona_id not in identities_by_id: if binding is not None: _release_binding(client, binding) metadata = _persisted_gate_metadata(gate_key) if metadata: restored = _restore_binding_from_metadata(gate_key, identities_by_id, metadata) if restored is not None: _GATE_BINDINGS[gate_key] = restored return restored ordered_identities = sorted( identities, key=lambda item: ( 0 if _gate_member_identity_id(item) == active_identity_id else 1, _gate_member_identity_id(item), ), ) binding = _build_binding(gate_key, ordered_identities) _GATE_BINDINGS[gate_key] = binding _persist_binding(binding) return binding dirty = False removed_persona_ids = [persona_id for persona_id in binding.members if persona_id not in identities_by_id] for persona_id in removed_persona_ids: member = binding.members.get(persona_id) if member is None: continue if member.is_creator: _release_binding(client, binding) remaining = [identities_by_id[key] for key in sorted(identities_by_id.keys())] rebuilt = _build_binding(gate_key, remaining) _GATE_BINDINGS[gate_key] = rebuilt _persist_binding(rebuilt) return rebuilt commit_handle = 0 try: commit_handle = client.remove_member(binding.root_group_handle, member.member_ref) finally: if commit_handle: try: client.release_commit(commit_handle) except Exception: pass _release_member(client, member) binding.members.pop(persona_id, None) binding.epoch += 1 dirty = True for persona_id, persona in identities_by_id.items(): if persona_id in binding.members: continue member_binding: _GateMemberBinding | None = None commit_handle = 0 try: member_binding = _create_member_binding( client, gate_id=gate_key, identity=persona, member_ref=binding.next_member_ref, is_creator=False, ) commit_handle = client.add_member( binding.root_group_handle, member_binding.key_package_handle or 0, ) member_binding.group_handle = client.commit_joined_group_handle(commit_handle, 0) binding.members[persona_id] = member_binding binding.next_member_ref += 1 binding.epoch += 1 dirty = True except Exception: if member_binding is not None: _release_member(client, member_binding) raise finally: if commit_handle: try: client.release_commit(commit_handle) except Exception: pass if dirty: _persist_binding(binding) return binding def _remove_gate_member_from_state(gate_key: str, member_id: str) -> dict[str, Any]: from services.mesh.mesh_wormhole_persona import ( _write_wormhole_persona_state, bootstrap_wormhole_persona_state, read_wormhole_persona_state, ) target = str(member_id or "").strip() bootstrap_wormhole_persona_state() state = read_wormhole_persona_state() personas = list(state.get("gate_personas", {}).get(gate_key) or []) remaining: list[dict[str, Any]] = [] removed_persona: dict[str, Any] | None = None for persona in personas: persona_id = str(persona.get("persona_id", "") or "").strip() node_id = str(persona.get("node_id", "") or "").strip() if not str(persona.get("label", "") or "").startswith("_reader") and target in {persona_id, node_id}: removed_persona = persona continue remaining.append(persona) if removed_persona is not None: if remaining: state.setdefault("gate_personas", {})[gate_key] = remaining else: state.setdefault("gate_personas", {}).pop(gate_key, None) active_persona_id = str(state.get("active_gate_personas", {}).get(gate_key, "") or "") if active_persona_id == str(removed_persona.get("persona_id", "") or ""): state.setdefault("active_gate_personas", {}).pop(gate_key, None) _write_wormhole_persona_state(state) return { "ok": True, "identity_scope": "persona", "persona_id": str(removed_persona.get("persona_id", "") or ""), "node_id": str(removed_persona.get("node_id", "") or ""), } session = dict(state.get("gate_sessions", {}).get(gate_key) or {}) if session.get("private_key"): session_node_id = str(session.get("node_id", "") or "").strip() if target in {session_node_id}: state.setdefault("gate_sessions", {}).pop(gate_key, None) _write_wormhole_persona_state(state) return { "ok": True, "identity_scope": "anonymous", "persona_id": "", "node_id": session_node_id, } return {"ok": False, "detail": "gate_member_not_found"} def remove_gate_member(gate_id: str, member_id: str, *, reason: str = "remove") -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) target = str(member_id or "").strip() if not gate_key: return {"ok": False, "detail": "gate_id required"} if not target: return {"ok": False, "detail": "member_id required"} try: binding_before = _sync_binding(gate_key) except Exception: logger.exception( "MLS gate member removal preflight failed for %s", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_mls_remove_failed"} previous_epoch = int(binding_before.epoch or 0) previous_valid_through_event_id = "" try: from services.mesh.mesh_hashchain import gate_store latest = gate_store.get_messages(gate_key, limit=1) if latest: previous_valid_through_event_id = str(latest[0].get("event_id", "") or "") except Exception: previous_valid_through_event_id = "" removed = _remove_gate_member_from_state(gate_key, target) if not removed.get("ok"): return removed try: binding_after = _sync_binding(gate_key) except Exception: logger.exception( "MLS gate member removal sync failed for %s", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_mls_remove_failed"} _HIGH_WATER_EPOCHS[gate_key] = max( int(binding_after.epoch or 0), int(_HIGH_WATER_EPOCHS.get(gate_key, 0) or 0), ) return { "ok": True, "gate_id": gate_key, "member_id": target, "identity_scope": str(removed.get("identity_scope", "") or ""), "persona_id": str(removed.get("persona_id", "") or ""), "node_id": str(removed.get("node_id", "") or ""), "reason": str(reason or ""), "previous_epoch": previous_epoch, "epoch": int(binding_after.epoch or 0), "previous_valid_through_event_id": previous_valid_through_event_id, } def _gate_is_solo(binding: "_GateBinding") -> bool: """Return True when a gate has no real peers (only the operator + the synthetic ``_reader`` identity that exists so MLS encrypt-then-self-decrypt works on a single-operator node). Phase 3.3: this lets compose_encrypted_gate_message surface a ``solo_pending`` flag without refusing the compose. The message still encrypts and stores normally; the flag tells the caller "no real peers yet — your message is sealed but nobody else can read it until someone joins this gate." This is the non-hostile pattern: never refuse, always surface the state. """ real_members = 0 for member in binding.members.values(): label = str(getattr(member, "label", "") or "") if label == "_reader": continue real_members += 1 if real_members > 1: return False return real_members <= 1 def compose_encrypted_gate_message(gate_id: str, plaintext: str, reply_to: str = "") -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) plaintext = str(plaintext or "") if not gate_key: return {"ok": False, "detail": "gate_id required"} if not plaintext.strip(): return {"ok": False, "detail": "plaintext required"} active_identity, active_source = _active_gate_member(gate_key) if not active_identity: return {"ok": False, "detail": "no active gate identity"} raw_ts = time.time() bucket_s = 60 ts = float(math.floor(raw_ts / bucket_s) * bucket_s) try: binding = _sync_binding(gate_key) persona_id = _gate_member_identity_id(active_identity) member = binding.members.get(persona_id) if member is None: _force_rebuild_binding(gate_key) binding = _sync_binding(gate_key) member = binding.members.get(persona_id) if member is None: return {"ok": False, "detail": "active gate identity is not mapped into the MLS group"} plaintext_with_epoch = _encode_gate_plaintext_envelope( plaintext, int(binding.epoch), reply_to, ) ciphertext = _privacy_client().encrypt_group_message( member.group_handle, plaintext_with_epoch.encode("utf-8"), ) except Exception: logger.exception( "MLS gate compose failed for %s", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_mls_compose_failed"} message_id = base64.b64encode(secrets.token_bytes(12)).decode("ascii") sender_ref = _sender_ref(_sender_ref_seed(active_identity), message_id) padded_ct = _pad_ciphertext_raw(ciphertext) # Look up envelope policy for this gate. _envelope_policy = _resolve_gate_envelope_policy(gate_key) # Create a durable gate envelope: the plaintext encrypted under the # gate's domain key (AES-256-GCM). This survives MLS group rebuilds # and process restarts. Only nodes holding the gate domain key can # decrypt — outsiders see opaque base64. gate_envelope: str = "" if _envelope_policy != "envelope_disabled": try: gate_envelope = _gate_envelope_encrypt( gate_key, plaintext, message_nonce=message_id, ) except GateSecretUnavailableError: return {"ok": False, "detail": "gate_envelope_required", "gate_id": gate_key} except Exception: logger.warning( "gate envelope encrypt failed for %s — MLS-only for this message", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_envelope_encrypt_failed", "gate_id": gate_key} # Compute envelope_hash: cryptographic binding of gate_envelope to the # signed payload. SHA-256 of the envelope ciphertext string. # envelope_disabled → no envelope → no hash. envelope_hash = "" if gate_envelope: envelope_hash = _gate_envelope_hash(gate_envelope) if _envelope_policy != "envelope_disabled" and (not gate_envelope or not envelope_hash): return {"ok": False, "detail": "gate_envelope_required", "gate_id": gate_key} payload = { "gate": gate_key, "ciphertext": _b64(padded_ct), "nonce": message_id, "sender_ref": sender_ref, "format": MLS_GATE_FORMAT, "epoch": int(binding.epoch), "transport_lock": "private_strong", } reply_to_val = str(reply_to or "").strip() if reply_to_val: payload["reply_to"] = reply_to_val if envelope_hash: payload["envelope_hash"] = envelope_hash # gate_envelope itself is NOT in the signed payload — envelope_hash binds it. signed = sign_gate_wormhole_event(gate_id=gate_key, event_type="gate_message", payload=payload) if not signed.get("signature"): return {"ok": False, "detail": str(signed.get("detail") or "gate_sign_failed")} _HIGH_WATER_EPOCHS[gate_key] = max( int(binding.epoch), int(_HIGH_WATER_EPOCHS.get(gate_key, 0) or 0), ) _lock_gate_format(gate_key, MLS_GATE_FORMAT) # No local plaintext retention: by design, the node only persists the # ciphertext on the private hashchain. The author does NOT keep a local # plaintext copy of their own message — if the device is compromised # later, the attacker can only decrypt messages for epochs the compromised # MLS state holds keys for (which excludes the sender's own sending- # ratchet output once it has advanced). The sender does still see what # they just typed in the compose response (below), so the UI can render # the optimistic post; after that, the ciphertext is the only record. # Phase 3.3: surface solo-mode without refusing the compose. A gate with # no real peers still encrypts and stores normally — the flag is purely # advisory so the UI/caller can show "your message is sealed but nobody # else can read it until someone joins this gate." solo_pending = _gate_is_solo(binding) return _ComposeResult( { "ok": True, "gate_id": gate_key, "identity_scope": "anonymous" if active_source == "anonymous" else str(signed.get("identity_scope", "") or "gate_persona"), "sender_id": str(signed.get("node_id", "") or ""), "public_key": str(signed.get("public_key", "") or ""), "public_key_algo": str(signed.get("public_key_algo", "") or ""), "protocol_version": str(signed.get("protocol_version", "") or ""), "sequence": int(signed.get("sequence", 0) or 0), "signature": str(signed.get("signature", "") or ""), "ciphertext": payload["ciphertext"], "nonce": payload["nonce"], "sender_ref": sender_ref, "format": MLS_GATE_FORMAT, "transport_lock": "private_strong", "timestamp": ts, "gate_envelope": gate_envelope, "envelope_hash": envelope_hash, "reply_to": reply_to_val, "solo_pending": solo_pending, # Echo the composer's plaintext back in the compose response so the # UI can render the post optimistically on the author's screen. This # is NOT persisted, NOT relayed, NOT cached — it only lives in the # HTTP response for this single compose call and in the client's # local UI state until the page refreshes. After that, the author # sees their own post the same way any other member does (KEY LOCKED # if their MLS state can't re-derive the sending-ratchet key, which # is MLS's forward-secrecy behavior by design). "self_plaintext": plaintext, }, legacy_epoch=int(binding.epoch), ) def sign_encrypted_gate_message( *, gate_id: str, epoch: int, ciphertext: str, nonce: str, payload_format: str = MLS_GATE_FORMAT, reply_to: str = "", compat_reply_to: bool = False, recovery_plaintext: str = "", envelope_hash: str = "", transport_lock: str = "private_strong", ) -> dict[str, Any]: """Sign an already encrypted gate payload without receiving plaintext.""" gate_key = _stable_gate_ref(gate_id) ciphertext = str(ciphertext or "").strip() nonce = str(nonce or "").strip() payload_format = str(payload_format or MLS_GATE_FORMAT).strip().lower() or MLS_GATE_FORMAT if not gate_key: return {"ok": False, "detail": "gate_id required"} if not ciphertext: return {"ok": False, "detail": "ciphertext required"} if not nonce: return {"ok": False, "detail": "nonce required"} if payload_format != MLS_GATE_FORMAT: return { "ok": False, "detail": "native encrypted gate signing requires MLS format", "required_format": MLS_GATE_FORMAT, "current_format": payload_format, } # Tor-style: gate signing is a LOCAL cryptographic operation on # already-encrypted ciphertext. It doesn't leak anything by itself — # only network release of the signed envelope does, and the release # path has its own tier floor that queues until the lane is ready. # Proceed with signing at any tier; kick off a background transport # warmup (in a worker thread so signing is never blocked) so the # release path unblocks as soon as possible. try: from services.wormhole_supervisor import get_transport_tier, connect_wormhole if get_transport_tier() == "public_degraded": import threading as _threading def _bg_connect() -> None: try: connect_wormhole(reason="gate_sign_auto_upgrade") except Exception: logger.debug("gate sign background transport kickoff failed", exc_info=True) _threading.Thread(target=_bg_connect, name="gate-sign-warmup", daemon=True).start() except Exception: logger.debug("gate sign transport probe failed", exc_info=True) active_identity, active_source = _active_gate_member(gate_key) if not active_identity: return {"ok": False, "detail": "no active gate identity"} try: binding = _sync_binding(gate_key) except Exception: logger.exception( "MLS gate sign failed during binding sync for %s", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_mls_sign_failed"} requested_epoch = int(epoch or 0) if requested_epoch > 0 and requested_epoch != int(binding.epoch): return { "ok": False, "detail": "gate_state_stale", "gate_id": gate_key, "current_epoch": int(binding.epoch), } sender_ref = _sender_ref(_sender_ref_seed(active_identity), nonce) payload = { "gate": gate_key, "ciphertext": ciphertext, "nonce": nonce, "sender_ref": sender_ref, "format": MLS_GATE_FORMAT, "epoch": int(binding.epoch), } transport_lock_val = str(transport_lock or "private_strong").strip().lower() or "private_strong" if transport_lock_val != "private_strong": return {"ok": False, "detail": "gate encrypted signing requires private_strong transport_lock"} payload["transport_lock"] = transport_lock_val reply_to_val = str(reply_to or "").strip() if reply_to_val and not compat_reply_to: return { "ok": False, "detail": "gate_encrypted_reply_to_hidden_required", "gate_id": gate_key, "compat_reply_to": False, } if reply_to_val: payload["reply_to"] = reply_to_val envelope_policy = _resolve_gate_envelope_policy(gate_key) envelope_hash_val = str(envelope_hash or "").strip() gate_envelope_val = "" recovery_plaintext_val = str(recovery_plaintext or "").strip() if recovery_plaintext_val and envelope_policy in {"envelope_always", "envelope_recovery"}: try: gate_envelope_val = _gate_envelope_encrypt( gate_key, recovery_plaintext_val, message_nonce=nonce, ) except GateSecretUnavailableError: return {"ok": False, "detail": "gate_envelope_required", "gate_id": gate_key} except Exception: logger.exception( "gate envelope encrypt failed during encrypted signing for %s", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_envelope_encrypt_failed", "gate_id": gate_key} if not gate_envelope_val: return {"ok": False, "detail": "gate_envelope_required", "gate_id": gate_key} envelope_hash_val = _gate_envelope_hash(gate_envelope_val) if envelope_policy == "envelope_always" and not gate_envelope_val and not envelope_hash_val: return {"ok": False, "detail": "gate_envelope_required", "gate_id": gate_key} if envelope_hash_val: payload["envelope_hash"] = envelope_hash_val signed = sign_gate_wormhole_event( gate_id=gate_key, event_type="gate_message", payload=payload, ) if not signed.get("signature"): return {"ok": False, "detail": str(signed.get("detail") or "gate_sign_failed")} bucket_s = 60 ts = float(math.floor(time.time() / bucket_s) * bucket_s) return { "ok": True, "gate_id": gate_key, "identity_scope": "anonymous" if active_source == "anonymous" else str(signed.get("identity_scope", "") or "gate_persona"), "sender_id": str(signed.get("node_id", "") or ""), "public_key": str(signed.get("public_key", "") or ""), "public_key_algo": str(signed.get("public_key_algo", "") or ""), "protocol_version": str(signed.get("protocol_version", "") or ""), "sequence": int(signed.get("sequence", 0) or 0), "signature": str(signed.get("signature", "") or ""), "epoch": int(binding.epoch), "ciphertext": ciphertext, "nonce": nonce, "sender_ref": sender_ref, "format": MLS_GATE_FORMAT, "transport_lock": transport_lock_val, "timestamp": ts, "reply_to": reply_to_val, "gate_envelope": gate_envelope_val, "envelope_hash": envelope_hash_val, } def _stamp_plaintext_on_chain( gate_key: str, event_id: str, plaintext: str, reply_to: str = "", *, allow_persist: bool = False, ) -> None: """Best-effort stamp of decrypted plaintext onto the private hashchain.""" if not allow_persist or not event_id or not plaintext: return try: from services.mesh.mesh_hashchain import gate_store gate_store.stamp_local_plaintext(gate_key, event_id, plaintext, reply_to) except Exception: pass def decrypt_gate_message_for_local_identity( *, gate_id: str, epoch: int, ciphertext: str, nonce: str, sender_ref: str = "", gate_envelope: str = "", envelope_hash: str = "", recovery_envelope: bool = False, event_id: str = "", ) -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) if not gate_key or not ciphertext: return {"ok": False, "detail": "gate_id and ciphertext required"} envelope_policy = _resolve_gate_envelope_policy(gate_key) envelope_fast_path_enabled = envelope_policy == "envelope_always" or ( recovery_envelope and envelope_policy == "envelope_recovery" ) # Fast path: gate envelope (AES-256-GCM under gate domain key) only for # explicit recovery reads or gates that intentionally keep the envelope on # the ordinary local-read path. No plaintext is stamped to disk. if envelope_fast_path_enabled: if gate_envelope: if not envelope_hash: if _stored_legacy_unbound_envelope_allowed(gate_key, event_id, gate_envelope): envelope_hash = _gate_envelope_hash(gate_envelope) else: return {"ok": False, "detail": "gate_envelope missing signed envelope_hash"} expected = _gate_envelope_hash(gate_envelope) legacy_unbound_envelope = bool( event_id and envelope_hash == expected and _stored_legacy_unbound_envelope_allowed(gate_key, event_id, gate_envelope) ) if expected != envelope_hash: return {"ok": False, "detail": "gate_envelope integrity check failed"} envelope_pt = _gate_envelope_decrypt( gate_key, gate_envelope, message_nonce=str(nonce or ""), message_epoch=int(epoch or 0), event_id=event_id, ) if envelope_pt is not None: return { "ok": True, "gate_id": gate_key, "epoch": int(epoch or 0), "plaintext": envelope_pt, "identity_scope": "gate_envelope", "legacy_unbound_envelope": legacy_unbound_envelope, } elif envelope_hash: return {"ok": False, "detail": "gate_envelope missing but envelope_hash present"} # No-local-plaintext policy: we deliberately do NOT consult any disk- # persisted plaintext or in-memory self-echo cache. Every read re-decrypts # from ciphertext using the current MLS member state. Messages the caller # has keys for decrypt normally; messages authored by the caller at an # earlier session (or from epochs before they joined) show as locked. active_identity, active_source = _active_gate_member(gate_key) if not active_identity: return {"ok": False, "detail": "no active gate identity"} # Try all group members (verifier path): on a single-operator node the # sender is also a member, and MLS's own-author limitation means the # sender's own group state can't decrypt their authored ciphertext — # but a *different* member state on the same node can. This path is # pure ciphertext → plaintext with no disk artifact. verifier_open = open_gate_ciphertext_for_verifier( gate_id=gate_key, ciphertext=str(ciphertext), format=MLS_GATE_FORMAT, epoch=int(epoch or 0), ) if verifier_open.get("ok"): verifier_pt = str(verifier_open.get("plaintext", "") or "") verifier_rt = str(verifier_open.get("reply_to", "") or "").strip() result = { "ok": True, "gate_id": gate_key, "epoch": int(verifier_open.get("epoch", epoch or 0) or 0), "plaintext": verifier_pt, "identity_scope": active_source if active_source == "anonymous" else "persona", } if verifier_rt: result["reply_to"] = verifier_rt return result # All MLS members on this node are the author — MLS's sending-ratchet # has advanced past this message so no local member state can decrypt # it. Under the no-local-plaintext policy this is the expected outcome # for your own past messages; the UI will render KEY LOCKED. if verifier_open.get("detail") == "gate_mls_self_authored": return { "ok": False, "detail": "gate_mls_self_authored", "self_authored": True, "identity_scope": active_source if active_source == "anonymous" else "persona", } try: binding = _sync_binding(gate_key) persona_id = _gate_member_identity_id(active_identity) member = binding.members.get(persona_id) if member is None: _force_rebuild_binding(gate_key) binding = _sync_binding(gate_key) member = binding.members.get(persona_id) if member is None: return {"ok": False, "detail": "active gate identity is not mapped into the MLS group"} decrypted_bytes = _privacy_client().decrypt_group_message( member.group_handle, _unpad_ciphertext_raw(_unb64(ciphertext)), ) except Exception: # No-local-plaintext policy: no cache fallback. If MLS can't decrypt # the ciphertext for this member state, the message is KEY LOCKED to # this caller — which is the correct behavior for an epoch they don't # have keys for, or for their own authored messages after MLS advanced # the sending ratchet. logger.debug( "MLS gate decrypt failed for %s (verifier already attempted)", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_mls_decrypt_failed"} raw = decrypted_bytes.decode("utf-8") actual_plaintext, decrypted_epoch, decrypted_reply_to = _decode_gate_plaintext_envelope(raw, int(epoch or 0)) _lock_gate_format(gate_key, MLS_GATE_FORMAT) # No plaintext stamped to disk — every read re-decrypts from ciphertext. result = { "ok": True, "gate_id": gate_key, "epoch": int(decrypted_epoch or epoch or 0), "plaintext": actual_plaintext, "identity_scope": "anonymous" if active_source == "anonymous" else "persona", } if decrypted_reply_to: result["reply_to"] = decrypted_reply_to return result def open_gate_ciphertext_for_verifier( *, gate_id: str, ciphertext: str, format: str, epoch: int, ) -> dict[str, Any]: gate_key = _stable_gate_ref(gate_id) if not gate_key or not ciphertext: return {"ok": False, "detail": "gate_id and ciphertext required"} if str(format or "").strip() != MLS_GATE_FORMAT: return {"ok": False, "detail": "unsupported gate ciphertext format"} with _STATE_LOCK: binding = _GATE_BINDINGS.get(gate_key) if binding is None: try: binding = _sync_binding(gate_key) except Exception: logger.exception( "MLS verifier open sync failed for %s", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_mls_verifier_open_failed"} last_error: Exception | None = None all_self_authored = True decoded = _unpad_ciphertext_raw(_unb64(ciphertext)) for persona_id, member in list(binding.members.items()): try: decrypted_bytes = _privacy_client().decrypt_group_message( member.group_handle, decoded, ) raw = decrypted_bytes.decode("utf-8") actual_plaintext, decrypted_epoch, decrypted_reply_to = _decode_gate_plaintext_envelope( raw, int(epoch or 0), ) _lock_gate_format(gate_key, MLS_GATE_FORMAT) result = { "ok": True, "gate_id": gate_key, "epoch": int(decrypted_epoch or epoch or 0), "plaintext": actual_plaintext, "opened_by_persona_id": persona_id, "identity_scope": "verifier", } if decrypted_reply_to: result["reply_to"] = decrypted_reply_to return result except Exception as exc: if "message from self" not in str(exc): all_self_authored = False last_error = exc continue if all_self_authored and last_error is not None: logger.debug( "MLS verifier open: all members are self for %s (self-authored message)", privacy_log_label(gate_key, label="gate"), ) return {"ok": False, "detail": "gate_mls_self_authored"} logger.error( "MLS verifier open failed for %s", privacy_log_label(gate_key, label="gate"), exc_info=last_error, ) return {"ok": False, "detail": "gate_mls_verifier_open_failed"}