Files
Shadowbroker/backend/services/mesh/mesh_gate_mls.py
T
2026-05-01 22:56:50 -06:00

2444 lines
93 KiB
Python

"""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"}