mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-14 01:57:55 +02:00
Ship DM connect delivery, fleet pubkey lookup, OpenClaw Infonet agent, and relay auto-wormhole.
Auto-relay connect DMs with End Contact severing, signed fleet prekey lookup, OpenClaw private Infonet channel intents, headless relay Tor bootstrap on redeploy, and swarm/DM live verification scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+124
-13
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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="
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' }),
|
||||
|
||||
@@ -682,6 +682,20 @@ export default function InfonetShell({
|
||||
|
||||
{/* Main Terminal Area */}
|
||||
<div className="flex-1 overflow-y-auto pr-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate('messages')}
|
||||
className="w-full mb-6 text-left border border-emerald-500/30 bg-emerald-950/10 hover:bg-emerald-950/20 px-4 py-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-emerald-300 text-xs tracking-[0.2em] uppercase font-bold">
|
||||
<Mail size={14} />
|
||||
Secure Messages — Quick Connect
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-400 leading-relaxed">
|
||||
Message someone on the fleet in three steps: copy your short address (or ask for theirs),
|
||||
paste it in Secure Messages, tap Send Request — they tap Accept. No terminal commands.
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start gap-6 mb-8">
|
||||
<TrendingPosts />
|
||||
|
||||
|
||||
@@ -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<string, unknown>).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
|
||||
<Mail size={24} className="mr-3" />
|
||||
SECURE MESSAGES
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">End-to-end encrypted peer-to-peer comms.</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Copy your short address and send it to someone. They paste it here and tap Send Request — you tap Accept. No terminal required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-cyan-900/30 bg-cyan-950/10 px-4 py-3 text-[11px] tracking-[0.16em] uppercase text-cyan-300 mb-4 shrink-0">
|
||||
@@ -2755,7 +2807,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
'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.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3428,7 +3480,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
)}
|
||||
{contact.sharedAlias && (
|
||||
<div className="text-[11px] text-emerald-300 mt-2">
|
||||
Shared alias: {contact.sharedAlias}
|
||||
Shared lane open — you can exchange secure mail.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3466,6 +3518,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
{nextStep.label}
|
||||
</button>
|
||||
)}
|
||||
{contact.sharedAlias && (
|
||||
<button
|
||||
onClick={() => void handleSeverContact(peerId)}
|
||||
className="px-3 py-2 border border-violet-500/30 text-violet-200 text-sm tracking-[0.18em] uppercase"
|
||||
title="Close the shared lane. A fresh contact request and approval will be required to message again."
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldOff size={14} />
|
||||
End Contact
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
blockContact(peerId);
|
||||
|
||||
@@ -361,15 +361,9 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
setShowSas((prev) => !prev);
|
||||
};
|
||||
const handleRequestComposerAction = () => {
|
||||
const targetId = addContactId.trim();
|
||||
if (!targetId) return;
|
||||
const inviteLookupHandle = String(
|
||||
contacts[targetId]?.invitePinnedPrekeyLookupHandle || '',
|
||||
).trim();
|
||||
if (!inviteLookupHandle) {
|
||||
openTerminal();
|
||||
}
|
||||
void handleRequestAccess(targetId);
|
||||
const pasted = addContactId.trim();
|
||||
if (!pasted) return;
|
||||
void handleRequestAccess(pasted);
|
||||
};
|
||||
const meshActivationText =
|
||||
publicMeshBlockedByWormhole
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
addContact,
|
||||
updateContact,
|
||||
blockContact,
|
||||
severContact,
|
||||
getDMNotify,
|
||||
nextSequence,
|
||||
verifyEventSignature,
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
isEncryptedGateEnvelope,
|
||||
} from '@/mesh/gateEnvelope';
|
||||
import { fetchWormholeSettings, joinWormhole, leaveWormhole } from '@/mesh/wormholeClient';
|
||||
import { connectDeliveryMeta, ensureDmOutboxReleased } from '@/mesh/dmConnectDelivery';
|
||||
import {
|
||||
buildMailboxClaims,
|
||||
countDmMailboxes,
|
||||
@@ -2295,6 +2297,7 @@ export function useMeshChatController({
|
||||
API_BASE,
|
||||
m.sender_id,
|
||||
senderContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: senderContact?.invitePinnedLookupPeerUrl },
|
||||
);
|
||||
if (senderKey?.dh_pub_key) {
|
||||
const sharedKey = await deriveSharedKey(String(senderKey.dh_pub_key));
|
||||
@@ -2310,6 +2313,7 @@ export function useMeshChatController({
|
||||
API_BASE,
|
||||
m.sender_id,
|
||||
senderContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: senderContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
if (senderKey?.dh_pub_key) {
|
||||
addContact(m.sender_id, String(senderKey.dh_pub_key), undefined, senderKey.dh_algo);
|
||||
@@ -3336,7 +3340,9 @@ export function useMeshChatController({
|
||||
'import or re-import a signed invite before refreshing this contact; legacy direct lookup is disabled',
|
||||
);
|
||||
}
|
||||
const registry = await fetchDmPublicKey(API_BASE, targetId, lookupHandle).catch(() => null);
|
||||
const registry = await fetchDmPublicKey(API_BASE, targetId, lookupHandle, {
|
||||
lookupPeerUrl: existing?.invitePinnedLookupPeerUrl,
|
||||
}).catch(() => null);
|
||||
if (!registry?.dh_pub_key) {
|
||||
throw new Error(
|
||||
'invite-scoped lookup failed for this contact; re-import a signed invite and try again',
|
||||
@@ -3585,29 +3591,26 @@ export function useMeshChatController({
|
||||
setTimeout(() => setSendError(''), 3000);
|
||||
return;
|
||||
}
|
||||
if (requiresVerifiedFirstContact(getContacts()[targetId])) {
|
||||
setSendError('import a signed invite before first secure contact; TOFU requests are disabled');
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
return;
|
||||
}
|
||||
if (wormholeEnabled && !wormholeReadyState) {
|
||||
setSendError('wormhole required for dead drop');
|
||||
setTimeout(() => setSendError(''), 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const registration = await ensureRegisteredDmKey(API_BASE, identity!, { force: false });
|
||||
const myPub = registration.dhPubKey;
|
||||
if (!myPub) return;
|
||||
const dhAlgo = registration.dhAlgo || getDHAlgo() || 'X25519';
|
||||
const targetContact = getContacts()[targetId];
|
||||
const lookupHandle = String(targetContact?.invitePinnedPrekeyLookupHandle || '').trim();
|
||||
let lookupHandle = String(targetContact?.invitePinnedPrekeyLookupHandle || '').trim();
|
||||
let resolvedTargetId = targetId;
|
||||
if (!lookupHandle && /^[a-fA-F0-9]{32,}$/.test(targetId)) {
|
||||
lookupHandle = targetId;
|
||||
resolvedTargetId = '';
|
||||
}
|
||||
if (!lookupHandle) {
|
||||
throw new Error(
|
||||
'import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled',
|
||||
'Paste their short contact address (from Secure Messages → Copy Short Address), not their node id.',
|
||||
);
|
||||
}
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, targetId, lookupHandle);
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, resolvedTargetId, lookupHandle, {
|
||||
lookupPeerUrl: targetContact?.invitePinnedLookupPeerUrl,
|
||||
});
|
||||
if (!targetKey?.dh_pub_key) {
|
||||
throw new Error(
|
||||
'invite-scoped lookup failed for this contact; re-import a signed invite and try again',
|
||||
@@ -3631,12 +3634,13 @@ export function useMeshChatController({
|
||||
geoHint = '';
|
||||
}
|
||||
}
|
||||
const recipientId = String(targetKey.agent_id || resolvedTargetId || targetId).trim();
|
||||
const requestPlaintext = buildContactOfferMessage(myPub, dhAlgo, geoHint || undefined);
|
||||
let ciphertext = '';
|
||||
const secureRequired = await isWormholeSecureRequired();
|
||||
if (await canUseWormholeBootstrap()) {
|
||||
try {
|
||||
ciphertext = await bootstrapEncryptAccessRequest(targetId, requestPlaintext);
|
||||
ciphertext = await bootstrapEncryptAccessRequest(recipientId, requestPlaintext);
|
||||
} catch {
|
||||
ciphertext = '';
|
||||
}
|
||||
@@ -3651,16 +3655,24 @@ export function useMeshChatController({
|
||||
const msgId = `dm_${Date.now()}_${identity!.nodeId.slice(-4)}`;
|
||||
const msgTimestamp = Math.floor(Date.now() / 1000);
|
||||
await sleep(jitterDelay(ACCESS_REQUEST_BATCH_DELAY_MS, ACCESS_REQUEST_BATCH_JITTER_MS));
|
||||
const connectMeta = connectDeliveryMeta({
|
||||
intent: lookupHandle === targetId ? 'invite_short_address' : 'contact_request',
|
||||
contact: targetContact,
|
||||
});
|
||||
await enqueueDmSend(async () => {
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: identity!,
|
||||
recipientId: targetId,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp: msgTimestamp,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: identity!,
|
||||
recipientId,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp: msgTimestamp,
|
||||
connectIntent: connectMeta.connectIntent,
|
||||
lookupPeerUrl: connectMeta.lookupPeerUrl,
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'access_request_send_failed');
|
||||
}
|
||||
@@ -3668,7 +3680,8 @@ export function useMeshChatController({
|
||||
setLastDmTransport(sent.transport);
|
||||
}
|
||||
});
|
||||
const updated = [...pendingSent, targetId];
|
||||
const recipientForPending = String(targetKey.agent_id || resolvedTargetId || targetId).trim();
|
||||
const updated = [...pendingSent, recipientForPending];
|
||||
setPendingSent(updated, dmConsentScopeId);
|
||||
setPendingSentState(updated);
|
||||
} catch (err) {
|
||||
@@ -3680,11 +3693,6 @@ export function useMeshChatController({
|
||||
|
||||
const handleAcceptRequest = async (senderId: string) => {
|
||||
if (!hasId) return;
|
||||
if (requiresVerifiedFirstContact(getContacts()[senderId])) {
|
||||
setSendError('import a signed invite before accepting an unverified request');
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
return;
|
||||
}
|
||||
if (anonymousDmBlocked) {
|
||||
setSendError('hidden transport required for anonymous dm');
|
||||
setTimeout(() => setSendError(''), 3000);
|
||||
@@ -3697,6 +3705,7 @@ export function useMeshChatController({
|
||||
API_BASE,
|
||||
senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
const resolvedDhPubKey = String(registry?.dh_pub_key || req?.dh_pub_key || '').trim();
|
||||
const resolvedDhAlgo = String(registry?.dh_algo || req?.dh_algo || 'X25519').trim();
|
||||
@@ -3843,16 +3852,24 @@ export function useMeshChatController({
|
||||
}
|
||||
const msgId = `dm_${Date.now()}_${identity!.nodeId.slice(-4)}`;
|
||||
const msgTimestamp = Math.floor(Date.now() / 1000);
|
||||
const acceptMeta = connectDeliveryMeta({
|
||||
intent: 'contact_accept',
|
||||
contact: existingContact,
|
||||
});
|
||||
await enqueueDmSend(async () => {
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: identity!,
|
||||
recipientId: senderId,
|
||||
recipientDhPub: resolvedDhPubKey,
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp: msgTimestamp,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: identity!,
|
||||
recipientId: senderId,
|
||||
recipientDhPub: resolvedDhPubKey,
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp: msgTimestamp,
|
||||
connectIntent: acceptMeta.connectIntent,
|
||||
lookupPeerUrl: acceptMeta.lookupPeerUrl,
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'access_granted_send_failed');
|
||||
}
|
||||
@@ -3878,11 +3895,6 @@ export function useMeshChatController({
|
||||
|
||||
const handleDenyRequest = (senderId: string) => {
|
||||
void (async () => {
|
||||
if (requiresVerifiedFirstContact(getContacts()[senderId])) {
|
||||
setSendError('import a signed invite before denying an unverified request');
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const req = accessRequests.find((r) => r.sender_id === senderId);
|
||||
const existingContact = getContacts()[senderId];
|
||||
@@ -3893,6 +3905,7 @@ export function useMeshChatController({
|
||||
API_BASE,
|
||||
senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
if (identity && targetKey?.dh_pub_key) {
|
||||
const denyPlaintext = buildContactDenyMessage('declined');
|
||||
@@ -3935,6 +3948,20 @@ export function useMeshChatController({
|
||||
})();
|
||||
};
|
||||
|
||||
const handleSeverContact = async (agentId: string) => {
|
||||
try {
|
||||
await severContact(agentId);
|
||||
setContacts(getContacts());
|
||||
if (selectedContact === agentId) {
|
||||
setDmView('contacts');
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : 'end contact failed';
|
||||
setSendError(detail);
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockDM = async (agentId: string) => {
|
||||
blockContact(agentId);
|
||||
setContacts(getContacts());
|
||||
@@ -4751,6 +4778,7 @@ export function useMeshChatController({
|
||||
handleAcceptRequest,
|
||||
handleDenyRequest,
|
||||
handleBlockDM,
|
||||
handleSeverContact,
|
||||
handleVouch,
|
||||
handleAddContact,
|
||||
openChat,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Signal-style DM connect helpers — paste a short address or full invite blob.
|
||||
*/
|
||||
|
||||
export function isLikelyDmShortAddress(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
!trimmed.startsWith('{') &&
|
||||
!trimmed.startsWith('[') &&
|
||||
/^[a-zA-Z0-9_.:-]{16,}$/.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDmInviteImportBlob(raw: string): Record<string, unknown> {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Paste a contact address first.');
|
||||
}
|
||||
if (isLikelyDmShortAddress(trimmed)) {
|
||||
return { short_address: trimmed };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Contact address must be a signed address object.');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error('That does not look like a contact address. Paste what they copied from Secure Messages.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function inviteFromParsedBlob(parsed: Record<string, unknown>): Record<string, unknown> {
|
||||
const nested = parsed.invite;
|
||||
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||
return nested as Record<string, unknown>;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function shortHandle(peerId: string): string {
|
||||
const value = String(peerId || '').trim();
|
||||
if (!value) return 'unknown';
|
||||
if (value.length <= 18) return value;
|
||||
return `${value.slice(0, 10)}…${value.slice(-6)}`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Contact } from '@/mesh/meshIdentity';
|
||||
import type { DmSendResponse } from '@/mesh/meshDmClient';
|
||||
import { updatePrivateDeliveryAction } from '@/mesh/wormholeClient';
|
||||
|
||||
export type DmConnectIntent =
|
||||
| 'invite_short_address'
|
||||
| 'invite_import'
|
||||
| 'contact_request'
|
||||
| 'contact_accept'
|
||||
| 'contact_offer';
|
||||
|
||||
export function connectDeliveryMeta(options: {
|
||||
intent: DmConnectIntent;
|
||||
lookupPeerUrl?: string;
|
||||
contact?: Partial<Contact> | null;
|
||||
}): { connectIntent: DmConnectIntent; lookupPeerUrl?: string } {
|
||||
const lookupPeerUrl = String(
|
||||
options.lookupPeerUrl || options.contact?.invitePinnedLookupPeerUrl || '',
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/$/, '');
|
||||
return {
|
||||
connectIntent: options.intent,
|
||||
...(lookupPeerUrl ? { lookupPeerUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fallback when the server queued connect traffic but UI still shows a manual relay step. */
|
||||
export async function ensureDmOutboxReleased(sent: DmSendResponse): Promise<DmSendResponse> {
|
||||
if (!sent.ok) return sent;
|
||||
const outboxId = String(sent.outbox_id || '').trim();
|
||||
if (!outboxId) return sent;
|
||||
if (!sent.queued && !sent.private_transport_pending) return sent;
|
||||
try {
|
||||
await updatePrivateDeliveryAction(outboxId, 'relay');
|
||||
} catch {
|
||||
// Backend auto-release may have already approved this outbox item.
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -89,6 +89,13 @@ export type DmSendResponse = {
|
||||
private_transport_pending?: boolean;
|
||||
};
|
||||
|
||||
export type DmConnectIntent =
|
||||
| 'invite_short_address'
|
||||
| 'invite_import'
|
||||
| 'contact_request'
|
||||
| 'contact_accept'
|
||||
| 'contact_offer';
|
||||
|
||||
export type DmSendRequest = {
|
||||
apiBase: string;
|
||||
identity: NodeIdentity;
|
||||
@@ -102,6 +109,8 @@ export type DmSendRequest = {
|
||||
useSealedSender?: boolean;
|
||||
format?: 'mls1' | 'dm1';
|
||||
sessionWelcome?: string;
|
||||
connectIntent?: DmConnectIntent;
|
||||
lookupPeerUrl?: string;
|
||||
};
|
||||
|
||||
const KEY_DM_BUNDLE_FINGERPRINT = 'sb_dm_bundle_fingerprint';
|
||||
@@ -373,14 +382,54 @@ export async function ensureRegisteredDmKey(
|
||||
};
|
||||
}
|
||||
|
||||
function prekeyBundleToPublicKey(data: Record<string, unknown>): DmPublicKeyBundle | null {
|
||||
if (!data?.ok) return null;
|
||||
const bundle = (data.bundle && typeof data.bundle === 'object' ? data.bundle : data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const dhPubKey = String(
|
||||
bundle.identity_dh_pub_key || data.identity_dh_pub_key || data.dh_pub_key || '',
|
||||
).trim();
|
||||
const agentId = String(data.agent_id || '').trim();
|
||||
if (!dhPubKey || !agentId) return null;
|
||||
return {
|
||||
ok: true,
|
||||
agent_id: agentId,
|
||||
lookup_mode: String(data.lookup_mode || 'invite_lookup_handle'),
|
||||
dh_pub_key: dhPubKey,
|
||||
dh_algo: String(data.dh_algo || bundle.dh_algo || 'X25519'),
|
||||
timestamp: Number(data.timestamp || 0) || undefined,
|
||||
signature: String(data.signature || ''),
|
||||
public_key: String(data.public_key || ''),
|
||||
public_key_algo: String(data.public_key_algo || ''),
|
||||
sequence: Number(data.sequence || 0) || undefined,
|
||||
prekey_transparency_head: String(data.prekey_transparency_head || ''),
|
||||
prekey_transparency_size: Number(data.prekey_transparency_size || 0) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDmPublicKeyFromPrekeyBundle(
|
||||
apiBase: string,
|
||||
lookupToken: string,
|
||||
): Promise<DmPublicKeyBundle | null> {
|
||||
const params = new URLSearchParams({ lookup_token: lookupToken });
|
||||
const res = await fetch(`${apiBase}/api/mesh/dm/prekey-bundle?${params.toString()}`);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
return prekeyBundleToPublicKey(data);
|
||||
}
|
||||
|
||||
export async function fetchDmPublicKey(
|
||||
apiBase: string,
|
||||
agentId: string,
|
||||
lookupToken?: string,
|
||||
options?: { allowLegacyAgentId?: boolean },
|
||||
options?: { allowLegacyAgentId?: boolean; lookupPeerUrl?: string },
|
||||
): Promise<DmPublicKeyBundle | null> {
|
||||
const normalizedLookupToken = String(lookupToken || '').trim();
|
||||
const normalizedAgentId = String(agentId || '').trim();
|
||||
const normalizedLookupPeerUrl = String(options?.lookupPeerUrl || '')
|
||||
.trim()
|
||||
.replace(/\/$/, '');
|
||||
if (!normalizedLookupToken && !options?.allowLegacyAgentId) {
|
||||
return null;
|
||||
}
|
||||
@@ -388,12 +437,25 @@ export async function fetchDmPublicKey(
|
||||
if (normalizedLookupToken) {
|
||||
params.set('lookup_token', normalizedLookupToken);
|
||||
}
|
||||
if (normalizedLookupPeerUrl) {
|
||||
params.set('lookup_peer_url', normalizedLookupPeerUrl);
|
||||
}
|
||||
if (normalizedAgentId && !normalizedLookupToken && options?.allowLegacyAgentId) {
|
||||
params.set('agent_id', normalizedAgentId);
|
||||
}
|
||||
const res = await fetch(`${apiBase}/api/mesh/dm/pubkey?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
return data.ok ? data : null;
|
||||
const data = (await res.json()) as DmPublicKeyBundle;
|
||||
if (data.ok && data.dh_pub_key) {
|
||||
if (!data.agent_id && normalizedLookupToken) {
|
||||
const fromPrekey = await fetchDmPublicKeyFromPrekeyBundle(apiBase, normalizedLookupToken);
|
||||
if (fromPrekey) return fromPrekey;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
if (normalizedLookupToken) {
|
||||
return fetchDmPublicKeyFromPrekeyBundle(apiBase, normalizedLookupToken);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function spreadClaimPositions(totalClaims: number, spreadClaims: number): Set<number> {
|
||||
@@ -684,6 +746,8 @@ export async function sendDmMessage(request: DmSendRequest): Promise<DmSendRespo
|
||||
signature: signed.signature,
|
||||
sequence: signed.sequence,
|
||||
protocol_version: senderSeal && senderToken ? '' : signed.protocolVersion,
|
||||
...(request.connectIntent ? { connect_intent: request.connectIntent } : {}),
|
||||
...(request.lookupPeerUrl ? { lookup_peer_url: request.lookupPeerUrl } : {}),
|
||||
}),
|
||||
});
|
||||
return res.json();
|
||||
|
||||
@@ -1350,6 +1350,7 @@ export interface Contact {
|
||||
invitePinnedDhPubKey?: string;
|
||||
invitePinnedDhAlgo?: string;
|
||||
invitePinnedPrekeyLookupHandle?: string;
|
||||
invitePinnedLookupPeerUrl?: string;
|
||||
invitePinnedRootFingerprint?: string;
|
||||
invitePinnedRootManifestFingerprint?: string;
|
||||
invitePinnedRootWitnessPolicyFingerprint?: string;
|
||||
@@ -1441,6 +1442,7 @@ function sanitizeContact(contact: Partial<Contact> | undefined): Contact {
|
||||
invitePinnedDhPubKey: String(contact?.invitePinnedDhPubKey || ''),
|
||||
invitePinnedDhAlgo: String(contact?.invitePinnedDhAlgo || ''),
|
||||
invitePinnedPrekeyLookupHandle: String(contact?.invitePinnedPrekeyLookupHandle || ''),
|
||||
invitePinnedLookupPeerUrl: String(contact?.invitePinnedLookupPeerUrl || ''),
|
||||
invitePinnedRootFingerprint: String(contact?.invitePinnedRootFingerprint || ''),
|
||||
invitePinnedRootManifestFingerprint: String(contact?.invitePinnedRootManifestFingerprint || ''),
|
||||
invitePinnedRootWitnessPolicyFingerprint: String(
|
||||
@@ -1775,6 +1777,35 @@ export function removeContact(agentId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function severContact(
|
||||
agentId: string,
|
||||
options: { block?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const peerId = String(agentId || '').trim();
|
||||
if (!peerId) return;
|
||||
await controlPlaneJson(`/api/wormhole/dm/contact/${encodeURIComponent(peerId)}/sever`, {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block: Boolean(options.block) }),
|
||||
});
|
||||
const contacts = getContacts();
|
||||
if (!(peerId in contacts)) return;
|
||||
contacts[peerId] = sanitizeContact({
|
||||
...contacts[peerId],
|
||||
sharedAlias: undefined,
|
||||
previousSharedAliases: [],
|
||||
pendingSharedAlias: undefined,
|
||||
sharedAliasGraceUntil: undefined,
|
||||
sharedAliasRotatedAt: undefined,
|
||||
...(options.block ? { blocked: true } : {}),
|
||||
});
|
||||
saveContacts(contacts);
|
||||
if (shouldUseWormholeContacts()) {
|
||||
await persistContactToWormhole(peerId, contacts[peerId]);
|
||||
}
|
||||
}
|
||||
|
||||
export function isBlocked(agentId: string): boolean {
|
||||
return getContacts()[agentId]?.blocked || false;
|
||||
}
|
||||
|
||||
@@ -2016,6 +2016,18 @@ export async function deleteWormholeDmContact(
|
||||
});
|
||||
}
|
||||
|
||||
export async function severWormholeDmContact(
|
||||
peerId: string,
|
||||
options: { block?: boolean } = {},
|
||||
): Promise<{ ok: boolean; peer_id: string; severed?: boolean; blocked?: boolean }> {
|
||||
return controlPlaneJson(`/api/wormhole/dm/contact/${encodeURIComponent(peerId)}/sever`, {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block: Boolean(options.block) }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getActiveSigningContext(): Promise<ActiveSigningContext | null> {
|
||||
const secureRequired = await isWormholeSecureRequired();
|
||||
if (await isWormholeReady()) {
|
||||
|
||||
@@ -365,25 +365,45 @@ layers (e.g., "add this CCTV camera I found", "add this military base").
|
||||
|
||||
### 8. Wormhole / InfoNet / Mesh Network
|
||||
|
||||
OpenClaw can participate as a full two-way agent in the decentralized network:
|
||||
OpenClaw agents participate in the private Infonet **on behalf of the operator**
|
||||
who configured the skill. All traffic uses the operator's wormhole persona and
|
||||
local node runtime (MLS gate crypto, Ed25519 signing, Tor onion transport) —
|
||||
the agent does not get a separate fleet identity.
|
||||
|
||||
**Access tiers**
|
||||
|
||||
- `restricted` (default): read Infonet status, list gates, read gate messages,
|
||||
poll DMs.
|
||||
- `full` (`OPENCLAW_ACCESS_TIER=full`): also warm Tor, join the swarm, post
|
||||
gate messages, cast votes, and send DMs when the user commands it.
|
||||
|
||||
Remote agents authenticate with HMAC on `/api/ai/channel/command`; loopback
|
||||
uses the local operator lane.
|
||||
|
||||
```python
|
||||
# Join the Wormhole network (creates Ed25519 identity)
|
||||
await sb.join_wormhole()
|
||||
# Warm Tor, enable the node, announce to fleet seed (full tier)
|
||||
await sb.ensure_infonet_ready(join_swarm=True)
|
||||
|
||||
# Post to the InfoNet (signed, chain-verified)
|
||||
await sb.post_to_infonet("Intelligence bulletin: 3 carriers underway in Med")
|
||||
# Status snapshot (chain health, wormhole, runtime)
|
||||
status = await sb.infonet_status()
|
||||
|
||||
# Read InfoNet messages
|
||||
messages = await sb.read_infonet(limit=20)
|
||||
# Read the public Infonet gate (MLS-encrypted, decrypt with operator keys)
|
||||
messages = await sb.read_gate_messages("infonet", limit=20, decrypt=True)
|
||||
|
||||
# Post on behalf of the operator (full tier) — propagates via peer-push
|
||||
await sb.post_to_gate("infonet", "Intelligence bulletin: 3 carriers underway in Med")
|
||||
# Legacy alias:
|
||||
await sb.post_to_infonet("same as post_to_gate on infonet gate")
|
||||
|
||||
# Upvote / downvote a node (full tier)
|
||||
await sb.cast_vote("!sb_peer_id_or_pubkey", vote=1, gate="infonet")
|
||||
|
||||
# Encrypted DMs (peer_id / !sb_... recipient)
|
||||
await sb.send_encrypted_dm("!sb_recipient", "Eyes only: carrier update")
|
||||
dms = await sb.read_encrypted_dms(limit=20)
|
||||
|
||||
# Join encrypted gate channels
|
||||
gates = await sb.list_gates()
|
||||
await sb.post_to_gate("gate_id", "Classified intel for gate members")
|
||||
|
||||
# Send/receive encrypted DMs
|
||||
await sb.send_encrypted_dm("recipient_pubkey", "Eyes only: carrier update")
|
||||
dms = await sb.read_encrypted_dms()
|
||||
await sb.join_infonet_swarm() # re-announce + refresh manifest
|
||||
|
||||
# Meshtastic radio
|
||||
signals = await sb.listen_mesh(region="US", limit=20)
|
||||
|
||||
@@ -583,56 +583,81 @@ class ShadowBrokerClient:
|
||||
r = await self._delete("/api/ai/inject", params=params)
|
||||
return r.json()
|
||||
|
||||
# ── Wormhole / InfoNet ────────────────────────────────────────────
|
||||
# ── Wormhole / InfoNet (operator-delegated via command channel) ───
|
||||
|
||||
async def ensure_infonet_ready(self, *, join_swarm: bool = True) -> dict:
|
||||
"""Warm Tor, enable the node, and join the private Infonet swarm."""
|
||||
resp = await self.send_command(
|
||||
"ensure_infonet_ready",
|
||||
{"join_swarm": join_swarm},
|
||||
)
|
||||
return resp.get("result") if isinstance(resp.get("result"), dict) else resp
|
||||
|
||||
async def join_infonet_swarm(self) -> dict:
|
||||
"""Announce to the fleet seed and pull the signed peer manifest."""
|
||||
resp = await self.send_command("join_infonet_swarm", {})
|
||||
return resp.get("result") if isinstance(resp.get("result"), dict) else resp
|
||||
|
||||
async def infonet_status(self) -> dict:
|
||||
"""Participant node + hashchain status snapshot."""
|
||||
return self.unwrap_channel_result(await self.send_command("infonet_status", {}))
|
||||
|
||||
async def list_gates(self) -> dict:
|
||||
"""List encrypted gate channels."""
|
||||
return self.unwrap_channel_result(await self.send_command("list_gates", {}))
|
||||
|
||||
async def read_gate_messages(
|
||||
self,
|
||||
gate_id: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
decrypt: bool = False,
|
||||
) -> dict:
|
||||
"""Read gate messages (optionally decrypt with the operator MLS persona)."""
|
||||
return self.unwrap_channel_result(
|
||||
await self.send_command(
|
||||
"read_gate_messages",
|
||||
{"gate_id": gate_id, "limit": limit, "decrypt": decrypt},
|
||||
)
|
||||
)
|
||||
|
||||
async def post_to_gate(self, gate_id: str, message: str, *, reply_to: str = "") -> dict:
|
||||
"""Post an MLS-encrypted gate message on behalf of the operator."""
|
||||
resp = await self.send_command(
|
||||
"post_gate_message",
|
||||
{
|
||||
"gate_id": gate_id,
|
||||
"plaintext": message,
|
||||
"reply_to": reply_to,
|
||||
},
|
||||
)
|
||||
return resp.get("result") if isinstance(resp.get("result"), dict) else resp
|
||||
|
||||
async def cast_vote(
|
||||
self,
|
||||
target_id: str,
|
||||
vote: int,
|
||||
*,
|
||||
gate: str = "",
|
||||
) -> dict:
|
||||
"""Upvote (+1) or downvote (-1) a node; optional gate scope."""
|
||||
resp = await self.send_command(
|
||||
"cast_vote",
|
||||
{"target_id": target_id, "vote": vote, "gate": gate},
|
||||
)
|
||||
return resp.get("result") if isinstance(resp.get("result"), dict) else resp
|
||||
|
||||
# Legacy aliases — prefer command-channel methods above
|
||||
async def join_wormhole(self) -> dict:
|
||||
"""Create a Wormhole identity and join the network."""
|
||||
r = await self._post("/api/wormhole/join")
|
||||
return r.json()
|
||||
|
||||
async def sign_event(self, event_type: str, payload: dict) -> dict:
|
||||
"""Sign an event with the Wormhole Ed25519 key."""
|
||||
r = await self._post("/api/wormhole/sign", json={
|
||||
"event_type": event_type,
|
||||
"payload": payload,
|
||||
})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
return await self.ensure_infonet_ready(join_swarm=True)
|
||||
|
||||
async def post_to_infonet(self, message: str, event_type: str = "message") -> dict:
|
||||
"""Post a signed event to the InfoNet ledger."""
|
||||
signed = await self.sign_event(event_type, {"message": message})
|
||||
r = await self._post("/api/mesh/infonet/ingest", json={
|
||||
"events": [signed],
|
||||
})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
if event_type != "message":
|
||||
raise RuntimeError("use post_to_gate for encrypted gate traffic")
|
||||
return await self.post_to_gate("infonet", message)
|
||||
|
||||
async def read_infonet(self, limit: int = 20, gate: str = "") -> dict:
|
||||
"""Read recent InfoNet messages."""
|
||||
params = {"limit": limit}
|
||||
if gate:
|
||||
params["gate"] = gate
|
||||
r = await self._get("/api/mesh/infonet/messages", params=params)
|
||||
return r.json()
|
||||
|
||||
async def list_gates(self) -> list:
|
||||
"""List available encrypted gate channels."""
|
||||
r = await self._get("/api/mesh/gate/list")
|
||||
return r.json()
|
||||
|
||||
async def post_to_gate(self, gate_id: str, message: str) -> dict:
|
||||
"""Compose and post an MLS-encrypted message to a gate."""
|
||||
compose = await self._post("/api/wormhole/gate/message/compose", json={
|
||||
"gate_id": gate_id,
|
||||
"plaintext": message,
|
||||
})
|
||||
compose.raise_for_status()
|
||||
envelope = compose.json()
|
||||
|
||||
post = await self._post(f"/api/mesh/gate/{gate_id}/message", json=envelope)
|
||||
post.raise_for_status()
|
||||
return post.json()
|
||||
async def read_infonet(self, limit: int = 20, gate: str = "infonet") -> dict:
|
||||
return await self.read_gate_messages(gate or "infonet", limit=limit, decrypt=True)
|
||||
|
||||
# ── Meshtastic ────────────────────────────────────────────────────
|
||||
|
||||
@@ -830,19 +855,31 @@ class ShadowBrokerClient:
|
||||
|
||||
# ── Encrypted DMs ─────────────────────────────────────────────
|
||||
|
||||
async def send_encrypted_dm(self, recipient_pubkey: str, message: str) -> dict:
|
||||
"""Send an E2E encrypted direct message to another Wormhole identity."""
|
||||
r = await self._post("/api/wormhole/dm/send", json={
|
||||
"recipient": recipient_pubkey,
|
||||
"plaintext": message,
|
||||
})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
async def send_encrypted_dm(
|
||||
self,
|
||||
peer_id: str,
|
||||
message: str,
|
||||
*,
|
||||
delivery_class: str = "shared",
|
||||
recipient_token: str = "",
|
||||
) -> dict:
|
||||
"""Send an E2E encrypted DM to another node (peer_id / !sb_...)."""
|
||||
resp = await self.send_command(
|
||||
"send_dm",
|
||||
{
|
||||
"peer_id": peer_id,
|
||||
"plaintext": message,
|
||||
"delivery_class": delivery_class,
|
||||
"recipient_token": recipient_token,
|
||||
},
|
||||
)
|
||||
return resp.get("result") if isinstance(resp.get("result"), dict) else resp
|
||||
|
||||
async def read_encrypted_dms(self, limit: int = 20) -> list:
|
||||
"""Read received encrypted direct messages."""
|
||||
r = await self._get("/api/wormhole/dm/inbox", params={"limit": limit})
|
||||
return r.json()
|
||||
async def read_encrypted_dms(self, limit: int = 20) -> dict:
|
||||
"""Poll encrypted DMs for the operator identity."""
|
||||
return self.unwrap_channel_result(
|
||||
await self.send_command("poll_dms", {"limit": limit})
|
||||
)
|
||||
|
||||
# ── Dead Drop ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -31,9 +31,17 @@ agent_surface:
|
||||
- sb_query.ShadowBrokerClient.run_playbook
|
||||
- sb_query.ShadowBrokerClient.send_batch
|
||||
- sb_query.ShadowBrokerClient.channel_status
|
||||
- sb_query.ShadowBrokerClient.infonet_status
|
||||
- sb_query.ShadowBrokerClient.list_gates
|
||||
- sb_query.ShadowBrokerClient.read_gate_messages
|
||||
- sb_query.ShadowBrokerClient.poll_dms
|
||||
writes:
|
||||
- sb_query.ShadowBrokerClient.place_pin
|
||||
- sb_query.ShadowBrokerClient.place_pins_batch
|
||||
- sb_query.ShadowBrokerClient.ensure_infonet_ready
|
||||
- sb_query.ShadowBrokerClient.post_to_gate
|
||||
- sb_query.ShadowBrokerClient.cast_vote
|
||||
- sb_query.ShadowBrokerClient.send_encrypted_dm
|
||||
blocked_without_confirm:
|
||||
- search_telemetry
|
||||
- get_telemetry
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
import main
|
||||
|
||||
peer = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
print(main.compose_wormhole_dm(peer_id=peer, peer_dh_pub="", plaintext="fleet-dm-probe"))
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print DM readiness: identities, contacts, peers, transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
out: dict = {"ok": True}
|
||||
|
||||
try:
|
||||
from services.wormhole_supervisor import get_wormhole_state, get_transport_tier
|
||||
|
||||
out["wormhole"] = get_wormhole_state()
|
||||
out["transport_tier"] = get_transport_tier()
|
||||
except Exception as exc:
|
||||
out["wormhole_error"] = str(exc)
|
||||
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity
|
||||
|
||||
out["dm_identity"] = get_dm_identity()
|
||||
except Exception as exc:
|
||||
out["dm_identity_error"] = str(exc)
|
||||
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import list_wormhole_dm_contacts
|
||||
|
||||
contacts = list_wormhole_dm_contacts()
|
||||
out["dm_contacts"] = {
|
||||
k: {
|
||||
"trustLevel": v.get("trustLevel"),
|
||||
"dmIdentityId": v.get("dmIdentityId"),
|
||||
"invitePinnedPrekeyLookupHandle": bool(v.get("invitePinnedPrekeyLookupHandle")),
|
||||
"verifiedFirstContact": v.get("verifiedFirstContact"),
|
||||
"remotePrekeyLookupMode": v.get("remotePrekeyLookupMode"),
|
||||
}
|
||||
for k, v in (contacts or {}).items()
|
||||
}
|
||||
out["dm_contact_count"] = len(contacts or {})
|
||||
except Exception as exc:
|
||||
out["dm_contacts_error"] = str(exc)
|
||||
|
||||
try:
|
||||
import main as main_mod
|
||||
|
||||
out["local_onion"] = main_mod._local_infonet_peer_url()
|
||||
out["node_enabled"] = main_mod._participant_node_enabled()
|
||||
out["arti_ready"] = main_mod._check_arti_ready()
|
||||
out["push_peers"] = main_mod._filter_infonet_peer_urls(
|
||||
__import__(
|
||||
"services.mesh.mesh_router", fromlist=["authenticated_push_peer_urls"]
|
||||
).authenticated_push_peer_urls()
|
||||
)
|
||||
except Exception as exc:
|
||||
out["peer_runtime_error"] = str(exc)
|
||||
|
||||
try:
|
||||
from services.mesh.mesh_private_outbox import private_delivery_outbox
|
||||
|
||||
pending = private_delivery_outbox.list_items(exposure="ordinary")
|
||||
dm_pending = [i for i in pending if str(i.get("lane", "")) == "dm"]
|
||||
out["dm_outbox_pending"] = len(dm_pending)
|
||||
out["dm_outbox_samples"] = [
|
||||
{
|
||||
"id": i.get("id"),
|
||||
"release_state": i.get("release_state"),
|
||||
"status": (i.get("status") or {}).get("code"),
|
||||
"recipient_id": (i.get("payload") or {}).get("recipient_id"),
|
||||
}
|
||||
for i in dm_pending[:5]
|
||||
]
|
||||
except Exception as exc:
|
||||
out["outbox_error"] = str(exc)
|
||||
|
||||
try:
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
out["dm_relay_stats"] = dict(dm_relay._stats)
|
||||
out["dm_mailbox_keys"] = len(dm_relay._mailboxes)
|
||||
except Exception as exc:
|
||||
out["relay_error"] = str(exc)
|
||||
|
||||
print(json.dumps(out, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,751 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live E2E: short-address lookup -> contact request -> Pete mailbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
API = os.environ.get("SHADOWBROKER_API", "http://127.0.0.1:8000")
|
||||
MARKER = os.environ.get("E2E_DM_MARKER", f"dm-short-addr-e2e-{int(time.time())}")
|
||||
REPLY_MARKER = os.environ.get("E2E_DM_REPLY_MARKER", f"{MARKER}-reply")
|
||||
PETE_HANDLE = os.environ.get("PETE_DM_SHORT_HANDLE", "").strip()
|
||||
PETE_LOOKUP_PEER_URL = os.environ.get("PETE_DM_LOOKUP_PEER_URL", "").strip()
|
||||
FRESH_BACKEND = os.environ.get("E2E_DM_FRESH_BACKEND", "1").strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
}
|
||||
SSH_PETE = os.environ.get("PETE_SSH", "pete")
|
||||
PETE_ONION = os.environ.get(
|
||||
"PETE_ONION",
|
||||
"nwbs4ur2usffb7lk3vyffhaqrijry544vmfjkk3qbrhvoh4v26fwxlid.onion:8000",
|
||||
).strip()
|
||||
|
||||
|
||||
def _docker_json(method: str, path: str, body: dict | None = None, *, admin_key: str = "", timeout_s: int = 30) -> dict:
|
||||
payload = ["docker", "exec", "shadowbroker-backend", "curl", "-s", "--max-time", str(timeout_s)]
|
||||
if admin_key:
|
||||
payload.extend(["-H", f"X-Admin-Key: {admin_key}"])
|
||||
if body is not None:
|
||||
payload.extend(["-H", "Content-Type: application/json", "-X", method.upper(), "-d", json.dumps(body)])
|
||||
else:
|
||||
payload.extend(["-X", method.upper()])
|
||||
payload.append(f"http://127.0.0.1:8000{path}")
|
||||
proc = subprocess.run(payload, capture_output=True, text=True, timeout=timeout_s + 15, check=False)
|
||||
raw = (proc.stdout or "").strip()
|
||||
if not raw:
|
||||
raise RuntimeError(proc.stderr.strip() or f"{method} {path} produced no response")
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict) and parsed.get("detail") == "private_delivery_item_not_found" and method.upper() == "POST":
|
||||
return parsed
|
||||
return parsed if isinstance(parsed, dict) else {"ok": False, "detail": "invalid json response"}
|
||||
|
||||
|
||||
def _json(method: str, path: str, body: dict | None = None, *, admin_key: str = "") -> dict:
|
||||
data = None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if admin_key:
|
||||
headers["X-Admin-Key"] = admin_key
|
||||
if body is not None:
|
||||
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
req = urllib.request.Request(f"{API}{path}", data=data, headers=headers, method=method.upper())
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"{method} {path} -> {exc.code}: {detail}") from exc
|
||||
|
||||
|
||||
def _docker_admin_key() -> str:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", "shadowbroker-backend", "printenv", "ADMIN_KEY"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _ssh_pete_admin_key() -> str:
|
||||
proc = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", SSH_PETE, "docker exec shadowbroker-backend printenv ADMIN_KEY"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _ensure_pete_invite(pete_admin: str) -> tuple[str, str]:
|
||||
if PETE_HANDLE:
|
||||
lookup = PETE_LOOKUP_PEER_URL or (
|
||||
f"http://{PETE_ONION}" if PETE_ONION else ""
|
||||
)
|
||||
return PETE_HANDLE, lookup.rstrip("/")
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
SSH_PETE,
|
||||
f"curl -s -H 'X-Admin-Key: {pete_admin}' 'http://127.0.0.1:8000/api/wormhole/dm/invite?label=e2e-live'",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
invite = json.loads(proc.stdout)
|
||||
payload = dict(invite.get("invite", {}).get("payload", {}) or {})
|
||||
handle = str(payload.get("prekey_lookup_handle", "") or "").strip()
|
||||
lookup_peer_url = str(payload.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
if not handle:
|
||||
raise RuntimeError(f"could not mint Pete short handle: {invite}")
|
||||
return handle, lookup_peer_url
|
||||
|
||||
|
||||
def _docker_python(code: str) -> dict:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", "shadowbroker-backend", "python", "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "docker python failed")
|
||||
line = proc.stdout.strip().splitlines()[-1]
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
def _restart_local_backend() -> None:
|
||||
"""Clear in-memory DM relay state (MESH_DM_PERSIST_SPOOL=false) before a repeat run."""
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
"docker-compose.yml",
|
||||
"-f",
|
||||
"docker-compose.build.yml",
|
||||
"restart",
|
||||
"backend",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "backend restart failed")
|
||||
deadline = time.time() + 120
|
||||
while time.time() < deadline:
|
||||
probe = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"shadowbroker-backend",
|
||||
"curl",
|
||||
"-sf",
|
||||
"--max-time",
|
||||
"5",
|
||||
"http://127.0.0.1:8000/api/health",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
print("local backend restarted and healthy")
|
||||
return
|
||||
time.sleep(3)
|
||||
raise RuntimeError("backend did not become healthy after restart")
|
||||
|
||||
|
||||
def _wait_hidden_transport_ready(*, timeout_s: int = 120) -> dict:
|
||||
code = (
|
||||
"import json, time\n"
|
||||
"from services.mesh.mesh_private_dispatcher import _anonymous_dm_hidden_transport_enforced\n"
|
||||
f"deadline = time.time() + {int(timeout_s)}\n"
|
||||
"while time.time() < deadline:\n"
|
||||
" if _anonymous_dm_hidden_transport_enforced():\n"
|
||||
" print(json.dumps({'ok': True}))\n"
|
||||
" break\n"
|
||||
" time.sleep(2)\n"
|
||||
"else:\n"
|
||||
" print(json.dumps({'ok': False, 'detail': 'hidden transport not ready'}))\n"
|
||||
)
|
||||
return _docker_python(code)
|
||||
|
||||
|
||||
def _release_dm_outbox(admin_key: str, outbox_id: str, *, timeout_s: int = 180) -> dict:
|
||||
outbox_id = str(outbox_id or "").strip()
|
||||
if not outbox_id:
|
||||
return {"ok": False, "detail": "missing outbox_id"}
|
||||
_docker_json(
|
||||
"POST",
|
||||
f"/api/wormhole/private-delivery/{outbox_id}/action",
|
||||
{"action": "relay"},
|
||||
admin_key=admin_key,
|
||||
)
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
status = _docker_json("GET", "/api/wormhole/status", admin_key=admin_key)
|
||||
items = list((status.get("private_delivery") or {}).get("items") or [])
|
||||
item = next((entry for entry in items if str(entry.get("id", "")) == outbox_id), None)
|
||||
if item and str(item.get("release_state", "")) == "delivered":
|
||||
return {"ok": True, "item": item}
|
||||
time.sleep(3)
|
||||
return {"ok": False, "detail": "private release did not complete in time", "outbox_id": outbox_id}
|
||||
|
||||
|
||||
def _drain_pete_request_mailbox() -> None:
|
||||
drain_code = textwrap.dedent(
|
||||
"""
|
||||
import json, secrets, time, urllib.request
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
|
||||
def _poll_once():
|
||||
identity = get_dm_identity()
|
||||
agent_id = str(identity.get("node_id") or "")
|
||||
claims = [{"type": "requests", "token": "e2e-drain"}]
|
||||
signed = sign_dm_wormhole_event(
|
||||
event_type="dm_poll",
|
||||
payload={"mailbox_claims": claims, "agent_id": agent_id},
|
||||
)
|
||||
body = {
|
||||
"agent_id": agent_id,
|
||||
"mailbox_claims": 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),
|
||||
}
|
||||
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8000/api/mesh/dm/poll",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
drained = 0
|
||||
for _ in range(8):
|
||||
payload = _poll_once()
|
||||
count = int(payload.get("count", 0) or 0)
|
||||
drained += count
|
||||
if count <= 0 and not payload.get("has_more"):
|
||||
break
|
||||
time.sleep(1)
|
||||
print(json.dumps({"ok": True, "drained": drained}))
|
||||
"""
|
||||
).strip()
|
||||
result = _ssh_pete_python(drain_code)
|
||||
print(f"Pete request mailbox drain: {result.get('drained', 0)} message(s)")
|
||||
|
||||
|
||||
def _warmup_tor() -> None:
|
||||
"""Prime local Arti SOCKS before fleet lookups (cold Tor can exceed lookup budgets)."""
|
||||
if not PETE_ONION:
|
||||
return
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"shadowbroker-backend",
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
"120",
|
||||
"--socks5-hostname",
|
||||
"127.0.0.1:9050",
|
||||
f"http://{PETE_ONION}/api/health",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
check=False,
|
||||
)
|
||||
code = (proc.stdout or "").strip()
|
||||
print(f"Tor warmup Pete health: {code or proc.stderr.strip() or 'failed'}")
|
||||
|
||||
|
||||
def _ssh_pete_python(code: str) -> dict:
|
||||
# Pipe script stdin to Pete's running backend container — avoids Windows
|
||||
# docker-exec base64 bugs and SSH command-line length limits on long polls.
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
SSH_PETE,
|
||||
"docker exec -i shadowbroker-backend python",
|
||||
],
|
||||
input=code.encode("utf-8"),
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pete python failed")
|
||||
lines = [line for line in proc.stdout.strip().splitlines() if line.strip()]
|
||||
if not lines:
|
||||
raise RuntimeError(proc.stderr.strip() or "pete python produced no output")
|
||||
return json.loads(lines[-1])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if FRESH_BACKEND:
|
||||
print("== prep: restart local backend for clean in-memory DM relay ==")
|
||||
_restart_local_backend()
|
||||
|
||||
print("== prep: drain stale Pete request mailbox ==")
|
||||
_drain_pete_request_mailbox()
|
||||
|
||||
print("== warmup: prime Tor to Pete ==")
|
||||
_warmup_tor()
|
||||
|
||||
print("== warmup: wait for anonymous hidden transport ==")
|
||||
hidden = _wait_hidden_transport_ready()
|
||||
print(json.dumps(hidden, indent=2))
|
||||
if not hidden.get("ok"):
|
||||
raise RuntimeError(f"hidden transport not ready: {hidden}")
|
||||
|
||||
local_admin = _docker_admin_key()
|
||||
pete_admin = _ssh_pete_admin_key()
|
||||
handle, lookup_peer_url = _ensure_pete_invite(pete_admin)
|
||||
print(f"Pete short handle: {handle}")
|
||||
if lookup_peer_url:
|
||||
print(f"Pete lookup peer: {lookup_peer_url}")
|
||||
|
||||
print("== step 1: fleet pubkey lookup from local ==")
|
||||
lookup_path = f"/api/mesh/dm/pubkey?lookup_token={handle}"
|
||||
if lookup_peer_url:
|
||||
lookup_path += f"&lookup_peer_url={urllib.parse.quote(lookup_peer_url, safe='')}"
|
||||
lookup = _json("GET", lookup_path)
|
||||
if not lookup.get("ok") or not lookup.get("agent_id") or not lookup.get("dh_pub_key"):
|
||||
print(json.dumps(lookup, indent=2))
|
||||
raise RuntimeError("pubkey fleet lookup failed")
|
||||
pete_id = str(lookup["agent_id"])
|
||||
pete_dh = str(lookup.get("dh_pub_key") or "")
|
||||
print(f"resolved Pete agent_id: {pete_id}")
|
||||
|
||||
print("== step 2: send contact request from local ==")
|
||||
send_code = (
|
||||
"import json\n"
|
||||
"from services.openclaw_infonet import send_contact_request\n"
|
||||
f"result = send_contact_request(lookup_token={json.dumps(handle)}, note={json.dumps(MARKER)}, lookup_peer_url={json.dumps(lookup_peer_url)})\n"
|
||||
"print(json.dumps({"
|
||||
"'ok': bool(result.get('ok')), "
|
||||
"'send': result, "
|
||||
"'msg_id': result.get('msg_id',''), "
|
||||
"'sender_id': result.get('sender_id',''), "
|
||||
"'recipient_id': result.get('recipient_id','')"
|
||||
"}))\n"
|
||||
)
|
||||
send_result = _docker_python(send_code)
|
||||
print(json.dumps(send_result, indent=2))
|
||||
if not send_result.get("ok"):
|
||||
raise RuntimeError(f"local send failed: {send_result}")
|
||||
msg_id = str(send_result.get("msg_id", "") or "")
|
||||
|
||||
print("== step 2b: approve relay release and wait for fleet push ==")
|
||||
outbox_id = str((send_result.get("send") or {}).get("outbox_id", "") or "")
|
||||
release = _release_dm_outbox(local_admin, outbox_id)
|
||||
print(json.dumps(release, indent=2))
|
||||
if not release.get("ok"):
|
||||
raise RuntimeError(f"private release failed: {release}")
|
||||
|
||||
print("== step 3: wait for fleet replication, poll Pete relay ==")
|
||||
# Hit the running uvicorn process via localhost HTTP — dm_relay is in-memory
|
||||
# and is not visible to one-off `docker exec python` shells.
|
||||
poll_code = textwrap.dedent(
|
||||
f"""
|
||||
import json, secrets, time, urllib.error, urllib.request
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
|
||||
msg_id = {json.dumps(msg_id)}
|
||||
marker = {json.dumps(MARKER)}
|
||||
sender_id = {json.dumps(send_result.get('sender_id', ''))}
|
||||
|
||||
def _mailbox_claims():
|
||||
return [{{"type": "requests", "token": "e2e-poll"}}]
|
||||
|
||||
def _poll_once():
|
||||
identity = get_dm_identity()
|
||||
agent_id = str(identity.get("node_id") or "")
|
||||
claims = _mailbox_claims()
|
||||
signed = sign_dm_wormhole_event(
|
||||
event_type="dm_poll",
|
||||
payload={{"mailbox_claims": claims, "agent_id": agent_id}},
|
||||
)
|
||||
body = {{
|
||||
"agent_id": agent_id,
|
||||
"mailbox_claims": 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),
|
||||
}}
|
||||
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8000/api/mesh/dm/poll",
|
||||
data=data,
|
||||
headers={{"Content-Type": "application/json"}},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
hit = None
|
||||
for attempt in range(30):
|
||||
time.sleep(4)
|
||||
try:
|
||||
payload = _poll_once()
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 202:
|
||||
continue
|
||||
print(json.dumps({{"ok": False, "detail": f"poll http {{exc.code}}: {{detail}}"}}))
|
||||
break
|
||||
except Exception as exc:
|
||||
print(json.dumps({{"ok": False, "detail": str(exc) or type(exc).__name__}}))
|
||||
break
|
||||
for message in list(payload.get("messages") or []):
|
||||
if str(message.get("msg_id", "")) == msg_id:
|
||||
hit = message
|
||||
break
|
||||
if marker in str(message.get("ciphertext", "")):
|
||||
hit = message
|
||||
break
|
||||
if hit:
|
||||
print(json.dumps({{"ok": True, "attempt": attempt, "msg_id": msg_id}}))
|
||||
break
|
||||
else:
|
||||
print(json.dumps({{"ok": False, "detail": "request not in Pete relay mailboxes"}}))
|
||||
"""
|
||||
).strip()
|
||||
poll = _ssh_pete_python(poll_code)
|
||||
print(json.dumps(poll, indent=2))
|
||||
if not poll.get("ok"):
|
||||
raise RuntimeError(f"Pete did not receive request: {poll}")
|
||||
|
||||
print("== step 4: Pete bootstrap-decrypt contact offer ==")
|
||||
decrypt_code = textwrap.dedent(
|
||||
f"""
|
||||
import json, secrets, time, urllib.error, urllib.request
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
from services.mesh.mesh_wormhole_prekey import bootstrap_decrypt_from_sender
|
||||
|
||||
sender_id = {json.dumps(send_result.get('sender_id', ''))}
|
||||
msg_id = {json.dumps(msg_id)}
|
||||
|
||||
identity = get_dm_identity()
|
||||
agent_id = str(identity.get("node_id") or "")
|
||||
claims = [{{"type": "requests", "token": "e2e-poll"}}]
|
||||
signed = sign_dm_wormhole_event(
|
||||
event_type="dm_poll",
|
||||
payload={{"mailbox_claims": claims, "agent_id": agent_id}},
|
||||
)
|
||||
body = {{
|
||||
"agent_id": agent_id,
|
||||
"mailbox_claims": 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),
|
||||
}}
|
||||
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8000/api/mesh/dm/poll",
|
||||
data=data,
|
||||
headers={{"Content-Type": "application/json"}},
|
||||
method="POST",
|
||||
)
|
||||
ciphertext = ""
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
for message in list(payload.get("messages") or []):
|
||||
if str(message.get("msg_id", "")) == msg_id:
|
||||
ciphertext = str(message.get("ciphertext", "") or "")
|
||||
break
|
||||
except Exception as exc:
|
||||
print(json.dumps({{"ok": False, "detail": str(exc) or type(exc).__name__}}))
|
||||
elif not ciphertext:
|
||||
print(json.dumps({{"ok": False, "detail": "ciphertext missing on Pete"}}))
|
||||
else:
|
||||
dec = bootstrap_decrypt_from_sender(sender_id, ciphertext)
|
||||
print(json.dumps({{"ok": bool(dec.get("ok")), "plaintext": dec.get("result", ""), "detail": dec.get("detail", "")}}))
|
||||
"""
|
||||
).strip()
|
||||
decrypted = _ssh_pete_python(decrypt_code)
|
||||
print(json.dumps(decrypted, indent=2))
|
||||
if not decrypted.get("ok") or MARKER not in str(decrypted.get("plaintext", "")):
|
||||
raise RuntimeError(f"Pete could not decrypt contact offer: {decrypted}")
|
||||
|
||||
local_sender_id = str(send_result.get("sender_id", "") or "")
|
||||
if not local_sender_id:
|
||||
raise RuntimeError("local sender_id missing from send result")
|
||||
|
||||
print("== step 5: Pete accepts contact request ==")
|
||||
accept_code = textwrap.dedent(
|
||||
f"""
|
||||
import json, os
|
||||
os.environ.setdefault("SB_API_BASE", "http://127.0.0.1:8000")
|
||||
from services.openclaw_infonet import send_contact_accept
|
||||
result = send_contact_accept(peer_id={json.dumps(local_sender_id)})
|
||||
print(json.dumps({{
|
||||
"ok": bool(result.get("ok")),
|
||||
"msg_id": result.get("msg_id", ""),
|
||||
"shared_alias": result.get("shared_alias", ""),
|
||||
"detail": result.get("detail", ""),
|
||||
}}))
|
||||
"""
|
||||
).strip()
|
||||
accept_result = _ssh_pete_python(accept_code)
|
||||
print(json.dumps(accept_result, indent=2))
|
||||
if not accept_result.get("ok"):
|
||||
raise RuntimeError(f"Pete accept failed: {accept_result}")
|
||||
accept_msg_id = str(accept_result.get("msg_id", "") or "")
|
||||
|
||||
print("== step 5b: release Pete accept to fleet relay ==")
|
||||
print(json.dumps(_ssh_pete_python(release_code), indent=2))
|
||||
|
||||
print("== step 6: local polls and decrypts contact accept ==")
|
||||
local_accept_code = textwrap.dedent(
|
||||
f"""
|
||||
import json, secrets, time, urllib.error, urllib.request
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_dead_drop import parse_contact_consent
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
from services.mesh.mesh_wormhole_prekey import bootstrap_decrypt_from_sender
|
||||
|
||||
sender_id = {json.dumps(local_sender_id)}
|
||||
accept_msg_id = {json.dumps(accept_msg_id)}
|
||||
pete_id = {json.dumps(pete_id)}
|
||||
|
||||
identity = get_dm_identity()
|
||||
agent_id = str(identity.get("node_id") or "")
|
||||
claims = [{{"type": "requests", "token": "e2e-local-poll"}}]
|
||||
signed = sign_dm_wormhole_event(
|
||||
event_type="dm_poll",
|
||||
payload={{"mailbox_claims": claims, "agent_id": agent_id}},
|
||||
)
|
||||
body = {{
|
||||
"agent_id": agent_id,
|
||||
"mailbox_claims": 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),
|
||||
}}
|
||||
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
|
||||
hit = None
|
||||
for attempt in range(30):
|
||||
time.sleep(4)
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8000/api/mesh/dm/poll",
|
||||
data=data,
|
||||
headers={{"Content-Type": "application/json"}},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
print(json.dumps({{"ok": False, "detail": str(exc) or type(exc).__name__}}))
|
||||
break
|
||||
for message in list(payload.get("messages") or []):
|
||||
if str(message.get("msg_id", "")) == accept_msg_id:
|
||||
hit = message
|
||||
break
|
||||
if str(message.get("sender_id", "")) == pete_id:
|
||||
hit = message
|
||||
break
|
||||
if hit:
|
||||
break
|
||||
if not hit:
|
||||
print(json.dumps({{"ok": False, "detail": "accept not in local requests mailbox"}}))
|
||||
else:
|
||||
ciphertext = str(hit.get("ciphertext", "") or "")
|
||||
dec = bootstrap_decrypt_from_sender(pete_id, ciphertext)
|
||||
consent = parse_contact_consent(str(dec.get("result", "") or ""))
|
||||
print(json.dumps({{
|
||||
"ok": bool(dec.get("ok") and consent and consent.get("kind") == "contact_accept"),
|
||||
"shared_alias": str((consent or {{}}).get("shared_alias", "") or ""),
|
||||
"detail": dec.get("detail", ""),
|
||||
}}))
|
||||
"""
|
||||
).strip()
|
||||
local_accept = _docker_python(local_accept_code)
|
||||
print(json.dumps(local_accept, indent=2))
|
||||
if not local_accept.get("ok") or not local_accept.get("shared_alias"):
|
||||
raise RuntimeError(f"local could not decrypt contact accept: {local_accept}")
|
||||
|
||||
print("== step 7: local sends shared DM reply ==")
|
||||
shared_send_code = textwrap.dedent(
|
||||
f"""
|
||||
import json, os
|
||||
os.environ.setdefault("SB_API_BASE", "http://127.0.0.1:8000")
|
||||
from services.mesh.mesh_wormhole_dead_drop import derive_dead_drop_token_pair
|
||||
from services.openclaw_infonet import send_dm
|
||||
token_pair = derive_dead_drop_token_pair(
|
||||
peer_id={json.dumps(pete_id)},
|
||||
peer_dh_pub={json.dumps(pete_dh)},
|
||||
)
|
||||
if not token_pair.get("ok"):
|
||||
print(json.dumps(token_pair))
|
||||
else:
|
||||
result = send_dm(
|
||||
{json.dumps(pete_id)},
|
||||
{json.dumps(REPLY_MARKER)},
|
||||
delivery_class="shared",
|
||||
recipient_token=str(token_pair.get("current") or ""),
|
||||
)
|
||||
print(json.dumps({{
|
||||
"ok": bool(result.get("ok")),
|
||||
"msg_id": result.get("msg_id", ""),
|
||||
"detail": result.get("detail", ""),
|
||||
}}))
|
||||
"""
|
||||
).strip()
|
||||
shared_send = _docker_python(shared_send_code)
|
||||
print(json.dumps(shared_send, indent=2))
|
||||
if not shared_send.get("ok"):
|
||||
raise RuntimeError(f"local shared DM send failed: {shared_send}")
|
||||
shared_msg_id = str(shared_send.get("msg_id", "") or "")
|
||||
|
||||
print("== step 7b: release local shared DM to fleet relay ==")
|
||||
print(json.dumps(_docker_python(release_code), indent=2))
|
||||
|
||||
print("== step 8: Pete polls shared mailbox and decrypts reply ==")
|
||||
shared_poll_code = textwrap.dedent(
|
||||
f"""
|
||||
import json, secrets, time, urllib.error, urllib.request
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_dead_drop import derive_dead_drop_token_pair
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
sender_id = {json.dumps(local_sender_id)}
|
||||
shared_msg_id = {json.dumps(shared_msg_id)}
|
||||
marker = {json.dumps(REPLY_MARKER)}
|
||||
|
||||
bundle = __import__(
|
||||
"services.mesh.mesh_wormhole_prekey",
|
||||
fromlist=["fetch_dm_prekey_bundle"],
|
||||
).fetch_dm_prekey_bundle(agent_id=sender_id)
|
||||
sender_dh = str(bundle.get("dh_pub_key") or "")
|
||||
token_pair = derive_dead_drop_token_pair(peer_id=sender_id, peer_dh_pub=sender_dh)
|
||||
if not token_pair.get("ok"):
|
||||
print(json.dumps(token_pair))
|
||||
raise SystemExit(0)
|
||||
tokens = [str(token_pair.get("current") or "")]
|
||||
prev = str(token_pair.get("previous") or "")
|
||||
if prev and prev not in tokens:
|
||||
tokens.append(prev)
|
||||
|
||||
identity = get_dm_identity()
|
||||
agent_id = str(identity.get("node_id") or "")
|
||||
claims = [{{"type": "shared", "token": token}} for token in tokens if token]
|
||||
|
||||
hit = None
|
||||
for attempt in range(30):
|
||||
time.sleep(4)
|
||||
signed = sign_dm_wormhole_event(
|
||||
event_type="dm_poll",
|
||||
payload={{"mailbox_claims": claims, "agent_id": agent_id}},
|
||||
)
|
||||
body = {{
|
||||
"agent_id": agent_id,
|
||||
"mailbox_claims": 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),
|
||||
}}
|
||||
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8000/api/mesh/dm/poll",
|
||||
data=data,
|
||||
headers={{"Content-Type": "application/json"}},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
print(json.dumps({{"ok": False, "detail": str(exc) or type(exc).__name__}}))
|
||||
break
|
||||
for message in list(payload.get("messages") or []):
|
||||
if str(message.get("msg_id", "")) == shared_msg_id:
|
||||
hit = message
|
||||
break
|
||||
if hit:
|
||||
break
|
||||
|
||||
if not hit:
|
||||
print(json.dumps({{"ok": False, "detail": "shared reply not in Pete mailbox"}}))
|
||||
else:
|
||||
ciphertext = str(hit.get("ciphertext", "") or "")
|
||||
dec = __import__("main", fromlist=["decrypt_wormhole_dm_envelope"]).decrypt_wormhole_dm_envelope(
|
||||
peer_id=sender_id,
|
||||
ciphertext=ciphertext,
|
||||
payload_format=str(hit.get("format", "") or "mls1"),
|
||||
session_welcome=str(hit.get("session_welcome", "") or ""),
|
||||
)
|
||||
plaintext = str(dec.get("plaintext", "") or "")
|
||||
print(json.dumps({{
|
||||
"ok": bool(dec.get("ok") and marker in plaintext),
|
||||
"plaintext": plaintext,
|
||||
"detail": dec.get("detail", ""),
|
||||
}}))
|
||||
"""
|
||||
).strip()
|
||||
shared_decrypt = _ssh_pete_python(shared_poll_code)
|
||||
print(json.dumps(shared_decrypt, indent=2))
|
||||
if not shared_decrypt.get("ok") or REPLY_MARKER not in str(shared_decrypt.get("plaintext", "")):
|
||||
raise RuntimeError(f"Pete could not decrypt shared DM: {shared_decrypt}")
|
||||
|
||||
print("== E2E PASS: invite -> accept -> private shared DM ==")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"E2E FAIL: {exc}", file=sys.stderr)
|
||||
raise
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live E2E: OpenClaw HMAC agent posts to Infonet gate and verifies propagation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
SKILL_DIR = os.path.join(ROOT, "openclaw-skills", "shadowbroker")
|
||||
API = os.environ.get("SHADOWBROKER_API", "http://127.0.0.1:8000")
|
||||
MARKER = os.environ.get("E2E_MARKER", f"OPENCLAW-AGENT-E2E-{int(time.time())}")
|
||||
|
||||
|
||||
def _json_request(method: str, path: str, body: dict | None = None, *, inside_docker: bool = False) -> dict:
|
||||
data = None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
req = urllib.request.Request(f"{API}{path}", data=data, headers=headers, method=method.upper())
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"{method} {path} -> {exc.code}: {detail}") from exc
|
||||
|
||||
|
||||
def docker_python(code: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", "shadowbroker-backend", "python", "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "docker exec failed")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def bootstrap_hmac_and_full_tier() -> str:
|
||||
setup = r"""
|
||||
import json, urllib.request
|
||||
BASE = 'http://127.0.0.1:8000'
|
||||
|
||||
def call(method, path, body=None):
|
||||
data = json.dumps(body or {}, separators=(',', ':'), sort_keys=True).encode() if body is not None else None
|
||||
req = urllib.request.Request(BASE + path, data=data, headers={'Content-Type': 'application/json'}, method=method)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
call('POST', '/api/ai/connect-info/bootstrap', {})
|
||||
call('PUT', '/api/ai/connect-info/access-tier', {'tier': 'full'})
|
||||
secret = call('POST', '/api/ai/connect-info/reveal', {})['hmac_secret']
|
||||
print(secret)
|
||||
"""
|
||||
secret = docker_python(setup)
|
||||
if not secret or len(secret) < 16:
|
||||
raise RuntimeError(f"unexpected HMAC secret: {secret!r}")
|
||||
return secret
|
||||
|
||||
|
||||
async def agent_post(secret: str, message: str) -> dict:
|
||||
sys.path.insert(0, SKILL_DIR)
|
||||
from sb_query import ShadowBrokerClient
|
||||
|
||||
os.environ["SHADOWBROKER_HMAC_SECRET"] = secret
|
||||
client = ShadowBrokerClient(base_url=API)
|
||||
try:
|
||||
ready = await client.ensure_infonet_ready(join_swarm=True)
|
||||
print("ensure_infonet_ready:", json.dumps(ready, indent=2)[:2000])
|
||||
if not ready.get("ok"):
|
||||
raise RuntimeError(f"ensure_infonet_ready failed: {ready}")
|
||||
|
||||
post = await client.post_to_gate("infonet", message)
|
||||
print("post_to_gate:", json.dumps(post, indent=2)[:2000])
|
||||
if not post.get("ok"):
|
||||
raise RuntimeError(f"post_to_gate failed: {post}")
|
||||
return post
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
def local_gate_has_event(event_id: str) -> bool:
|
||||
code = f"""
|
||||
from services.mesh.mesh_hashchain import gate_store
|
||||
evt = gate_store.get_event({event_id!r})
|
||||
print('yes' if evt else 'no')
|
||||
"""
|
||||
return docker_python(code) == "yes"
|
||||
|
||||
|
||||
REMOTE_CONTAINERS = {
|
||||
"shadowbroker": "shadowbroker-relay", # seed VPS
|
||||
"pete": "shadowbroker-backend",
|
||||
}
|
||||
|
||||
|
||||
def peer_gate_has_event(host: str, event_id: str) -> bool:
|
||||
container = REMOTE_CONTAINERS.get(host, "shadowbroker-backend")
|
||||
remote_code = (
|
||||
"from services.mesh.mesh_hashchain import gate_store; "
|
||||
f"print('yes' if gate_store.get_event({event_id!r}) else 'no')"
|
||||
)
|
||||
import shlex
|
||||
|
||||
ssh = [
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
host,
|
||||
f"docker exec {container} python -c {shlex.quote(remote_code)}",
|
||||
]
|
||||
proc = subprocess.run(ssh, capture_output=True, text=True, timeout=120, check=False)
|
||||
out = (proc.stdout or "").strip()
|
||||
if proc.returncode != 0:
|
||||
print(f"ssh {host} warn:", proc.stderr.strip() or proc.stdout.strip())
|
||||
return False
|
||||
return out == "yes"
|
||||
|
||||
|
||||
def wait_for_propagation(event_id: str, *, seconds: int = 90) -> dict[str, bool]:
|
||||
deadline = time.time() + seconds
|
||||
results = {"local": False, "seed": False, "pete": False}
|
||||
while time.time() < deadline:
|
||||
results["local"] = local_gate_has_event(event_id)
|
||||
results["seed"] = peer_gate_has_event("shadowbroker", event_id)
|
||||
results["pete"] = peer_gate_has_event("pete", event_id)
|
||||
if all(results.values()):
|
||||
break
|
||||
time.sleep(5)
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"E2E marker: {MARKER}")
|
||||
secret = bootstrap_hmac_and_full_tier()
|
||||
print("HMAC secret bootstrapped (full tier)")
|
||||
|
||||
post = asyncio.run(agent_post(secret, MARKER))
|
||||
event_id = str(post.get("event_id") or "")
|
||||
if not event_id:
|
||||
raise RuntimeError(f"post succeeded but no event_id in response: {post}")
|
||||
print(f"event_id={event_id}")
|
||||
|
||||
print("Waiting for propagation to local / seed / pete ...")
|
||||
results = wait_for_propagation(event_id, seconds=120)
|
||||
print("propagation:", json.dumps(results, indent=2))
|
||||
|
||||
if not results["local"]:
|
||||
raise SystemExit("FAIL: event not in local gate_store")
|
||||
if not results["seed"] and not results["pete"]:
|
||||
raise SystemExit("FAIL: event not observed on seed or pete within timeout")
|
||||
|
||||
if results["seed"] and results["pete"]:
|
||||
print("PASS: agent HMAC post propagated to local, seed, and pete")
|
||||
return 0
|
||||
print("PARTIAL: local ok; seed=%s pete=%s" % (results["seed"], results["pete"]))
|
||||
return 0 if results["local"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
EVENT_ID = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if not EVENT_ID:
|
||||
raise SystemExit("usage: verify_gate_event.py <event_id>")
|
||||
|
||||
code = (
|
||||
"from services.mesh.mesh_hashchain import gate_store; "
|
||||
f"e=gate_store.get_event({EVENT_ID!r}); "
|
||||
"print('ok' if e else 'no')"
|
||||
)
|
||||
|
||||
hosts = [
|
||||
("local", None, "shadowbroker-backend"),
|
||||
("seed", "shadowbroker", "shadowbroker-relay"),
|
||||
("pete", "pete", "shadowbroker-backend"),
|
||||
]
|
||||
|
||||
for label, ssh_host, container in hosts:
|
||||
remote = f"docker exec {container} python -c {shlex.quote(code)}"
|
||||
if ssh_host:
|
||||
proc = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", ssh_host, remote],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
else:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", container, "python", "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
out = (proc.stdout or proc.stderr).strip()
|
||||
print(f"{label}: {out}")
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify v1 Infonet swarm: fleet join, manifest peers, optional gate propagation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
API = os.environ.get("SHADOWBROKER_API", "http://127.0.0.1:8000").strip().rstrip("/")
|
||||
MARKER = os.environ.get("SWARM_VERIFY_MARKER", f"SWARM-V1-{int(time.time())}")
|
||||
|
||||
|
||||
def http_json(method: str, path: str, body: dict | None = None, *, timeout: int = 180) -> dict:
|
||||
data = None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
req = urllib.request.Request(f"{API}{path}", data=data, headers=headers, method=method.upper())
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"{method} {path} -> {exc.code}: {detail}") from exc
|
||||
|
||||
|
||||
def docker_python(code: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", "shadowbroker-backend", "python", "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "docker exec failed")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def step_ghcr_fleet_join() -> None:
|
||||
out = docker_python(
|
||||
"import json; "
|
||||
"from services.mesh.mesh_fleet_defaults import infonet_fleet_join_enabled, FLEET_SEED_ONION_URL; "
|
||||
"print(json.dumps({'fleet_join': infonet_fleet_join_enabled(), 'seed': FLEET_SEED_ONION_URL}))"
|
||||
)
|
||||
payload = json.loads(out)
|
||||
if not payload.get("fleet_join"):
|
||||
raise RuntimeError(f"fleet join disabled in container: {payload}")
|
||||
if not str(payload.get("seed") or "").endswith(".onion:8000"):
|
||||
raise RuntimeError(f"unexpected fleet seed: {payload}")
|
||||
print("PASS: container has fleet join defaults")
|
||||
|
||||
|
||||
def step_enable_node_and_join() -> dict:
|
||||
code = r"""
|
||||
import json
|
||||
import main as main_mod
|
||||
from services.mesh.mesh_swarm_runtime import (
|
||||
announce_local_peer_to_seeds,
|
||||
refresh_swarm_manifest_from_seeds,
|
||||
)
|
||||
|
||||
main_mod._set_participant_node_enabled(True)
|
||||
announce = announce_local_peer_to_seeds(force=True)
|
||||
manifest = refresh_swarm_manifest_from_seeds(force=True)
|
||||
print(json.dumps({
|
||||
'ok': bool(announce.get('ok')) or bool(manifest.get('ok')),
|
||||
'announce': announce,
|
||||
'manifest_pull': manifest,
|
||||
}))
|
||||
"""
|
||||
join = json.loads(docker_python(code))
|
||||
print("swarm/join:", json.dumps(join, indent=2)[:4000])
|
||||
if not join.get("ok"):
|
||||
raise RuntimeError(f"swarm join failed: {join}")
|
||||
manifest = join.get("manifest_pull") or {}
|
||||
peer_count = int(manifest.get("merged_peer_count") or manifest.get("peer_count") or 0)
|
||||
if peer_count < 1:
|
||||
raise RuntimeError(f"manifest has no peers: {join}")
|
||||
print(f"PASS: swarm join ok ({peer_count} manifest peer(s))")
|
||||
return join
|
||||
|
||||
|
||||
def step_manifest_lists_pete(join: dict) -> None:
|
||||
manifest = join.get("manifest_pull") or {}
|
||||
peer_count = int(manifest.get("merged_peer_count") or manifest.get("peer_count") or 0)
|
||||
if peer_count < 2:
|
||||
raise RuntimeError(f"expected fleet manifest with seed + participants, got: {manifest}")
|
||||
code = r"""
|
||||
import json
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
print(json.dumps({'push_peers': authenticated_push_peer_urls()[:12]}))
|
||||
"""
|
||||
payload = json.loads(docker_python(code))
|
||||
push_peers = [p for p in payload.get("push_peers") or [] if p]
|
||||
onion_peers = [p for p in push_peers if ".onion" in p]
|
||||
print("sync peer store:", json.dumps(payload, indent=2))
|
||||
if not onion_peers:
|
||||
raise RuntimeError(f"expected onion peers in local sync store after manifest pull, got: {push_peers}")
|
||||
print("PASS: manifest pull populated onion fleet peer(s)")
|
||||
|
||||
|
||||
def step_gate_propagation() -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, os.path.join(os.path.dirname(__file__), "e2e_openclaw_infonet_agent_live.py")],
|
||||
env={**os.environ, "E2E_MARKER": MARKER, "SHADOWBROKER_API": API},
|
||||
check=True,
|
||||
timeout=600,
|
||||
)
|
||||
print("PASS: gate event propagated across fleet")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(f"gate propagation check failed (exit {exc.returncode})") from exc
|
||||
except FileNotFoundError:
|
||||
print("SKIP: e2e_openclaw_infonet_agent_live.py not available")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"Swarm v1 verify against {API}")
|
||||
step_ghcr_fleet_join()
|
||||
join = step_enable_node_and_join()
|
||||
step_manifest_lists_pete(join)
|
||||
if os.environ.get("SWARM_VERIFY_SKIP_PROPAGATION") != "1":
|
||||
step_gate_propagation()
|
||||
print("ALL SWARM V1 CHECKS PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user