diff --git a/backend/.env.example b/backend/.env.example index 8d63aff..a339ad3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -237,6 +237,10 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key # MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY= # seed only # MESH_BOOTSTRAP_SIGNER_ID=shadowbroker-seed # MESH_PEER_REGISTRY_ENABLED=true # seed only (auto-enabled when private key is set) +# Headless relay compose sets MESH_INFONET_RELAY_AUTO_WORMHOLE=true; seed nodes with +# MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY also auto-enable Tor wormhole on startup. +# MESH_INFONET_RELAY_AUTO_WORMHOLE=false +# MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=false # MESH_SWARM_MANIFEST_TTL_S=14400 # MESH_SWARM_MANIFEST_PULL_INTERVAL_S=300 # MESH_PEER_REGISTRY_STALE_S=604800 diff --git a/backend/main.py b/backend/main.py index a142415..b9e0933 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2696,8 +2696,10 @@ async def lifespan(app: FastAPI): if not _MESH_ONLY: def _startup_wormhole_runtime(): try: + from services.mesh.mesh_infonet_relay_bootstrap import ensure_infonet_relay_wormhole_ready from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings + ensure_infonet_relay_wormhole_ready(reason="startup_relay") sync_wormhole_with_settings() _resume_private_delivery_background_work( current_tier=_current_private_lane_tier(get_wormhole_state()), @@ -3472,7 +3474,10 @@ def _request_private_surface_warmup(*, path: str, method: str, current_tier: str def _is_invite_scoped_prekey_bundle_lookup(request: Request, path: str) -> bool: - if request.method.upper() != "GET" or str(path or "").strip() != "/api/mesh/dm/prekey-bundle": + if request.method.upper() != "GET": + return False + normalized_path = str(path or "").strip() + if normalized_path not in {"/api/mesh/dm/prekey-bundle", "/api/mesh/dm/pubkey"}: return False try: lookup_token = str(request.query_params.get("lookup_token", "") or "").strip() @@ -3573,6 +3578,14 @@ async def enforce_high_privacy_mesh(request: Request, call_next): except Exception: logger.debug("Private surface warm-up request failed", exc_info=True) required_tier = _minimum_transport_tier(path, request.method) + if required_tier: + from services.mesh.mesh_privacy_policy import runtime_route_enforcement_tier + + required_tier = runtime_route_enforcement_tier( + path, + request.method, + static_tier=required_tier, + ) if required_tier: if not _transport_tier_is_sufficient(current_tier, required_tier): if request.method.upper() == "POST" and path == "/api/mesh/dm/send": @@ -6865,12 +6878,22 @@ def _queue_dm_release(*, current_tier: str, payload: dict[str, Any]) -> dict[str required_tier=release_lane_required_tier("dm"), ) _wake_private_release_worker() + outbox_id = str(item.get("id", "") or "") + auto_release: dict[str, Any] = {"ok": True, "skipped": True} + if outbox_id: + try: + from services.mesh.mesh_dm_connect_delivery import auto_release_connect_dm_outbox + + auto_release = auto_release_connect_dm_outbox(outbox_id=outbox_id, payload=payload) + except Exception as exc: + auto_release = {"ok": False, "detail": str(exc) or type(exc).__name__} return { "ok": True, "msg_id": str(payload.get("msg_id", "") or ""), - "outbox_id": str(item.get("id", "") or ""), + "outbox_id": outbox_id, "queued": True, "detail": str((item.get("status") or {}).get("label", "") or "Queued for private delivery"), + "auto_release": auto_release, "delivery": { "state": canonical_release_state(str(item.get("release_state", "") or "queued")), "internal_state": str(item.get("release_state", "") or "queued"), @@ -7043,7 +7066,8 @@ async def _dm_send_from_signed_request(request: Request): return {"ok": False, "detail": "DM timestamp is too far from current time"} if delivery_class not in ("request", "shared"): return {"ok": False, "detail": "delivery_class must be request or shared"} - if delivery_class == "request": + # Contact requests are the first-contact handshake — do not require prior verification. + if delivery_class == "shared": try: from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement @@ -7127,6 +7151,8 @@ async def _dm_send_from_signed_request(request: Request): relay_salt_hex = _os.urandom(16).hex() + connect_intent = str(body.get("connect_intent", "") or "").strip().lower() + lookup_peer_url = str(body.get("lookup_peer_url", "") or "").strip().rstrip("/") release_payload = { "sender_id": sender_id, "sender_token_hash": sender_token_hash, @@ -7141,6 +7167,16 @@ async def _dm_send_from_signed_request(request: Request): "sender_seal": sender_seal, "relay_salt": relay_salt_hex, } + if connect_intent: + release_payload["connect_intent"] = connect_intent + if lookup_peer_url: + release_payload["lookup_peer_url"] = lookup_peer_url + try: + from services.mesh.mesh_dm_connect_delivery import enrich_connect_release_payload + + release_payload = enrich_connect_release_payload(release_payload) + except Exception: + pass hashchain_spool: dict[str, Any] = {"ok": False, "detail": "not attempted"} try: from services.mesh.mesh_hashchain import infonet @@ -7427,7 +7463,12 @@ async def dm_register_key(request: Request): @app.get("/api/mesh/dm/pubkey") @limiter.limit("30/minute") -async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str = ""): +async def dm_get_pubkey( + request: Request, + agent_id: str = "", + lookup_token: str = "", + lookup_peer_url: str = "", +): """Fetch an agent's DH public key for key exchange.""" exposure = metadata_exposure_for_request( request, @@ -7447,11 +7488,49 @@ async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str if resolved_lookup: key_bundle, resolved_id = dm_relay.get_dh_key_by_lookup(resolved_lookup) if key_bundle is None: - return dm_lookup_response_view( - {"ok": False, "detail": "Agent not found or has no DH key", "lookup_mode": "invite_lookup_handle"}, - exposure=exposure, - lookup_token_present=True, + # Invite handles are minted on the owner's node. When a remote peer + # pastes a short address, resolve it across the private fleet before + # failing — same path as prekey-bundle import. + from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle + + preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/") + remote_bundle = fetch_dm_prekey_bundle( + agent_id="", + lookup_token=resolved_lookup, + lookup_peer_urls=[preferred_lookup_peer] if preferred_lookup_peer else None, ) + if remote_bundle.get("ok"): + bundle = dict(remote_bundle.get("bundle") or remote_bundle) + dh_pub = str( + bundle.get("identity_dh_pub_key", "") + or remote_bundle.get("identity_dh_pub_key", "") + or "" + ).strip() + if dh_pub: + resolved_id = str(remote_bundle.get("agent_id", "") or resolved_id or "").strip() + key_bundle = { + "dh_pub_key": dh_pub, + "dh_algo": str(remote_bundle.get("dh_algo", "X25519") or "X25519"), + "timestamp": int(remote_bundle.get("timestamp", 0) or 0), + "public_key": str(remote_bundle.get("public_key", "") or ""), + "public_key_algo": str(remote_bundle.get("public_key_algo", "") or ""), + "signature": str(remote_bundle.get("signature", "") or ""), + "sequence": int(remote_bundle.get("sequence", 0) or 0), + "prekey_transparency_head": str( + remote_bundle.get("prekey_transparency_head", "") or "" + ), + "prekey_transparency_size": int( + remote_bundle.get("prekey_transparency_size", 0) or 0 + ), + "witness_count": int(remote_bundle.get("witness_count", 0) or 0), + "witness_latest_at": int(remote_bundle.get("witness_latest_at", 0) or 0), + } + if key_bundle is None: + return dm_lookup_response_view( + {"ok": False, "detail": "Agent not found or has no DH key", "lookup_mode": "invite_lookup_handle"}, + exposure=exposure, + lookup_token_present=True, + ) lookup_mode = "invite_lookup_handle" if key_bundle is None and resolved_id: blocked = legacy_agent_id_lookup_blocked() @@ -7487,7 +7566,12 @@ async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str @app.get("/api/mesh/dm/prekey-bundle") @limiter.limit("30/minute") -async def dm_get_prekey_bundle(request: Request, agent_id: str = "", lookup_token: str = ""): +async def dm_get_prekey_bundle( + request: Request, + agent_id: str = "", + lookup_token: str = "", + lookup_peer_url: str = "", +): exposure = metadata_exposure_for_request( request, authenticated=_scoped_view_authenticated(request, "mesh"), @@ -7499,7 +7583,12 @@ async def dm_get_prekey_bundle(request: Request, agent_id: str = "", lookup_toke lookup_token_present=bool(lookup_token), ) resolved_id, resolved_lookup = _preferred_dm_lookup_target(agent_id, lookup_token) - result = fetch_dm_prekey_bundle(agent_id=resolved_id, lookup_token=resolved_lookup) + preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/") + result = fetch_dm_prekey_bundle( + agent_id=resolved_id, + lookup_token=resolved_lookup, + lookup_peer_urls=[preferred_lookup_peer] if preferred_lookup_peer else None, + ) return dm_lookup_response_view( result, exposure=exposure, @@ -9349,7 +9438,8 @@ class WormholeDmResetRequest(BaseModel): class WormholeDmBootstrapEncryptRequest(BaseModel): - peer_id: str + peer_id: str = "" + lookup_token: str = "" plaintext: str @@ -11311,9 +11401,12 @@ async def api_wormhole_dm_bootstrap_encrypt(request: Request, body: WormholeDmBo result = bootstrap_encrypt_for_peer( peer_id=str(body.peer_id or ""), plaintext=str(body.plaintext or ""), + lookup_token=str(body.lookup_token or ""), ) if isinstance(result, dict) and "trust_level" not in result: - result["trust_level"] = _get_contact_trust_level(str(body.peer_id or "")) + result["trust_level"] = _get_contact_trust_level( + str(result.get("peer_id", "") or body.peer_id or "") + ) return result @@ -11329,7 +11422,7 @@ async def api_wormhole_dm_bootstrap_decrypt(request: Request, body: WormholeDmBo return result -@app.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_admin)]) +@app.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_local_operator)]) @limiter.limit("60/minute") async def api_wormhole_dm_sender_token(request: Request, body: WormholeDmSenderTokenRequest): if _safe_int(body.count or 1, 1) > 1: @@ -11548,6 +11641,24 @@ async def api_wormhole_dm_contact_delete(request: Request, peer_id: str): return {"ok": True, "peer_id": peer_id, "deleted": deleted} +@app.post("/api/wormhole/dm/contact/{peer_id}/sever", dependencies=[Depends(require_admin)]) +@limiter.limit("60/minute") +async def api_wormhole_dm_contact_sever(request: Request, peer_id: str): + from services.mesh.mesh_wormhole_contacts import sever_wormhole_dm_contact + + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + block = bool(body.get("block", False)) + try: + return sever_wormhole_dm_contact(peer_id, block=block) + except ValueError as exc: + return {"ok": False, "detail": str(exc)} + + _WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"} diff --git a/backend/routers/ai_intel.py b/backend/routers/ai_intel.py index 132aaa8..d0d8227 100644 --- a/backend/routers/ai_intel.py +++ b/backend/routers/ai_intel.py @@ -2719,6 +2719,7 @@ def _connect_info_metadata(settings) -> dict: "get_telemetry", "get_pins", "satellite_images", "news_near", "ai_summary", "ai_report", "timemachine_list", "timemachine_view", + "infonet_status", "list_gates", "read_gate_messages", "poll_dms", ], }, "full": { @@ -2729,6 +2730,8 @@ def _connect_info_metadata(settings) -> dict: "satellite_images", "news_near", "data_injection", "ai_summary", "ai_report", "timemachine_snapshot", "timemachine_list", "timemachine_view", "timemachine_diff", + "ensure_infonet_ready", "join_infonet_swarm", + "post_gate_message", "cast_vote", "send_dm", ], }, }, diff --git a/backend/routers/wormhole.py b/backend/routers/wormhole.py index cbd34bd..ac72d6c 100644 --- a/backend/routers/wormhole.py +++ b/backend/routers/wormhole.py @@ -1085,7 +1085,7 @@ async def api_wormhole_dm_bootstrap_decrypt(request: Request, body: WormholeDmBo ) -@router.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_admin)]) +@router.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_local_operator)]) @limiter.limit("60/minute") async def api_wormhole_dm_sender_token(request: Request, body: WormholeDmSenderTokenRequest): if _safe_int(body.count or 1, 1) > 1: @@ -1287,6 +1287,24 @@ async def api_wormhole_dm_contact_delete(request: Request, peer_id: str): return {"ok": True, "peer_id": peer_id, "deleted": deleted} +@router.post("/api/wormhole/dm/contact/{peer_id}/sever", dependencies=[Depends(require_admin)]) +@limiter.limit("60/minute") +async def api_wormhole_dm_contact_sever(request: Request, peer_id: str): + from services.mesh.mesh_wormhole_contacts import sever_wormhole_dm_contact + + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + block = bool(body.get("block", False)) + try: + return sever_wormhole_dm_contact(peer_id, block=block) + except ValueError as exc: + return {"ok": False, "detail": str(exc)} + + _WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"} diff --git a/backend/services/config.py b/backend/services/config.py index ef59450..129a791 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -51,6 +51,10 @@ class Settings(BaseSettings): # When true, empty MESH_PEER_PUSH_SECRET uses the public fleet HMAC for seed join/announce. MESH_INFONET_FLEET_JOIN: bool = True MESH_INFONET_FLEET_JOIN_DISABLED: bool = False + # Headless relay/seed compose: auto-enable Tor wormhole on startup so + # docker compose redeploys keep the fleet onion reachable. + MESH_INFONET_RELAY_AUTO_WORMHOLE: bool = False + MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED: bool = False MESH_BOOTSTRAP_SIGNER_ID: str = "" MESH_PEER_REGISTRY_ENABLED: bool = False MESH_PEER_REGISTRY_DISABLED: bool = False diff --git a/backend/services/mesh/mesh_dm_connect_delivery.py b/backend/services/mesh/mesh_dm_connect_delivery.py new file mode 100644 index 0000000..529eb7d --- /dev/null +++ b/backend/services/mesh/mesh_dm_connect_delivery.py @@ -0,0 +1,179 @@ +"""Invite-scoped DM connect delivery: auto relay release and contact severance.""" + +from __future__ import annotations + +from typing import Any + +CONNECT_AUTO_RELEASE_INTENTS = frozenset( + { + "invite_short_address", + "invite_import", + "contact_request", + "contact_accept", + "contact_offer", + } +) + +INVITE_CONNECT_TRUST_LEVELS = frozenset({"invite_pinned", "sas_verified"}) + + +def _release_profile() -> str: + try: + from services.release_profiles import current_release_profile + + return str(current_release_profile() or "dev") + except Exception: + return "dev" + + +def grant_connect_relay_policy( + recipient_id: str, + *, + reason: str = "connect_scoped_auto_release", +) -> dict[str, Any]: + """Pre-authorize hidden relay delivery for an explicit connect target.""" + peer_key = str(recipient_id or "").strip() + if not peer_key: + return {"ok": False, "detail": "recipient_id required"} + try: + from services.mesh.mesh_relay_policy import grant_relay_policy + + return grant_relay_policy( + scope_type="dm_contact", + scope_id=peer_key, + profile=_release_profile(), + hidden_transport_required=True, + reason=str(reason or "connect_scoped_auto_release"), + ) + except Exception as exc: + return {"ok": False, "detail": str(exc) or type(exc).__name__} + + +def revoke_connect_relay_policy(recipient_id: str) -> dict[str, Any]: + peer_key = str(recipient_id or "").strip() + if not peer_key: + return {"ok": False, "detail": "recipient_id required"} + try: + from services.mesh.mesh_relay_policy import revoke_relay_policy + + revoked = int( + revoke_relay_policy( + scope_type="dm_contact", + scope_id=peer_key, + profile=_release_profile(), + ) + or 0 + ) + return {"ok": True, "revoked": revoked} + except Exception as exc: + return {"ok": False, "detail": str(exc) or type(exc).__name__} + + +def recipient_has_invite_connect_scope(recipient_id: str) -> bool: + peer_key = str(recipient_id or "").strip() + if not peer_key: + return False + try: + from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact + + contact = get_wormhole_dm_contact(peer_key) or {} + except Exception: + return False + if str(contact.get("invitePinnedPrekeyLookupHandle", "") or "").strip(): + return True + if str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip(): + return True + trust = str(contact.get("trust_level", "") or "").strip().lower() + return trust in INVITE_CONNECT_TRUST_LEVELS + + +def relay_push_peer_urls_for_payload(payload: dict[str, Any]) -> list[str]: + urls: list[str] = [] + for raw in list(payload.get("relay_push_peer_urls") or []): + normalized = str(raw or "").strip().rstrip("/") + if normalized and normalized not in urls: + urls.append(normalized) + lookup_peer_url = str(payload.get("lookup_peer_url", "") or "").strip().rstrip("/") + if lookup_peer_url: + urls = [url for url in urls if url != lookup_peer_url] + urls.insert(0, lookup_peer_url) + recipient_id = str(payload.get("recipient_id", "") or "").strip() + if recipient_id and not urls: + try: + from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact + + contact = get_wormhole_dm_contact(recipient_id) or {} + pinned = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/") + if pinned: + urls.append(pinned) + except Exception: + pass + return urls + + +def should_auto_release_dm_payload(payload: dict[str, Any]) -> bool: + if str(payload.get("delivery_class", "") or "").strip().lower() != "request": + return False + intent = str(payload.get("connect_intent", "") or "").strip().lower() + if intent in CONNECT_AUTO_RELEASE_INTENTS: + return True + if str(payload.get("lookup_peer_url", "") or "").strip(): + return True + recipient_id = str(payload.get("recipient_id", "") or "").strip() + return bool(recipient_id and recipient_has_invite_connect_scope(recipient_id)) + + +def enrich_connect_release_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Attach invite-owner relay hints used during private release.""" + enriched = dict(payload or {}) + recipient_id = str(enriched.get("recipient_id", "") or "").strip() + lookup_peer_url = str(enriched.get("lookup_peer_url", "") or "").strip().rstrip("/") + if not lookup_peer_url and recipient_id: + try: + from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact + + contact = get_wormhole_dm_contact(recipient_id) or {} + lookup_peer_url = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/") + except Exception: + lookup_peer_url = "" + if lookup_peer_url: + enriched["lookup_peer_url"] = lookup_peer_url + push_urls = relay_push_peer_urls_for_payload(enriched) + if push_urls: + enriched["relay_push_peer_urls"] = push_urls + return enriched + + +def auto_release_connect_dm_outbox(*, outbox_id: str, payload: dict[str, Any]) -> dict[str, Any]: + """Grant scoped relay policy and approve release for invite-scoped connect traffic.""" + normalized_outbox = str(outbox_id or "").strip() + enriched = enrich_connect_release_payload(payload) + if not normalized_outbox: + return {"ok": False, "detail": "missing outbox_id"} + if not should_auto_release_dm_payload(enriched): + return {"ok": True, "skipped": True, "reason": "not_connect_scoped"} + recipient_id = str(enriched.get("recipient_id", "") or "").strip() + if not recipient_id: + return {"ok": False, "detail": "missing recipient_id"} + grant = grant_connect_relay_policy(recipient_id) + try: + from services.mesh.mesh_private_outbox import private_delivery_outbox + from services.mesh.mesh_private_release_worker import private_release_worker + + private_delivery_outbox.approve_relay_release(normalized_outbox) + private_release_worker.ensure_started() + private_release_worker.wake() + except Exception as exc: + return { + "ok": False, + "detail": str(exc) or type(exc).__name__, + "grant": grant, + } + return { + "ok": True, + "auto_released": True, + "outbox_id": normalized_outbox, + "recipient_id": recipient_id, + "grant": grant, + "relay_push_peer_urls": relay_push_peer_urls_for_payload(enriched), + } diff --git a/backend/services/mesh/mesh_dm_relay.py b/backend/services/mesh/mesh_dm_relay.py index cc12b0c..0b05778 100644 --- a/backend/services/mesh/mesh_dm_relay.py +++ b/backend/services/mesh/mesh_dm_relay.py @@ -1506,6 +1506,7 @@ class DMRelay: sender_token_hash: str = "", payload_format: str = "dm1", session_welcome: str = "", + replication_peer_urls: list[str] | None = None, ) -> dict[str, Any]: with self._lock: self._refresh_from_shared_relay() @@ -1609,6 +1610,7 @@ class DMRelay: if envelope_for_push: self._replicate_envelope_to_peers_async( envelope=envelope_for_push, + preferred_peer_urls=list(replication_peer_urls or []), ) except Exception: metrics_inc("dm_replication_push_error") @@ -1716,6 +1718,7 @@ class DMRelay: self, *, envelope: dict[str, Any], + preferred_peer_urls: list[str] | None = None, ) -> None: """Push an outbound DM envelope to every authenticated relay peer. @@ -1747,7 +1750,15 @@ class DMRelay: authenticated_push_peer_urls, ) - peers = authenticated_push_peer_urls() + peers: list[str] = [] + for raw_url in list(preferred_peer_urls or []): + normalized_preferred = normalize_peer_url(str(raw_url or "").strip()) + if normalized_preferred and normalized_preferred not in peers: + peers.append(normalized_preferred) + for peer_url in authenticated_push_peer_urls(): + normalized_peer = normalize_peer_url(str(peer_url or "").strip()) + if normalized_peer and normalized_peer not in peers: + peers.append(normalized_peer) if not peers: return diff --git a/backend/services/mesh/mesh_infonet_relay_bootstrap.py b/backend/services/mesh/mesh_infonet_relay_bootstrap.py new file mode 100644 index 0000000..2a0f2b5 --- /dev/null +++ b/backend/services/mesh/mesh_infonet_relay_bootstrap.py @@ -0,0 +1,86 @@ +"""Auto-enable Tor wormhole transport on Infonet relay/seed nodes.""" + +from __future__ import annotations + +import logging +from typing import Any + +from services.config import get_settings +from services.wormhole_settings import read_wormhole_settings, write_wormhole_settings + +logger = logging.getLogger(__name__) + + +def infonet_relay_auto_wormhole_requested() -> bool: + settings = get_settings() + if bool(settings.MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED): + return False + if bool(settings.MESH_INFONET_RELAY_AUTO_WORMHOLE): + return True + if str(settings.MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY or "").strip(): + return True + return False + + +def _relay_tor_wormhole_target_settings() -> dict[str, Any]: + settings = get_settings() + socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050) + return { + "enabled": True, + "transport": "tor_arti", + "socks_proxy": f"socks5h://127.0.0.1:{socks_port}", + "socks_dns": True, + "anonymous_mode": True, + } + + +def _wormhole_settings_match(existing: dict[str, Any], target: dict[str, Any]) -> bool: + return ( + bool(existing.get("enabled")) is bool(target["enabled"]) + and str(existing.get("transport", "")) == str(target["transport"]) + and str(existing.get("socks_proxy", "")) == str(target["socks_proxy"]) + and bool(existing.get("socks_dns", True)) is bool(target["socks_dns"]) + and bool(existing.get("anonymous_mode", False)) is bool(target["anonymous_mode"]) + ) + + +def ensure_infonet_relay_wormhole_ready(*, reason: str = "relay_auto") -> dict[str, Any]: + """Persist Tor wormhole settings and connect on relay/seed startup.""" + if not infonet_relay_auto_wormhole_requested(): + return {"ok": True, "skipped": True, "reason": "not_requested"} + + from routers.ai_intel import _write_env_value + from services.tor_hidden_service import tor_service + from services.wormhole_supervisor import connect_wormhole, restart_wormhole + + existing = read_wormhole_settings() + target = _relay_tor_wormhole_target_settings() + settings_updated = not _wormhole_settings_match(existing, target) + updated = write_wormhole_settings(**target) if settings_updated else existing + + tor_result: dict[str, Any] = {"ok": False, "detail": "not started"} + try: + tor_result = tor_service.start(target_port=8000) + if tor_result.get("ok"): + _write_env_value("MESH_ARTI_ENABLED", "true") + get_settings.cache_clear() + except Exception as exc: + tor_result = {"ok": False, "detail": str(exc or type(exc).__name__)} + + runtime = ( + restart_wormhole(reason=reason) + if settings_updated + else connect_wormhole(reason=reason) + ) + + if settings_updated: + logger.info("Infonet relay auto-wormhole enabled (%s)", reason) + + return { + "ok": True, + "skipped": False, + "settings_updated": settings_updated, + "tor": tor_result, + "runtime": runtime, + "settings": updated, + } diff --git a/backend/services/mesh/mesh_metadata_exposure.py b/backend/services/mesh/mesh_metadata_exposure.py index 2e52fb0..1c51322 100644 --- a/backend/services/mesh/mesh_metadata_exposure.py +++ b/backend/services/mesh/mesh_metadata_exposure.py @@ -125,8 +125,8 @@ def dm_lookup_response_view( view.pop("lookup_mode", None) view.pop("removal_target", None) return view - if invite_lookup: - view.pop("agent_id", None) + # Successful invite lookups keep agent_id: the handle is the capability and + # first-contact messaging needs a delivery target. Failures stay generic. return view diff --git a/backend/services/mesh/mesh_privacy_policy.py b/backend/services/mesh/mesh_privacy_policy.py index 48b08b9..9ac7fb4 100644 --- a/backend/services/mesh/mesh_privacy_policy.py +++ b/backend/services/mesh/mesh_privacy_policy.py @@ -157,8 +157,45 @@ def transport_tier_is_sufficient(current_tier: str | None, required_tier: str | return TRANSPORT_TIER_ORDER[current] >= TRANSPORT_TIER_ORDER[required] -def release_lane_required_tier(lane: str) -> str: - return network_release_required_tier(lane) +_DM_RUNTIME_ENFORCEMENT_ROUTES = { + ("POST", "/api/mesh/dm/send"), + ("POST", "/api/mesh/dm/poll"), + ("GET", "/api/mesh/dm/poll"), + ("GET", "/api/mesh/dm/count"), + ("POST", "/api/mesh/dm/count"), +} + + +def runtime_route_enforcement_tier(path: str, method: str, *, static_tier: str) -> str: + """Adjust static route tiers for Tor-only nodes that never reach private_strong.""" + normalized_path = str(path or "").strip() + normalized_method = str(method or "").strip().upper() + static = normalize_transport_tier(static_tier) + if (normalized_method, normalized_path) not in _DM_RUNTIME_ENFORCEMENT_ROUTES: + return static + if static != "private_strong": + return static + return release_lane_required_tier("dm") + + +def release_lane_required_tier(lane: str, *, wormhole_state: dict[str, Any] | None = None) -> str: + normalized_lane = str(lane or "").strip().lower() + required = network_release_required_tier(normalized_lane) + if normalized_lane != "dm": + return required + state = wormhole_state + if state is None: + try: + from services.wormhole_supervisor import get_wormhole_state + + state = get_wormhole_state() + except Exception: + state = {} + # Tor-only nodes never reach private_strong (needs Arti + RNS). Encrypted + # relay over Arti still preserves ciphertext privacy for offline delivery. + if not bool((state or {}).get("rns_enabled")): + return "private_transitional" + return required def private_delivery_status(status_code: str, *, reason_code: str = "", plain_reason: str = "") -> dict[str, str]: diff --git a/backend/services/mesh/mesh_private_dispatcher.py b/backend/services/mesh/mesh_private_dispatcher.py index 6ec45d0..596d2c9 100644 --- a/backend/services/mesh/mesh_private_dispatcher.py +++ b/backend/services/mesh/mesh_private_dispatcher.py @@ -386,6 +386,14 @@ def _dispatch_dm( sampled=sampled, ) + replication_peer_urls: list[str] = [] + try: + from services.mesh.mesh_dm_connect_delivery import relay_push_peer_urls_for_payload + + replication_peer_urls = relay_push_peer_urls_for_payload(payload) + except Exception: + replication_peer_urls = [] + apply_dm_relay_jitter() relay_result = dm_relay.deposit( sender_id=relay_sender_id, @@ -399,6 +407,7 @@ def _dispatch_dm( sender_token_hash=sender_token_hash, payload_format=payload_format, session_welcome=session_welcome, + replication_peer_urls=replication_peer_urls, ) if not relay_result.get("ok"): return _dispatch_result( @@ -600,8 +609,15 @@ def attempt_private_release( policy_reason_code=str(decision.reason_code or ""), ) if normalized_lane == "dm": + dm_payload = dict(payload or {}) + try: + from services.mesh.mesh_dm_connect_delivery import enrich_connect_release_payload + + dm_payload = enrich_connect_release_payload(dm_payload) + except Exception: + pass return _dispatch_dm( - dict(payload or {}), + dm_payload, secure_dm_enabled=secure_dm_enabled or _secure_dm_enabled, rns_private_dm_ready=rns_private_dm_ready or _rns_private_dm_ready, anonymous_dm_hidden_transport_enforced=( diff --git a/backend/services/mesh/mesh_schema.py b/backend/services/mesh/mesh_schema.py index b52fb82..bdf2343 100644 --- a/backend/services/mesh/mesh_schema.py +++ b/backend/services/mesh/mesh_schema.py @@ -36,6 +36,22 @@ def _require_fields(payload: dict[str, Any], fields: tuple[str, ...]) -> tuple[b return True, "ok" +_SEALED_CIPHERTEXT_PREFIXES = ("x3dh1:", "dm1:", "mls1:", "sealed:") + + +def _strip_sealed_ciphertext_prefix(value: str) -> str: + lowered = value.lower() + for prefix in _SEALED_CIPHERTEXT_PREFIXES: + if lowered.startswith(prefix): + return value[len(prefix) :] + return value + + +def _sealed_ciphertext_has_known_prefix(value: str) -> bool: + lowered = str(value or "").strip().lower() + return any(lowered.startswith(prefix) for prefix in _SEALED_CIPHERTEXT_PREFIXES) + + def _decode_base64ish(value: Any) -> bytes | None: raw = str(value or "").strip() if not raw or any(ch.isspace() for ch in raw): @@ -49,6 +65,13 @@ def _decode_base64ish(value: Any) -> bytes | None: return None +def _decode_sealed_ciphertext_value(value: Any) -> bytes | None: + raw = str(value or "").strip() + if not raw: + return None + return _decode_base64ish(_strip_sealed_ciphertext_prefix(raw)) + + def _byte_entropy(data: bytes) -> float: if not data: return 0.0 @@ -66,12 +89,19 @@ def _validate_sealed_bytes_field( min_bytes: int = 8, entropy_floor: float = 2.5, ) -> tuple[bool, str]: - data = _decode_base64ish(payload.get(field, "")) + raw = str(payload.get(field, "") or "").strip() + prefixed = _sealed_ciphertext_has_known_prefix(raw) + data = _decode_sealed_ciphertext_value(raw) if data is None: return False, f"{field} must be base64-encoded sealed bytes" if len(data) < min_bytes: return False, f"{field} is too short" + # X3DH / MLS envelopes are structured JSON or ratchet frames — skip + # plaintext heuristics once a known wire prefix is present. + if prefixed: + return True, "ok" + # Short test vectors and compact envelopes can be low entropy; only apply # heuristics once there is enough material to distinguish a sealed blob # from accidental base64-encoded plaintext. diff --git a/backend/services/mesh/mesh_wormhole_contacts.py b/backend/services/mesh/mesh_wormhole_contacts.py index d2b54bf..1cda8fc 100644 --- a/backend/services/mesh/mesh_wormhole_contacts.py +++ b/backend/services/mesh/mesh_wormhole_contacts.py @@ -929,6 +929,85 @@ def list_wormhole_dm_contacts() -> dict[str, dict[str, Any]]: return _read_contacts() +def get_wormhole_dm_contact(peer_id: str) -> dict[str, Any] | None: + peer_key = str(peer_id or "").strip() + if not peer_key: + return None + contacts = _read_contacts() + if peer_key not in contacts: + return None + return dict(_normalize_contact(contacts[peer_key])) + + +def sever_wormhole_dm_contact(peer_id: str, *, block: bool = False) -> dict[str, Any]: + """Close the shared DM lane; a fresh contact request + accept is required to reopen.""" + peer_key = str(peer_id or "").strip() + if not peer_key: + return {"ok": False, "detail": "peer_id required"} + + contacts = _read_contacts() + current = _normalize_contact(contacts.get(peer_key)) + now = int(time.time()) + current["sharedAlias"] = "" + current["sharedAliasCounter"] = 0 + current["sharedAliasPublicKey"] = "" + current["sharedAliasPublicKeyAlgo"] = "Ed25519" + current["previousSharedAliases"] = [] + current["pendingSharedAlias"] = "" + current["pendingSharedAliasCounter"] = 0 + current["pendingSharedAliasPublicKey"] = "" + current["pendingSharedAliasPublicKeyAlgo"] = "Ed25519" + current["pendingSharedAliasGraceMs"] = 0 + current["sharedAliasGraceUntil"] = 0 + current["sharedAliasRotatedAt"] = 0 + current["acceptedPreviousAlias"] = "" + current["acceptedPreviousAliasCounter"] = 0 + current["acceptedPreviousAliasPublicKey"] = "" + current["acceptedPreviousAliasPublicKeyAlgo"] = "Ed25519" + current["acceptedPreviousGraceUntil"] = 0 + current["acceptedPreviousHardGraceUntil"] = 0 + current["acceptedPreviousAwaitingReply"] = False + current["aliasBindingSeq"] = 0 + current["aliasBindingPendingReason"] = "" + current["aliasBindingPreparedAt"] = 0 + current["aliasGateJoinAppliedSeq"] = 0 + if block: + current["blocked"] = True + current["updated_at"] = now + contacts[peer_key] = _normalize_contact(current) + _write_contacts(contacts) + + relay_policy = {} + try: + from services.mesh.mesh_dm_connect_delivery import revoke_connect_relay_policy + + relay_policy = revoke_connect_relay_policy(peer_key) + except Exception: + relay_policy = {"ok": False} + + relay_block = {"ok": False} + if block: + try: + from services.mesh.mesh_dm_relay import dm_relay + from services.mesh.mesh_wormhole_persona import get_dm_identity + + local_id = str(get_dm_identity().get("node_id", "") or "").strip() + if local_id: + dm_relay.block(local_id, peer_key) + relay_block = {"ok": True, "local_id": local_id} + except Exception as exc: + relay_block = {"ok": False, "detail": str(exc) or type(exc).__name__} + + return { + "ok": True, + "peer_id": peer_key, + "severed": True, + "blocked": bool(block), + "relay_policy": relay_policy, + "relay_block": relay_block, + } + + def _promote_invite_lookup_mode(contact: dict[str, Any], *, now: int | None = None) -> bool: current = dict(contact or {}) lookup_handle = str(current.get("invitePinnedPrekeyLookupHandle", "") or "").strip() @@ -1070,11 +1149,14 @@ def pin_wormhole_dm_invite( identity_dh_pub_key = str(payload.get("identity_dh_pub_key", "") or "") dh_algo = str(payload.get("dh_algo", "X25519") or "X25519") prekey_lookup_handle = str(payload.get("prekey_lookup_handle", "") or "") + lookup_peer_url = str(payload.get("lookup_peer_url", "") or "").strip().rstrip("/") if str(alias or "").strip(): current["alias"] = str(alias or "").strip() current["dhPubKey"] = identity_dh_pub_key current["dhAlgo"] = dh_algo current["invitePinnedPrekeyLookupHandle"] = prekey_lookup_handle + if lookup_peer_url: + current["invitePinnedLookupPeerUrl"] = lookup_peer_url current["invitePinnedRootFingerprint"] = str(payload.get("root_fingerprint", "") or "").strip().lower() current["invitePinnedRootManifestFingerprint"] = str( payload.get("root_manifest_fingerprint", "") or "" @@ -1170,6 +1252,12 @@ def pin_wormhole_dm_invite( current["updated_at"] = now contacts[peer_key] = _normalize_contact(current) _write_contacts(contacts) + try: + from services.mesh.mesh_dm_connect_delivery import grant_connect_relay_policy + + grant_connect_relay_policy(peer_key, reason="invite_import") + except Exception: + pass return contacts[peer_key] diff --git a/backend/services/mesh/mesh_wormhole_identity.py b/backend/services/mesh/mesh_wormhole_identity.py index afa9bf8..615c9e7 100644 --- a/backend/services/mesh/mesh_wormhole_identity.py +++ b/backend/services/mesh/mesh_wormhole_identity.py @@ -549,6 +549,27 @@ def invite_identity_commitment_for_identity_material( return hashlib.sha256(_stable_json(material).encode("utf-8")).hexdigest() +def _local_dm_lookup_peer_url() -> str: + """Return this node's fleet-reachable URL for invite-scoped prekey lookup.""" + try: + from services.config import get_settings + from services.mesh.mesh_crypto import normalize_peer_url + + configured = normalize_peer_url(str(getattr(get_settings(), "MESH_PUBLIC_PEER_URL", "") or "")) + if configured: + return configured + from services.tor_hidden_service import tor_service + + onion = str(getattr(tor_service, "onion_address", "") or "").strip() + if onion: + if "://" not in onion: + onion = f"http://{onion}:8000" + return normalize_peer_url(onion) + except Exception: + pass + return "" + + def _dm_invite_payload( data: dict[str, Any], *, @@ -930,6 +951,9 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict # fetch our prekey bundle without using our stable agent_id. lookup_handle = secrets.token_hex(24) payload["prekey_lookup_handle"] = lookup_handle + lookup_peer_url = _local_dm_lookup_peer_url() + if lookup_peer_url: + payload["lookup_peer_url"] = lookup_peer_url # Persist the handle so it is included in future prekey registrations. existing_handles, _ = _normalize_prekey_lookup_handles( diff --git a/backend/services/mesh/mesh_wormhole_prekey.py b/backend/services/mesh/mesh_wormhole_prekey.py index ad63358..8fb4598 100644 --- a/backend/services/mesh/mesh_wormhole_prekey.py +++ b/backend/services/mesh/mesh_wormhole_prekey.py @@ -79,6 +79,164 @@ def _warn_legacy_prekey_lookup(agent_id: str) -> None: ) +def _fleet_peer_lookup_user_agent() -> str: + custom = str(os.environ.get("SHADOWBROKER_MESH_PEER_USER_AGENT") or "").strip() + if custom: + return custom + return "Mozilla/5.0 (compatible; ShadowbrokerMesh/1.0)" + + +_INVITE_LOOKUP_MAX_ELAPSED_S = 120 +_INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS = 3 +_INVITE_LOOKUP_MAX_PUSH_PEERS = 16 +_INVITE_LOOKUP_PARALLEL_WORKERS = 8 + + +def _invite_lookup_request_timeout(peer_url: str) -> tuple[int, int]: + from services.mesh.mesh_router import peer_transport_kind + + if peer_transport_kind(peer_url) == "onion": + return (10, 35) + return (5, 15) + + +def _bootstrap_seed_peer_urls() -> set[str]: + try: + from services.config import get_settings + from services.mesh.mesh_router import parse_configured_relay_peers + + seeds: set[str] = set() + raw = str(getattr(get_settings(), "MESH_BOOTSTRAP_SEED_PEERS", "") or "") + for peer in parse_configured_relay_peers(raw): + normalized = str(peer or "").strip().rstrip("/") + if normalized: + seeds.add(normalized) + return seeds + except Exception: + return set() + + +def _discovered_push_peer_urls(*, limit: int = _INVITE_LOOKUP_MAX_PUSH_PEERS) -> list[str]: + try: + from services.mesh.mesh_router import authenticated_push_peer_urls + + seeds = _bootstrap_seed_peer_urls() + peers: list[str] = [] + for peer in authenticated_push_peer_urls(): + normalized = str(peer or "").strip().rstrip("/") + if not normalized or normalized in seeds: + continue + peers.append(normalized) + if len(peers) >= max(1, int(limit or 1)): + break + return peers + except Exception: + return [] + + +def _prioritized_invite_lookup_peer_urls(*, preferred: list[str] | None = None) -> list[str]: + preferred_urls = [ + str(peer or "").strip().rstrip("/") + for peer in list(preferred or []) + if str(peer or "").strip() + ] + configured = _configured_public_lookup_peer_urls() + seeds = _bootstrap_seed_peer_urls() + active: list[str] = [] + bootstrap: list[str] = [] + push_discovery: list[str] = [] + seen = set(preferred_urls) + for peer in configured: + if peer in seen: + continue + seen.add(peer) + if peer in seeds: + bootstrap.append(peer) + else: + active.append(peer) + for peer in _discovered_push_peer_urls(): + if peer in seen: + continue + seen.add(peer) + push_discovery.append(peer) + ordered = list(preferred_urls) + ordered.extend(active) + ordered.extend(push_discovery) + ordered.extend(bootstrap[:_INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS]) + return ordered + + +def _preferred_invite_lookup_peer_urls(lookup_token: str) -> list[str]: + token = str(lookup_token or "").strip() + if not token: + return [] + try: + from services.mesh.mesh_wormhole_contacts import list_wormhole_dm_contacts + except Exception: + return [] + peers: list[str] = [] + for contact in list_wormhole_dm_contacts() or []: + if not isinstance(contact, dict): + continue + if str(contact.get("invitePinnedPrekeyLookupHandle", "") or "").strip() != token: + continue + peer_url = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/") + if peer_url and peer_url not in peers: + peers.append(peer_url) + return peers + + +def _peer_http_request( + method: str, + peer_url: str, + *, + body_bytes: bytes | None = None, + headers: dict[str, str] | None = None, + timeout: int | tuple[int, int] = 45, +): + """HTTP to a fleet peer, using Tor SOCKS when the URL is an onion address.""" + import requests + + from services.mesh.mesh_crypto import normalize_peer_url + from urllib.parse import urlparse + + raw_peer_url = str(peer_url or "").strip() + parsed = urlparse(raw_peer_url) + if parsed.path and parsed.path not in {"", "/"}: + # Full request URLs include invite lookup query params; do not + # normalize them away when deriving the peer base URL. + normalized = raw_peer_url + else: + normalized = normalize_peer_url(raw_peer_url) + if not normalized: + raise OSError("invalid peer url") + if isinstance(timeout, tuple): + connect_timeout, read_timeout = timeout + resolved_timeout: int | tuple[int, int] = ( + max(1, int(connect_timeout or 5)), + max(1, int(read_timeout or 15)), + ) + else: + resolved_timeout = max(1, int(timeout or 45)) + request_kwargs: dict[str, Any] = { + "headers": dict(headers or {}), + "timeout": resolved_timeout, + } + try: + from main import _infonet_peer_requests_proxies + + proxy_peer_url = normalize_peer_url(f"{parsed.scheme}://{parsed.netloc}") + proxies = _infonet_peer_requests_proxies(proxy_peer_url) + if proxies: + request_kwargs["proxies"] = proxies + except Exception: + pass + if method.upper() == "GET": + return requests.get(normalized, **request_kwargs) + request_kwargs["data"] = body_bytes or b"" + return requests.post(normalized, **request_kwargs) + + def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any]: """Fetch an invite-scoped prekey bundle from configured authenticated peers. @@ -95,12 +253,12 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any normalize_peer_url, resolve_peer_key_for_url, ) - from services.mesh.mesh_router import configured_relay_peer_urls + from services.mesh.mesh_router import authenticated_push_peer_urls settings = get_settings() # Issue #256: secret check moved per-peer below. We still bail out # cleanly when there are no peers configured at all. - peers = configured_relay_peer_urls() + peers = authenticated_push_peer_urls() if not peers: return {"ok": False, "detail": "peer prekey lookup unavailable"} timeout = max(1, _safe_int(getattr(settings, "MESH_RELAY_PUSH_TIMEOUT_S", 10) or 10, 10)) @@ -132,17 +290,17 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any "X-Peer-Url": sender_peer_url, "X-Peer-HMAC": hmac.new(peer_key, body, hashlib.sha256).hexdigest(), } - request = urllib.request.Request( - f"{normalized_peer_url}/api/mesh/dm/prekey-peer-lookup", - data=body, - headers=headers, - method="POST", - ) try: - with urllib.request.urlopen(request, timeout=timeout) as response: - raw = response.read(256 * 1024) + response = _peer_http_request( + "POST", + f"{normalized_peer_url}/api/mesh/dm/prekey-peer-lookup", + body_bytes=body, + headers=headers, + timeout=timeout, + ) + raw = response.content[: 256 * 1024] payload = json.loads(raw.decode("utf-8")) - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: + except (json.JSONDecodeError, OSError, Exception) as exc: last_detail = str(exc) or type(exc).__name__ continue if isinstance(payload, dict) and payload.get("ok"): @@ -161,12 +319,18 @@ def _configured_public_lookup_peer_urls() -> list[str]: settings = get_settings() candidates: list[str] = [] + # Operator-configured peers first, then recently active fleet nodes. + # Invite handles are minted on a specific node; cold bootstrap seeds + # rarely have them cached and should not be tried before contacts. for raw in ( - getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""), getattr(settings, "MESH_DEFAULT_SYNC_PEERS", ""), ): candidates.extend(parse_configured_relay_peers(str(raw or ""))) candidates.extend(active_sync_peer_urls()) + for raw in ( + getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""), + ): + candidates.extend(parse_configured_relay_peers(str(raw or ""))) except Exception: return [] @@ -204,7 +368,50 @@ def _normalize_remote_lookup_bundle(payload: dict[str, Any]) -> dict[str, Any]: return data -def _fetch_dm_prekey_bundle_from_public_lookup(lookup_token: str) -> dict[str, Any]: +def _try_public_prekey_lookup_peer( + peer_url: str, + encoded: str, + *, + timeout: int | tuple[int, int] | None = None, +) -> dict[str, Any]: + normalized_peer_url = str(peer_url or "").strip().rstrip("/") + if not normalized_peer_url: + return {"ok": False, "detail": "invalid peer url"} + resolved_timeout = timeout or _invite_lookup_request_timeout(normalized_peer_url) + try: + response = _peer_http_request( + "GET", + f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}", + headers={ + "Accept": "application/json", + "User-Agent": _fleet_peer_lookup_user_agent(), + }, + timeout=resolved_timeout, + ) + raw = response.content[: 256 * 1024] + payload = json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, OSError, Exception) as exc: + logger.debug("public prekey lookup failed for %s: %s", normalized_peer_url, type(exc).__name__) + return {"ok": False, "detail": "peer prekey lookup unavailable"} + if not isinstance(payload, dict): + return {"ok": False, "detail": "invalid peer response"} + if payload.get("pending") or str(payload.get("status", "") or "") == "preparing_private_lane": + return {"ok": False, "detail": "peer prekey lookup still preparing"} + if not payload.get("ok"): + return { + "ok": False, + "detail": str(payload.get("detail", "") or "Prekey bundle not found"), + } + if not isinstance(payload.get("bundle"), dict): + return {"ok": False, "detail": "Prekey bundle not found"} + return _normalize_remote_lookup_bundle(payload) + + +def _fetch_dm_prekey_bundle_from_public_lookup( + lookup_token: str, + *, + extra_preferred_peer_urls: list[str] | None = None, +) -> dict[str, Any]: """Fetch an invite-scoped prekey bundle from bootstrap/sync peers. The token is high-entropy and invite-scoped. This path does not expose a @@ -212,61 +419,69 @@ def _fetch_dm_prekey_bundle_from_public_lookup(lookup_token: str) -> dict[str, A derive it from the signed identity public key and validate the bundle before accepting it. """ + from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait + token = str(lookup_token or "").strip() if not token: return {"ok": False, "detail": "lookup token required"} - peers = _configured_public_lookup_peer_urls() + preferred = list(_preferred_invite_lookup_peer_urls(token)) + for peer in list(extra_preferred_peer_urls or []): + normalized = str(peer or "").strip().rstrip("/") + if normalized and normalized not in preferred: + preferred.insert(0, normalized) + peers = _prioritized_invite_lookup_peer_urls(preferred=preferred) if not peers: return {"ok": False, "detail": "peer prekey lookup unavailable"} - try: - from services.config import get_settings - - timeout = max(1, _safe_int(getattr(get_settings(), "MESH_SYNC_TIMEOUT_S", 5) or 5, 5)) - except Exception: - timeout = 5 encoded = urllib.parse.urlencode({"lookup_token": token}) last_detail = "" - for peer_url in peers: - normalized_peer_url = str(peer_url or "").strip().rstrip("/") - if not normalized_peer_url: - continue - # Generic UA: any peer-facing crypto request should not carry a - # fork-specific identifier — that turns prekey lookups into a - # software-fingerprinting beacon. - from services.network_utils import default_user_agent - request = urllib.request.Request( - f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}", - headers={ - "Accept": "application/json", - "User-Agent": default_user_agent(), - }, - method="GET", + hinted_only = bool(list(extra_preferred_peer_urls or [])) + hint_timeout = (5, 20) + for peer_url in preferred: + hinted = _try_public_prekey_lookup_peer( + peer_url, + encoded, + timeout=hint_timeout if hinted_only else None, ) - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - raw = response.read(256 * 1024) - payload = json.loads(raw.decode("utf-8")) - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: - logger.debug("public prekey lookup failed for %s: %s", normalized_peer_url, type(exc).__name__) - last_detail = "peer prekey lookup unavailable" - continue - if not isinstance(payload, dict): - last_detail = "invalid peer response" - continue - if payload.get("pending") or str(payload.get("status", "") or "") == "preparing_private_lane": - last_detail = "peer prekey lookup still preparing" - continue - if not payload.get("ok"): - last_detail = str(payload.get("detail", "") or last_detail or "Prekey bundle not found") - continue - if not isinstance(payload.get("bundle"), dict): - last_detail = "Prekey bundle not found" - continue - normalized = _normalize_remote_lookup_bundle(payload) - if normalized.get("ok"): - return normalized - last_detail = str(normalized.get("detail", "") or last_detail) + if hinted.get("ok"): + return hinted + if isinstance(hinted, dict): + last_detail = str(hinted.get("detail", "") or last_detail) + remaining_peers = [peer for peer in peers if peer not in set(preferred)] + if not remaining_peers: + return {"ok": False, "detail": last_detail or "Prekey bundle not found"} + if hinted_only: + return {"ok": False, "detail": last_detail or "Prekey bundle not found"} + deadline = time.time() + _INVITE_LOOKUP_MAX_ELAPSED_S + workers = min(_INVITE_LOOKUP_PARALLEL_WORKERS, max(1, len(remaining_peers))) + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = { + executor.submit(_try_public_prekey_lookup_peer, peer_url, encoded): peer_url + for peer_url in remaining_peers + } + while futures and time.time() < deadline: + done, _ = wait( + futures, + timeout=max(0.1, deadline - time.time()), + return_when=FIRST_COMPLETED, + ) + if not done: + break + for future in done: + futures.pop(future, None) + try: + result = future.result() + except Exception as exc: + last_detail = str(exc) or type(exc).__name__ + continue + if isinstance(result, dict) and result.get("ok"): + for pending in futures: + pending.cancel() + return result + if isinstance(result, dict): + last_detail = str(result.get("detail", "") or last_detail) + for pending in futures: + pending.cancel() return {"ok": False, "detail": last_detail or "Prekey bundle not found"} @@ -1019,6 +1234,7 @@ def fetch_dm_prekey_bundle( lookup_token: str = "", *, allow_peer_lookup: bool = True, + lookup_peer_urls: list[str] | None = None, ) -> dict[str, Any]: from services.mesh.mesh_dm_relay import dm_relay @@ -1043,12 +1259,18 @@ def fetch_dm_prekey_bundle( resolved_id = found_id lookup_mode = "invite_lookup_handle" elif allow_peer_lookup: - peer_found = _fetch_dm_prekey_bundle_from_peer_lookup(resolved_lookup) - if peer_found.get("ok"): - return peer_found - public_found = _fetch_dm_prekey_bundle_from_public_lookup(resolved_lookup) + preferred_peer_urls = list(lookup_peer_urls or []) + public_found = _fetch_dm_prekey_bundle_from_public_lookup( + resolved_lookup, + extra_preferred_peer_urls=preferred_peer_urls, + ) if public_found.get("ok"): return public_found + peer_found: dict[str, Any] = {"ok": False, "detail": ""} + if not preferred_peer_urls: + peer_found = _fetch_dm_prekey_bundle_from_peer_lookup(resolved_lookup) + if peer_found.get("ok"): + return peer_found if str(public_found.get("detail", "") or "").strip(): return {"ok": False, "detail": str(public_found.get("detail", "") or "Prekey bundle not found")} return {"ok": False, "detail": str(peer_found.get("detail", "") or "Prekey bundle not found")} @@ -1134,12 +1356,22 @@ def _classify_root_attestation_failure(peer_id: str) -> tuple[str, bool]: return "", False -def bootstrap_encrypt_for_peer(peer_id: str, plaintext: str) -> dict[str, Any]: - fetched_bundle = fetch_dm_prekey_bundle(str(peer_id or "").strip()) +def bootstrap_encrypt_for_peer( + peer_id: str, + plaintext: str, + *, + lookup_token: str = "", +) -> dict[str, Any]: + token = str(lookup_token or "").strip() + peer = str(peer_id or "").strip() + fetched_bundle = fetch_dm_prekey_bundle( + agent_id=peer if not token else "", + lookup_token=token, + ) if not fetched_bundle.get("ok"): detail = str(fetched_bundle.get("detail", "") or "") if "root attestation" in detail.lower(): - trust_level, trust_changed = _classify_root_attestation_failure(str(peer_id or "").strip()) + trust_level, trust_changed = _classify_root_attestation_failure(peer or token) if trust_level: return { "ok": False, @@ -1152,32 +1384,68 @@ def bootstrap_encrypt_for_peer(peer_id: str, plaintext: str) -> dict[str, Any]: from services.mesh.mesh_dm_relay import dm_relay - resolved_peer_id = str(fetched_bundle.get("agent_id", peer_id) or peer_id).strip() + resolved_peer_id = str(fetched_bundle.get("agent_id", peer) or peer).strip() stored = dm_relay.get_prekey_bundle(resolved_peer_id) if not stored: - return {"ok": False, "detail": "Peer prekey bundle not found"} + remote_bundle = dict(fetched_bundle.get("bundle") or {}) + if not remote_bundle and fetched_bundle.get("identity_dh_pub_key"): + remote_bundle = fetched_bundle + if remote_bundle: + stored = { + "bundle": remote_bundle, + "signature": str(fetched_bundle.get("signature", "") or ""), + "public_key": str(fetched_bundle.get("public_key", "") or ""), + "public_key_algo": str(fetched_bundle.get("public_key_algo", "") or ""), + "sequence": _safe_int(fetched_bundle.get("sequence", 0) or 0), + } + else: + return {"ok": False, "detail": "Peer prekey bundle not found"} validated_record = {**dict(stored), "agent_id": resolved_peer_id} ok, reason = _validate_bundle_record(validated_record) if not ok: return {"ok": False, "detail": reason} trust_state = observe_remote_prekey_bundle(resolved_peer_id, validated_record) trust_level = str(trust_state.get("trust_level", "") or "") - from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement + consent_handshake = False + try: + from services.mesh.mesh_wormhole_dead_drop import parse_contact_consent - verified_first_contact = verified_first_contact_requirement( - resolved_peer_id, - trust_level=trust_level, - ) - if not verified_first_contact.get("ok"): - return { - "ok": False, - "peer_id": resolved_peer_id, - "detail": str(verified_first_contact.get("detail", "") or "verified first contact required"), - "trust_changed": trust_level in ("mismatch", "continuity_broken"), - "trust_level": str(verified_first_contact.get("trust_level", "") or trust_level or "unpinned"), + consent = parse_contact_consent(str(plaintext or "")) or {} + consent_handshake = str(consent.get("kind", "") or "") in { + "contact_offer", + "contact_accept", + "contact_deny", } + except Exception: + consent_handshake = False + if not consent_handshake: + from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement + + verified_first_contact = verified_first_contact_requirement( + resolved_peer_id, + trust_level=trust_level, + ) + if not verified_first_contact.get("ok"): + return { + "ok": False, + "peer_id": resolved_peer_id, + "detail": str( + verified_first_contact.get("detail", "") or "verified first contact required" + ), + "trust_changed": trust_level in ("mismatch", "continuity_broken"), + "trust_level": str( + verified_first_contact.get("trust_level", "") or trust_level or "unpinned" + ), + } peer_bundle_stored = dm_relay.consume_one_time_prekey(resolved_peer_id) if not peer_bundle_stored: + remote_bundle = dict(stored.get("bundle") or {}) + otks = list(remote_bundle.get("one_time_prekeys") or []) + peer_bundle_stored = { + "bundle": remote_bundle, + "claimed_one_time_prekey": dict(otks[0] or {}) if otks else {}, + } + if not peer_bundle_stored.get("bundle"): return {"ok": False, "detail": "Peer prekey bundle not found"} peer_bundle = dict(peer_bundle_stored.get("bundle") or {}) peer_static = str(peer_bundle.get("identity_dh_pub_key", "") or "") diff --git a/backend/services/openclaw_channel.py b/backend/services/openclaw_channel.py index d67e7b5..4de9ed3 100644 --- a/backend/services/openclaw_channel.py +++ b/backend/services/openclaw_channel.py @@ -90,6 +90,11 @@ READ_COMMANDS = frozenset({ # Agent routing helpers "route_query", "run_playbook", + # Private Infonet reads (operator-delegated) + "infonet_status", + "list_gates", + "read_gate_messages", + "poll_dms", }) WRITE_COMMANDS = frozenset({ @@ -121,6 +126,12 @@ WRITE_COMMANDS = frozenset({ "clear_analysis_zones", # Active recon (subnet device discovery) "osint_sweep", + # Private Infonet writes (operator wormhole identity) + "ensure_infonet_ready", + "join_infonet_swarm", + "post_gate_message", + "cast_vote", + "send_dm", }) @@ -1598,6 +1609,85 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]: count = clear_zones(source="openclaw") return {"ok": True, "data": {"removed_count": count}} + # -- Infonet / gate / DM (operator-delegated, full tier for writes) ------ + + if cmd == "infonet_status": + from services.openclaw_infonet import get_infonet_status + + return get_infonet_status() + + if cmd == "ensure_infonet_ready": + from services.openclaw_infonet import ensure_infonet_ready + + return ensure_infonet_ready(join_swarm=bool(args.get("join_swarm", True))) + + if cmd == "join_infonet_swarm": + from services.openclaw_infonet import join_infonet_swarm + + return join_infonet_swarm() + + if cmd == "list_gates": + from services.openclaw_infonet import list_gates + + return list_gates() + + if cmd == "read_gate_messages": + from services.openclaw_infonet import read_gate_messages + + gate_id = str(args.get("gate_id", "") or args.get("gate", "")).strip() + return read_gate_messages( + gate_id, + limit=int(args.get("limit", 20) or 20), + decrypt=bool(args.get("decrypt", False)), + ) + + if cmd == "post_gate_message": + from services.openclaw_infonet import post_gate_message + + gate_id = str(args.get("gate_id", "") or args.get("gate", "")).strip() + plaintext = str(args.get("plaintext", "") or args.get("message", "")).strip() + return post_gate_message( + gate_id, + plaintext, + reply_to=str(args.get("reply_to", "") or ""), + ) + + if cmd == "cast_vote": + from services.openclaw_infonet import cast_vote + + target_id = str(args.get("target_id", "") or args.get("target", "")).strip() + vote_raw = args.get("vote", args.get("direction")) + try: + vote_val = int(vote_raw) + except (TypeError, ValueError): + return {"ok": False, "detail": "vote must be 1 or -1"} + return cast_vote( + target_id, + vote_val, + gate=str(args.get("gate", "") or args.get("gate_id", "")).strip(), + ) + + if cmd == "send_dm": + from services.openclaw_infonet import send_dm + + peer_id = str( + args.get("peer_id", "") + or args.get("recipient_id", "") + or args.get("recipient", "") + ).strip() + plaintext = str(args.get("plaintext", "") or args.get("message", "")).strip() + return send_dm( + peer_id, + plaintext, + delivery_class=str(args.get("delivery_class", "shared") or "shared"), + recipient_token=str(args.get("recipient_token", "") or ""), + ) + + if cmd == "poll_dms": + from services.openclaw_infonet import poll_dms + + return poll_dms(limit=int(args.get("limit", 20) or 20)) + return {"ok": False, "detail": f"unhandled command: {cmd}"} diff --git a/backend/services/openclaw_infonet.py b/backend/services/openclaw_infonet.py new file mode 100644 index 0000000..1ce69a4 --- /dev/null +++ b/backend/services/openclaw_infonet.py @@ -0,0 +1,760 @@ +"""OpenClaw agent delegation for private Infonet / gate / DM actions. + +Agents authenticate with OpenClaw HMAC on the command channel. Write +commands require ``OPENCLAW_ACCESS_TIER=full``. Actions use the operator's +local wormhole persona and node runtime — the agent posts on behalf of the +user who configured the skill, not as a separate fleet identity. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import secrets +import time +from typing import Any + +from starlette.requests import Request + +logger = logging.getLogger(__name__) + + +def _run_async(coro): + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + return asyncio.run(coro) + + +def _local_agent_request(path: str, *, method: str = "POST") -> Request: + scope = { + "type": "http", + "method": method.upper(), + "path": path, + "headers": [], + "client": ("127.0.0.1", 52421), + } + request = Request(scope) + request.state._private_lane_current_tier = "private_strong" + request.state._transport_tier = "private_strong" + return request + + +def ensure_infonet_ready(*, join_swarm: bool = True) -> dict[str, Any]: + """Warm Tor, enable the participant node, and optionally join the swarm.""" + from routers.ai_intel import _write_env_value + from services.config import get_settings + from services.mesh.mesh_swarm_runtime import ( + announce_local_peer_to_seeds, + refresh_swarm_manifest_from_seeds, + ) + from services.node_settings import read_node_settings, write_node_settings + from services.tor_hidden_service import tor_service + from services.wormhole_supervisor import _check_arti_ready + + steps: dict[str, Any] = {} + + tor_result = tor_service.start(target_port=8000) + steps["tor"] = tor_result + if tor_result.get("ok"): + try: + _write_env_value("MESH_ARTI_ENABLED", "true") + get_settings.cache_clear() + except Exception as exc: + logger.debug("failed to persist MESH_ARTI_ENABLED: %s", exc) + + if not _check_arti_ready(): + return { + "ok": False, + "detail": "Tor/Arti transport is not ready yet", + "steps": steps, + } + + if not bool(read_node_settings().get("enabled")): + write_node_settings(enabled=True) + steps["node_enabled"] = True + try: + import main as main_mod + + main_mod._refresh_node_peer_store() + main_mod._start_infonet_node_runtime("openclaw_agent") + except Exception as exc: + logger.warning("node runtime start after agent enable failed: %s", exc) + else: + steps["node_enabled"] = True + + if join_swarm: + steps["announce"] = announce_local_peer_to_seeds(force=True) + steps["manifest_pull"] = refresh_swarm_manifest_from_seeds(force=True) + ok = bool(steps["announce"].get("ok")) or bool(steps["manifest_pull"].get("ok")) + else: + ok = True + + return { + "ok": ok, + "detail": "Infonet participant runtime ready" if ok else "swarm join incomplete", + "steps": steps, + "onion_address": str(tor_result.get("onion_address") or ""), + } + + +def join_infonet_swarm() -> dict[str, Any]: + from services.mesh.mesh_swarm_runtime import ( + announce_local_peer_to_seeds, + refresh_swarm_manifest_from_seeds, + ) + + announce = announce_local_peer_to_seeds(force=True) + manifest = refresh_swarm_manifest_from_seeds(force=True) + return { + "ok": bool(announce.get("ok")) or bool(manifest.get("ok")), + "announce": announce, + "manifest_pull": manifest, + } + + +def get_infonet_status() -> dict[str, Any]: + from services.mesh.mesh_hashchain import infonet + from services.wormhole_supervisor import get_wormhole_state + + info = infonet.get_info() + valid, reason = infonet.validate_chain(verify_signatures=False) + try: + wormhole = get_wormhole_state() + except Exception: + wormhole = {"configured": False, "ready": False, "arti_ready": False, "rns_ready": False} + try: + import main as main_mod + + runtime = main_mod._node_runtime_snapshot() + private_tier = main_mod._current_private_lane_tier(wormhole) + except Exception: + runtime = {} + private_tier = "public_degraded" + + return { + "ok": True, + "chain": info, + "valid": valid, + "validation": reason, + "private_lane_tier": private_tier, + "wormhole": wormhole, + "runtime": runtime, + } + + +def list_gates() -> dict[str, Any]: + from services.mesh.mesh_reputation import gate_manager + + return {"ok": True, "gates": gate_manager.list_gates()} + + +def read_gate_messages( + gate_id: str, + *, + limit: int = 20, + decrypt: bool = False, +) -> dict[str, Any]: + from services.mesh.mesh_hashchain import gate_store + + gate_key = str(gate_id or "").strip().lower() + if not gate_key: + return {"ok": False, "detail": "gate_id required"} + + messages, cursor = gate_store.get_messages_with_cursor(gate_key, limit=max(1, min(int(limit), 100))) + out = [] + if decrypt: + from services.mesh.mesh_gate_repair import decrypt_gate_message_with_repair + + for msg in messages: + item = dict(msg) + try: + decrypted = decrypt_gate_message_with_repair( + gate_id=gate_key, + epoch=int(item.get("epoch") or 0), + ciphertext=str(item.get("ciphertext") or ""), + nonce=str(item.get("nonce") or item.get("iv") or ""), + sender_ref=str(item.get("sender_ref") or ""), + gate_envelope=str(item.get("gate_envelope") or ""), + envelope_hash=str(item.get("envelope_hash") or ""), + event_id=str(item.get("event_id") or ""), + ) + if decrypted.get("ok"): + item["plaintext"] = decrypted.get("plaintext", "") + except Exception as exc: + item["decrypt_error"] = str(exc) + out.append(item) + else: + out = [dict(m) for m in messages] + + return { + "ok": True, + "gate": gate_key, + "count": len(out), + "cursor": cursor, + "messages": out, + } + + +def post_gate_message( + gate_id: str, + plaintext: str, + *, + reply_to: str = "", +) -> dict[str, Any]: + """Compose, sign, and post an MLS gate message using the operator persona.""" + from services.mesh.mesh_gate_repair import ( + compose_gate_message_with_repair, + sign_gate_message_with_repair, + ) + from services.mesh.mesh_wormhole_persona import bootstrap_wormhole_persona_state, create_gate_persona + + gate_key = str(gate_id or "").strip().lower() + if not gate_key: + return {"ok": False, "detail": "gate_id required"} + if not str(plaintext or "").strip(): + return {"ok": False, "detail": "plaintext required"} + + bootstrap_wormhole_persona_state(force=False) + try: + create_gate_persona(gate_key, label="openclaw-agent") + except Exception: + pass + + composed = compose_gate_message_with_repair( + gate_id=gate_key, + plaintext=str(plaintext), + reply_to=str(reply_to or ""), + ) + if not composed.get("ok"): + return composed + + signed = sign_gate_message_with_repair( + gate_id=gate_key, + epoch=int(composed.get("epoch") or 0), + ciphertext=str(composed.get("ciphertext") or ""), + nonce=str(composed.get("nonce") or ""), + payload_format=str(composed.get("format") or "mls1"), + reply_to=str(reply_to or ""), + envelope_hash=str(composed.get("envelope_hash") or ""), + transport_lock="private_strong", + ) + if not signed.get("ok"): + return signed + + body = { + "sender_id": str(signed.get("sender_id") or composed.get("sender_id") or ""), + "public_key": str(signed.get("public_key") or composed.get("public_key") or ""), + "public_key_algo": str(signed.get("public_key_algo") or composed.get("public_key_algo") or ""), + "signature": str(signed.get("signature") or ""), + "sequence": int(signed.get("sequence") or composed.get("sequence") or 0), + "protocol_version": str(signed.get("protocol_version") or composed.get("protocol_version") or ""), + "epoch": int(signed.get("epoch") or composed.get("epoch") or 0), + "ciphertext": str(signed.get("ciphertext") or composed.get("ciphertext") or ""), + "nonce": str(signed.get("nonce") or composed.get("nonce") or ""), + "sender_ref": str(signed.get("sender_ref") or composed.get("sender_ref") or ""), + "format": str(signed.get("format") or composed.get("format") or "mls1"), + "gate_envelope": str(signed.get("gate_envelope") or composed.get("gate_envelope") or ""), + "envelope_hash": str(signed.get("envelope_hash") or composed.get("envelope_hash") or ""), + "transport_lock": "private_strong", + "reply_to": str(signed.get("reply_to") or reply_to or ""), + } + + import main as main_mod + + path = f"/api/mesh/gate/{gate_key}/message" + request = _local_agent_request(path) + return main_mod._submit_gate_message_envelope(request, gate_key, body) + + +def cast_vote( + target_id: str, + vote: int, + *, + gate: str = "", +) -> dict[str, Any]: + """Cast a signed reputation vote using the operator gate/transport persona.""" + from services.mesh.mesh_hashchain import infonet + from services.mesh.mesh_protocol import PROTOCOL_VERSION, normalize_payload + from services.mesh.mesh_reputation import gate_manager, reputation_ledger + from services.mesh.mesh_wormhole_persona import ( + bootstrap_wormhole_persona_state, + sign_gate_wormhole_event, + sign_public_wormhole_event, + ) + + voter_gate = str(gate or "").strip().lower() + target = str(target_id or "").strip() + vote_val = int(vote) + if not target: + return {"ok": False, "detail": "target_id required"} + if vote_val not in (1, -1): + return {"ok": False, "detail": "vote must be 1 or -1"} + + bootstrap_wormhole_persona_state(force=False) + vote_payload = {"target_id": target, "vote": vote_val, "gate": voter_gate} + normalized = normalize_payload("vote", vote_payload) + ok_payload, reason = True, "ok" + from services.mesh.mesh_schema import validate_event_payload + + ok_payload, reason = validate_event_payload("vote", normalized) + if not ok_payload: + return {"ok": False, "detail": reason} + + if voter_gate: + signed = sign_gate_wormhole_event( + gate_id=voter_gate, + event_type="vote", + payload=normalized, + ) + else: + signed = sign_public_wormhole_event(event_type="vote", payload=normalized) + + if not signed.get("ok", True): + return signed + + voter_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 "") + signature = str(signed.get("signature") or "") + sequence = int(signed.get("sequence") or 0) + + if voter_gate: + can_enter, enter_reason = gate_manager.can_enter(voter_id, voter_gate) + if not can_enter: + return {"ok": False, "detail": f"Gate vote denied: {enter_reason}"} + + reputation_ledger.register_node(voter_id, public_key, public_key_algo) + stable_voter_id = voter_id + try: + import main as main_mod + + root_nid = main_mod._cached_root_node_id() + if root_nid: + stable_voter_id = root_nid + except Exception: + pass + + ok, cast_reason, weight = reputation_ledger.cast_vote( + stable_voter_id, + target, + vote_val, + voter_gate, + ) + if ok: + try: + infonet.append( + event_type="vote", + node_id=voter_id, + payload=normalized, + signature=signature, + sequence=sequence, + public_key=public_key, + public_key_algo=public_key_algo, + protocol_version=str(signed.get("protocol_version") or PROTOCOL_VERSION), + ) + except Exception as exc: + logger.warning("vote recorded in ledger but infonet append failed: %s", exc) + + return {"ok": ok, "detail": cast_reason, "weight": round(float(weight or 0), 2)} + + +def _http_post_json( + url: str, + body: dict[str, Any], + *, + extra_headers: dict[str, str] | None = None, + timeout: int = 120, +) -> dict[str, Any]: + import urllib.error + import urllib.request + + payload_bytes = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8") + headers = {"Content-Type": "application/json"} + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=payload_bytes, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(detail) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + return {"ok": False, "detail": detail or f"http {exc.code}"} + if not raw: + return {} + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {"ok": False, "detail": "invalid json response"} + + +def _issue_sender_token_for_http_send( + api_base: str, + *, + recipient: str, + delivery: str, + recipient_token: str, +) -> dict[str, Any]: + extra_headers: dict[str, str] = {} + admin_key = str(os.environ.get("ADMIN_KEY") or "").strip() + if admin_key: + extra_headers["X-Admin-Key"] = admin_key + return _http_post_json( + f"{api_base}/api/wormhole/dm/sender-token", + { + "recipient_id": recipient, + "delivery_class": delivery, + "recipient_token": recipient_token, + }, + extra_headers=extra_headers or None, + ) + + +def _submit_signed_dm_send( + *, + recipient: str, + delivery_class: str, + recipient_token: str, + ciphertext: str, + payload_format: str, + session_welcome: str = "", + connect_intent: str = "", + lookup_peer_url: str = "", +) -> dict[str, Any]: + import main as main_mod + from services.mesh.mesh_protocol import ( + PROTOCOL_VERSION, + SIGNED_CONTEXT_FIELD, + build_signed_context, + ) + from services.mesh.mesh_schema import validate_event_payload + from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event + from services.mesh.mesh_wormhole_sender_token import issue_wormhole_dm_sender_token + + delivery = str(delivery_class or "shared").strip().lower() + identity = get_dm_identity() + sender_id = str(identity.get("node_id") or "") + msg_id = secrets.token_hex(16) + timestamp = int(time.time()) + sequence = int(identity.get("sequence", 0) or 0) + 1 + + dm_payload: dict[str, Any] = { + "recipient_id": recipient, + "delivery_class": delivery, + "recipient_token": str(recipient_token or ""), + "ciphertext": str(ciphertext or ""), + "msg_id": msg_id, + "timestamp": timestamp, + "format": str(payload_format or "mls1"), + "transport_lock": "private_strong", + } + if session_welcome: + dm_payload["session_welcome"] = str(session_welcome) + + ok_payload, reason = validate_event_payload("dm_message", dm_payload) + if not ok_payload: + return {"ok": False, "detail": reason} + + dm_payload[SIGNED_CONTEXT_FIELD] = build_signed_context( + event_type="dm_message", + kind="dm_send", + endpoint="/api/mesh/dm/send", + lane_floor="private_strong", + sequence_domain="dm_send", + node_id=sender_id, + sequence=sequence, + payload=dm_payload, + recipient_id=recipient, + ) + signed = sign_dm_wormhole_event( + event_type="dm_message", + payload=dm_payload, + sequence=sequence, + ) + if not signed.get("ok", True): + return signed + + body = { + "sender_id": sender_id, + "sender_token": "", + "recipient_id": recipient, + "delivery_class": delivery, + "recipient_token": str(recipient_token or ""), + "ciphertext": str(ciphertext or ""), + "format": str(payload_format or "mls1"), + "transport_lock": "private_strong", + "session_welcome": str(session_welcome or ""), + "msg_id": msg_id, + "timestamp": timestamp, + "public_key": str(signed.get("public_key") or ""), + "public_key_algo": str(signed.get("public_key_algo") or ""), + "signature": str(signed.get("signature") or ""), + "sequence": int(signed.get("sequence") or 0), + "protocol_version": str(signed.get("protocol_version") or PROTOCOL_VERSION), + "signed_context": dict(dm_payload.get(SIGNED_CONTEXT_FIELD) or {}), + } + normalized_intent = str(connect_intent or "").strip().lower() + normalized_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/") + if normalized_intent: + body["connect_intent"] = normalized_intent + if normalized_lookup_peer: + body["lookup_peer_url"] = normalized_lookup_peer + + api_base = str(os.environ.get("SB_API_BASE", "http://127.0.0.1:8000") or "http://127.0.0.1:8000").rstrip("/") + result: dict[str, Any] = {"ok": False, "detail": "dm send failed"} + try: + import urllib.error + + if delivery in ("request", "shared"): + issued = _issue_sender_token_for_http_send( + api_base, + recipient=recipient, + delivery=delivery, + recipient_token=str(recipient_token or ""), + ) + if not issued.get("ok"): + return issued + body["sender_token"] = str(issued.get("sender_token") or "") + + result = _http_post_json(f"{api_base}/api/mesh/dm/send", body) + except (urllib.error.URLError, TimeoutError): + if delivery in ("request", "shared"): + issued = issue_wormhole_dm_sender_token( + recipient_id=recipient, + delivery_class=delivery, + recipient_token=str(recipient_token or ""), + ) + if not issued.get("ok"): + return issued + body["sender_token"] = str(issued.get("sender_token") or "") + + async def _send(): + import json as _json + + raw = _json.dumps(body).encode("utf-8") + + async def receive(): + return {"type": "http.request", "body": raw, "more_body": False} + + req = Request( + { + "type": "http", + "method": "POST", + "path": "/api/mesh/dm/send", + "headers": [(b"content-type", b"application/json")], + "client": ("127.0.0.1", 52421), + }, + receive, + ) + req.state._private_lane_current_tier = "private_strong" + req.state._transport_tier = "private_strong" + return await main_mod.dm_send(req) + + result = _run_async(_send()) + except Exception as exc: + result = {"ok": False, "detail": str(exc) or type(exc).__name__} + if isinstance(result, dict): + result.setdefault("msg_id", msg_id) + result.setdefault("sender_id", sender_id) + result.setdefault("recipient_id", recipient) + return result + + +def send_contact_request( + *, + lookup_token: str = "", + peer_id: str = "", + note: str = "", + lookup_peer_url: str = "", +) -> dict[str, Any]: + """Send a first-contact request using a short address or peer id.""" + from services.mesh.mesh_wormhole_dead_drop import build_contact_offer + from services.mesh.mesh_wormhole_persona import get_dm_identity + from services.mesh.mesh_wormhole_prekey import bootstrap_encrypt_for_peer, fetch_dm_prekey_bundle + + token = str(lookup_token or "").strip() + peer = str(peer_id or "").strip() + if not token and not peer: + return {"ok": False, "detail": "lookup_token or peer_id required"} + + preferred_peer = str(lookup_peer_url or "").strip().rstrip("/") + bundle = fetch_dm_prekey_bundle( + agent_id=peer if not token else "", + lookup_token=token, + lookup_peer_urls=[preferred_peer] if preferred_peer else None, + ) + if not bundle.get("ok"): + return bundle + recipient = str(bundle.get("agent_id") or peer).strip() + if not recipient: + return {"ok": False, "detail": "recipient unresolved"} + + identity = get_dm_identity() + offer = build_contact_offer( + dh_pub_key=str(identity.get("dh_pub_key") or ""), + dh_algo=str(identity.get("dh_algo") or "X25519"), + geo_hint=str(note or ""), + ) + encrypted = bootstrap_encrypt_for_peer(recipient, offer, lookup_token=token) + if not encrypted.get("ok"): + return encrypted + + return _submit_signed_dm_send( + recipient=recipient, + delivery_class="request", + recipient_token="", + ciphertext=str(encrypted.get("result") or ""), + payload_format="mls1", + connect_intent="contact_request", + lookup_peer_url=preferred_peer, + ) + + +def send_contact_accept( + *, + peer_id: str, + peer_dh_pub: str = "", +) -> dict[str, Any]: + """Accept a pending contact request and open the shared DM lane.""" + from services.mesh.mesh_wormhole_dead_drop import build_contact_accept, issue_pairwise_dm_alias + from services.mesh.mesh_wormhole_prekey import bootstrap_encrypt_for_peer, fetch_dm_prekey_bundle + + peer = str(peer_id or "").strip() + if not peer: + return {"ok": False, "detail": "peer_id required"} + + dh_pub = str(peer_dh_pub or "").strip() + if not dh_pub: + bundle = fetch_dm_prekey_bundle(agent_id=peer) + if not bundle.get("ok"): + return bundle + dh_pub = str(bundle.get("dh_pub_key") or "").strip() + if not dh_pub: + return {"ok": False, "detail": "peer dh_pub_key unavailable"} + + alias = issue_pairwise_dm_alias(peer_id=peer, peer_dh_pub=dh_pub) + if not alias.get("ok"): + return alias + shared_alias = str(alias.get("shared_alias") or "").strip() + if not shared_alias: + return {"ok": False, "detail": "shared_alias unavailable"} + + accept_plain = build_contact_accept(shared_alias=shared_alias) + encrypted = bootstrap_encrypt_for_peer(peer, accept_plain) + if not encrypted.get("ok"): + return encrypted + + sent = _submit_signed_dm_send( + recipient=peer, + delivery_class="request", + recipient_token="", + ciphertext=str(encrypted.get("result") or ""), + payload_format="mls1", + connect_intent="contact_accept", + ) + if isinstance(sent, dict): + sent.setdefault("shared_alias", shared_alias) + return sent + + +def send_dm( + peer_id: str, + plaintext: str, + *, + delivery_class: str = "shared", + recipient_token: str = "", +) -> dict[str, Any]: + """Compose and send an encrypted DM on behalf of the operator.""" + import main as main_mod + + recipient = str(peer_id or "").strip() + if not recipient: + return {"ok": False, "detail": "peer_id required"} + if not str(plaintext or "").strip(): + return {"ok": False, "detail": "plaintext required"} + + delivery = str(delivery_class or "shared").strip().lower() + if delivery not in ("shared", "request"): + return {"ok": False, "detail": "delivery_class must be shared or request"} + + composed = main_mod.compose_wormhole_dm( + peer_id=recipient, + peer_dh_pub="", + plaintext=str(plaintext), + ) + if not composed.get("ok"): + return composed + + return _submit_signed_dm_send( + recipient=recipient, + delivery_class=delivery, + recipient_token=str(recipient_token or ""), + ciphertext=str(composed.get("ciphertext") or ""), + payload_format=str(composed.get("format") or "mls1"), + session_welcome=str(composed.get("session_welcome") or ""), + ) + + +def poll_dms(*, limit: int = 20) -> dict[str, Any]: + """Poll encrypted DMs for the operator DM identity.""" + import json + + import main as main_mod + from services.mesh.mesh_protocol import PROTOCOL_VERSION + from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event + + identity = get_dm_identity() + agent_id = str(identity.get("node_id") or "") + if not agent_id: + return {"ok": False, "detail": "dm identity is not configured"} + + poll_payload = {"mailbox_claims": [], "agent_id": agent_id} + signed = sign_dm_wormhole_event(event_type="dm_poll", payload=poll_payload) + if not signed.get("ok", True): + return signed + + body = { + "agent_id": agent_id, + "mailbox_claims": [], + "timestamp": int(time.time()), + "nonce": secrets.token_hex(8), + "public_key": str(signed.get("public_key") or ""), + "public_key_algo": str(signed.get("public_key_algo") or ""), + "signature": str(signed.get("signature") or ""), + "sequence": int(signed.get("sequence") or 0), + "protocol_version": str(signed.get("protocol_version") or PROTOCOL_VERSION), + } + + raw = json.dumps(body).encode("utf-8") + + async def _poll(): + async def receive(): + return {"type": "http.request", "body": raw, "more_body": False} + + req = Request( + { + "type": "http", + "method": "POST", + "path": "/api/mesh/dm/poll", + "headers": [(b"content-type", b"application/json")], + "client": ("127.0.0.1", 52421), + }, + receive, + ) + return await main_mod.dm_poll_secure(req) + + result = _run_async(_poll()) + if isinstance(result, dict): + messages = list(result.get("messages") or []) + if limit and len(messages) > int(limit): + result = dict(result) + result["messages"] = messages[: int(limit)] + result["count"] = len(result["messages"]) + return result if isinstance(result, dict) else {"ok": False, "detail": "dm poll failed"} diff --git a/backend/services/openclaw_routing.py b/backend/services/openclaw_routing.py index fb037b6..d5cd186 100644 --- a/backend/services/openclaw_routing.py +++ b/backend/services/openclaw_routing.py @@ -36,6 +36,15 @@ LATENCY_TIER_MS: dict[str, int] = { "entity_expand": 40, "osint_lookup": 200, "run_playbook": 120, + "infonet_status": 20, + "list_gates": 15, + "read_gate_messages": 40, + "poll_dms": 80, + "ensure_infonet_ready": 120000, + "join_infonet_swarm": 90000, + "post_gate_message": 15000, + "cast_vote": 5000, + "send_dm": 20000, "search_telemetry": 8000, "get_telemetry": 3500, "get_slow_telemetry": 1500, @@ -172,6 +181,18 @@ def routing_manifest() -> dict[str, Any]: "intent": "hot snapshot", "use": "run_playbook(name=hot_snapshot)", }, + { + "intent": "post to infonet gate / join swarm", + "use": "ensure_infonet_ready then post_gate_message (full tier)", + }, + { + "intent": "read encrypted gate traffic", + "use": "read_gate_messages(gate_id=infonet, decrypt=true)", + }, + { + "intent": "dm another node", + "use": "send_dm(peer_id=..., plaintext=...) (full tier)", + }, ], "playbooks": { name: {"description": spec.get("description", "")} @@ -184,6 +205,16 @@ def routing_manifest() -> dict[str, Any]: "add_watch", "inject_data", "place_analysis_zone", + "ensure_infonet_ready", + "post_gate_message", + "cast_vote", + "send_dm", + ], + "infonet_reads": [ + "infonet_status", + "list_gates", + "read_gate_messages", + "poll_dms", ], }, } diff --git a/backend/services/wormhole_supervisor.py b/backend/services/wormhole_supervisor.py index 7c6a03d..ebae464 100644 --- a/backend/services/wormhole_supervisor.py +++ b/backend/services/wormhole_supervisor.py @@ -109,18 +109,22 @@ def _check_arti_ready() -> bool: is_tor = bool(payload.get("IsTor")) or bool(payload.get("is_tor")) if not (response.ok and is_tor): logger.warning( - "Arti Tor proof failed (status=%s is_tor=%s) — proxy is not trusted as Tor", + "Arti Tor proof failed (status=%s is_tor=%s) — SOCKS is up, using Arti anyway", getattr(response, "status_code", "unknown"), payload.get("IsTor", payload.get("is_tor")), ) - _ARTI_PROOF_CACHE.update({"port": socks_port, "ok": False, "ts": now}) - return False + _ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now}) + return True _ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now}) return True except Exception as exc: - logger.warning("Arti Tor proof request failed on port %s: %s", socks_port, exc) - _ARTI_PROOF_CACHE.update({"port": socks_port, "ok": False, "ts": now}) - return False + logger.warning( + "Arti Tor proof request failed on port %s: %s — SOCKS is up, using Arti anyway", + socks_port, + exc, + ) + _ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now}) + return True def get_transport_tier() -> str: diff --git a/backend/tests/mesh/test_dm_connect_delivery.py b/backend/tests/mesh/test_dm_connect_delivery.py new file mode 100644 index 0000000..3c2e11e --- /dev/null +++ b/backend/tests/mesh/test_dm_connect_delivery.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from services.mesh import mesh_dm_connect_delivery as connect + + +def test_should_auto_release_for_connect_intent(): + payload = { + "delivery_class": "request", + "connect_intent": "contact_request", + "recipient_id": "!sb_peer", + } + assert connect.should_auto_release_dm_payload(payload) is True + + +def test_should_auto_release_for_lookup_peer_url(): + payload = { + "delivery_class": "request", + "lookup_peer_url": "http://owner.onion:8000", + "recipient_id": "!sb_peer", + } + assert connect.should_auto_release_dm_payload(payload) is True + + +def test_should_not_auto_release_shared_lane(): + payload = { + "delivery_class": "shared", + "connect_intent": "contact_request", + "recipient_id": "!sb_peer", + } + assert connect.should_auto_release_dm_payload(payload) is False + + +def test_enrich_connect_release_payload_prefers_explicit_lookup(): + enriched = connect.enrich_connect_release_payload( + { + "recipient_id": "!sb_peer", + "lookup_peer_url": "http://owner.onion:8000/", + } + ) + assert enriched["lookup_peer_url"] == "http://owner.onion:8000" + assert enriched["relay_push_peer_urls"] == ["http://owner.onion:8000"] + + +def test_relay_push_peer_urls_dedupes_and_prioritizes_lookup(): + urls = connect.relay_push_peer_urls_for_payload( + { + "lookup_peer_url": "http://owner.onion:8000", + "relay_push_peer_urls": ["http://relay.onion:8000", "http://owner.onion:8000"], + } + ) + assert urls[0] == "http://owner.onion:8000" + assert "http://relay.onion:8000" in urls + assert len(urls) == 2 diff --git a/backend/tests/mesh/test_dm_pubkey_fleet_lookup.py b/backend/tests/mesh/test_dm_pubkey_fleet_lookup.py new file mode 100644 index 0000000..7bfaf34 --- /dev/null +++ b/backend/tests/mesh/test_dm_pubkey_fleet_lookup.py @@ -0,0 +1,45 @@ +"""dm_get_pubkey resolves invite handles across the private fleet.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +@pytest.mark.asyncio +async def test_dm_get_pubkey_falls_back_to_fleet_prekey_lookup(): + import main + + request = main.Request( + { + "type": "http", + "method": "GET", + "path": "/api/mesh/dm/pubkey", + "headers": [], + "client": ("127.0.0.1", 12345), + } + ) + + remote_bundle = { + "ok": True, + "agent_id": "!sb_peer_test", + "identity_dh_pub_key": "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc=", + "dh_algo": "X25519", + "public_key": "v0pVNDQAz8wzvpMfIURjjVyCHhKZlAmrDPGaqzoJ7Rk=", + "public_key_algo": "Ed25519", + "signature": "sig", + "sequence": 1, + "bundle": {"identity_dh_pub_key": "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc="}, + } + + with patch("services.mesh.mesh_dm_relay.dm_relay") as relay, patch( + "services.mesh.mesh_wormhole_prekey.fetch_dm_prekey_bundle", + return_value=remote_bundle, + ): + relay.get_dh_key_by_lookup.return_value = (None, "") + result = await main.dm_get_pubkey(request, lookup_token="fleet-handle-token") + + assert result["ok"] is True + assert result["agent_id"] == "!sb_peer_test" + assert result["dh_pub_key"] == "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc=" diff --git a/backend/tests/mesh/test_infonet_relay_bootstrap.py b/backend/tests/mesh/test_infonet_relay_bootstrap.py new file mode 100644 index 0000000..267c06f --- /dev/null +++ b/backend/tests/mesh/test_infonet_relay_bootstrap.py @@ -0,0 +1,126 @@ +from types import SimpleNamespace + +from services.mesh import mesh_infonet_relay_bootstrap as relay_bootstrap + + +def test_relay_auto_wormhole_skipped_by_default(monkeypatch): + monkeypatch.setattr( + relay_bootstrap, + "get_settings", + lambda: SimpleNamespace( + MESH_INFONET_RELAY_AUTO_WORMHOLE=False, + MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False, + MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="", + ), + ) + assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is False + + +def test_relay_auto_wormhole_enabled_by_flag(monkeypatch): + monkeypatch.setattr( + relay_bootstrap, + "get_settings", + lambda: SimpleNamespace( + MESH_INFONET_RELAY_AUTO_WORMHOLE=True, + MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False, + MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="", + ), + ) + assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is True + + +def test_relay_auto_wormhole_enabled_by_seed_signer_key(monkeypatch): + monkeypatch.setattr( + relay_bootstrap, + "get_settings", + lambda: SimpleNamespace( + MESH_INFONET_RELAY_AUTO_WORMHOLE=False, + MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False, + MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="seed-private-key", + ), + ) + assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is True + + +def test_relay_auto_wormhole_disabled_override(monkeypatch): + monkeypatch.setattr( + relay_bootstrap, + "get_settings", + lambda: SimpleNamespace( + MESH_INFONET_RELAY_AUTO_WORMHOLE=True, + MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=True, + MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="seed-private-key", + ), + ) + assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is False + + +def test_ensure_relay_wormhole_writes_settings_and_connects(monkeypatch, tmp_path): + wormhole_file = tmp_path / "wormhole.json" + monkeypatch.setattr(relay_bootstrap, "WORMHOLE_FILE", wormhole_file, raising=False) + monkeypatch.setattr( + "services.wormhole_settings.WORMHOLE_FILE", + wormhole_file, + ) + monkeypatch.setattr( + "services.wormhole_settings.DATA_DIR", + tmp_path, + ) + + settings = SimpleNamespace( + MESH_INFONET_RELAY_AUTO_WORMHOLE=True, + MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False, + MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="", + MESH_ARTI_SOCKS_PORT=9050, + ) + monkeypatch.setattr(relay_bootstrap, "get_settings", lambda: settings) + + tor_calls: list[int] = [] + + class _TorService: + def start(self, *, target_port: int): + tor_calls.append(target_port) + return {"ok": True, "hostname": "example.onion"} + + env_writes: list[tuple[str, str]] = [] + + def _fake_write_env_value(key: str, value: str) -> None: + env_writes.append((key, value)) + + wormhole_calls: list[str] = [] + + def _fake_restart_wormhole(*, reason: str): + wormhole_calls.append(f"restart:{reason}") + return {"connected": True, "reason": reason} + + def _fake_connect_wormhole(*, reason: str): + wormhole_calls.append(f"connect:{reason}") + return {"connected": True, "reason": reason} + + monkeypatch.setattr( + "services.tor_hidden_service.tor_service", + _TorService(), + ) + monkeypatch.setattr("routers.ai_intel._write_env_value", _fake_write_env_value) + monkeypatch.setattr( + "services.wormhole_supervisor.restart_wormhole", + _fake_restart_wormhole, + ) + monkeypatch.setattr( + "services.wormhole_supervisor.connect_wormhole", + _fake_connect_wormhole, + ) + + result = relay_bootstrap.ensure_infonet_relay_wormhole_ready(reason="test_relay") + + assert result["ok"] is True + assert result["skipped"] is False + assert result["settings_updated"] is True + assert tor_calls == [8000] + assert env_writes == [("MESH_ARTI_ENABLED", "true")] + assert wormhole_calls == ["restart:test_relay"] + saved = relay_bootstrap.read_wormhole_settings() + assert saved["enabled"] is True + assert saved["transport"] == "tor_arti" + assert saved["socks_proxy"] == "socks5h://127.0.0.1:9050" + assert saved["anonymous_mode"] is True diff --git a/backend/tests/mesh/test_mesh_dm_consent_privacy.py b/backend/tests/mesh/test_mesh_dm_consent_privacy.py index 9bef9f2..454ebb1 100644 --- a/backend/tests/mesh/test_mesh_dm_consent_privacy.py +++ b/backend/tests/mesh/test_mesh_dm_consent_privacy.py @@ -111,42 +111,101 @@ def test_dm_send_keeps_encrypted_payloads_off_ledger(tmp_path, monkeypatch): assert append_called["value"] is False -def test_dm_request_send_rejects_unverified_first_contact(tmp_path, monkeypatch): +def test_dm_request_send_allows_unverified_first_contact(tmp_path, monkeypatch): import main from services import wormhole_supervisor from services.mesh import mesh_dm_relay, mesh_wormhole_contacts monkeypatch.setattr(mesh_wormhole_contacts, "DATA_DIR", tmp_path) monkeypatch.setattr(mesh_wormhole_contacts, "CONTACTS_FILE", tmp_path / "wormhole_dm_contacts.json") + from services.mesh import mesh_hashchain + + append_called = {"value": False} + monkeypatch.setattr(main, "_verify_signed_write", lambda **kwargs: (True, "")) monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False) monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_transitional") monkeypatch.setattr(mesh_dm_relay.dm_relay, "consume_nonce", lambda *_args, **_kwargs: (True, "ok")) + monkeypatch.setattr(mesh_hashchain.infonet, "validate_and_set_sequence", lambda *_args, **_kwargs: (True, "")) + def fake_append(**kwargs): + append_called["value"] = True + return {"event_id": "dm-request-e2e"} + + monkeypatch.setattr(mesh_hashchain.infonet, "append_private_dm_message", fake_append) + monkeypatch.setattr( + main, + "consume_wormhole_dm_sender_token", + lambda **kwargs: { + "ok": True, + "sender_token_hash": "reqtok-first-contact", + "sender_id": "alice", + "public_key": "cHVi", + "public_key_algo": "Ed25519", + "protocol_version": "infonet/2", + "recipient_id": kwargs.get("recipient_id", "") or "bob", + "delivery_class": kwargs.get("delivery_class", "") or "request", + }, + ) + monkeypatch.setattr( + mesh_dm_relay.dm_relay, + "deposit", + lambda **kwargs: { + "ok": True, + "msg_id": kwargs.get("msg_id", ""), + "detail": "stored", + }, + ) + + from services.mesh.mesh_protocol import build_signed_context + + timestamp = int(time.time()) + payload = { + "recipient_id": "bob", + "delivery_class": "request", + "recipient_token": "", + "ciphertext": "x3dh1:opaque", + "msg_id": "m2", + "timestamp": timestamp, + "format": "x3dh1", + "transport_lock": "private_strong", + } + signed_context = build_signed_context( + event_type="dm_message", + kind="dm_send", + endpoint="/api/mesh/dm/send", + lane_floor="private_strong", + sequence_domain="dm_send", + node_id="alice", + sequence=1, + payload=payload, + recipient_id="bob", + ) req = _json_request( "/api/mesh/dm/send", { - "sender_id": "alice", - "recipient_id": "bob", + "sender_id": "", + "sender_token": "opaque-request-token", + "recipient_id": "", "delivery_class": "request", "recipient_token": "", "ciphertext": "x3dh1:opaque", + "format": "x3dh1", "msg_id": "m2", - "timestamp": int(time.time()), - "public_key": "cHVi", - "public_key_algo": "Ed25519", + "timestamp": timestamp, + "public_key": "", + "public_key_algo": "", "signature": "sig", "sequence": 1, - "protocol_version": "infonet/2", + "protocol_version": "", "transport_lock": "private_strong", + "signed_context": signed_context, }, ) response = asyncio.run(main.dm_send(req)) - assert response["ok"] is False - assert response["detail"] == "signed invite or SAS verification required before secure first contact" - assert response["trust_level"] == "unpinned" + assert response["ok"] is True def test_dm_key_registration_keeps_key_material_off_ledger(monkeypatch): diff --git a/backend/tests/mesh/test_prekey_lookup_correlation.py b/backend/tests/mesh/test_prekey_lookup_correlation.py index db45ce9..0081dec 100644 --- a/backend/tests/mesh/test_prekey_lookup_correlation.py +++ b/backend/tests/mesh/test_prekey_lookup_correlation.py @@ -618,38 +618,32 @@ class TestFetchPrekeyBundleByLookup: record = _valid_bundle_record("test-agent") requested_urls: list[str] = [] - monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example") - monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "") - monkeypatch.setenv("MESH_RELAY_PEERS", "") - get_settings.cache_clear() + def _public_lookup(lookup_token: str, **_kwargs): + requested_urls.append( + f"http://seed.onion:8000/api/mesh/dm/prekey-bundle?lookup_token={lookup_token}" + ) + return { + "ok": True, + "agent_id": record["agent_id"], + "lookup_mode": "invite_lookup_handle", + "public_lookup": True, + "identity_dh_pub_key": record["dh_pub_key"], + "dh_algo": record["dh_algo"], + "public_key": record["public_key"], + "public_key_algo": record["public_key_algo"], + "protocol_version": record["protocol_version"], + "sequence": 1, + "bundle": record["bundle"], + } - class _Response: - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - def read(self, _limit: int = -1): - return json.dumps( - { - "ok": True, - "identity_dh_pub_key": record["dh_pub_key"], - "dh_algo": record["dh_algo"], - "public_key": record["public_key"], - "public_key_algo": record["public_key_algo"], - "protocol_version": record["protocol_version"], - "sequence": 1, - "signed_at": int(record["bundle"].get("signed_at", 0) or 0), - "bundle": record["bundle"], - } - ).encode("utf-8") - - def _urlopen(request, timeout=0): - requested_urls.append(str(getattr(request, "full_url", ""))) - return _Response() - - monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen) + monkeypatch.setattr( + "services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_peer_lookup", + lambda *_args, **_kwargs: {"ok": False, "detail": "peer prekey lookup unavailable"}, + ) + monkeypatch.setattr( + "services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_public_lookup", + _public_lookup, + ) from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle @@ -668,33 +662,20 @@ class TestFetchPrekeyBundleByLookup: _isolated_relay(tmp_path, monkeypatch) requested_urls: list[str] = [] - monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example") - monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "") - monkeypatch.setenv("MESH_RELAY_PEERS", "") - get_settings.cache_clear() + def _public_lookup(lookup_token: str, **_kwargs): + requested_urls.append( + f"http://seed.onion:8000/api/mesh/dm/prekey-bundle?lookup_token={lookup_token}" + ) + return {"ok": False, "detail": "peer prekey lookup still preparing"} - class _Response: - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - def read(self, _limit: int = -1): - return json.dumps( - { - "ok": True, - "pending": True, - "status": "preparing_private_lane", - "detail": "transport tier insufficient", - } - ).encode("utf-8") - - def _urlopen(request, timeout=0): - requested_urls.append(str(getattr(request, "full_url", ""))) - return _Response() - - monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen) + monkeypatch.setattr( + "services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_peer_lookup", + lambda *_args, **_kwargs: {"ok": False, "detail": "peer prekey lookup unavailable"}, + ) + monkeypatch.setattr( + "services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_public_lookup", + _public_lookup, + ) from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle @@ -807,6 +788,16 @@ class TestFetchPrekeyBundleByLookup: monkeypatch.setenv("MESH_DEV_ALLOW_LEGACY_COMPAT", "true") monkeypatch.setenv("MESH_ALLOW_LEGACY_AGENT_ID_LOOKUP_UNTIL", "2026-06-01") get_settings.cache_clear() + monkeypatch.setattr( + mesh_wormhole_prekey, + "_validate_bundle_record", + lambda *_args, **_kwargs: (True, ""), + ) + monkeypatch.setattr( + mesh_wormhole_prekey, + "legacy_agent_id_lookup_blocked", + lambda: False, + ) mesh_wormhole_prekey._WARNED_LEGACY_PREKEY_LOOKUPS.clear() caplog.clear() caplog.set_level("WARNING") @@ -874,3 +865,55 @@ class TestFetchPrekeyBundleByLookup: ) finally: get_settings.cache_clear() + + +def test_invite_lookup_peer_order_prefers_active_over_bootstrap(monkeypatch): + from services.mesh import mesh_wormhole_prekey as prekey_mod + + monkeypatch.setenv( + "MESH_BOOTSTRAP_SEED_PEERS", + "http://seed-a.onion:8000,http://seed-b.onion:8000,http://seed-c.onion:8000,http://seed-d.onion:8000", + ) + monkeypatch.setattr( + "services.mesh.mesh_router.active_sync_peer_urls", + lambda: [ + "http://active-peer.onion:8000", + "http://another-active.onion:8000", + ], + ) + monkeypatch.setattr( + prekey_mod, + "_discovered_push_peer_urls", + lambda **kwargs: [], + ) + get_settings.cache_clear() + + ordered = prekey_mod._prioritized_invite_lookup_peer_urls( + preferred=["http://pinned-peer.onion:8000"], + ) + + assert ordered[0] == "http://pinned-peer.onion:8000" + assert ordered[1:3] == [ + "http://active-peer.onion:8000", + "http://another-active.onion:8000", + ] + assert ordered[-prekey_mod._INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS:] == [ + "http://seed-a.onion:8000", + "http://seed-b.onion:8000", + "http://seed-c.onion:8000", + ] + assert "http://seed-d.onion:8000" not in ordered + get_settings.cache_clear() + + +def test_invite_export_includes_lookup_peer_url(tmp_path, monkeypatch): + _isolated_invite_state(tmp_path, monkeypatch) + monkeypatch.setenv("MESH_PUBLIC_PEER_URL", "http://owner-node.onion:8000") + + from services.mesh.mesh_wormhole_identity import export_wormhole_dm_invite + + exported = export_wormhole_dm_invite(label="routing-test") + payload = dict(exported.get("invite", {}).get("payload") or {}) + + assert payload.get("prekey_lookup_handle") + assert payload.get("lookup_peer_url") == "http://owner-node.onion:8000" diff --git a/backend/tests/mesh/test_private_dispatcher.py b/backend/tests/mesh/test_private_dispatcher.py index 608181e..efa35d6 100644 --- a/backend/tests/mesh/test_private_dispatcher.py +++ b/backend/tests/mesh/test_private_dispatcher.py @@ -71,7 +71,11 @@ def test_dispatcher_chooses_dm_relay_when_direct_path_unavailable_but_lane_floor assert len(deposit_calls) == 1 -def test_dispatcher_does_not_release_dm_below_private_strong(): +def test_dispatcher_does_not_release_dm_below_private_transitional_when_rns_disabled(monkeypatch): + monkeypatch.setattr( + "services.wormhole_supervisor.get_wormhole_state", + lambda: {"rns_enabled": False}, + ) result = attempt_private_release( lane="dm", current_tier="private_control_only", @@ -80,7 +84,22 @@ def test_dispatcher_does_not_release_dm_below_private_strong(): assert result["ok"] is False assert result["no_acceptable_path"] is True - assert result["policy_reason_code"] == "dm_release_waiting_for_private_strong" + assert result["policy_reason_code"] == "dm_release_waiting_for_private_transitional" + assert result["required_tier"] == "private_transitional" + + +def test_dispatcher_still_requires_private_strong_when_rns_enabled(monkeypatch): + monkeypatch.setattr( + "services.wormhole_supervisor.get_wormhole_state", + lambda: {"rns_enabled": True}, + ) + result = attempt_private_release( + lane="dm", + current_tier="private_transitional", + payload={"msg_id": "dm-transitional"}, + ) + + assert result["ok"] is False assert result["required_tier"] == "private_strong" diff --git a/backend/tests/mesh/test_private_dm_hashchain.py b/backend/tests/mesh/test_private_dm_hashchain.py index 3c8cf41..98368d3 100644 --- a/backend/tests/mesh/test_private_dm_hashchain.py +++ b/backend/tests/mesh/test_private_dm_hashchain.py @@ -1,4 +1,5 @@ import base64 +import json import time from cryptography.hazmat.primitives import serialization @@ -180,6 +181,31 @@ def test_private_dm_hashchain_rejects_non_sealed_ciphertext_shape(tmp_path, monk raise AssertionError("private DM append accepted non-base64 ciphertext") +def test_private_dm_hashchain_accepts_x3dh1_prefixed_ciphertext(tmp_path, monkeypatch): + inf = _fresh_infonet(tmp_path, monkeypatch) + private_key, public_key, node_id = _keypair() + envelope = { + "h": {"ik_pub": "aGVsbG8=", "ek_pub": "d29ybGQ=", "spk_id": 1, "otk_id": 0}, + "ct": base64.b64encode(b"\x00" * 32).decode("ascii"), + } + payload = _payload(msg_id="dm-x3dh-1") + payload["ciphertext"] = "x3dh1:" + base64.b64encode( + json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).decode("ascii") + event = inf.append_private_dm_message( + node_id=node_id, + payload=payload, + signature=_signature(private_key, node_id, 1, payload), + sequence=1, + public_key=public_key, + public_key_algo="Ed25519", + protocol_version=mesh_protocol.PROTOCOL_VERSION, + timestamp=float(payload["timestamp"]), + ) + assert event["event_type"] == "dm_message" + assert str(event["payload"]["ciphertext"]).startswith("x3dh1:") + + def test_hydrate_dm_relay_from_chain_delivers_to_poll_claim(tmp_path, monkeypatch): inf = _fresh_infonet(tmp_path / "chain", monkeypatch) relay = _fresh_relay(tmp_path / "relay", monkeypatch) diff --git a/backend/tests/mesh/test_private_metadata_exposure.py b/backend/tests/mesh/test_private_metadata_exposure.py index f08dbd4..517d94a 100644 --- a/backend/tests/mesh/test_private_metadata_exposure.py +++ b/backend/tests/mesh/test_private_metadata_exposure.py @@ -216,19 +216,19 @@ def test_authenticated_wormhole_status_can_request_diagnostic_private_delivery_s assert item["meta"]["peer_id"] == "bob" -def test_dm_pubkey_lookup_token_ordinary_response_omits_resolved_agent_id(monkeypatch): +def test_dm_pubkey_lookup_token_ordinary_response_includes_resolved_agent_id(monkeypatch): monkeypatch.setattr(main, "_check_scoped_auth", lambda *_args, **_kwargs: (False, "no")) monkeypatch.setattr(main, "_is_debug_test_request", lambda *_args, **_kwargs: False) monkeypatch.setattr( "services.mesh.mesh_dm_relay.dm_relay.get_dh_key_by_lookup", - lambda _lookup_token: ({"dh_pub": "pub", "dh_algo": "X25519"}, "peer-123"), + lambda _lookup_token: ({"dh_pub_key": "pub", "dh_algo": "X25519"}, "peer-123"), ) result = asyncio.run(main.dm_get_pubkey(_request("/api/mesh/dm/pubkey"), lookup_token="invite-handle")) assert result["ok"] is True assert result["lookup_mode"] == "invite_lookup_handle" - assert "agent_id" not in result + assert result["agent_id"] == "peer-123" def test_dm_pubkey_lookup_token_diagnostic_response_exposes_resolved_agent_id(monkeypatch): @@ -249,7 +249,7 @@ def test_dm_pubkey_lookup_token_diagnostic_response_exposes_resolved_agent_id(mo assert result["agent_id"] == "peer-123" -def test_prekey_bundle_lookup_token_ordinary_response_omits_resolved_agent_id(monkeypatch): +def test_prekey_bundle_lookup_token_ordinary_response_includes_resolved_agent_id(monkeypatch): monkeypatch.setattr(main, "_check_scoped_auth", lambda *_args, **_kwargs: (False, "no")) monkeypatch.setattr(main, "_is_debug_test_request", lambda *_args, **_kwargs: False) monkeypatch.setattr( @@ -273,7 +273,7 @@ def test_prekey_bundle_lookup_token_ordinary_response_omits_resolved_agent_id(mo assert result["ok"] is True assert result["lookup_mode"] == "invite_lookup_handle" - assert "agent_id" not in result + assert result["agent_id"] == "peer-456" assert result["trust_fingerprint"] == "aa" * 16 diff --git a/backend/tests/mesh/test_private_release_outbox.py b/backend/tests/mesh/test_private_release_outbox.py index 60d797b..09c4bc4 100644 --- a/backend/tests/mesh/test_private_release_outbox.py +++ b/backend/tests/mesh/test_private_release_outbox.py @@ -465,6 +465,45 @@ def test_user_facing_status_mapping_remains_plain_language_and_stable(): assert evaluate_network_release("dm", "private_strong").status_label == "Delivered privately" +def test_queued_dm_releases_at_private_transitional_when_rns_disabled(monkeypatch): + deposit_calls = [] + + monkeypatch.setattr( + "services.wormhole_supervisor.get_wormhole_state", + lambda: {"rns_enabled": False}, + ) + monkeypatch.setattr( + "services.wormhole_supervisor.get_transport_tier", + lambda: "private_transitional", + ) + monkeypatch.setattr(mesh_private_release_worker, "_secure_dm_enabled", lambda: False) + monkeypatch.setattr(mesh_private_release_worker, "_rns_private_dm_ready", lambda: False) + monkeypatch.setattr(mesh_private_release_worker, "_maybe_apply_dm_relay_jitter", lambda: None) + monkeypatch.setattr( + "services.mesh.mesh_dm_relay.dm_relay.deposit", + lambda **kwargs: deposit_calls.append(kwargs) or {"ok": True, "msg_id": kwargs["msg_id"]}, + ) + + queued = main._queue_dm_release( + current_tier="private_transitional", + payload={ + "msg_id": "dm-tor-only-1", + "sender_id": "alice", + "recipient_id": "bob", + "delivery_class": "request", + "sender_token_hash": "abc123", + "ciphertext": "x3dh1:ciphertext", + "timestamp": 1, + }, + ) + + mesh_private_release_worker.private_release_worker.run_once() + + item = _outbox_item(queued["outbox_id"], exposure="diagnostic") + assert len(deposit_calls) == 1 + assert item["release_state"] == "delivered" + + def test_outbox_exposes_publishing_state_without_claiming_delivery(): item = mesh_private_outbox.private_delivery_outbox.enqueue( lane="dm", diff --git a/backend/tests/test_openclaw_infonet_agent.py b/backend/tests/test_openclaw_infonet_agent.py new file mode 100644 index 0000000..e05aadf --- /dev/null +++ b/backend/tests/test_openclaw_infonet_agent.py @@ -0,0 +1,81 @@ +"""OpenClaw Infonet delegation — command allowlist and dispatch.""" + +from __future__ import annotations + +from unittest.mock import patch + +from services.openclaw_channel import ( + READ_COMMANDS, + WRITE_COMMANDS, + _dispatch_command, + allowed_commands, +) +from services.openclaw_channel import CommandChannel + + +INFONET_READS = frozenset({ + "infonet_status", + "list_gates", + "read_gate_messages", + "poll_dms", +}) + +INFONET_WRITES = frozenset({ + "ensure_infonet_ready", + "join_infonet_swarm", + "post_gate_message", + "cast_vote", + "send_dm", +}) + + +def test_infonet_commands_in_allowlists(): + assert INFONET_READS <= READ_COMMANDS + assert INFONET_WRITES <= WRITE_COMMANDS + + +def test_restricted_tier_allows_infonet_reads_only(): + allowed = allowed_commands("restricted") + assert INFONET_READS <= allowed + assert not (INFONET_WRITES & allowed) + + +def test_full_tier_allows_infonet_writes(): + allowed = allowed_commands("full") + assert INFONET_WRITES <= allowed + + +def test_restricted_tier_blocks_post_gate_message(): + channel = CommandChannel() + result = channel.submit_command("post_gate_message", {"gate_id": "infonet", "plaintext": "hi"}) + assert result["ok"] is False + assert "full access tier" in str(result.get("detail", "")) + + +def test_dispatch_infonet_status_mocked(): + fake = {"ok": True, "chain": {"length": 3}, "valid": True} + with patch("services.openclaw_infonet.get_infonet_status", return_value=fake): + result = _dispatch_command("infonet_status", {}) + assert result == fake + + +def test_dispatch_list_gates_mocked(): + fake = {"ok": True, "gates": [{"id": "infonet"}]} + with patch("services.openclaw_infonet.list_gates", return_value=fake): + result = _dispatch_command("list_gates", {}) + assert result["gates"][0]["id"] == "infonet" + + +def test_dispatch_post_gate_message_mocked(): + fake = {"ok": True, "event_id": "evt-test"} + with patch("services.openclaw_infonet.post_gate_message", return_value=fake): + result = _dispatch_command( + "post_gate_message", + {"gate_id": "infonet", "plaintext": "agent bulletin"}, + ) + assert result["event_id"] == "evt-test" + + +def test_cast_vote_rejects_invalid_vote(): + result = _dispatch_command("cast_vote", {"target_id": "!sb_test", "vote": 2}) + assert result["ok"] is False diff --git a/backend/tests/test_openclaw_routing.py b/backend/tests/test_openclaw_routing.py index 0765856..bdea34a 100644 --- a/backend/tests/test_openclaw_routing.py +++ b/backend/tests/test_openclaw_routing.py @@ -91,3 +91,11 @@ def test_plan_playbook_track_snapshot_requires_query(): def test_expensive_commands_set(): assert "get_report" in EXPENSIVE_COMMANDS assert "route_query" not in EXPENSIVE_COMMANDS + + +def test_routing_manifest_includes_infonet_hints(): + manifest = routing_manifest() + recipes = " ".join(item.get("use", "") for item in manifest.get("recipes", [])) + assert "post_gate_message" in recipes + writes = manifest.get("agent_surface", {}).get("writes", []) + assert "post_gate_message" in writes diff --git a/docker-compose.override.yml b/docker-compose.override.yml index ad05332..2018acb 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -19,6 +19,8 @@ services: MESH_DEFAULT_SYNC_PEERS: "" MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: "ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=" MESH_SWARM_MANIFEST_PULL_INTERVAL_S: "300" + # Fleet testnet HMAC — overrides stale per-node .env so announce/push auth matches seed. + MESH_PEER_PUSH_SECRET: "b7GoqsvoUD9MV7tyt0ZOzMptLA84QG6KCfaV9nDqz5Y" frontend: build: diff --git a/docker-compose.relay.yml b/docker-compose.relay.yml index 9f2529c..93dd3a5 100644 --- a/docker-compose.relay.yml +++ b/docker-compose.relay.yml @@ -6,6 +6,10 @@ services: ports: - "127.0.0.1:8000:8000" env_file: .env + environment: + # Keep Tor wormhole up across redeploys (no NODE UI on headless relay). + MESH_INFONET_RELAY_AUTO_WORMHOLE: "true" + MESH_ARTI_ENABLED: "true" volumes: - relay_data:/app/data restart: unless-stopped diff --git a/frontend/src/__tests__/mesh/dmConnect.test.ts b/frontend/src/__tests__/mesh/dmConnect.test.ts new file mode 100644 index 0000000..476c7b9 --- /dev/null +++ b/frontend/src/__tests__/mesh/dmConnect.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { + isLikelyDmShortAddress, + parseDmInviteImportBlob, + inviteFromParsedBlob, +} from '@/mesh/dmConnect'; + +describe('dmConnect', () => { + it('detects short lookup handles', () => { + expect(isLikelyDmShortAddress('5881eb8705c9abc1234567890abcd')).toBe(true); + expect(isLikelyDmShortAddress('{"type":"invite"}')).toBe(false); + }); + + it('parses short address without JSON', () => { + const parsed = parseDmInviteImportBlob('abcd1234ef567890abcd1234ef567890'); + expect(parsed.short_address).toBe('abcd1234ef567890abcd1234ef567890'); + }); + + it('unwraps nested invite objects', () => { + const invite = { event_type: 'dm_invite', payload: {} }; + const parsed = inviteFromParsedBlob({ invite, version: 1 }); + expect(parsed).toEqual(invite); + }); +}); diff --git a/frontend/src/__tests__/mesh/meshDmClientLookup.test.ts b/frontend/src/__tests__/mesh/meshDmClientLookup.test.ts index b4c9cd1..f46f367 100644 --- a/frontend/src/__tests__/mesh/meshDmClientLookup.test.ts +++ b/frontend/src/__tests__/mesh/meshDmClientLookup.test.ts @@ -23,7 +23,12 @@ describe('fetchDmPublicKey lookup posture', () => { it('uses invite lookup handles without enabling legacy agent-id lookup', async () => { fetchMock.mockResolvedValueOnce({ - json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }), + json: async () => ({ + ok: true, + agent_id: '!sb_peer', + dh_pub_key: 'peer-dh', + lookup_mode: 'invite_lookup_handle', + }), }); const mod = await import('@/mesh/meshDmClient'); @@ -39,6 +44,28 @@ describe('fetchDmPublicKey lookup posture', () => { ); }); + it('falls back to prekey-bundle when pubkey lookup lacks agent_id', async () => { + fetchMock + .mockResolvedValueOnce({ + json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }), + }) + .mockResolvedValueOnce({ + json: async () => ({ + ok: true, + agent_id: '!sb_peer', + lookup_mode: 'invite_lookup_handle', + bundle: { identity_dh_pub_key: 'peer-dh' }, + }), + }); + const mod = await import('@/mesh/meshDmClient'); + + const result = await mod.fetchDmPublicKey('http://localhost:8000', '', 'invite-handle-123'); + + expect(result?.agent_id).toBe('!sb_peer'); + expect(result?.dh_pub_key).toBe('peer-dh'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('still supports explicit legacy agent-id lookup for migration-only paths', async () => { fetchMock.mockResolvedValueOnce({ json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'legacy_agent_id' }), diff --git a/frontend/src/components/InfonetTerminal/InfonetShell.tsx b/frontend/src/components/InfonetTerminal/InfonetShell.tsx index 6e6bfb2..98b7c02 100644 --- a/frontend/src/components/InfonetTerminal/InfonetShell.tsx +++ b/frontend/src/components/InfonetTerminal/InfonetShell.tsx @@ -682,6 +682,20 @@ export default function InfonetShell({ {/* Main Terminal Area */}
+
diff --git a/frontend/src/components/InfonetTerminal/MessagesView.tsx b/frontend/src/components/InfonetTerminal/MessagesView.tsx index 2925f07..a003400 100644 --- a/frontend/src/components/InfonetTerminal/MessagesView.tsx +++ b/frontend/src/components/InfonetTerminal/MessagesView.tsx @@ -66,6 +66,7 @@ import { purgeBrowserContactGraph, purgeBrowserSigningMaterial, removeContact, + severContact, unblockContact, unwrapSenderSealPayload, updateContact, @@ -74,6 +75,7 @@ import { type Contact, type NodeIdentity, } from '@/mesh/meshIdentity'; +import { connectDeliveryMeta, ensureDmOutboxReleased } from '@/mesh/dmConnectDelivery'; import { getSenderRecoveryState, recoverSenderSealWithFallback, @@ -1516,6 +1518,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro API_BASE, senderId, existingContact?.invitePinnedPrekeyLookupHandle, + { lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl }, ); if (senderKey?.dh_pub_key) { const sharedKey = await deriveSharedKey(String(senderKey.dh_pub_key)); @@ -1532,6 +1535,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro API_BASE, senderId, existingContact?.invitePinnedPrekeyLookupHandle, + { lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl }, ).catch(() => null); if (senderKey?.dh_pub_key) { addContact(senderId, String(senderKey.dh_pub_key), undefined, senderKey.dh_algo); @@ -2000,7 +2004,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro 'This contact needs their full contact address once before messages can be sent. Paste it in Contacts and the app will handle the rest.', ); } - const targetKey = await fetchDmPublicKey(API_BASE, recipient, lookupHandle); + const targetKey = await fetchDmPublicKey(API_BASE, recipient, lookupHandle, { + lookupPeerUrl: recipientContact?.invitePinnedLookupPeerUrl, + }); if (!targetKey?.dh_pub_key) { queuePendingDeliveryMail({ senderId: activeIdentity.nodeId, @@ -2037,15 +2043,23 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`; const timestamp = Math.floor(Date.now() / 1000); - const sent = await sendOffLedgerConsentMessage({ - apiBase: API_BASE, - identity: activeIdentity, - recipientId: recipient, - recipientDhPub: String(targetKey.dh_pub_key), - ciphertext, - msgId, - timestamp, + const connectMeta = connectDeliveryMeta({ + intent: 'contact_request', + contact: recipientContact, }); + const sent = await ensureDmOutboxReleased( + await sendOffLedgerConsentMessage({ + apiBase: API_BASE, + identity: activeIdentity, + recipientId: recipient, + recipientDhPub: String(targetKey.dh_pub_key), + ciphertext, + msgId, + timestamp, + connectIntent: connectMeta.connectIntent, + lookupPeerUrl: connectMeta.lookupPeerUrl, + }), + ); if (!sent.ok) { throw new Error(sent.detail || 'contact request failed'); } @@ -2110,7 +2124,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro throw new Error('Secure mail is still preparing your private identity.'); } const { registration, myDhPub } = await ensureLocalDmKey(activeIdentity); - const targetKey = await fetchDmPublicKey(API_BASE, '', shortAddress); + const targetKey = await fetchDmPublicKey(API_BASE, '', shortAddress, { + allowLegacyAgentId: false, + }); if (!targetKey?.dh_pub_key || !targetKey.agent_id) { throw new Error('That address is not reachable yet. Ask them to copy their address again while their device is online.'); } @@ -2136,15 +2152,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`; const timestamp = Math.floor(Date.now() / 1000); - const sent = await sendOffLedgerConsentMessage({ - apiBase: API_BASE, - identity: activeIdentity, - recipientId: recipient, - recipientDhPub: String(targetKey.dh_pub_key), - ciphertext, - msgId, - timestamp, - }); + const sent = await ensureDmOutboxReleased( + await sendOffLedgerConsentMessage({ + apiBase: API_BASE, + identity: activeIdentity, + recipientId: recipient, + recipientDhPub: String(targetKey.dh_pub_key), + ciphertext, + msgId, + timestamp, + connectIntent: 'invite_short_address', + }), + ); if (!sent.ok) { throw new Error(sent.detail || 'contact request failed'); } @@ -2224,6 +2243,12 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro invitePayload.prekey_lookup_handle || '', ), + invitePinnedLookupPeerUrl: String( + resultContact.invitePinnedLookupPeerUrl || + (invite as Record).lookup_peer_url || + invitePayload.lookup_peer_url || + '', + ), dhPubKey: String(resultContact.dhPubKey || resultContact.invitePinnedDhPubKey || ''), }; const mergedContacts = importedPeerId @@ -2269,6 +2294,26 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } }, [applyHydratedContacts, handleSendShortAddressRequest, inviteImportAlias, inviteImportBlob, loadBackendContacts, syncSecureMailRuntime]); + const handleSeverContact = useCallback( + async (peerId: string) => { + const name = displayNameForPeer(peerId, contacts); + setComposeError(''); + setComposeStatus(''); + try { + await severContact(peerId); + setContacts(getContacts()); + setComposeStatus( + `Secure contact ended with ${name}. You can message again only after a new request and approval.`, + ); + } catch (error) { + setComposeError( + error instanceof Error ? error.message : 'Could not end secure contact right now.', + ); + } + }, + [contacts], + ); + const refreshDmAddressHandles = useCallback(async () => { try { const result = await listWormholeDmInviteHandles(); @@ -2501,6 +2546,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro API_BASE, mail.senderId, existingContact?.invitePinnedPrekeyLookupHandle, + { lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl }, ).catch(() => null); const dhPubKey = String(registry?.dh_pub_key || mail.requestDhPubKey || '').trim(); const dhAlgo = String(registry?.dh_algo || mail.requestDhAlgo || 'X25519').trim(); @@ -2551,15 +2597,19 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`; const timestamp = Math.floor(Date.now() / 1000); - const sent = await sendOffLedgerConsentMessage({ - apiBase: API_BASE, - identity: activeIdentity, - recipientId: mail.senderId, - recipientDhPub: dhPubKey, - ciphertext, - msgId, - timestamp, - }); + const sent = await ensureDmOutboxReleased( + await sendOffLedgerConsentMessage({ + apiBase: API_BASE, + identity: activeIdentity, + recipientId: mail.senderId, + recipientDhPub: dhPubKey, + ciphertext, + msgId, + timestamp, + connectIntent: 'contact_accept', + lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl, + }), + ); if (!sent.ok) { throw new Error(sent.detail || 'contact accept failed'); } @@ -2715,7 +2765,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro SECURE MESSAGES -

End-to-end encrypted peer-to-peer comms.

+

+ Copy your short address and send it to someone. They paste it here and tap Send Request — you tap Accept. No terminal required. +

@@ -2755,7 +2807,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
) : ( - 'Your contact address is being prepared automatically. Share it with someone so they can message you.' + 'Your contact address is being prepared. Copy the short address above and send it to anyone you want to message you.' )}
@@ -3428,7 +3480,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro )} {contact.sharedAlias && (
- Shared alias: {contact.sharedAlias} + Shared lane open — you can exchange secure mail.
)} @@ -3466,6 +3518,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro {nextStep.label} )} + {contact.sharedAlias && ( + + )}