diff --git a/.gitignore b/.gitignore index 536b152..c8a2600 100644 --- a/.gitignore +++ b/.gitignore @@ -173,6 +173,8 @@ backend/services/test_*.py # Local analysis & dev tools backend/analyze_xlsx.py backend/services/ais_cache.json +graphify/ +graphify-out/ # ======================== # Internal docs & brainstorming (never commit) diff --git a/backend/auth.py b/backend/auth.py index 907c917..3cf4d7c 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -361,6 +361,8 @@ async def _verify_openclaw_hmac(request: Request) -> bool: # Bind request body: digest the raw bytes so any body tampering # invalidates the signature. Empty/absent bodies hash as sha256(b""). body_bytes = await request.body() + # Keep the cached body available for downstream handlers that call request.json(). + request._body = body_bytes body_digest = _hashlib_mod.sha256(body_bytes).hexdigest() # Compute expected signature: HMAC-SHA256(secret, METHOD|path|ts|nonce|body_digest) diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 5ceea6f..1c49507 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -14,4 +14,9 @@ if [ -d /app/image-data ]; then done fi +if [ -z "${PRIVACY_CORE_ALLOWED_SHA256:-}" ] && [ -f /app/libprivacy_core.so ]; then + PRIVACY_CORE_ALLOWED_SHA256="$(sha256sum /app/libprivacy_core.so | awk '{print $1}')" + export PRIVACY_CORE_ALLOWED_SHA256 +fi + exec "$@" diff --git a/backend/main.py b/backend/main.py index 29ff823..a60a341 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from typing import Any from json import JSONDecodeError -APP_VERSION = "0.9.75" +APP_VERSION = "0.9.79" logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -3061,6 +3061,24 @@ def _resume_private_delivery_background_work(*, current_tier: str, reason: str) ) +def _is_public_meshtastic_lane_path(path: str, method: str) -> bool: + """Routes for the public Meshtastic MQTT lane. + + These are intentionally outside the Wormhole/Infonet private transport + lifecycle. Polling public MeshChat must not wake or re-enable Wormhole. + """ + normalized_path = str(path or "").strip() + method_name = str(method or "").upper() + if method_name == "POST" and normalized_path == "/api/mesh/meshtastic/send": + return True + if method_name == "GET" and normalized_path in { + "/api/mesh/messages", + "/api/mesh/channels", + }: + return True + return False + + def _upgrade_invite_scoped_contact_preferences_background() -> dict[str, Any]: try: from services.mesh.mesh_wormhole_contacts import upgrade_invite_scoped_contact_preferences @@ -3092,7 +3110,11 @@ def _refresh_lookup_handle_rotation_background(*, reason: str) -> dict[str, Any] @app.middleware("http") async def enforce_high_privacy_mesh(request: Request, call_next): path = request.url.path - if path.startswith("/api/mesh") or path.startswith("/api/wormhole/gate/") or path.startswith("/api/wormhole/dm/"): + private_mesh_path = path.startswith("/api/mesh") and not _is_public_meshtastic_lane_path( + path, + request.method, + ) + if private_mesh_path or path.startswith("/api/wormhole/gate/") or path.startswith("/api/wormhole/dm/"): request.state._private_lane_started_at = time.perf_counter() current_tier = "public_degraded" try: @@ -3193,7 +3215,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next): # Don't block the request on the upgrade — the transport # manager will converge in the background. if ( - path.startswith("/api/mesh") + private_mesh_path and str(data.get("privacy_profile", "default")).lower() == "high" and not bool(data.get("enabled")) ): @@ -3426,8 +3448,16 @@ async def update_layers(update: LayerUpdate, request: Request): from services.sigint_bridge import sigint_grid if old_mesh and not new_mesh: - sigint_grid.mesh.stop() - logger.info("Meshtastic MQTT bridge stopped (layer disabled)") + try: + from services.meshtastic_mqtt_settings import mqtt_bridge_enabled + keep_chat_running = mqtt_bridge_enabled() + except Exception: + keep_chat_running = False + if keep_chat_running: + logger.info("Meshtastic map layer disabled; MQTT bridge kept running for MeshChat") + else: + sigint_grid.mesh.stop() + logger.info("Meshtastic MQTT bridge stopped (layer disabled)") elif not old_mesh and new_mesh: # Respect the global MESH_MQTT_ENABLED gate even when the UI layer is # toggled on. The layer toggle should not bypass the opt-in flag that @@ -4361,9 +4391,11 @@ async def mesh_send(request: Request): any_ok = any(r.ok for r in results) # ─── Mirror to Meshtastic bridge feed ──────────────────────── - # The MQTT broker won't echo our own publishes back to our subscriber, - # so inject successfully-sent messages into the bridge's deque directly. - if any_ok and envelope.routed_via == "meshtastic": + # The MQTT broker won't echo our own publishes back to our subscriber, so + # inject successfully-sent channel broadcasts into the bridge directly. + # Node-targeted packets must not appear in the public channel feed. + is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None + if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination: try: from services.sigint_bridge import sigint_grid @@ -4371,16 +4403,22 @@ async def mesh_send(request: Request): if bridge: from datetime import datetime - bridge.messages.appendleft( + append_text = getattr(bridge, "append_text_message", None) + message_record = ( { "from": MeshtasticTransport.mesh_address_for_sender(node_id), - "to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast", + "to": "broadcast", "text": message, "region": credentials.get("mesh_region", "US"), + "root": credentials.get("mesh_region", "US"), "channel": body.get("channel", "LongFast"), "timestamp": datetime.utcnow().isoformat() + "Z", } ) + if callable(append_text): + append_text(message_record) + else: + bridge.messages.appendleft(message_record) except Exception: pass # Non-critical @@ -4390,6 +4428,8 @@ async def mesh_send(request: Request): "event_id": "", "routed_via": envelope.routed_via, "route_reason": envelope.route_reason, + "direct": is_direct_destination, + "channel_echo": not is_direct_destination, "results": [r.to_dict() for r in results], } @@ -4488,6 +4528,7 @@ async def mesh_messages( root: str = "", channel: str = "", limit: int = 30, + include_direct: bool = False, ): """Get recent Meshtastic text messages from the MQTT bridge.""" from services.sigint_bridge import sigint_grid @@ -4509,6 +4550,12 @@ async def mesh_messages( msgs = [m for m in msgs if m.get("root", "").upper() == root_filter] if channel: msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()] + if not include_direct: + msgs = [ + m + for m in msgs + if str(m.get("to") or "broadcast").strip().lower() in {"", "broadcast", "^all"} + ] return msgs[: min(limit, 100)] @@ -8789,6 +8836,16 @@ export_wormhole_dm_invite = getattr( "export_wormhole_dm_invite", _wormhole_identity_unavailable, ) +list_prekey_lookup_handle_records_for_ui = getattr( + _mesh_wormhole_identity, + "list_prekey_lookup_handle_records_for_ui", + _wormhole_identity_unavailable, +) +revoke_prekey_lookup_handle = getattr( + _mesh_wormhole_identity, + "revoke_prekey_lookup_handle", + _wormhole_identity_unavailable, +) import_wormhole_dm_invite = getattr( _mesh_wormhole_identity, "import_wormhole_dm_invite", @@ -8935,6 +8992,13 @@ async def api_get_node_settings(request: Request): @limiter.limit("10/minute") async def api_set_node_settings(request: Request, body: NodeSettingsUpdate): _refresh_node_peer_store() + if bool(body.enabled): + try: + from services.transport_lane_isolation import disable_public_mesh_lane + + disable_public_mesh_lane(reason="private_node_enabled") + except Exception as exc: + logger.warning("Failed to disable public Mesh while enabling private node: %s", exc) result = _set_participant_node_enabled(bool(body.enabled)) if bool(body.enabled): _kick_public_sync_background("operator_enable") @@ -9659,7 +9723,7 @@ async def api_get_wormhole_status(request: Request): ) -@app.post("/api/wormhole/join", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/join") @limiter.limit("10/minute") async def api_wormhole_join(request: Request): existing = read_wormhole_settings() @@ -9713,7 +9777,7 @@ async def api_wormhole_join(request: Request): } -@app.post("/api/wormhole/leave", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/leave") @limiter.limit("10/minute") async def api_wormhole_leave(request: Request): updated = write_wormhole_settings(enabled=False) @@ -9730,7 +9794,7 @@ async def api_wormhole_leave(request: Request): } -@app.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)]) +@app.get("/api/wormhole/identity") @limiter.limit("30/minute") async def api_wormhole_identity(request: Request): try: @@ -9743,7 +9807,7 @@ async def api_wormhole_identity(request: Request): raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc -@app.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/identity/bootstrap") @limiter.limit("10/minute") async def api_wormhole_identity_bootstrap(request: Request): bootstrap_wormhole_identity() @@ -9776,8 +9840,24 @@ async def api_wormhole_dm_identity(request: Request): @app.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") -async def api_wormhole_dm_invite(request: Request): - return export_wormhole_dm_invite() +async def api_wormhole_dm_invite( + request: Request, + label: str = Query("", max_length=96), + expires_in_s: int = Query(0, ge=0, le=2_592_000), +): + return export_wormhole_dm_invite(label=label, expires_in_s=expires_in_s) + + +@app.get("/api/wormhole/dm/invite/handles", dependencies=[Depends(require_local_operator)]) +@limiter.limit("30/minute") +async def api_wormhole_dm_invite_handles(request: Request): + return list_prekey_lookup_handle_records_for_ui() + + +@app.delete("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)]) +@limiter.limit("30/minute") +async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str): + return revoke_prekey_lookup_handle(handle) @app.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)]) @@ -10507,7 +10587,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest): ) -@app.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/enter") @limiter.limit("20/minute") async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): gate_id = str(body.gate_id or "") @@ -10521,25 +10601,25 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): return result -@app.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/leave") @limiter.limit("20/minute") async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest): return leave_gate(str(body.gate_id or "")) -@app.get("/api/wormhole/gate/{gate_id}/identity", dependencies=[Depends(require_local_operator)]) +@app.get("/api/wormhole/gate/{gate_id}/identity") @limiter.limit("30/minute") async def api_wormhole_gate_identity(request: Request, gate_id: str): return get_active_gate_identity(gate_id) -@app.get("/api/wormhole/gate/{gate_id}/personas", dependencies=[Depends(require_local_operator)]) +@app.get("/api/wormhole/gate/{gate_id}/personas") @limiter.limit("30/minute") async def api_wormhole_gate_personas(request: Request, gate_id: str): return list_gate_personas(gate_id) -@app.get("/api/wormhole/gate/{gate_id}/key", dependencies=[Depends(require_local_operator)]) +@app.get("/api/wormhole/gate/{gate_id}/key") @limiter.limit("30/minute") async def api_wormhole_gate_key_status(request: Request, gate_id: str): exposure = metadata_exposure_for_request(request, authenticated=True) @@ -10563,7 +10643,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat return result -@app.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/persona/create") @limiter.limit("20/minute") async def api_wormhole_gate_persona_create( request: Request, body: WormholeGatePersonaCreateRequest @@ -10579,7 +10659,7 @@ async def api_wormhole_gate_persona_create( return result -@app.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/persona/activate") @limiter.limit("20/minute") async def api_wormhole_gate_persona_activate( request: Request, body: WormholeGatePersonaActivateRequest @@ -10595,7 +10675,7 @@ async def api_wormhole_gate_persona_activate( return result -@app.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/persona/clear") @limiter.limit("20/minute") async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest): gate_id = str(body.gate_id or "") @@ -10609,7 +10689,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe return result -@app.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/persona/retire") @limiter.limit("20/minute") async def api_wormhole_gate_persona_retire( request: Request, body: WormholeGatePersonaActivateRequest @@ -10690,7 +10770,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate return composed -@app.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/message/sign-encrypted") @limiter.limit("30/minute") async def api_wormhole_gate_message_sign_encrypted( request: Request, @@ -10722,7 +10802,7 @@ async def api_wormhole_gate_message_sign_encrypted( return signed -@app.post("/api/wormhole/gate/message/post-encrypted", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/message/post-encrypted") @limiter.limit("30/minute") async def api_wormhole_gate_message_post_encrypted( request: Request, @@ -10902,13 +10982,13 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat return {"ok": True, "results": results} -@app.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/state/export") @limiter.limit("30/minute") async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest): return export_gate_state_snapshot_with_repair(str(body.gate_id or "")) -@app.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)]) +@app.post("/api/wormhole/gate/proof") @limiter.limit("30/minute") async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest): proof = _sign_gate_access_proof(str(body.gate_id or "")) @@ -11455,7 +11535,7 @@ async def api_wormhole_health(request: Request): return _redact_wormhole_status(full_state, authenticated=ok) -@app.post("/api/wormhole/connect", dependencies=[Depends(require_admin)]) +@app.post("/api/wormhole/connect") @limiter.limit("10/minute") async def api_wormhole_connect(request: Request): settings = read_wormhole_settings() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 858dde7..8358aec 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,7 +7,7 @@ py-modules = [] [project] name = "backend" -version = "0.9.75" +version = "0.9.79" requires-python = ">=3.10" dependencies = [ "apscheduler==3.10.3", @@ -43,7 +43,7 @@ dev = ["pytest>=8.3.4", "pytest-asyncio==0.25.0", "ruff>=0.9.0", "black>=24.0.0" [tool.ruff.lint] # The current backend carries historical style debt in large legacy modules. -# Keep CI focused on actionable correctness checks for the v0.9.75 release. +# Keep CI focused on actionable correctness checks for the v0.9.79 release. ignore = ["E401", "E402", "E701", "E731", "E741", "F401", "F402", "F541", "F811", "F841"] [tool.black] diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 536122a..33053b5 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -132,6 +132,13 @@ async def api_get_node_settings(request: Request): @limiter.limit("10/minute") async def api_set_node_settings(request: Request, body: NodeSettingsUpdate): _refresh_node_peer_store() + if bool(body.enabled): + try: + from services.transport_lane_isolation import disable_public_mesh_lane + + disable_public_mesh_lane(reason="private_node_enabled") + except Exception as exc: + logger.warning("Failed to disable public Mesh while enabling private node: %s", exc) result = _set_participant_node_enabled(bool(body.enabled)) if bool(body.enabled): try: @@ -174,17 +181,22 @@ async def api_set_meshtastic_mqtt_settings(request: Request, body: MeshtasticMqt enabled_requested = updates.get("enabled") settings = write_meshtastic_mqtt_settings(**updates) + if isinstance(enabled_requested, bool): + logger.info("Meshtastic MQTT settings update: enabled=%s", enabled_requested) if enabled_requested is True: # Public MQTT and Wormhole are intentionally mutually exclusive lanes. try: + from services.node_settings import write_node_settings from services.wormhole_settings import write_wormhole_settings from services.wormhole_supervisor import disconnect_wormhole write_wormhole_settings(enabled=False) disconnect_wormhole(reason="public_mesh_enabled") + write_node_settings(enabled=False) + _set_participant_node_enabled(False) except Exception as exc: - logger.warning("Failed to disable Wormhole while enabling public mesh: %s", exc) + logger.warning("Failed to disable private mesh lane while enabling public mesh: %s", exc) if bool(settings.get("enabled")): if sigint_grid.mesh.is_running(): @@ -357,8 +369,8 @@ async def api_reset_all_agent_credentials(request: Request): return { "ok": True, - "new_hmac_secret": new_secret, - "detail": "All agent credentials have been reset. Reconfigure your agent with the new credentials.", + "hmac_regenerated": True, + "detail": "All agent credentials have been reset. Use the agent connection screen to generate or reveal replacement credentials.", **results, } diff --git a/backend/routers/ai_intel.py b/backend/routers/ai_intel.py index 8e292e5..d5df1ca 100644 --- a/backend/routers/ai_intel.py +++ b/backend/routers/ai_intel.py @@ -1585,7 +1585,7 @@ async def agent_tool_manifest(request: Request): return { "ok": True, - "version": "0.9.75", + "version": "0.9.79", "access_tier": access_tier, "available_commands": available_commands, "transport": { @@ -2221,7 +2221,7 @@ async def api_capabilities(request: Request): access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower() return { "ok": True, - "version": "0.9.75", + "version": "0.9.79", "auth": { "method": "HMAC-SHA256", "headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"], diff --git a/backend/routers/data.py b/backend/routers/data.py index 6e5cc33..384660c 100644 --- a/backend/routers/data.py +++ b/backend/routers/data.py @@ -335,8 +335,16 @@ async def update_layers(update: LayerUpdate, request: Request): logger.info("AIS stream started (ship layer enabled)") from services.sigint_bridge import sigint_grid if old_mesh and not new_mesh: - sigint_grid.mesh.stop() - logger.info("Meshtastic MQTT bridge stopped (layer disabled)") + try: + from services.meshtastic_mqtt_settings import mqtt_bridge_enabled + keep_chat_running = mqtt_bridge_enabled() + except Exception: + keep_chat_running = False + if keep_chat_running: + logger.info("Meshtastic map layer disabled; MQTT bridge kept running for MeshChat") + else: + sigint_grid.mesh.stop() + logger.info("Meshtastic MQTT bridge stopped (layer disabled)") elif not old_mesh and new_mesh: try: from services.meshtastic_mqtt_settings import mqtt_bridge_enabled diff --git a/backend/routers/health.py b/backend/routers/health.py index f47c067..edd48d9 100644 --- a/backend/routers/health.py +++ b/backend/routers/health.py @@ -8,7 +8,7 @@ from services.data_fetcher import get_latest_data from services.schemas import HealthResponse import os -APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.75") +APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.79") router = APIRouter() diff --git a/backend/routers/mesh_public.py b/backend/routers/mesh_public.py index 374212c..ae9f4bd 100644 --- a/backend/routers/mesh_public.py +++ b/backend/routers/mesh_public.py @@ -721,9 +721,11 @@ async def mesh_send(request: Request): any_ok = any(r.ok for r in results) # ─── Mirror to Meshtastic bridge feed ──────────────────────── - # The MQTT broker won't echo our own publishes back to our subscriber, - # so inject successfully-sent messages into the bridge's deque directly. - if any_ok and envelope.routed_via == "meshtastic": + # The MQTT broker won't echo our own publishes back to our subscriber, so + # inject successfully-sent channel broadcasts into the bridge directly. + # Node-targeted packets must not appear in the public channel feed. + is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None + if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination: try: from services.sigint_bridge import sigint_grid @@ -734,7 +736,7 @@ async def mesh_send(request: Request): bridge.messages.appendleft( { "from": MeshtasticTransport.mesh_address_for_sender(node_id), - "to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast", + "to": "broadcast", "text": message, "region": credentials.get("mesh_region", "US"), "channel": body.get("channel", "LongFast"), @@ -750,6 +752,8 @@ async def mesh_send(request: Request): "event_id": "", "routed_via": envelope.routed_via, "route_reason": envelope.route_reason, + "direct": is_direct_destination, + "channel_echo": not is_direct_destination, "results": [r.to_dict() for r in results], } @@ -818,9 +822,10 @@ async def meshtastic_public_send(request: Request): if not cb_ok: results = [TransportResult(False, "meshtastic", cb_reason)] else: + is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None envelope.route_reason = ( "Local public Meshtastic MQTT path" - if MeshtasticTransport._parse_node_id(destination) is None + if not is_direct_destination else "Local public Meshtastic direct node path" ) credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")} @@ -830,23 +835,28 @@ async def meshtastic_public_send(request: Request): results = [result] any_ok = any(r.ok for r in results) - if any_ok and envelope.routed_via == "meshtastic": + is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None + if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination: try: from datetime import datetime from services.sigint_bridge import sigint_grid bridge = sigint_grid.mesh if bridge: - bridge.messages.appendleft( - { - "from": MeshtasticTransport.mesh_address_for_sender(sender_id), - "to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast", - "text": message, - "region": str(body.get("mesh_region", "US") or "US"), - "channel": str(body.get("channel", "LongFast") or "LongFast"), - "timestamp": datetime.utcnow().isoformat() + "Z", - } - ) + record = { + "from": MeshtasticTransport.mesh_address_for_sender(sender_id), + "to": "broadcast", + "text": message, + "region": str(body.get("mesh_region", "US") or "US"), + "root": str(body.get("mesh_region", "US") or "US"), + "channel": str(body.get("channel", "LongFast") or "LongFast"), + "timestamp": datetime.utcnow().isoformat() + "Z", + } + append_text = getattr(bridge, "append_text_message", None) + if callable(append_text): + append_text(record) + else: + bridge.messages.appendleft(record) except Exception: pass @@ -856,6 +866,8 @@ async def meshtastic_public_send(request: Request): "event_id": "", "routed_via": envelope.routed_via, "route_reason": envelope.route_reason, + "direct": is_direct_destination, + "channel_echo": not is_direct_destination, "results": [r.to_dict() for r in results], } @@ -954,6 +966,7 @@ async def mesh_messages( root: str = "", channel: str = "", limit: int = 30, + include_direct: bool = False, ): """Get recent Meshtastic text messages from the MQTT bridge.""" from services.sigint_bridge import sigint_grid @@ -975,6 +988,12 @@ async def mesh_messages( msgs = [m for m in msgs if m.get("root", "").upper() == root_filter] if channel: msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()] + if not include_direct: + msgs = [ + m + for m in msgs + if str(m.get("to") or "broadcast").strip().lower() in {"", "broadcast", "^all"} + ] return msgs[: min(limit, 100)] diff --git a/backend/routers/wormhole.py b/backend/routers/wormhole.py index 16d6ca7..0ccd5fe 100644 --- a/backend/routers/wormhole.py +++ b/backend/routers/wormhole.py @@ -78,6 +78,21 @@ export_wormhole_dm_invite = getattr( "export_wormhole_dm_invite", _wormhole_identity_unavailable, ) +list_prekey_lookup_handle_records_for_ui = getattr( + _mesh_wormhole_identity, + "list_prekey_lookup_handle_records_for_ui", + _wormhole_identity_unavailable, +) +rename_prekey_lookup_handle = getattr( + _mesh_wormhole_identity, + "rename_prekey_lookup_handle", + _wormhole_identity_unavailable, +) +revoke_prekey_lookup_handle = getattr( + _mesh_wormhole_identity, + "revoke_prekey_lookup_handle", + _wormhole_identity_unavailable, +) import_wormhole_dm_invite = getattr( _mesh_wormhole_identity, "import_wormhole_dm_invite", @@ -311,6 +326,10 @@ class WormholeDmInviteImportRequest(BaseModel): alias: str = "" +class WormholeDmInviteHandleUpdateRequest(BaseModel): + label: str = "" + + class WormholeDmSenderTokenRequest(BaseModel): recipient_id: str delivery_class: str @@ -477,6 +496,7 @@ def decrypt_wormhole_dm_envelope( remote_alias: str | None = None, session_welcome: str | None = None, ) -> dict[str, Any]: + """Delegate to main.py, which owns current MLS/alias/legacy gating behavior.""" import main as _m return _m.decrypt_wormhole_dm_envelope( @@ -489,71 +509,13 @@ def decrypt_wormhole_dm_envelope( session_welcome=session_welcome, ) - resolved_local, resolved_remote = _resolve_dm_aliases( - peer_id=peer_id, - local_alias=local_alias, - remote_alias=remote_alias, - ) - normalized_format = str(payload_format or "dm1").strip().lower() or "dm1" - if normalized_format != "mls1" and is_dm_locked_to_mls(resolved_local, resolved_remote): - return { - "ok": False, - "detail": "DM session is locked to MLS format", - "required_format": "mls1", - "current_format": normalized_format, - } - if normalized_format == "mls1": - has_session = has_mls_dm_session(resolved_local, resolved_remote) - if not has_session.get("ok"): - return has_session - if not has_session.get("exists"): - ensured = ensure_mls_dm_session(resolved_local, resolved_remote, str(session_welcome or "")) - if not ensured.get("ok"): - return ensured - decrypted = decrypt_mls_dm( - resolved_local, - resolved_remote, - str(ciphertext or ""), - str(nonce or ""), - ) - if not decrypted.get("ok"): - return decrypted - return { - "ok": True, - "peer_id": str(peer_id or "").strip(), - "local_alias": resolved_local, - "remote_alias": resolved_remote, - "plaintext": str(decrypted.get("plaintext", "") or ""), - "format": "mls1", - } - - from services.wormhole_supervisor import get_transport_tier - - current_tier = get_transport_tier() - if str(current_tier or "").startswith("private_"): - return { - "ok": False, - "detail": "MLS format required in private transport mode — legacy DM decrypt blocked", - } - logger.warning("legacy dm decrypt path used") - legacy = decrypt_wormhole_dm(peer_id=str(peer_id or ""), ciphertext=str(ciphertext or "")) - if not legacy.get("ok"): - return legacy - return { - "ok": True, - "peer_id": str(peer_id or "").strip(), - "local_alias": resolved_local, - "remote_alias": resolved_remote, - "plaintext": str(legacy.get("result", "") or ""), - "format": "dm1", - } # --- Routes --- @router.get("/api/settings/wormhole") -@limiter.limit("30/minute") +@limiter.limit("240/minute") async def api_get_wormhole_settings(request: Request): settings = await asyncio.to_thread(read_wormhole_settings) return _redact_wormhole_settings(settings, authenticated=_scoped_view_authenticated(request, "wormhole")) @@ -582,248 +544,9 @@ async def api_set_wormhole_settings(request: Request, body: WormholeUpdate): return {**updated, "requires_restart": False, "runtime": state} -class PrivacyProfileUpdate(BaseModel): - profile: str - - -class WormholeSignRequest(BaseModel): - event_type: str - payload: dict - sequence: int | None = None - gate_id: str | None = None - - -class WormholeSignRawRequest(BaseModel): - message: str - - -class WormholeDmEncryptRequest(BaseModel): - peer_id: str - peer_dh_pub: str = "" - plaintext: str - local_alias: str | None = None - remote_alias: str | None = None - remote_prekey_bundle: dict[str, Any] | None = None - - -class WormholeDmComposeRequest(BaseModel): - peer_id: str - peer_dh_pub: str = "" - plaintext: str - local_alias: str | None = None - remote_alias: str | None = None - remote_prekey_bundle: dict[str, Any] | None = None - - -class WormholeDmDecryptRequest(BaseModel): - peer_id: str - ciphertext: str - format: str = "dm1" - nonce: str = "" - local_alias: str | None = None - remote_alias: str | None = None - session_welcome: str | None = None - - -class WormholeDmResetRequest(BaseModel): - peer_id: str | None = None - - -class WormholeDmBootstrapEncryptRequest(BaseModel): - peer_id: str - plaintext: str - - -class WormholeDmBootstrapDecryptRequest(BaseModel): - sender_id: str = "" - ciphertext: str - - -class WormholeDmSenderTokenRequest(BaseModel): - recipient_id: str - delivery_class: str - recipient_token: str = "" - count: int = 1 - - -class WormholeOpenSealRequest(BaseModel): - sender_seal: str - candidate_dh_pub: str = "" - recipient_id: str - expected_msg_id: str - - -class WormholeBuildSealRequest(BaseModel): - recipient_id: str - recipient_dh_pub: str = "" - msg_id: str - timestamp: int - - -class WormholeDeadDropTokenRequest(BaseModel): - peer_id: str - peer_dh_pub: str = "" - peer_ref: str = "" - - -class WormholePairwiseAliasRequest(BaseModel): - peer_id: str - peer_dh_pub: str = "" - - -class WormholePairwiseAliasRotateRequest(BaseModel): - peer_id: str - peer_dh_pub: str = "" - grace_ms: int = 45_000 - - -class WormholeDeadDropContactsRequest(BaseModel): - contacts: list[dict[str, Any]] - limit: int = 24 - - -class WormholeSasRequest(BaseModel): - peer_id: str - peer_dh_pub: str = "" - words: int = 8 - peer_ref: str = "" - - -class WormholeGateRequest(BaseModel): - gate_id: str - rotate: bool = False - - -class WormholeGatePersonaCreateRequest(BaseModel): - gate_id: str - label: str = "" - - -class WormholeGatePersonaActivateRequest(BaseModel): - gate_id: str - persona_id: str - - -class WormholeGateKeyGrantRequest(BaseModel): - gate_id: str - recipient_node_id: str - recipient_dh_pub: str - recipient_scope: str = "member" - - -class WormholeGateComposeRequest(BaseModel): - gate_id: str - plaintext: str - reply_to: str = "" - compat_plaintext: bool = False - - -class WormholeGateDecryptRequest(BaseModel): - gate_id: str - epoch: int = 0 - ciphertext: str - nonce: str = "" - sender_ref: str = "" - format: str = "mls1" - gate_envelope: str = "" - envelope_hash: str = "" - recovery_envelope: bool = False - compat_decrypt: bool = False - event_id: str = "" - - -class WormholeGateDecryptBatchRequest(BaseModel): - messages: list[WormholeGateDecryptRequest] - - -class WormholeGateRotateRequest(BaseModel): - gate_id: str - reason: str = "manual_rotate" - -def decrypt_wormhole_dm_envelope( - *, - peer_id: str, - ciphertext: str, - payload_format: str = "dm1", - nonce: str = "", - local_alias: str | None = None, - remote_alias: str | None = None, - session_welcome: str | None = None, -) -> dict[str, Any]: - import main as _m - - return _m.decrypt_wormhole_dm_envelope( - peer_id=peer_id, - ciphertext=ciphertext, - payload_format=payload_format, - nonce=nonce, - local_alias=local_alias, - remote_alias=remote_alias, - session_welcome=session_welcome, - ) - - resolved_local, resolved_remote = _resolve_dm_aliases( - peer_id=peer_id, - local_alias=local_alias, - remote_alias=remote_alias, - ) - normalized_format = str(payload_format or "dm1").strip().lower() or "dm1" - if normalized_format != "mls1" and is_dm_locked_to_mls(resolved_local, resolved_remote): - return { - "ok": False, - "detail": "DM session is locked to MLS format", - "required_format": "mls1", - "current_format": normalized_format, - } - if normalized_format == "mls1": - has_session = has_mls_dm_session(resolved_local, resolved_remote) - if not has_session.get("ok"): - return has_session - if not has_session.get("exists"): - ensured = ensure_mls_dm_session(resolved_local, resolved_remote, str(session_welcome or "")) - if not ensured.get("ok"): - return ensured - decrypted = decrypt_mls_dm( - resolved_local, - resolved_remote, - str(ciphertext or ""), - str(nonce or ""), - ) - if not decrypted.get("ok"): - return decrypted - return { - "ok": True, - "peer_id": str(peer_id or "").strip(), - "local_alias": resolved_local, - "remote_alias": resolved_remote, - "plaintext": str(decrypted.get("plaintext", "") or ""), - "format": "mls1", - } - - from services.wormhole_supervisor import get_transport_tier - - current_tier = get_transport_tier() - if str(current_tier or "").startswith("private_"): - return { - "ok": False, - "detail": "MLS format required in private transport mode — legacy DM decrypt blocked", - } - logger.warning("legacy dm decrypt path used") - legacy = decrypt_wormhole_dm(peer_id=str(peer_id or ""), ciphertext=str(ciphertext or "")) - if not legacy.get("ok"): - return legacy - return { - "ok": True, - "peer_id": str(peer_id or "").strip(), - "local_alias": resolved_local, - "remote_alias": resolved_remote, - "plaintext": str(legacy.get("result", "") or ""), - "format": "dm1", - } - @router.get("/api/settings/privacy-profile") -@limiter.limit("30/minute") +@limiter.limit("240/minute") async def api_get_privacy_profile(request: Request): data = await asyncio.to_thread(read_wormhole_settings) return _redact_privacy_profile_settings( @@ -833,7 +556,7 @@ async def api_get_privacy_profile(request: Request): @router.get("/api/settings/wormhole-status") -@limiter.limit("30/minute") +@limiter.limit("240/minute") async def api_get_wormhole_status(request: Request): state = await asyncio.to_thread(get_wormhole_state) transport_tier = _current_private_lane_tier(state) @@ -866,7 +589,7 @@ async def api_get_wormhole_status(request: Request): ) -@router.post("/api/wormhole/join", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/join") @limiter.limit("10/minute") async def api_wormhole_join(request: Request): from services.config import get_settings @@ -907,7 +630,7 @@ async def api_wormhole_join(request: Request): ) # Enable node participation so the sync/push workers connect to peers. - # This is the voluntary opt-in — the node only joins the network when + # This is the voluntary opt-in — the node only joins the network when # the user explicitly opens the Wormhole. from services.node_settings import write_node_settings @@ -923,7 +646,7 @@ async def api_wormhole_join(request: Request): } -@router.post("/api/wormhole/leave", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/leave") @limiter.limit("10/minute") async def api_wormhole_leave(request: Request): updated = write_wormhole_settings(enabled=False) @@ -940,8 +663,8 @@ async def api_wormhole_leave(request: Request): } -@router.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)]) -@limiter.limit("30/minute") +@router.get("/api/wormhole/identity") +@limiter.limit("240/minute") async def api_wormhole_identity(request: Request): try: bootstrap_wormhole_persona_state() @@ -951,7 +674,7 @@ async def api_wormhole_identity(request: Request): raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc -@router.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/identity/bootstrap") @limiter.limit("10/minute") async def api_wormhole_identity_bootstrap(request: Request): bootstrap_wormhole_identity() @@ -970,7 +693,7 @@ async def api_wormhole_identity_bootstrap(request: Request): @router.get("/api/wormhole/dm/identity", dependencies=[Depends(require_local_operator)]) -@limiter.limit("30/minute") +@limiter.limit("240/minute") async def api_wormhole_dm_identity(request: Request): try: bootstrap_wormhole_persona_state() @@ -982,8 +705,34 @@ async def api_wormhole_dm_identity(request: Request): @router.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") -async def api_wormhole_dm_invite(request: Request): - return export_wormhole_dm_invite() +async def api_wormhole_dm_invite( + request: Request, + label: str = Query("", max_length=96), + expires_in_s: int = Query(0, ge=0, le=2_592_000), +): + return export_wormhole_dm_invite(label=label, expires_in_s=expires_in_s) + + +@router.get("/api/wormhole/dm/invite/handles", dependencies=[Depends(require_local_operator)]) +@limiter.limit("240/minute") +async def api_wormhole_dm_invite_handles(request: Request): + return list_prekey_lookup_handle_records_for_ui() + + +@router.patch("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)]) +@limiter.limit("60/minute") +async def api_wormhole_dm_invite_handle_update( + request: Request, + handle: str, + body: WormholeDmInviteHandleUpdateRequest, +): + return rename_prekey_lookup_handle(handle, str(body.label or "").strip()) + + +@router.delete("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)]) +@limiter.limit("30/minute") +async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str): + return revoke_prekey_lookup_handle(handle) @router.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)]) @@ -1024,7 +773,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest): ) -@router.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/enter") @limiter.limit("20/minute") async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): gate_id = str(body.gate_id or "") @@ -1038,25 +787,25 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): return result -@router.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/leave") @limiter.limit("20/minute") async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest): return leave_gate(str(body.gate_id or "")) -@router.get("/api/wormhole/gate/{gate_id}/identity", dependencies=[Depends(require_local_operator)]) +@router.get("/api/wormhole/gate/{gate_id}/identity") @limiter.limit("30/minute") async def api_wormhole_gate_identity(request: Request, gate_id: str): return get_active_gate_identity(gate_id) -@router.get("/api/wormhole/gate/{gate_id}/personas", dependencies=[Depends(require_local_operator)]) +@router.get("/api/wormhole/gate/{gate_id}/personas") @limiter.limit("30/minute") async def api_wormhole_gate_personas(request: Request, gate_id: str): return list_gate_personas(gate_id) -@router.get("/api/wormhole/gate/{gate_id}/key", dependencies=[Depends(require_local_operator)]) +@router.get("/api/wormhole/gate/{gate_id}/key") @limiter.limit("30/minute") async def api_wormhole_gate_key_status(request: Request, gate_id: str): import main as _m @@ -1080,7 +829,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat return result -@router.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/persona/create") @limiter.limit("20/minute") async def api_wormhole_gate_persona_create( request: Request, body: WormholeGatePersonaCreateRequest @@ -1096,7 +845,7 @@ async def api_wormhole_gate_persona_create( return result -@router.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/persona/activate") @limiter.limit("20/minute") async def api_wormhole_gate_persona_activate( request: Request, body: WormholeGatePersonaActivateRequest @@ -1112,7 +861,7 @@ async def api_wormhole_gate_persona_activate( return result -@router.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/persona/clear") @limiter.limit("20/minute") async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest): gate_id = str(body.gate_id or "") @@ -1126,7 +875,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe return result -@router.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/persona/retire") @limiter.limit("20/minute") async def api_wormhole_gate_persona_retire( request: Request, body: WormholeGatePersonaActivateRequest @@ -1195,7 +944,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate return await _m.api_wormhole_gate_message_compose(request, body) -@router.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/message/sign-encrypted") @limiter.limit("30/minute") async def api_wormhole_gate_message_sign_encrypted( request: Request, @@ -1205,7 +954,7 @@ async def api_wormhole_gate_message_sign_encrypted( return await _m.api_wormhole_gate_message_sign_encrypted(request, body) -@router.post("/api/wormhole/gate/message/post-encrypted", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/message/post-encrypted") @limiter.limit("30/minute") async def api_wormhole_gate_message_post_encrypted( request: Request, @@ -1255,14 +1004,14 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat return await _m.api_wormhole_gate_messages_decrypt(request, body) -@router.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/state/export") @limiter.limit("30/minute") async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest): import main as _m return await _m.api_wormhole_gate_state_export(request, body) -@router.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)]) +@router.post("/api/wormhole/gate/proof") @limiter.limit("30/minute") async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest): proof = _sign_gate_access_proof(str(body.gate_id or "")) @@ -1547,7 +1296,7 @@ class PrivateDeliveryActionRequest(BaseModel): @router.get("/api/wormhole/status") -@limiter.limit("30/minute") +@limiter.limit("240/minute") async def api_wormhole_status(request: Request): import main as _m @@ -1590,7 +1339,7 @@ async def api_wormhole_private_delivery_action( @router.get("/api/wormhole/health") -@limiter.limit("30/minute") +@limiter.limit("240/minute") async def api_wormhole_health(request: Request): state = get_wormhole_state() transport_tier = _current_private_lane_tier(state) @@ -1611,7 +1360,7 @@ async def api_wormhole_health(request: Request): return _redact_wormhole_status(full_state, authenticated=ok) -@router.post("/api/wormhole/connect", dependencies=[Depends(require_admin)]) +@router.post("/api/wormhole/connect") @limiter.limit("10/minute") async def api_wormhole_connect(request: Request): settings = read_wormhole_settings() diff --git a/backend/services/fetchers/aircraft_database.py b/backend/services/fetchers/aircraft_database.py index 2194d3b..8fc3a13 100644 --- a/backend/services/fetchers/aircraft_database.py +++ b/backend/services/fetchers/aircraft_database.py @@ -32,7 +32,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600 _LIST_TIMEOUT_S = 30 _DOWNLOAD_TIMEOUT_S = 600 _USER_AGENT = ( - "ShadowBroker-OSINT/0.9.75 " + "ShadowBroker-OSINT/0.9.79 " "(+https://github.com/BigBodyCobain/Shadowbroker; " "contact: bigbodycobain@gmail.com)" ) diff --git a/backend/services/fetchers/meshtastic_map.py b/backend/services/fetchers/meshtastic_map.py index 7974e67..2be61cd 100644 --- a/backend/services/fetchers/meshtastic_map.py +++ b/backend/services/fetchers/meshtastic_map.py @@ -182,7 +182,7 @@ def fetch_meshtastic_nodes(): callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip() except Exception: callsign = "" - ua_base = "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)" + ua_base = "ShadowBroker-OSINT/0.9.79 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)" user_agent = f"{ua_base}; node={callsign}" if callsign else ua_base try: diff --git a/backend/services/fetchers/route_database.py b/backend/services/fetchers/route_database.py index 433858b..71cfca0 100644 --- a/backend/services/fetchers/route_database.py +++ b/backend/services/fetchers/route_database.py @@ -25,7 +25,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600 _HTTP_TIMEOUT_S = 60 _USER_AGENT = ( - "ShadowBroker-OSINT/0.9.75 " + "ShadowBroker-OSINT/0.9.79 " "(+https://github.com/BigBodyCobain/Shadowbroker; " "contact: bigbodycobain@gmail.com)" ) diff --git a/backend/services/mesh/mesh_dm_relay.py b/backend/services/mesh/mesh_dm_relay.py index 068181a..b732d59 100644 --- a/backend/services/mesh/mesh_dm_relay.py +++ b/backend/services/mesh/mesh_dm_relay.py @@ -1264,6 +1264,21 @@ class DMRelay: ) self._save() + def unregister_prekey_lookup_alias(self, alias: str) -> bool: + """Remove an invite-scoped lookup alias from the local relay.""" + handle = str(alias or "").strip() + if not handle: + return False + removed = False + with self._lock: + self._refresh_from_shared_relay() + if handle in self._prekey_lookup_aliases: + del self._prekey_lookup_aliases[handle] + removed = True + if removed: + self._save() + return removed + def consume_one_time_prekey(self, agent_id: str) -> dict[str, Any] | None: """Atomically claim the next published one-time prekey for a peer bundle.""" claimed: dict[str, Any] | None = None diff --git a/backend/services/mesh/mesh_router.py b/backend/services/mesh/mesh_router.py index 06c65ef..d693efc 100644 --- a/backend/services/mesh/mesh_router.py +++ b/backend/services/mesh/mesh_router.py @@ -520,7 +520,7 @@ class MeshtasticTransport: def _on_connect(client, userdata, flags, rc): if rc == 0: - info = client.publish(topic, payload, qos=0) + info = client.publish(topic, payload, qos=1) info.wait_for_publish(timeout=5) published[0] = True client.disconnect() @@ -550,9 +550,9 @@ class MeshtasticTransport: True, self.NAME, ( - f"Published direct to !{to_node:08x} via {region}/{channel}" + f"Broker accepted direct publish to !{to_node:08x} via {region}/{channel}" if direct_node is not None - else f"Published to {region}/{channel} ({len(payload)}B protobuf)" + else f"Broker accepted channel publish to {region}/{channel} ({len(payload)}B protobuf)" ), ) except Exception as e: diff --git a/backend/services/mesh/mesh_wormhole_identity.py b/backend/services/mesh/mesh_wormhole_identity.py index fa99325..a6dc03a 100644 --- a/backend/services/mesh/mesh_wormhole_identity.py +++ b/backend/services/mesh/mesh_wormhole_identity.py @@ -11,6 +11,7 @@ import base64 import hmac import hashlib import json +import logging import secrets import time from typing import Any @@ -51,6 +52,8 @@ PREKEY_LOOKUP_ROTATE_BEFORE_REMAINING_USES = 8 PREKEY_LOOKUP_ROTATION_OVERLAP_S = 12 * 60 * 60 PREKEY_LOOKUP_ROTATION_ACTIVE_CAP = 4 +logger = logging.getLogger(__name__) + def _safe_int(val, default=0) -> int: try: @@ -107,6 +110,7 @@ def _default_identity() -> dict[str, Any]: def _prekey_lookup_handle_record( handle: str, *, + label: str = "", issued_at: int = 0, expires_at: int = 0, max_uses: int = 0, @@ -125,6 +129,7 @@ def _prekey_lookup_handle_record( bounded_max_uses = max(1, _safe_int(max_uses or PREKEY_LOOKUP_HANDLE_MAX_USES, PREKEY_LOOKUP_HANDLE_MAX_USES)) return { "handle": str(handle or "").strip(), + "label": str(label or "").strip()[:96], "issued_at": issued, "expires_at": bounded_expires_at, "max_uses": bounded_max_uses, @@ -152,8 +157,10 @@ def _coerce_prekey_lookup_handle_record( max_uses = _safe_int(value.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES) use_count = _safe_int(value.get("use_count", value.get("uses", 0)) or 0, 0) last_used_at = _safe_int(value.get("last_used_at", value.get("last_used", 0)) or 0, 0) + label = str(value.get("label", "") or "").strip() return _prekey_lookup_handle_record( handle, + label=label, issued_at=issued_at, expires_at=expires_at, max_uses=max_uses, @@ -228,6 +235,23 @@ def _fresh_prekey_lookup_handle_record(*, now: int | None = None) -> dict[str, A ) +def _prekey_registration_failure_blocks_dm_invite(detail: str) -> bool: + """Only trust-root failures block address export; transport warm-up can finish later.""" + lowered = str(detail or "").lower() + critical_markers = ( + "root transparency", + "external root witness", + "stable root", + "witness threshold", + "witness finality", + "root manifest", + "root witness", + "manifest_fingerprint", + "policy fingerprint", + ) + return any(marker in lowered for marker in critical_markers) + + def _bounded_lookup_handle_records( records: list[dict[str, Any]], *, @@ -884,6 +908,7 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict existing_handles.append( _prekey_lookup_handle_record( lookup_handle, + label=str(label or "").strip(), issued_at=issued_at, expires_at=expires_at, ) @@ -920,14 +945,25 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict except Exception: pass + prekey_registration: dict[str, Any] = {"ok": False, "detail": "prekey bundle publish not attempted"} try: from services.mesh.mesh_wormhole_prekey import register_wormhole_prekey_bundle - registered = register_wormhole_prekey_bundle() - if not registered.get("ok"): - return {"ok": False, "detail": str(registered.get("detail", "") or "prekey bundle registration failed")} + prekey_registration = register_wormhole_prekey_bundle() + if not prekey_registration.get("ok"): + detail = str(prekey_registration.get("detail", "") or "prekey bundle registration failed") + if _prekey_registration_failure_blocks_dm_invite(detail): + return {"ok": False, "detail": detail} + logger.warning( + "DM invite prekey publish pending: %s", + detail, + ) except Exception as exc: - return {"ok": False, "detail": str(exc) or "prekey bundle registration failed"} + prekey_registration = {"ok": False, "detail": str(exc) or "prekey bundle registration failed"} + detail = str(prekey_registration.get("detail", "") or "") + if _prekey_registration_failure_blocks_dm_invite(detail): + return {"ok": False, "detail": detail} + logger.warning("DM invite prekey publish pending: %s", prekey_registration["detail"]) invite_node_id, invite_public_key, invite_private_key = _generate_invite_signing_identity() payload = _attach_dm_invite_root_distribution(payload) @@ -958,6 +994,8 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict "peer_id": str(invite_node_id or ""), "trust_fingerprint": str(payload.get("identity_commitment", "") or ""), "invite": invite, + "prekey_publish_pending": not bool(prekey_registration.get("ok")), + "prekey_registration": prekey_registration, } @@ -980,6 +1018,140 @@ def get_prekey_lookup_handle_records() -> list[dict[str, Any]]: ] +def list_prekey_lookup_handle_records_for_ui(*, now: int | None = None) -> dict[str, Any]: + """Return shareable DM address records without exposing local identity secrets.""" + current_time = _safe_int(now or time.time(), int(time.time())) + addresses: list[dict[str, Any]] = [] + for record in get_prekey_lookup_handle_records(): + handle = str(record.get("handle", "") or "").strip() + if not handle: + continue + expires_at = _effective_prekey_lookup_handle_expires_at(record) + max_uses = max( + 1, + _safe_int( + record.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES, + PREKEY_LOOKUP_HANDLE_MAX_USES, + ), + ) + use_count = max(0, _safe_int(record.get("use_count", 0) or 0, 0)) + addresses.append( + { + "handle": handle, + "label": str(record.get("label", "") or "").strip(), + "issued_at": _safe_int(record.get("issued_at", 0) or 0, 0), + "expires_at": expires_at, + "max_uses": max_uses, + "use_count": use_count, + "remaining_uses": max(0, max_uses - use_count), + "last_used_at": _safe_int(record.get("last_used_at", 0) or 0, 0), + "expired": bool(expires_at > 0 and current_time >= expires_at), + "exhausted": bool(use_count >= max_uses), + } + ) + addresses.sort(key=lambda item: _safe_int(item.get("issued_at", 0) or 0, 0), reverse=True) + return {"ok": True, "addresses": addresses} + + +def rename_prekey_lookup_handle(handle: str, label: str) -> dict[str, Any]: + """Rename an active invite-scoped DM lookup handle without changing the handle.""" + lookup_handle = str(handle or "").strip() + next_label = str(label or "").strip()[:96] + if not lookup_handle: + return {"ok": False, "detail": "missing_lookup_handle"} + + current_time = int(time.time()) + data = read_wormhole_identity() + existing, _ = _normalize_prekey_lookup_handles( + data.get("prekey_lookup_handles", []), + fallback_issued_at=current_time, + now=current_time, + ) + updated = False + next_records: list[dict[str, Any]] = [] + for record in existing: + current = dict(record) + if str(current.get("handle", "") or "").strip() == lookup_handle: + current["label"] = next_label + updated = True + next_records.append(current) + + if not updated: + return { + "ok": False, + "handle": lookup_handle, + "label": next_label, + "updated": False, + "detail": "lookup_handle_not_found", + } + + normalized_records, _ = _normalize_prekey_lookup_handles( + next_records, + fallback_issued_at=current_time, + now=current_time, + ) + _write_identity({"prekey_lookup_handles": normalized_records}) + return { + "ok": True, + "handle": lookup_handle, + "label": next_label, + "updated": True, + } + + +def revoke_prekey_lookup_handle(handle: str) -> dict[str, Any]: + """Revoke an invite-scoped DM lookup handle for future first-contact attempts.""" + lookup_handle = str(handle or "").strip() + if not lookup_handle: + return {"ok": False, "detail": "missing_lookup_handle"} + current_time = int(time.time()) + data = read_wormhole_identity() + existing, _ = _normalize_prekey_lookup_handles( + data.get("prekey_lookup_handles", []), + fallback_issued_at=current_time, + now=current_time, + ) + next_records = [ + dict(record) + for record in existing + if str(record.get("handle", "") or "").strip() != lookup_handle + ] + identity_removed = len(next_records) != len(existing) + if identity_removed: + _write_identity({"prekey_lookup_handles": next_records}) + + relay_removed = False + try: + from services.mesh.mesh_dm_relay import dm_relay + + relay_removed = bool(dm_relay.unregister_prekey_lookup_alias(lookup_handle)) + except Exception: + relay_removed = False + + republished = False + detail = "" + if identity_removed: + try: + from services.mesh.mesh_wormhole_prekey import register_wormhole_prekey_bundle + + registered = register_wormhole_prekey_bundle() + republished = bool(registered.get("ok")) + if not republished: + detail = str(registered.get("detail", "") or "prekey bundle republish failed") + except Exception as exc: + detail = str(exc) or "prekey bundle republish failed" + + return { + "ok": True, + "handle": lookup_handle, + "revoked": bool(identity_removed or relay_removed), + "identity_removed": identity_removed, + "relay_removed": relay_removed, + "republished": republished, + "detail": detail, + } + + def record_prekey_lookup_handle_use(handle: str, *, now: int | None = None) -> dict[str, Any] | None: lookup_handle = str(handle or "").strip() if not lookup_handle: @@ -999,6 +1171,7 @@ def record_prekey_lookup_handle_use(handle: str, *, now: int | None = None) -> d if str(current.get("handle", "") or "").strip() == lookup_handle: current = _prekey_lookup_handle_record( lookup_handle, + label=str(current.get("label", "") or "").strip(), issued_at=_safe_int(current.get("issued_at", 0) or 0, current_time), expires_at=_safe_int(current.get("expires_at", 0) or 0, 0), max_uses=_safe_int(current.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES), @@ -1129,6 +1302,7 @@ def maybe_rotate_prekey_lookup_handles(*, now: int | None = None) -> dict[str, A candidate_records.append( _prekey_lookup_handle_record( old_handle, + label=str(record.get("label", "") or "").strip(), issued_at=_safe_int(record.get("issued_at", 0) or 0, current_time), expires_at=overlap_expires_at, max_uses=_safe_int(record.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES), diff --git a/backend/services/mesh/mesh_wormhole_root_manifest.py b/backend/services/mesh/mesh_wormhole_root_manifest.py index 2d06435..50a3601 100644 --- a/backend/services/mesh/mesh_wormhole_root_manifest.py +++ b/backend/services/mesh/mesh_wormhole_root_manifest.py @@ -12,6 +12,7 @@ from __future__ import annotations import base64 import hashlib import json +import logging import time from pathlib import Path from typing import Any @@ -23,7 +24,7 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from services.mesh.mesh_crypto import build_signature_payload, derive_node_id, verify_node_binding, verify_signature from services.mesh.mesh_protocol import PROTOCOL_VERSION -from services.mesh.mesh_secure_storage import read_domain_json, write_domain_json +from services.mesh.mesh_secure_storage import SecureStorageError, read_domain_json, write_domain_json from services.mesh.mesh_wormhole_identity import root_identity_fingerprint_for_material from services.mesh.mesh_wormhole_persona import ( bootstrap_wormhole_persona_state, @@ -51,6 +52,7 @@ DEFAULT_ROOT_WITNESS_THRESHOLD = 2 DEFAULT_ROOT_WITNESS_MANAGEMENT_SCOPE = "local" DEFAULT_ROOT_WITNESS_INDEPENDENCE_GROUP = "local_system" DEFAULT_ROOT_EXTERNAL_WITNESS_MAX_AGE_S = 3600 +logger = logging.getLogger(__name__) def _safe_int(val: Any, default: int = 0) -> int: @@ -461,12 +463,22 @@ def witness_policy_fingerprint(policy: dict[str, Any]) -> str: def read_root_distribution_state() -> dict[str, Any]: - raw = read_domain_json( - ROOT_DISTRIBUTION_DOMAIN, - ROOT_DISTRIBUTION_FILE, - _default_state, - base_dir=DATA_DIR, - ) + try: + raw = read_domain_json( + ROOT_DISTRIBUTION_DOMAIN, + ROOT_DISTRIBUTION_FILE, + _default_state, + base_dir=DATA_DIR, + ) + except SecureStorageError as exc: + detail = str(exc) + if "Failed to decrypt domain JSON" not in detail: + raise + logger.warning( + "Root distribution state could not decrypt; regenerating local witness distribution: %s", + detail, + ) + raw = _default_state() state = {**_default_state(), **dict(raw or {})} state["witness_identity"] = {**_empty_witness_identity(), **dict(state.get("witness_identity") or {})} witness_identities, witness_changed = _normalize_witness_identities( diff --git a/backend/services/mesh/meshtastic_topics.py b/backend/services/mesh/meshtastic_topics.py index c28d8ca..a1de2d1 100644 --- a/backend/services/mesh/meshtastic_topics.py +++ b/backend/services/mesh/meshtastic_topics.py @@ -108,8 +108,18 @@ def normalize_topic_filter(value: str) -> str | None: return "/".join(parts) -def _default_topic_for_root(root: str) -> str: - return f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#" +def _default_topics_for_root(root: str) -> list[str]: + """Return the default LongFast subscriptions for a region root. + + The public broker carries protobuf/encrypted traffic under ``/e/`` and + companion decoded JSON traffic under ``/json/``. Positions often arrive on + the protobuf path, while public text is commonly easiest to observe on the + JSON path. + """ + return [ + f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#", + f"msh/{root}/2/json/{DEFAULT_CHANNEL}/#", + ] def build_subscription_topics( @@ -124,7 +134,11 @@ def build_subscription_topics( # via MESH_MQTT_EXTRA_ROOTS to avoid flooding the public broker. roots.extend(root for root in (normalize_root(item) for item in _split_config_values(extra_roots)) if root) - topics = [_default_topic_for_root(root) for root in _dedupe(roots)] + topics = [ + topic + for root in _dedupe(roots) + for topic in _default_topics_for_root(root) + ] topics.extend( topic for topic in ( diff --git a/backend/services/network_utils.py b/backend/services/network_utils.py index 107de93..0082551 100644 --- a/backend/services/network_utils.py +++ b/backend/services/network_utils.py @@ -73,7 +73,7 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None, both Python requests and the barebones Windows system curl. """ default_headers = { - "User-Agent": "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)", + "User-Agent": "ShadowBroker-OSINT/0.9.79 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)", } if headers: default_headers.update(headers) diff --git a/backend/services/shodan_connector.py b/backend/services/shodan_connector.py index 42dfcbd..aaa124c 100644 --- a/backend/services/shodan_connector.py +++ b/backend/services/shodan_connector.py @@ -20,7 +20,7 @@ from cachetools import TTLCache logger = logging.getLogger(__name__) _SHODAN_BASE = "https://api.shodan.io" -_USER_AGENT = "ShadowBroker/0.9.75 local Shodan connector" +_USER_AGENT = "ShadowBroker/0.9.79 local Shodan connector" _REQUEST_TIMEOUT = 15 _MIN_INTERVAL_SECONDS = 1.05 # Shodan docs say API plans are rate limited to ~1 req/sec. _DEFAULT_SEARCH_PAGES = 1 diff --git a/backend/services/sigint_bridge.py b/backend/services/sigint_bridge.py index f210856..cfd0f4a 100644 --- a/backend/services/sigint_bridge.py +++ b/backend/services/sigint_bridge.py @@ -545,6 +545,198 @@ class MeshtasticBridge: self._message_dedupe[key] = now return False + @staticmethod + def _message_dedupe_key(message: dict) -> str: + sender = str(message.get("from") or "???").strip().lower() + recipient = str(message.get("to") or "broadcast").strip().lower() + text = str(message.get("text") or "").strip() + channel = str(message.get("channel") or "LongFast").strip().lower() + root = str(message.get("root") or message.get("region") or "").strip().lower() + if root == "us": + root = "us" + return f"{sender}:{recipient}:{root}:{channel}:{text}" + + def append_text_message(self, message: dict, *, dedupe_window_s: float = 5.0) -> bool: + """Append a Meshtastic text message unless it is a near-immediate echo.""" + if not str(message.get("text") or "").strip(): + return False + now = time.time() + cutoff = now - max(1.0, dedupe_window_s) + next_message = dict(message) + next_message.setdefault("to", "broadcast") + next_message.setdefault("channel", "LongFast") + next_message.setdefault("timestamp", datetime.utcnow().isoformat() + "Z") + key = self._message_dedupe_key(next_message) + for existing in list(self.messages)[:40]: + if self._message_dedupe_key(existing) != key: + continue + try: + existing_ts_raw = existing.get("timestamp") + existing_ts = ( + datetime.fromisoformat(str(existing_ts_raw).replace("Z", "+00:00")).timestamp() + if existing_ts_raw + else now + ) + except Exception: + existing_ts = now + if existing_ts >= cutoff: + if not existing.get("root") and next_message.get("root"): + existing["root"] = next_message.get("root") + if not existing.get("region") and next_message.get("region"): + existing["region"] = next_message.get("region") + return False + self.messages.appendleft(next_message) + return True + + @staticmethod + def _coerce_node_ref(value) -> str: + """Normalize Meshtastic node identifiers into the public !xxxxxxxx form.""" + if value is None: + return "" + if isinstance(value, int): + return f"!{value & 0xFFFFFFFF:08x}" + raw = str(value).strip() + if not raw: + return "" + if raw.startswith("!"): + return raw + lowered = raw.lower() + if lowered.startswith("0x"): + try: + return f"!{int(lowered, 16) & 0xFFFFFFFF:08x}" + except ValueError: + return raw + if raw.isdigit(): + try: + return f"!{int(raw) & 0xFFFFFFFF:08x}" + except ValueError: + return raw + if len(raw) == 8 and all(ch in "0123456789abcdefABCDEF" for ch in raw): + return f"!{raw.lower()}" + return raw + + @staticmethod + def _first_text_value(*values) -> str: + for value in values: + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + if isinstance(value, str): + text = value.strip() + if text: + return MeshtasticBridge._repair_text_mojibake(text) + return "" + + @staticmethod + def _repair_text_mojibake(text: str) -> str: + """Repair common UTF-8-as-Latin-1 mojibake from MQTT JSON bridges.""" + if not text or not any(marker in text for marker in ("Ã", "Ð", "Ñ")): + return text + try: + repaired = text.encode("latin-1").decode("utf-8").strip() + except UnicodeError: + return text + if repaired and repaired != text: + return repaired + return text + + @staticmethod + def _first_present(*values): + for value in values: + if value is not None and value != "": + return value + return None + + def _extract_json_text_message(self, data: dict, topic: str) -> dict | None: + """Extract a public Meshtastic text event from decoded MQTT JSON. + + Meshtastic JSON brokers are not perfectly uniform. Some packets expose + text at the top level, some under ``decoded`` or ``payload``. Keep this + permissive for receive, but only return messages with non-empty text. + """ + if not isinstance(data, dict): + return None + topic_meta = parse_topic_metadata(topic) + packet = data.get("packet") if isinstance(data.get("packet"), dict) else {} + decoded = data.get("decoded") if isinstance(data.get("decoded"), dict) else {} + payload_obj = data.get("payload") + payload = payload_obj if isinstance(payload_obj, dict) else {} + decoded_payload_obj = decoded.get("payload") if decoded else None + decoded_payload = decoded_payload_obj if isinstance(decoded_payload_obj, dict) else {} + + text = self._first_text_value( + data.get("text"), + data.get("message"), + data.get("msg"), + payload_obj if isinstance(payload_obj, str) else "", + payload.get("text"), + payload.get("message"), + payload.get("msg"), + payload.get("payload") if isinstance(payload.get("payload"), str) else "", + decoded.get("text"), + decoded.get("message"), + decoded.get("payload") if isinstance(decoded.get("payload"), str) else "", + decoded_payload.get("text"), + decoded_payload.get("message"), + decoded_payload.get("msg"), + ) + if not text: + return None + + sender = self._coerce_node_ref( + self._first_present( + data.get("from"), + data.get("fromId"), + data.get("from_id"), + data.get("sender"), + data.get("senderId"), + data.get("sender_id"), + packet.get("from"), + packet.get("fromId"), + packet.get("from_id"), + decoded.get("from"), + ) + ) + recipient = self._coerce_node_ref( + self._first_present( + data.get("to"), + data.get("toId"), + data.get("to_id"), + data.get("recipient"), + data.get("recipientId"), + data.get("recipient_id"), + packet.get("to"), + packet.get("toId"), + packet.get("to_id"), + decoded.get("to"), + ) + ) + if not recipient or recipient in {"!ffffffff", "broadcast"}: + recipient = "broadcast" + + timestamp = datetime.utcnow().isoformat() + "Z" + rx_time = self._first_present( + data.get("rxTime"), + data.get("rx_time"), + data.get("timestamp"), + packet.get("rxTime"), + packet.get("timestamp"), + ) + if isinstance(rx_time, (int, float)) and rx_time > 0: + try: + timestamp = datetime.fromtimestamp(float(rx_time), tz=timezone.utc).isoformat() + except (OSError, ValueError): + pass + + return { + "from": sender or topic.split("/")[-1], + "to": recipient, + "text": text[:500], + "region": topic_meta["region"], + "root": topic_meta["root"], + "channel": topic_meta["channel"], + "timestamp": timestamp, + } + def start(self): if self._thread and self._thread.is_alive(): if not self._stop.is_set(): @@ -693,6 +885,9 @@ class MeshtasticBridge: if "/json/" in topic: try: data = json.loads(payload) + text_message = self._extract_json_text_message(data, topic) + if text_message: + self.append_text_message(text_message, dedupe_window_s=30.0) if self._rate_limited(): return self._ingest_data(data, topic) @@ -715,7 +910,7 @@ class MeshtasticBridge: topic_meta["root"], ): return - self.messages.appendleft( + self.append_text_message( { "from": data.get("from", "???"), "to": recipient, diff --git a/backend/services/transport_lane_isolation.py b/backend/services/transport_lane_isolation.py new file mode 100644 index 0000000..646fb5e --- /dev/null +++ b/backend/services/transport_lane_isolation.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def disable_public_mesh_lane(*, reason: str = "private_lane_enabled") -> dict[str, Any]: + """Disable public Meshtastic MQTT before private Wormhole/Infonet starts.""" + result: dict[str, Any] = { + "ok": True, + "reason": reason, + "settings_disabled": False, + "runtime_stopped": False, + } + + # Scheduled Wormhole prewarm must not mutate the user's explicit public + # MeshChat session. Only a deliberate private-lane activation should sever + # the public MQTT lane. + normalized_reason = str(reason or "").strip().lower() + if normalized_reason == "wormhole_scheduled_prewarm" or normalized_reason.endswith(":scheduled_prewarm"): + try: + from services.meshtastic_mqtt_settings import mqtt_bridge_enabled + + if mqtt_bridge_enabled(): + logger.info("Keeping public Mesh lane active during Wormhole prewarm: %s", reason) + result["skipped"] = True + result["skip_reason"] = "public_mesh_user_enabled" + return result + except Exception as exc: + logger.debug("Could not inspect public Mesh state during %s: %s", reason, exc) + + logger.info("Disabling public Mesh lane: %s", reason) + + try: + from services.meshtastic_mqtt_settings import write_meshtastic_mqtt_settings + + settings = write_meshtastic_mqtt_settings(enabled=False) + result["settings_disabled"] = not bool(settings.get("enabled")) + except Exception as exc: + logger.warning("Failed to disable public Mesh settings during %s: %s", reason, exc) + result["ok"] = False + result["settings_error"] = str(exc) + + try: + from services.sigint_bridge import sigint_grid + + if sigint_grid.mesh.is_running(): + sigint_grid.mesh.stop() + result["runtime_stopped"] = not sigint_grid.mesh.is_running() + except Exception as exc: + logger.warning("Failed to stop public Mesh runtime during %s: %s", reason, exc) + result["ok"] = False + result["runtime_error"] = str(exc) + + return result diff --git a/backend/services/unusual_whales_connector.py b/backend/services/unusual_whales_connector.py index 5e9429d..acf97fc 100644 --- a/backend/services/unusual_whales_connector.py +++ b/backend/services/unusual_whales_connector.py @@ -24,7 +24,7 @@ from cachetools import TTLCache logger = logging.getLogger(__name__) _FINNHUB_BASE = "https://finnhub.io/api/v1" -_USER_AGENT = "ShadowBroker/0.9.75 Finnhub connector" +_USER_AGENT = "ShadowBroker/0.9.79 Finnhub connector" _REQUEST_TIMEOUT = 12 _MIN_INTERVAL_SECONDS = 0.35 # Stay well under 60 calls/min diff --git a/backend/services/wormhole_supervisor.py b/backend/services/wormhole_supervisor.py index 602ac06..7c6a03d 100644 --- a/backend/services/wormhole_supervisor.py +++ b/backend/services/wormhole_supervisor.py @@ -243,6 +243,48 @@ def _pid_alive(pid: int) -> bool: return True +def _find_wormhole_server_pid() -> int: + if os.name == "nt": + return 0 + proc_dir = Path("/proc") + if not proc_dir.exists(): + return 0 + current_pid = os.getpid() + script_name = WORMHOLE_SCRIPT.name + script_path = str(WORMHOLE_SCRIPT) + for entry in proc_dir.iterdir(): + if not entry.name.isdigit(): + continue + pid = int(entry.name) + if pid == current_pid: + continue + try: + raw = (entry / "cmdline").read_bytes() + except OSError: + continue + cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace") + if script_path in cmdline or script_name in cmdline: + return pid + return 0 + + +def _terminate_pid(pid: int, *, timeout_s: float = 5.0) -> None: + if os.name == "nt" or pid <= 0: + return + try: + os.kill(pid, signal.SIGTERM) + except Exception: + return + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline and _pid_alive(pid): + time.sleep(0.1) + if _pid_alive(pid): + try: + os.kill(pid, signal.SIGKILL) + except Exception: + pass + + def _probe_ready(timeout_s: float = 1.5) -> bool: try: with urlopen(f"http://{WORMHOLE_HOST}:{WORMHOLE_PORT}/api/health", timeout=timeout_s) as resp: @@ -266,17 +308,32 @@ def _probe_json(path: str, timeout_s: float = 1.5) -> dict[str, Any] | None: def _current_runtime_state() -> dict[str, Any]: settings = read_wormhole_settings() status = read_wormhole_status() + configured = bool(settings.get("enabled")) running = False + ready = False pid = int(status.get("pid", 0) or 0) - if _PROCESS and _PROCESS.poll() is None: + if not configured: + # Disabled private transport must stay disabled even if a stale local + # wormhole process is still answering on the health port. Public + # MeshChat relies on this state to keep the MQTT and Wormhole lanes + # mutually exclusive. + pid = 0 + ready = False + elif _PROCESS and _PROCESS.poll() is None: running = True pid = int(_PROCESS.pid or 0) - elif _pid_alive(pid): - running = True - elif _probe_ready(timeout_s=0.35): - running = True - pid = 0 - ready = running and _probe_ready() + else: + if _pid_alive(pid): + running = True + else: + discovered_pid = _find_wormhole_server_pid() + if discovered_pid > 0: + running = True + pid = discovered_pid + if not running and _probe_ready(timeout_s=0.35): + running = True + pid = 0 + ready = running and _probe_ready() if not running: pid = 0 transport_active = status.get("transport_active", "") if ready else "" @@ -319,13 +376,13 @@ def _current_runtime_state() -> dict[str, Any]: anonymous_mode = bool(settings.get("anonymous_mode")) anonymous_mode_ready = bool( anonymous_mode - and settings.get("enabled") + and configured and ready and effective_transport in {"tor", "tor_arti", "i2p", "mixnet"} ) snapshot = { "installed": _installed(), - "configured": bool(settings.get("enabled")), + "configured": configured, "running": running, "ready": ready, "transport_configured": str(settings.get("transport", "direct") or "direct"), @@ -395,6 +452,12 @@ def get_wormhole_state() -> dict[str, Any]: def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]: with _LOCK: _invalidate_state_cache() + try: + from services.transport_lane_isolation import disable_public_mesh_lane + + disable_public_mesh_lane(reason=f"wormhole_{reason}") + except Exception as exc: + logger.warning("Failed to enforce public/private lane isolation during %s: %s", reason, exc) settings = read_wormhole_settings() if not settings.get("enabled"): settings = settings.copy() @@ -487,8 +550,8 @@ def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]: def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]: with _LOCK: _invalidate_state_cache() - current = _current_runtime_state() - pid = int(current.get("pid", 0) or 0) + status = read_wormhole_status() + pid = int(status.get("pid", 0) or 0) global _PROCESS if _PROCESS and _PROCESS.poll() is None: try: @@ -499,14 +562,15 @@ def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]: _PROCESS.kill() except Exception: pass - elif os.name != "nt" and _pid_alive(pid): - try: - os.kill(pid, signal.SIGTERM) - except Exception: - pass + if os.name != "nt": + _terminate_pid(pid) + discovered_pid = _find_wormhole_server_pid() + if discovered_pid > 0 and discovered_pid != pid: + _terminate_pid(discovered_pid) _PROCESS = None write_wormhole_status( reason=reason, + configured=False, running=False, ready=False, pid=0, diff --git a/backend/tests/mesh/test_meshtastic_topics.py b/backend/tests/mesh/test_meshtastic_topics.py index 58ad849..2e581b7 100644 --- a/backend/tests/mesh/test_meshtastic_topics.py +++ b/backend/tests/mesh/test_meshtastic_topics.py @@ -2,15 +2,21 @@ from services.mesh.meshtastic_topics import build_subscription_topics, known_roo def test_default_subscription_is_longfast_only(): - assert build_subscription_topics() == ["msh/US/2/e/LongFast/#"] + assert build_subscription_topics() == [ + "msh/US/2/e/LongFast/#", + "msh/US/2/json/LongFast/#", + ] assert known_roots() == ["US"] def test_extra_roots_are_longfast_only(): assert build_subscription_topics(extra_roots="EU_868,ANZ") == [ "msh/US/2/e/LongFast/#", + "msh/US/2/json/LongFast/#", "msh/EU_868/2/e/LongFast/#", + "msh/EU_868/2/json/LongFast/#", "msh/ANZ/2/e/LongFast/#", + "msh/ANZ/2/json/LongFast/#", ] diff --git a/desktop-shell/package-lock.json b/desktop-shell/package-lock.json index ee898a8..4c6847f 100644 --- a/desktop-shell/package-lock.json +++ b/desktop-shell/package-lock.json @@ -1,12 +1,12 @@ { "name": "@shadowbroker/desktop-shell", - "version": "0.9.75", + "version": "0.9.79", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shadowbroker/desktop-shell", - "version": "0.9.75", + "version": "0.9.79", "devDependencies": { "typescript": "^5.6.0" } diff --git a/desktop-shell/package.json b/desktop-shell/package.json index 51b7ccc..305d083 100644 --- a/desktop-shell/package.json +++ b/desktop-shell/package.json @@ -1,6 +1,6 @@ { "name": "@shadowbroker/desktop-shell", - "version": "0.9.75", + "version": "0.9.79", "private": true, "description": "ShadowBroker desktop shell packaging, runtime bridge, and release tooling", "scripts": { diff --git a/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock b/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock index 3214a04..d9d04a7 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock +++ b/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock @@ -4201,7 +4201,7 @@ dependencies = [ [[package]] name = "shadowbroker-tauri-shell" -version = "0.9.75" +version = "0.9.79" dependencies = [ "axum", "base64 0.22.1", diff --git a/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml b/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml index 88d6e85..88181db 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml +++ b/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shadowbroker-tauri-shell" -version = "0.9.75" +version = "0.9.79" edition = "2021" [build-dependencies] diff --git a/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json b/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json index 36a4b71..194982d 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json +++ b/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShadowBroker", - "version": "0.9.75", + "version": "0.9.79", "identifier": "com.shadowbroker.desktop", "build": { "frontendDist": "../../../frontend/out", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f1afd1b..5dba293 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.9.75", + "version": "0.9.79", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.9.75", + "version": "0.9.79", "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@tauri-apps/plugin-process": "^2.3.1", diff --git a/frontend/package.json b/frontend/package.json index 5b27709..cf96860 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.9.75", + "version": "0.9.79", "private": true, "scripts": { "dev": "node scripts/dev-all.cjs", diff --git a/frontend/src/__tests__/desktop/updateRuntime.test.ts b/frontend/src/__tests__/desktop/updateRuntime.test.ts index 8d5dfbb..91cd8a7 100644 --- a/frontend/src/__tests__/desktop/updateRuntime.test.ts +++ b/frontend/src/__tests__/desktop/updateRuntime.test.ts @@ -9,12 +9,12 @@ import { } from '@/lib/updateRuntime'; const RELEASE: GitHubLatestRelease = { - html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.75', + html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.79', assets: [ - { name: 'ShadowBroker_0.9.75_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' }, - { name: 'ShadowBroker_0.9.75_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' }, - { name: 'ShadowBroker_0.9.75_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' }, - { name: 'ShadowBroker_0.9.75_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' }, + { name: 'ShadowBroker_0.9.79_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' }, + { name: 'ShadowBroker_0.9.79_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' }, + { name: 'ShadowBroker_0.9.79_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' }, + { name: 'ShadowBroker_0.9.79_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' }, ], }; diff --git a/frontend/src/components/ChangelogModal.tsx b/frontend/src/components/ChangelogModal.tsx index bbbfba5..a165bc2 100644 --- a/frontend/src/components/ChangelogModal.tsx +++ b/frontend/src/components/ChangelogModal.tsx @@ -20,7 +20,7 @@ import { Heart, } from 'lucide-react'; -const CURRENT_VERSION = '0.9.75'; +const CURRENT_VERSION = '0.9.79'; const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`; const RELEASE_TITLE = 'Onboarding, Live Feeds, Mesh, and Agent Hardening'; diff --git a/frontend/src/components/InfonetTerminal/MessagesView.tsx b/frontend/src/components/InfonetTerminal/MessagesView.tsx index 6d30ece..f5235b6 100644 --- a/frontend/src/components/InfonetTerminal/MessagesView.tsx +++ b/frontend/src/components/InfonetTerminal/MessagesView.tsx @@ -6,7 +6,9 @@ import { Ban, Check, ChevronLeft, + Copy, Inbox, + KeyRound, Mail, PencilLine, RefreshCcw, @@ -89,13 +91,18 @@ import { import { fetchWormholeStatus, fetchWormholeIdentity, + exportWormholeDmInvite, getWormholeDmInviteImportErrorResult, importWormholeDmInvite, isWormholeReady, isWormholeSecureRequired, + listWormholeDmInviteHandles, prepareWormholeInteractiveLane, issueWormholePairwiseAlias, openWormholeSenderSeal, + renameWormholeDmInviteHandle, + revokeWormholeDmInviteHandle, + type WormholeDmAddressRecord, } from '@/mesh/wormholeIdentityClient'; import { updatePrivateDeliveryAction, @@ -149,6 +156,23 @@ interface MailboxSnapshot { items: MailItem[]; } +interface LocalDmAddress { + id: string; + label: string; + handle: string; + peerId: string; + trustFingerprint: string; + inviteBlob: string; + createdAt: number; + revokedAt?: number; + expiresAt?: number; +} + +interface LocalDmAddressSnapshot { + version: 1; + addresses: LocalDmAddress[]; +} + interface ComposeDraft { recipient: string; subject: string; @@ -179,6 +203,10 @@ function mailboxStorageKey(scopeId: string): string { return `sb_infonet_mailbox_v1:${scopeId}`; } +function dmAddressStorageKey(scopeId: string): string { + return `sb_infonet_dm_addresses_v1:${scopeId}`; +} + function sortMessages(items: MailItem[]): MailItem[] { return [...items].sort((a, b) => { if (b.timestamp !== a.timestamp) { @@ -253,6 +281,63 @@ function saveMailbox(scopeId: string, items: MailItem[]): void { } } +function loadDmAddresses(scopeId: string): LocalDmAddress[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(dmAddressStorageKey(scopeId)); + if (!raw) return []; + const parsed = JSON.parse(raw) as LocalDmAddressSnapshot; + if (parsed?.version !== STORAGE_VERSION || !Array.isArray(parsed.addresses)) { + return []; + } + return parsed.addresses + .filter((item) => item && typeof item.handle === 'string' && item.handle.trim()) + .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + } catch { + return []; + } +} + +function saveDmAddresses(scopeId: string, addresses: LocalDmAddress[]): void { + if (typeof window === 'undefined') return; + try { + const payload: LocalDmAddressSnapshot = { + version: STORAGE_VERSION, + addresses: addresses.slice(0, 32), + }; + localStorage.setItem(dmAddressStorageKey(scopeId), JSON.stringify(payload)); + } catch { + /* ignore */ + } +} + +function inviteLookupHandle(invite: Record | undefined): string { + const payload = invite?.payload; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return ''; + } + return String((payload as Record).prekey_lookup_handle || '').trim(); +} + +function shortHandle(value: string): string { + const clean = String(value || '').trim(); + if (clean.length <= 16) return clean; + return `${clean.slice(0, 8)}...${clean.slice(-6)}`; +} + +function formatDmAddressDate(value?: number): string { + if (!value) return 'never'; + try { + return new Date(value * 1000).toLocaleString(); + } catch { + return String(value); + } +} + +function dmAddressShareText(address: LocalDmAddress): string { + return String(address.handle || '').trim(); +} + function encodeMailPayload(subject: string, body: string): string { const cleanSubject = subject.trim() || 'Secure Message'; return `${MAIL_SUBJECT_PREFIX}${cleanSubject}\n\n${body.trim()}`; @@ -545,13 +630,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro subject: '', body: '', }); - const [contactRequestTarget, setContactRequestTarget] = useState(''); const [inviteImportAlias, setInviteImportAlias] = useState(''); const [inviteImportBlob, setInviteImportBlob] = useState(''); const [inviteBusy, setInviteBusy] = useState(false); const [inviteScanOpen, setInviteScanOpen] = useState(false); const [inviteScanStatus, setInviteScanStatus] = useState(''); - const [dmLaneWarmStatus, setDmLaneWarmStatus] = useState(''); + const [dmAddressLabel, setDmAddressLabel] = useState(''); + const [dmAddresses, setDmAddresses] = useState([]); + const [remoteDmHandles, setRemoteDmHandles] = useState>({}); + const [dmAddressEditLabels, setDmAddressEditLabels] = useState>({}); + const [dmAddressBusy, setDmAddressBusy] = useState(''); + const [dmAddressCopyStatus, setDmAddressCopyStatus] = useState(''); + const [, setDmLaneWarmStatus] = useState(''); const [privateDelivery, setPrivateDelivery] = useState(null); const [privateDeliveryBusyId, setPrivateDeliveryBusyId] = useState(''); const inviteVideoRef = useRef(null); @@ -566,12 +656,17 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro useEffect(() => { setMessages(loadMailbox(scopeId)); + setDmAddresses(loadDmAddresses(scopeId)); }, [scopeId]); useEffect(() => { saveMailbox(scopeId, sortMessages(messages)); }, [messages, scopeId]); + useEffect(() => { + saveDmAddresses(scopeId, dmAddresses); + }, [dmAddresses, scopeId]); + useEffect(() => { let alive = true; let timer: ReturnType | null = null; @@ -651,6 +746,31 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro ), [privateDelivery], ); + const activeDmAddresses = useMemo( + () => + dmAddresses.filter((address) => { + const server = remoteDmHandles[address.handle]; + return !address.revokedAt && !server?.expired && !server?.exhausted && !server?.revoked; + }), + [dmAddresses, remoteDmHandles], + ); + const managedDmAddresses = useMemo(() => { + const seen = new Set(dmAddresses.map((address) => address.handle)); + const serverOnly = Object.values(remoteDmHandles) + .filter((address) => address.handle && !seen.has(address.handle)) + .map((address) => ({ + id: `remote-dm-address-${address.handle}`, + label: address.label || 'DM address', + handle: address.handle, + peerId: '', + trustFingerprint: '', + inviteBlob: '', + createdAt: address.issued_at || 0, + expiresAt: address.expires_at || undefined, + })); + return [...dmAddresses, ...serverOnly].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + }, [dmAddresses, remoteDmHandles]); + const primaryDmAddress = activeDmAddresses[0] || null; const resolveMessagingIdentity = useCallback(async () => { const localIdentity = getNodeIdentity(); @@ -1562,25 +1682,6 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } }, [contacts, dmLaneReady, draft, ensureSecureMailLane, identity, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState]); - const handleSendContactRequest = useCallback(async () => { - const recipient = contactRequestTarget.trim(); - if (!recipient) { - setComposeError('Enter an agent ID to send a contact request.'); - return; - } - setDraft((prev) => ({ - ...prev, - recipient, - })); - setActiveTab('compose'); - setComposeError(''); - setComposeStatus( - requiresVerifiedFirstContact(contacts[recipient]) - ? 'Signed invite import is required before first contact. Unverified TOFU contact requests are disabled.' - : '', - ); - }, [contactRequestTarget, contacts]); - const handleImportInvite = useCallback(async () => { const raw = inviteImportBlob.trim(); if (!raw) { @@ -1643,6 +1744,174 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } }, [ensureSecureMailLane, inviteImportAlias, inviteImportBlob, wormholeReadyState]); + const refreshDmAddressHandles = useCallback(async () => { + try { + const result = await listWormholeDmInviteHandles(); + const next: Record = {}; + for (const address of result.addresses || []) { + if (address.handle) { + next[address.handle] = address; + } + } + setRemoteDmHandles(next); + } catch { + /* best effort only */ + } + }, []); + + useEffect(() => { + if (!identity) return; + void refreshDmAddressHandles(); + }, [identity, refreshDmAddressHandles]); + + const handleGenerateDmAddress = useCallback(async () => { + setDmAddressBusy('generate'); + setDmAddressCopyStatus(''); + setComposeError(''); + setComposeStatus(''); + try { + const label = dmAddressLabel.trim() || `DM address ${new Date().toLocaleString()}`; + const exported = await exportWormholeDmInvite({ label }); + if (!exported.ok || !exported.invite) { + throw new Error(exported.detail || 'DM address generation failed'); + } + const handle = inviteLookupHandle(exported.invite as unknown as Record); + if (!handle) { + throw new Error('DM address did not include a lookup handle.'); + } + const inviteBlob = JSON.stringify( + { + type: 'shadowbroker.infonet.dm.invite', + version: 1, + label, + created_at: Math.floor(Date.now() / 1000), + invite: exported.invite, + }, + null, + 2, + ); + const record: LocalDmAddress = { + id: randomId('dm-address'), + label, + handle, + peerId: exported.peer_id, + trustFingerprint: exported.trust_fingerprint, + inviteBlob, + createdAt: Math.floor(Date.now() / 1000), + expiresAt: Number((exported.invite.payload as Record)?.expires_at || 0) || undefined, + }; + setDmAddresses((prev) => [record, ...prev.filter((item) => item.handle !== handle)].slice(0, 32)); + setDmAddressLabel(''); + await navigator.clipboard?.writeText(handle).catch(() => undefined); + setDmAddressCopyStatus( + exported.prekey_publish_pending + ? `Generated and copied ${label} address. Private delivery will activate as soon as the lane finishes connecting.` + : `Generated and copied ${label} address.`, + ); + await refreshDmAddressHandles(); + } catch (error) { + setComposeError(error instanceof Error ? error.message : 'DM address generation failed'); + } finally { + setDmAddressBusy(''); + } + }, [dmAddressLabel, refreshDmAddressHandles]); + + const handleCopyDmAddress = useCallback(async (address: LocalDmAddress) => { + setDmAddressBusy(`copy:${address.handle}`); + setDmAddressCopyStatus(''); + try { + const shareText = dmAddressShareText(address); + if (!shareText) { + throw new Error('This address has no local handle to copy.'); + } + await navigator.clipboard?.writeText(shareText); + setDmAddressCopyStatus(`Copied ${address.label || shortHandle(address.handle)}.`); + } catch (error) { + setDmAddressCopyStatus( + error instanceof Error ? error.message : 'Copy failed. Select the address and copy it manually.', + ); + } finally { + setDmAddressBusy(''); + } + }, []); + + const handleRevokeDmAddress = useCallback( + async (address: LocalDmAddress) => { + setDmAddressBusy(`revoke:${address.handle}`); + setDmAddressCopyStatus(''); + try { + const result = await revokeWormholeDmInviteHandle(address.handle); + setDmAddresses((prev) => + prev.map((item) => + item.handle === address.handle + ? { ...item, inviteBlob: '', revokedAt: Math.floor(Date.now() / 1000) } + : item, + ), + ); + setDmAddressCopyStatus( + result.revoked + ? `Revoked ${address.label || shortHandle(address.handle)} for new first-contact and removed the local share text.` + : `${address.label || shortHandle(address.handle)} was already inactive.`, + ); + await refreshDmAddressHandles(); + } catch (error) { + setComposeError(error instanceof Error ? error.message : 'DM address revoke failed'); + } finally { + setDmAddressBusy(''); + } + }, + [refreshDmAddressHandles], + ); + + const handleRenameDmAddress = useCallback( + async (address: LocalDmAddress, nextLabel: string) => { + const label = nextLabel.trim() || 'DM address'; + setDmAddressBusy(`rename:${address.handle}`); + setDmAddressCopyStatus(''); + setComposeError(''); + try { + const result = await renameWormholeDmInviteHandle(address.handle, label); + if (!result.ok) { + throw new Error(result.detail || 'DM address label update failed'); + } + setDmAddresses((prev) => { + const found = prev.some((item) => item.handle === address.handle); + const updated = prev.map((item) => + item.handle === address.handle ? { ...item, label } : item, + ); + return found ? updated : [{ ...address, label }, ...prev].slice(0, 32); + }); + setRemoteDmHandles((prev) => + prev[address.handle] + ? { ...prev, [address.handle]: { ...prev[address.handle], label } } + : prev, + ); + setDmAddressEditLabels((prev) => { + const next = { ...prev }; + delete next[address.handle]; + return next; + }); + setDmAddressCopyStatus(`Renamed ${shortHandle(address.handle)} to ${label}.`); + await refreshDmAddressHandles(); + } catch (error) { + setComposeError(error instanceof Error ? error.message : 'DM address label update failed'); + } finally { + setDmAddressBusy(''); + } + }, + [refreshDmAddressHandles], + ); + + const handleForgetDmAddress = useCallback((address: LocalDmAddress) => { + setDmAddresses((prev) => prev.filter((item) => item.handle !== address.handle)); + setDmAddressEditLabels((prev) => { + const next = { ...prev }; + delete next[address.handle]; + return next; + }); + setDmAddressCopyStatus(`Forgot local record for ${address.label || shortHandle(address.handle)}.`); + }, []); + const handleStartInviteScan = useCallback(() => { setComposeError(''); setComposeStatus(''); @@ -1850,23 +2119,20 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro }, []); const statusLine = useMemo(() => { - if (dmLaneWarmStatus) { - return dmLaneWarmStatus; - } - if (!wormholeReadyState) { - return 'Secure mail is preparing the local obfuscated identity in the background.'; - } - if (!dmLaneReady) { - return 'Secure mail is starting the direct obfuscated DM transport in the background.'; - } if (!identity) { - return 'Secure mail is preparing the local private identity in the background.'; + return 'Secure identity is loading.'; + } + if (!wormholeReadyState || !dmLaneReady) { + return 'Private message delivery is connecting. You can generate and copy your public address now.'; } if (syncing) { return 'SYNCING SECURE MAILBOX...'; } - return `SECURE MAIL READY - ${serverPendingCount} remote items still pending on the server.`; - }, [dmLaneReady, dmLaneWarmStatus, identity, serverPendingCount, syncing, wormholeReadyState]); + if (serverPendingCount > 0) { + return `SECURE MAIL READY - ${serverPendingCount} queued server item${serverPendingCount === 1 ? '' : 's'} still syncing.`; + } + return 'SECURE MAIL READY'; + }, [dmLaneReady, identity, serverPendingCount, syncing, wormholeReadyState]); return (
@@ -1899,6 +2165,62 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro {statusLine}
+
+
+
+
+ + My Public Address +
+
+ {primaryDmAddress ? ( + <> +
{primaryDmAddress.label || 'Default address'}
+
+ {dmAddressShareText(primaryDmAddress)} +
+ + ) : ( + 'Generate an address, then send it to someone so they can contact you.' + )} +
+
+
+ {primaryDmAddress && ( + + )} + + +
+
+ {dmAddressCopyStatus && ( +
{dmAddressCopyStatus}
+ )} +
+ {(pollError || composeError || composeStatus) && (
{pollError && ( @@ -2135,13 +2457,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
- {dmLaneWarmStatus - ? dmLaneWarmStatus - : !wormholeReadyState - ? 'Secure mail is waking up in the background. You can finish the draft now and send when the lane is ready.' - : !dmLaneReady - ? 'Secure mail is bringing the private lane online in the background.' - : 'If the recipient is not already in your contacts, sending from here opens with a secure contact request first. Full mail begins after they accept.'} + {!wormholeReadyState || !dmLaneReady + ? 'Private message lane is still connecting. You can write now and send when it is ready.' + : 'To message someone new, paste their public address in Contacts first. Existing contacts can be messaged from here.'}
{privateDeliveryRows.length > 0 && ( @@ -2209,7 +2527,6 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro - + {dmAddressCopyStatus && ( +
{dmAddressCopyStatus}
+ )} +
+ {managedDmAddresses.length === 0 ? ( +
No public address yet. Generate one above.
+ ) : ( + managedDmAddresses.map((address) => { + const server = remoteDmHandles[address.handle]; + const inactive = Boolean(address.revokedAt || server?.expired || server?.exhausted || server?.revoked); + const shareText = dmAddressShareText(address); + const editLabel = dmAddressEditLabels[address.handle] ?? address.label ?? ''; + return ( +
+
+
+
+
+ {address.label || 'DM address'} +
+
+ Created: {formatDmAddressDate(address.createdAt)} + {address.expiresAt ? ` / Expires: ${formatDmAddressDate(address.expiresAt)}` : ''} +
+
+ {inactive + ? 'Inactive' + : `${server?.remaining_uses ?? 'active'} first-contact uses left`} +
+
+
+ + + {inactive && ( + + )} +
+
+
+ + setDmAddressEditLabels((prev) => ({ + ...prev, + [address.handle]: event.target.value, + })) + } + className="bg-transparent border border-gray-800 px-3 py-2 text-xs text-white outline-none focus:border-emerald-500/40" + placeholder="Address label" + spellCheck={false} + /> + +
+
+ {shareText || 'Address unavailable locally.'} +
+
+ Revoking disables this public address for new first-contact requests. Existing approved + contacts remain contacts. +
+
+
+ ); + }) + )} +
-
-
- Import Verified Invite +
+
+ + Paste Someone's Address
+
+ Paste the public address someone gave you. Once imported, they show up as a contact + and you can send secure mail from Compose. +
+ +