From 11ea345518da332de221e6abbb3de0b53aeb4e00 Mon Sep 17 00:00:00 2001 From: BigBodyCobain <43977454+BigBodyCobain@users.noreply.github.com> Date: Mon, 18 May 2026 11:22:38 -0600 Subject: [PATCH 1/2] Harden infonet control surfaces --- backend/main.py | 50 +- backend/routers/ai_intel.py | 7 +- backend/routers/data.py | 4 +- backend/routers/wormhole.py | 24 +- backend/services/fetchers/_store.py | 2 +- backend/services/fetchers/crowdthreat.py | 17 + backend/services/mesh/mesh_hashchain.py | 21 +- .../services/mesh/mesh_wormhole_identity.py | 128 ++- backend/services/mesh/mesh_wormhole_prekey.py | 118 +++ .../tests/mesh/test_mesh_anonymous_mode.py | 58 +- .../tests/mesh/test_mesh_infonet_ingest.py | 17 +- .../mesh/test_prekey_lookup_correlation.py | 94 ++ .../mesh/test_s16d_dm_invite_bootstrap.py | 56 ++ backend/tests/test_control_surface_auth.py | 69 ++ backend/tests/test_crowdthreat_opt_in.py | 52 ++ docker-compose.yml | 4 +- .../mesh/messagesViewFirstContact.test.tsx | 246 ++++- .../wormholeIdentityClientProfiles.test.ts | 4 +- .../proxy/proxyAdminKeyInjection.test.ts | 72 ++ frontend/src/app/api/[...path]/route.ts | 3 + frontend/src/app/page.tsx | 4 +- .../InfonetTerminal/MessagesView.tsx | 873 +++++++++++++++--- .../InfonetTerminal/NetworkStats.tsx | 38 +- .../MeshChat/useMeshChatController.ts | 66 +- frontend/src/lib/controlPlane.ts | 6 +- frontend/src/mesh/controlPlaneStatusClient.ts | 2 + frontend/src/mesh/meshDmWorkerClient.ts | 1 + frontend/src/mesh/meshIdentity.ts | 21 +- .../src/mesh/wormholeDmBootstrapClient.ts | 2 + frontend/src/mesh/wormholeIdentityClient.ts | 27 +- 30 files changed, 1810 insertions(+), 276 deletions(-) create mode 100644 backend/tests/test_control_surface_auth.py create mode 100644 backend/tests/test_crowdthreat_opt_in.py diff --git a/backend/main.py b/backend/main.py index fe996ac..f69819b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3061,6 +3061,17 @@ def _request_private_surface_warmup(*, path: str, method: str, current_tier: str ) +def _is_invite_scoped_prekey_bundle_lookup(request: Request, path: str) -> bool: + if request.method.upper() != "GET" or str(path or "").strip() != "/api/mesh/dm/prekey-bundle": + return False + try: + lookup_token = str(request.query_params.get("lookup_token", "") or "").strip() + agent_id = str(request.query_params.get("agent_id", "") or "").strip() + except Exception: + return False + return bool(lookup_token) and not agent_id + + def _resume_private_delivery_background_work(*, current_tier: str, reason: str) -> None: pending_items = private_delivery_outbox.pending_items() if not pending_items: @@ -3191,6 +3202,17 @@ async def enforce_high_privacy_mesh(request: Request, call_next): # transport has not finished coming up yet. request.state._private_control_transport_pending = current_tier == "public_degraded" request.state._private_lane_current_tier = current_tier + elif _is_invite_scoped_prekey_bundle_lookup(request, path): + # A copied DM address carries a high-entropy invite lookup + # handle. Returning the public prekey bundle for that + # handle is the bootstrap step that lets first contact get + # saved; blocking it behind the full private lane creates a + # circular warm-up failure. Stable agent_id lookup still + # follows the normal transport-tier policy. + request.state._invite_prekey_lookup_transport_pending = ( + current_tier == "public_degraded" + ) + request.state._private_lane_current_tier = current_tier else: # Tor-style: instead of failing, keep trying in the # background and return an ok:True "preparing" response @@ -3323,7 +3345,7 @@ async def force_refresh(request: Request): return {"status": "refreshing in background"} -@app.post("/api/ais/feed") +@app.post("/api/ais/feed", dependencies=[Depends(require_local_operator)]) @limiter.limit("60/minute") async def ais_feed(request: Request): """Accept AIS-catcher HTTP JSON feed (POST decoded AIS messages).""" @@ -3418,7 +3440,7 @@ class LayerUpdate(BaseModel): layers: dict[str, bool] -@app.post("/api/layers") +@app.post("/api/layers", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def update_layers(update: LayerUpdate, request: Request): """Receive frontend layer toggle state. Starts/stops streams accordingly.""" @@ -9812,7 +9834,7 @@ async def api_wormhole_leave(request: Request): } -@app.get("/api/wormhole/identity") +@app.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_identity(request: Request): try: @@ -9825,7 +9847,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") +@app.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_wormhole_identity_bootstrap(request: Request): bootstrap_wormhole_identity() @@ -10605,7 +10627,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest): ) -@app.post("/api/wormhole/gate/enter") +@app.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): gate_id = str(body.gate_id or "") @@ -10619,7 +10641,7 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): return result -@app.post("/api/wormhole/gate/leave") +@app.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest): return leave_gate(str(body.gate_id or "")) @@ -10661,7 +10683,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat return result -@app.post("/api/wormhole/gate/persona/create") +@app.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_create( request: Request, body: WormholeGatePersonaCreateRequest @@ -10677,7 +10699,7 @@ async def api_wormhole_gate_persona_create( return result -@app.post("/api/wormhole/gate/persona/activate") +@app.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_activate( request: Request, body: WormholeGatePersonaActivateRequest @@ -10693,7 +10715,7 @@ async def api_wormhole_gate_persona_activate( return result -@app.post("/api/wormhole/gate/persona/clear") +@app.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest): gate_id = str(body.gate_id or "") @@ -10707,7 +10729,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe return result -@app.post("/api/wormhole/gate/persona/retire") +@app.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_retire( request: Request, body: WormholeGatePersonaActivateRequest @@ -10788,7 +10810,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate return composed -@app.post("/api/wormhole/gate/message/sign-encrypted") +@app.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_message_sign_encrypted( request: Request, @@ -11000,13 +11022,13 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat return {"ok": True, "results": results} -@app.post("/api/wormhole/gate/state/export") +@app.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)]) @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") +@app.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest): proof = _sign_gate_access_proof(str(body.gate_id or "")) @@ -11553,7 +11575,7 @@ async def api_wormhole_health(request: Request): return _redact_wormhole_status(full_state, authenticated=ok) -@app.post("/api/wormhole/connect") +@app.post("/api/wormhole/connect", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_wormhole_connect(request: Request): settings = read_wormhole_settings() diff --git a/backend/routers/ai_intel.py b/backend/routers/ai_intel.py index d5df1ca..bea38d4 100644 --- a/backend/routers/ai_intel.py +++ b/backend/routers/ai_intel.py @@ -379,14 +379,13 @@ async def api_refresh_layer_feed(request: Request, layer_id: str): # Agent Actions endpoint — frontend polls this for UI commands from the agent # --------------------------------------------------------------------------- -@router.get("/api/ai/agent-actions") +@router.get("/api/ai/agent-actions", dependencies=[Depends(require_local_operator)]) @limiter.limit("120/minute") async def get_agent_actions(request: Request): """Frontend polls for pending agent display actions (destructive read). - No auth required — this only contains display directives (show image, - fly to location), not sensitive data. The agent authenticates when - pushing actions through the command channel. + Local operator access is required because polling destructively drains + the shared operator action queue. """ actions = pop_agent_actions() return {"ok": True, "actions": actions} diff --git a/backend/routers/data.py b/backend/routers/data.py index 384660c..bed0f29 100644 --- a/backend/routers/data.py +++ b/backend/routers/data.py @@ -266,7 +266,7 @@ async def force_refresh(request: Request): return {"status": "refreshing in background"} -@router.post("/api/ais/feed") +@router.post("/api/ais/feed", dependencies=[Depends(require_local_operator)]) @limiter.limit("60/minute") async def ais_feed(request: Request): """Accept AIS-catcher HTTP JSON feed (POST decoded AIS messages).""" @@ -304,7 +304,7 @@ async def update_viewport(vp: ViewportUpdate, request: Request): # noqa: ARG001 return {"status": "ok"} -@router.post("/api/layers") +@router.post("/api/layers", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def update_layers(update: LayerUpdate, request: Request): """Receive frontend layer toggle state. Starts/stops streams accordingly.""" diff --git a/backend/routers/wormhole.py b/backend/routers/wormhole.py index e9ccfc0..d814c5d 100644 --- a/backend/routers/wormhole.py +++ b/backend/routers/wormhole.py @@ -663,7 +663,7 @@ async def api_wormhole_leave(request: Request): } -@router.get("/api/wormhole/identity") +@router.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)]) @limiter.limit("240/minute") async def api_wormhole_identity(request: Request): try: @@ -674,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") +@router.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_wormhole_identity_bootstrap(request: Request): bootstrap_wormhole_identity() @@ -773,7 +773,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest): ) -@router.post("/api/wormhole/gate/enter") +@router.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): gate_id = str(body.gate_id or "") @@ -787,7 +787,7 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): return result -@router.post("/api/wormhole/gate/leave") +@router.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest): return leave_gate(str(body.gate_id or "")) @@ -829,7 +829,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat return result -@router.post("/api/wormhole/gate/persona/create") +@router.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_create( request: Request, body: WormholeGatePersonaCreateRequest @@ -845,7 +845,7 @@ async def api_wormhole_gate_persona_create( return result -@router.post("/api/wormhole/gate/persona/activate") +@router.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_activate( request: Request, body: WormholeGatePersonaActivateRequest @@ -861,7 +861,7 @@ async def api_wormhole_gate_persona_activate( return result -@router.post("/api/wormhole/gate/persona/clear") +@router.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest): gate_id = str(body.gate_id or "") @@ -875,7 +875,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe return result -@router.post("/api/wormhole/gate/persona/retire") +@router.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)]) @limiter.limit("20/minute") async def api_wormhole_gate_persona_retire( request: Request, body: WormholeGatePersonaActivateRequest @@ -944,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") +@router.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_message_sign_encrypted( request: Request, @@ -1004,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") +@router.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)]) @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") +@router.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest): proof = _sign_gate_access_proof(str(body.gate_id or "")) @@ -1360,7 +1360,7 @@ async def api_wormhole_health(request: Request): return _redact_wormhole_status(full_state, authenticated=ok) -@router.post("/api/wormhole/connect") +@router.post("/api/wormhole/connect", dependencies=[Depends(require_local_operator)]) @limiter.limit("10/minute") async def api_wormhole_connect(request: Request): settings = read_wormhole_settings() diff --git a/backend/services/fetchers/_store.py b/backend/services/fetchers/_store.py index 882bdae..1751237 100644 --- a/backend/services/fetchers/_store.py +++ b/backend/services/fetchers/_store.py @@ -318,7 +318,7 @@ active_layers: dict[str, bool] = { "uap_sightings": True, "wastewater": True, "ai_intel": True, - "crowdthreat": True, + "crowdthreat": False, "sar": True, } diff --git a/backend/services/fetchers/crowdthreat.py b/backend/services/fetchers/crowdthreat.py index 5229bd0..2e9bbb2 100644 --- a/backend/services/fetchers/crowdthreat.py +++ b/backend/services/fetchers/crowdthreat.py @@ -7,6 +7,7 @@ No API key required — the /threats endpoint is unauthenticated. """ import logging +import os from services.network_utils import fetch_with_curl from services.fetchers._store import latest_data, _data_lock, _mark_fresh, is_any_active @@ -16,6 +17,16 @@ logger = logging.getLogger("services.data_fetcher") _CT_BASE = "https://backend.crowdthreat.world" + +def crowdthreat_fetch_enabled() -> bool: + """Return True only when the operator explicitly opts into CrowdThreat pulls.""" + return str(os.environ.get("CROWDTHREAT_ENABLED", "")).strip().lower() in { + "1", + "true", + "yes", + "on", + } + # CrowdThreat category_id → icon ID used on the MapLibre layer _CATEGORY_ICON = { 1: "ct-security", # Security & Conflict (red) @@ -43,6 +54,12 @@ _CATEGORY_COLOUR = { @with_retry(max_retries=2, base_delay=5) def fetch_crowdthreat(): """Fetch verified threat reports from CrowdThreat public API.""" + if not crowdthreat_fetch_enabled(): + logger.debug("CrowdThreat fetch skipped; set CROWDTHREAT_ENABLED=true to opt in") + with _data_lock: + latest_data["crowdthreat"] = [] + _mark_fresh("crowdthreat") + return if not is_any_active("crowdthreat"): return diff --git a/backend/services/mesh/mesh_hashchain.py b/backend/services/mesh/mesh_hashchain.py index 2ca11dc..454c09c 100644 --- a/backend/services/mesh/mesh_hashchain.py +++ b/backend/services/mesh/mesh_hashchain.py @@ -1438,6 +1438,7 @@ class Infonet: # Running counters — avoid O(N) scans in get_info() self._type_counts: dict[str, int] = {} self._active_count: int = 0 + self._registered_nodes: set[str] = set() self._chain_bytes: int = 2 # Start with "[]" empty JSON array self._dirty = False self._save_lock = threading.Lock() @@ -1518,6 +1519,7 @@ class Infonet: self._last_validated_index = 0 self._type_counts = {} self._active_count = 0 + self._registered_nodes = set() self._chain_bytes = 2 def _rebuild_state(self) -> None: @@ -1566,10 +1568,15 @@ class Infonet: now = time.time() self._type_counts = {} self._active_count = 0 + self._registered_nodes = set() self._chain_bytes = 2 # "[]" for evt in self.events: t = evt.get("event_type", "unknown") self._type_counts[t] = self._type_counts.get(t, 0) + 1 + if t == "node_register": + node_id = str(evt.get("node_id", "") or "") + if node_id: + self._registered_nodes.add(node_id) is_eph = evt.get("payload", {}).get("ephemeral") or evt.get("payload", {}).get("_ephemeral") if not is_eph or (now - evt.get("timestamp", 0)) < EPHEMERAL_TTL: self._active_count += 1 @@ -1579,6 +1586,10 @@ class Infonet: """Incrementally update counters when a new event is appended.""" t = evt.get("event_type", "unknown") self._type_counts[t] = self._type_counts.get(t, 0) + 1 + if t == "node_register": + node_id = str(evt.get("node_id", "") or "") + if node_id: + self._registered_nodes.add(node_id) self._active_count += 1 self._chain_bytes += len(json.dumps(evt)) + 2 @@ -2247,6 +2258,7 @@ class Infonet: self.event_index[event_id] = len(self.events) - 1 self.head_hash = event_id self.node_sequences[node_id] = sequence + self._update_counters_for_event(evt) accepted += 1 expected_prev = event_id self._replay_filter.add(event_id) @@ -2552,6 +2564,8 @@ class Infonet: # Apply fork self.events = prefix + ordered self._rebuild_state() + self._rebuild_revocations() + self._rebuild_counters() self._save() try: from services.mesh.mesh_metrics import increment as metrics_inc @@ -2681,6 +2695,8 @@ class Infonet: "head_hash_full": self.head_hash, "chain_lock": self.chain_lock(), "known_nodes": len(self.node_sequences), + "author_nodes": len(self.node_sequences), + "registered_nodes": len(self._registered_nodes), "event_types": dict(self._type_counts), "chain_size_kb": round(self._chain_bytes / 1024, 1), "unsigned_events": 0, @@ -2716,8 +2732,9 @@ class Infonet: if len(new_events) != before: self.events = new_events - # Rebuild index - self.event_index = {e["event_id"]: i for i, e in enumerate(self.events)} + self._rebuild_state() + self._rebuild_revocations() + self._rebuild_counters() self._save() logger.info(f"Infonet cleanup: removed {before - len(new_events)} expired events") diff --git a/backend/services/mesh/mesh_wormhole_identity.py b/backend/services/mesh/mesh_wormhole_identity.py index a6dc03a..afa9bf8 100644 --- a/backend/services/mesh/mesh_wormhole_identity.py +++ b/backend/services/mesh/mesh_wormhole_identity.py @@ -17,7 +17,7 @@ import time from typing import Any from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.asymmetric import ed25519, x25519 from services.mesh.mesh_crypto import ( build_signature_payload, @@ -464,6 +464,37 @@ def _bundle_fingerprint(data: dict[str, Any]) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() +def _ensure_dm_dh_material(data: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """Repair legacy/corrupt DM identities that kept signing keys but lost DH material.""" + if str(data.get("dh_pub_key", "") or "").strip() and str(data.get("dh_private_key", "") or "").strip(): + return data, False + + dh_priv = x25519.X25519PrivateKey.generate() + dh_priv_raw = dh_priv.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + dh_pub_raw = dh_priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + repaired = { + **dict(data or {}), + "dh_pub_key": base64.b64encode(dh_pub_raw).decode("ascii"), + "dh_algo": "X25519", + "dh_private_key": base64.b64encode(dh_priv_raw).decode("ascii"), + "last_dh_timestamp": int(time.time()), + "bundle_fingerprint": "", + "bundle_sequence": 0, + "bundle_registered_at": 0, + "prekey_bundle_registered_at": 0, + "prekey_transparency_head": "", + "prekey_transparency_size": 0, + } + return _write_identity(repaired), True + + def trust_fingerprint_for_identity_material( *, agent_id: str, @@ -830,10 +861,11 @@ def _sign_dm_invite_payload( def register_wormhole_dm_key(force: bool = False) -> dict[str, Any]: data = read_wormhole_identity() + data, repaired_dh = _ensure_dm_dh_material(data) timestamp = int(time.time()) fingerprint = _bundle_fingerprint(data) - if not force and fingerprint and fingerprint == data.get("bundle_fingerprint"): + if not force and not repaired_dh and fingerprint and fingerprint == data.get("bundle_fingerprint"): return { "ok": True, **_public_view(data), @@ -1525,11 +1557,101 @@ def import_wormhole_dm_invite(invite: dict[str, Any], *, alias: str = "") -> dic "detail": "compat dm invite import disabled; ask the sender to re-export a current signed invite", } + def _prekey_missing_or_pending(detail: str) -> bool: + lower = str(detail or "").strip().lower() + return any( + phrase in lower + for phrase in ( + "prekey bundle not found", + "invite prekey bundle not found", + "peer prekey lookup unavailable", + "peer prekey lookup still preparing", + "transport tier insufficient", + "preparing_private_lane", + ) + ) + + def _pin_pending_invite_prekey(detail: str) -> dict[str, Any]: + if invite_version < DM_INVITE_VERSION: + return {"ok": False, "detail": detail or "invite prekey bundle not found"} + invite_root_distribution = _verify_dm_invite_root_distribution(payload) + if not invite_root_distribution.get("ok"): + return invite_root_distribution + attested = _verify_dm_invite_identity_attestation( + envelope=envelope, + payload=payload, + resolved_root_node_id=str(invite_root_distribution.get("root_node_id", "") or ""), + resolved_root_public_key=str(invite_root_distribution.get("root_public_key", "") or ""), + resolved_root_public_key_algo=str( + invite_root_distribution.get("root_public_key_algo", "Ed25519") or "Ed25519" + ), + resolved_root_manifest_fingerprint=str( + invite_root_distribution.get("root_manifest_fingerprint", "") or "" + ).strip().lower(), + ) + if not attested.get("ok"): + return attested + pending_peer_id = str(verified.get("peer_id", "") or "").strip() + trust_fingerprint = str(verified.get("trust_fingerprint", "") or "").strip().lower() + contact = pin_wormhole_dm_invite( + pending_peer_id, + invite_payload={ + "trust_fingerprint": trust_fingerprint, + "public_key": "", + "public_key_algo": "Ed25519", + "identity_dh_pub_key": "", + "dh_algo": "X25519", + "prekey_lookup_handle": lookup_handle, + "issued_at": int(payload.get("issued_at", 0) or 0), + "expires_at": int(payload.get("expires_at", 0) or 0), + "label": str(payload.get("label", "") or ""), + "root_node_id": str(attested.get("root_node_id", "") or ""), + "root_public_key": str(attested.get("root_public_key", "") or ""), + "root_public_key_algo": str(attested.get("root_public_key_algo", "Ed25519") or "Ed25519"), + "root_fingerprint": str(attested.get("root_fingerprint", "") or ""), + "root_manifest_fingerprint": str(invite_root_distribution.get("root_manifest_fingerprint", "") or ""), + "root_witness_policy_fingerprint": str( + invite_root_distribution.get("root_witness_policy_fingerprint", "") or "" + ), + "root_witness_threshold": _safe_int( + invite_root_distribution.get("root_witness_threshold", 0) or 0, + 0, + ), + "root_witness_count": _safe_int(invite_root_distribution.get("root_witness_count", 0) or 0, 0), + "root_witness_domain_count": _safe_int( + invite_root_distribution.get("root_witness_domain_count", 0) or 0, + 0, + ), + "root_manifest_generation": _safe_int( + invite_root_distribution.get("root_manifest_generation", 0) or 0, + 0, + ), + "root_rotation_proven": bool(invite_root_distribution.get("root_rotation_proven")), + }, + alias=resolved_alias, + attested=True, + ) + return { + "ok": True, + "peer_id": pending_peer_id, + "invite_peer_id": pending_peer_id, + "trust_fingerprint": trust_fingerprint, + "trust_level": str(contact.get("trust_level", "") or ""), + "detail": "Contact saved.", + "invite_attested": True, + "pending_prekey": True, + "prekey_detail": detail or "invite prekey bundle not found", + "contact": contact, + } + from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle fetched = fetch_dm_prekey_bundle(lookup_token=lookup_handle) if not fetched.get("ok"): - return {"ok": False, "detail": str(fetched.get("detail", "") or "invite prekey bundle not found")} + fetch_detail = str(fetched.get("detail", "") or "invite prekey bundle not found") + if _prekey_missing_or_pending(fetch_detail): + return _pin_pending_invite_prekey(fetch_detail) + return {"ok": False, "detail": fetch_detail} resolved_peer_id = str(fetched.get("agent_id", "") or "").strip() if not resolved_peer_id: diff --git a/backend/services/mesh/mesh_wormhole_prekey.py b/backend/services/mesh/mesh_wormhole_prekey.py index d54c2bf..7ac348f 100644 --- a/backend/services/mesh/mesh_wormhole_prekey.py +++ b/backend/services/mesh/mesh_wormhole_prekey.py @@ -11,6 +11,7 @@ import os import random import time import urllib.error +import urllib.parse import urllib.request from typing import Any @@ -150,6 +151,118 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any return {"ok": False, "detail": last_detail or "Prekey bundle not found"} +def _configured_public_lookup_peer_urls() -> list[str]: + try: + from services.config import get_settings + from services.mesh.mesh_router import active_sync_peer_urls, parse_configured_relay_peers + + settings = get_settings() + candidates: list[str] = [] + for raw in ( + getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""), + getattr(settings, "MESH_DEFAULT_SYNC_PEERS", ""), + ): + candidates.extend(parse_configured_relay_peers(str(raw or ""))) + candidates.extend(active_sync_peer_urls()) + except Exception: + return [] + + seen: set[str] = set() + peers: list[str] = [] + for candidate in candidates: + peer = str(candidate or "").strip().rstrip("/") + if not peer or peer in seen: + continue + seen.add(peer) + peers.append(peer) + return peers + + +def _normalize_remote_lookup_bundle(payload: dict[str, Any]) -> dict[str, Any]: + data = dict(payload or {}) + bundle = dict(data.get("bundle") or {}) + public_key = str(data.get("public_key", "") or bundle.get("public_key", "") or "").strip() + if not public_key: + return {"ok": False, "detail": "Prekey bundle missing signing key"} + agent_id = str(data.get("agent_id", "") or "").strip() or derive_node_id(public_key) + if not agent_id: + return {"ok": False, "detail": "Prekey bundle public key binding mismatch"} + data["agent_id"] = agent_id + data["public_key"] = public_key + data["public_key_algo"] = str(data.get("public_key_algo", "") or bundle.get("public_key_algo", "Ed25519") or "Ed25519") + data["protocol_version"] = str(data.get("protocol_version", "") or bundle.get("protocol_version", PROTOCOL_VERSION) or PROTOCOL_VERSION) + data["bundle"] = bundle + ok, reason = _validate_bundle_record(data) + if not ok: + return {"ok": False, "detail": reason} + data["ok"] = True + data["lookup_mode"] = "invite_lookup_handle" + data["public_lookup"] = True + return data + + +def _fetch_dm_prekey_bundle_from_public_lookup(lookup_token: str) -> dict[str, Any]: + """Fetch an invite-scoped prekey bundle from bootstrap/sync peers. + + The token is high-entropy and invite-scoped. This path does not expose a + stable agent_id to the peer; if the ordinary peer response omits agent_id, + derive it from the signed identity public key and validate the bundle before + accepting it. + """ + token = str(lookup_token or "").strip() + if not token: + return {"ok": False, "detail": "lookup token required"} + peers = _configured_public_lookup_peer_urls() + if not peers: + return {"ok": False, "detail": "peer prekey lookup unavailable"} + try: + from services.config import get_settings + + timeout = max(1, _safe_int(getattr(get_settings(), "MESH_SYNC_TIMEOUT_S", 5) or 5, 5)) + except Exception: + timeout = 5 + + encoded = urllib.parse.urlencode({"lookup_token": token}) + last_detail = "" + for peer_url in peers: + normalized_peer_url = str(peer_url or "").strip().rstrip("/") + if not normalized_peer_url: + continue + request = urllib.request.Request( + f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}", + headers={ + "Accept": "application/json", + "User-Agent": "ShadowBroker-Infonet/0.9 (+https://github.com/BigBodyCobain/Shadowbroker)", + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read(256 * 1024) + payload = json.loads(raw.decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: + logger.debug("public prekey lookup failed for %s: %s", normalized_peer_url, type(exc).__name__) + last_detail = "peer prekey lookup unavailable" + continue + if not isinstance(payload, dict): + last_detail = "invalid peer response" + continue + if payload.get("pending") or str(payload.get("status", "") or "") == "preparing_private_lane": + last_detail = "peer prekey lookup still preparing" + continue + if not payload.get("ok"): + last_detail = str(payload.get("detail", "") or last_detail or "Prekey bundle not found") + continue + if not isinstance(payload.get("bundle"), dict): + last_detail = "Prekey bundle not found" + continue + normalized = _normalize_remote_lookup_bundle(payload) + if normalized.get("ok"): + return normalized + last_detail = str(normalized.get("detail", "") or last_detail) + return {"ok": False, "detail": last_detail or "Prekey bundle not found"} + + def _b64(data: bytes) -> str: return base64.b64encode(data).decode("ascii") @@ -926,6 +1039,11 @@ def fetch_dm_prekey_bundle( peer_found = _fetch_dm_prekey_bundle_from_peer_lookup(resolved_lookup) if peer_found.get("ok"): return peer_found + public_found = _fetch_dm_prekey_bundle_from_public_lookup(resolved_lookup) + if public_found.get("ok"): + return public_found + if str(public_found.get("detail", "") or "").strip(): + return {"ok": False, "detail": str(public_found.get("detail", "") or "Prekey bundle not found")} return {"ok": False, "detail": str(peer_found.get("detail", "") or "Prekey bundle not found")} else: return {"ok": False, "detail": "Prekey bundle not found"} diff --git a/backend/tests/mesh/test_mesh_anonymous_mode.py b/backend/tests/mesh/test_mesh_anonymous_mode.py index 4c65992..3604f36 100644 --- a/backend/tests/mesh/test_mesh_anonymous_mode.py +++ b/backend/tests/mesh/test_mesh_anonymous_mode.py @@ -5,7 +5,7 @@ from starlette.requests import Request from starlette.responses import Response -def _request(path: str, method: str = "POST") -> Request: +def _request(path: str, method: str = "POST", query_string: bytes = b"") -> Request: return Request( { "type": "http", @@ -13,6 +13,7 @@ def _request(path: str, method: str = "POST") -> Request: "client": ("test", 12345), "method": method, "path": path, + "query_string": query_string, } ) @@ -504,6 +505,61 @@ def test_private_infonet_gate_write_returns_preparing_state_when_wormhole_not_re get_settings.cache_clear() +def test_invite_scoped_prekey_lookup_reaches_handler_while_lane_prepares(monkeypatch): + """Copied-address import must not be blocked by private-lane warmup.""" + import main + import auth + from services.config import get_settings + from services import wormhole_supervisor + + monkeypatch.setenv("MESH_PRIVATE_CLEARNET_FALLBACK", "block") + monkeypatch.setenv("MESH_BLOCK_LEGACY_NODE_ID_COMPAT", "true") + monkeypatch.setenv("MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP", "true") + monkeypatch.setenv("MESH_ALLOW_COMPAT_DM_INVITE_IMPORT", "false") + get_settings.cache_clear() + monkeypatch.setattr( + auth, + "_anonymous_mode_state", + lambda: { + "enabled": False, + "wormhole_enabled": True, + "ready": False, + "effective_transport": "direct", + }, + ) + monkeypatch.setattr( + wormhole_supervisor, + "get_wormhole_state", + lambda: { + "configured": True, + "ready": False, + "rns_ready": False, + "arti_ready": False, + }, + ) + + called = {"value": False} + + async def call_next(_request: Request) -> Response: + called["value"] = True + return Response(status_code=200) + + response = asyncio.run( + main.enforce_high_privacy_mesh( + _request( + "/api/mesh/dm/prekey-bundle", + method="GET", + query_string=b"lookup_token=invite-handle", + ), + call_next, + ) + ) + + assert response.status_code == 200 + assert called["value"] is True + get_settings.cache_clear() + + def test_private_dm_send_blocks_at_transitional_tier(monkeypatch): import main import auth diff --git a/backend/tests/mesh/test_mesh_infonet_ingest.py b/backend/tests/mesh/test_mesh_infonet_ingest.py index 174de51..0530b58 100644 --- a/backend/tests/mesh/test_mesh_infonet_ingest.py +++ b/backend/tests/mesh/test_mesh_infonet_ingest.py @@ -47,6 +47,11 @@ def test_infonet_ingest_accepts_valid_event(tmp_path, monkeypatch): assert result["accepted"] == 1 assert inf.head_hash == evt.event_id + info = inf.get_info() + assert info["known_nodes"] == 1 + assert info["author_nodes"] == 1 + assert info["total_events"] == 1 + assert info["event_types"]["message"] == 1 def test_verify_node_binding_accepts_current_and_compat_ids_only(monkeypatch): @@ -64,6 +69,8 @@ def test_verify_node_binding_accepts_current_and_compat_ids_only(monkeypatch): f"{current[len(mesh_crypto.NODE_ID_PREFIX):len(mesh_crypto.NODE_ID_PREFIX) + 8]}" ) + monkeypatch.setenv("MESH_DEV_ALLOW_LEGACY_COMPAT", "true") + monkeypatch.setenv("MESH_BLOCK_LEGACY_NODE_ID_COMPAT", "false") monkeypatch.setenv("MESH_ALLOW_LEGACY_NODE_ID_COMPAT_UNTIL", "2099-01-01") from services.config import get_settings @@ -98,7 +105,7 @@ def test_infonet_append_rejects_missing_signature_fields(tmp_path, monkeypatch): assert "signature" in str(exc).lower() -def test_infonet_load_fails_closed_on_hash_mismatch(tmp_path, monkeypatch): +def test_infonet_load_quarantines_and_resets_on_hash_mismatch(tmp_path, monkeypatch): monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path) monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json") @@ -135,8 +142,12 @@ def test_infonet_load_fails_closed_on_hash_mismatch(tmp_path, monkeypatch): encoding="utf-8", ) - with pytest.raises(ValueError, match="Hash mismatch on event load"): - mesh_hashchain.Infonet() + inf = mesh_hashchain.Infonet() + + assert inf.events == [] + assert inf.head_hash == mesh_hashchain.GENESIS_HASH + assert not mesh_hashchain.CHAIN_FILE.exists() + assert list(tmp_path.glob("infonet.json.quarantine.*")) def test_validate_gate_message_payload_rejects_plaintext_shape(): diff --git a/backend/tests/mesh/test_prekey_lookup_correlation.py b/backend/tests/mesh/test_prekey_lookup_correlation.py index 37b6b2c..db45ce9 100644 --- a/backend/tests/mesh/test_prekey_lookup_correlation.py +++ b/backend/tests/mesh/test_prekey_lookup_correlation.py @@ -12,6 +12,7 @@ Tests verify: """ import hashlib +import json import time from services.config import get_settings @@ -611,6 +612,99 @@ class TestFetchPrekeyBundleByLookup: "peer prekey lookup unavailable", } + def test_fetch_lookup_token_uses_bootstrap_peer_without_agent_id(self, tmp_path, monkeypatch): + """Invite lookup can resolve through bootstrap peers without exposing agent_id.""" + _isolated_relay(tmp_path, monkeypatch) + record = _valid_bundle_record("test-agent") + requested_urls: list[str] = [] + + monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example") + monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "") + monkeypatch.setenv("MESH_RELAY_PEERS", "") + get_settings.cache_clear() + + class _Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit: int = -1): + return json.dumps( + { + "ok": True, + "identity_dh_pub_key": record["dh_pub_key"], + "dh_algo": record["dh_algo"], + "public_key": record["public_key"], + "public_key_algo": record["public_key_algo"], + "protocol_version": record["protocol_version"], + "sequence": 1, + "signed_at": int(record["bundle"].get("signed_at", 0) or 0), + "bundle": record["bundle"], + } + ).encode("utf-8") + + def _urlopen(request, timeout=0): + requested_urls.append(str(getattr(request, "full_url", ""))) + return _Response() + + monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen) + + from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle + + result = fetch_dm_prekey_bundle(agent_id="", lookup_token="bootstrap-handle") + + assert result["ok"] is True + assert result["agent_id"] == record["agent_id"] + assert result["lookup_mode"] == "invite_lookup_handle" + assert result["public_lookup"] is True + assert requested_urls + assert "lookup_token=bootstrap-handle" in requested_urls[0] + assert "agent_id" not in requested_urls[0] + + def test_fetch_lookup_token_does_not_parse_peer_pending_as_bundle(self, tmp_path, monkeypatch): + """A peer's private-lane pending response is not a malformed prekey bundle.""" + _isolated_relay(tmp_path, monkeypatch) + requested_urls: list[str] = [] + + monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example") + monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "") + monkeypatch.setenv("MESH_RELAY_PEERS", "") + get_settings.cache_clear() + + class _Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit: int = -1): + return json.dumps( + { + "ok": True, + "pending": True, + "status": "preparing_private_lane", + "detail": "transport tier insufficient", + } + ).encode("utf-8") + + def _urlopen(request, timeout=0): + requested_urls.append(str(getattr(request, "full_url", ""))) + return _Response() + + monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen) + + from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle + + result = fetch_dm_prekey_bundle(agent_id="", lookup_token="bootstrap-handle") + + assert requested_urls + assert result["ok"] is False + assert result["detail"] == "peer prekey lookup still preparing" + assert result["detail"] != "Prekey bundle missing signing key" + def test_fetch_agent_id_uses_pinned_contact_lookup_handle(self, tmp_path, monkeypatch): """Pinned invite lookup handle is used before direct agent_id lookup.""" relay = _isolated_relay(tmp_path, monkeypatch) diff --git a/backend/tests/mesh/test_s16d_dm_invite_bootstrap.py b/backend/tests/mesh/test_s16d_dm_invite_bootstrap.py index ae84644..bf256b6 100644 --- a/backend/tests/mesh/test_s16d_dm_invite_bootstrap.py +++ b/backend/tests/mesh/test_s16d_dm_invite_bootstrap.py @@ -71,6 +71,38 @@ def _fresh_wormhole_state(tmp_path, monkeypatch): return relay, mesh_wormhole_identity, mesh_wormhole_contacts, mesh_wormhole_prekey +def test_register_wormhole_dm_key_repairs_missing_local_dh_material(tmp_path, monkeypatch): + relay, identity_mod, _contacts_mod, _prekey_mod = _fresh_wormhole_state(tmp_path, monkeypatch) + identity = identity_mod.read_wormhole_identity() + original_node_id = identity["node_id"] + original_public_key = identity["public_key"] + original_private_key = identity["private_key"] + + identity_mod.write_dm_identity( + { + **identity, + "dh_pub_key": "", + "dh_private_key": "", + "bundle_fingerprint": "", + "bundle_sequence": 0, + "bundle_registered_at": 0, + } + ) + + registered = identity_mod.register_wormhole_dm_key() + repaired = identity_mod.read_wormhole_identity() + + assert registered["ok"] is True + assert registered["dh_pub_key"] + assert registered["dh_algo"] == "X25519" + assert repaired["dh_pub_key"] == registered["dh_pub_key"] + assert repaired["dh_private_key"] + assert repaired["node_id"] == original_node_id + assert repaired["public_key"] == original_public_key + assert repaired["private_key"] == original_private_key + assert relay.get_dh_key(original_node_id)["dh_pub_key"] == registered["dh_pub_key"] + + def _export_verified_invite(identity_mod): exported = identity_mod.export_wormhole_dm_invite() assert exported["ok"] is True @@ -460,6 +492,30 @@ def test_imported_dm_invite_pins_contact_as_invite_pinned(tmp_path, monkeypatch) assert contacts_mod.list_wormhole_dm_contacts()[imported["peer_id"]]["trust_level"] == "invite_pinned" +def test_imported_dm_invite_saves_pending_contact_when_prekey_not_visible(tmp_path, monkeypatch): + _relay, identity_mod, contacts_mod, prekey_mod = _fresh_wormhole_state(tmp_path, monkeypatch) + exported, verified = _export_verified_invite(identity_mod) + monkeypatch.setattr( + prekey_mod, + "fetch_dm_prekey_bundle", + lambda **_kw: {"ok": False, "detail": "Prekey bundle not found"}, + ) + + imported = identity_mod.import_wormhole_dm_invite(exported["invite"], alias="alice") + contact = imported["contact"] + + assert imported["ok"] is True + assert imported["pending_prekey"] is True + assert imported["peer_id"] == verified["peer_id"] + assert contact["alias"] == "alice" + assert contact["trust_level"] == "invite_pinned" + assert contact["invitePinnedPrekeyLookupHandle"] == exported["invite"]["payload"]["prekey_lookup_handle"] + assert contact["remotePrekeyLookupMode"] == "invite_lookup_handle" + assert contact["remotePrekeyFingerprint"] == verified["trust_fingerprint"] + assert contact["dhPubKey"] == "" + assert contacts_mod.list_wormhole_dm_contacts()[verified["peer_id"]]["trust_level"] == "invite_pinned" + + def test_imported_dm_invite_requires_root_attested_prekey_bundle(tmp_path, monkeypatch): relay, identity_mod, _contacts_mod, _prekey_mod = _fresh_wormhole_state(tmp_path, monkeypatch) diff --git a/backend/tests/test_control_surface_auth.py b/backend/tests/test_control_surface_auth.py new file mode 100644 index 0000000..27f21f8 --- /dev/null +++ b/backend/tests/test_control_surface_auth.py @@ -0,0 +1,69 @@ +"""Regression coverage for operator-only control surfaces.""" + +import pytest + + +@pytest.mark.parametrize( + ("method", "path", "payload"), + [ + ("get", "/api/wormhole/identity", None), + ("post", "/api/wormhole/identity/bootstrap", {}), + ("post", "/api/wormhole/gate/enter", {"gate_id": "general-talk"}), + ("post", "/api/wormhole/gate/leave", {"gate_id": "general-talk"}), + ("post", "/api/wormhole/sign", {"event_type": "gate_event", "payload": {"ok": True}}), + ("post", "/api/wormhole/gate/key/rotate", {"gate_id": "general-talk", "reason": "test"}), + ( + "post", + "/api/wormhole/gate/key/grant", + { + "gate_id": "general-talk", + "recipient_node_id": "node-test", + "recipient_dh_pub": "dh-test", + }, + ), + ("post", "/api/wormhole/gate/persona/create", {"gate_id": "general-talk", "label": "test"}), + ( + "post", + "/api/wormhole/gate/persona/activate", + {"gate_id": "general-talk", "persona_id": "persona-test"}, + ), + ("post", "/api/wormhole/gate/persona/clear", {"gate_id": "general-talk"}), + ( + "post", + "/api/wormhole/gate/persona/retire", + {"gate_id": "general-talk", "persona_id": "persona-test"}, + ), + ( + "post", + "/api/wormhole/gate/message/sign-encrypted", + { + "gate_id": "general-talk", + "epoch": 1, + "ciphertext": "ciphertext", + "nonce": "nonce", + "format": "mls1", + "envelope_hash": "hash", + }, + ), + ("post", "/api/wormhole/gate/message/compose", {"gate_id": "general-talk", "plaintext": "hello"}), + ("post", "/api/wormhole/sign-raw", {"message": "raw"}), + ("post", "/api/wormhole/gate/state/export", {"gate_id": "general-talk"}), + ("post", "/api/wormhole/gate/proof", {"gate_id": "general-talk"}), + ("post", "/api/wormhole/connect", {}), + ("post", "/api/layers", {"layers": {"viirs_nightlights": True}}), + ("post", "/api/ais/feed", {"msgs": []}), + ], +) +def test_remote_control_surface_rejects_without_local_operator_or_admin( + remote_client, method, path, payload +): + request = getattr(remote_client, method) + response = request(path, json=payload) if payload is not None else request(path) + + assert response.status_code == 403 + + +def test_remote_agent_actions_poll_rejects_without_local_operator_or_admin(remote_client): + response = remote_client.get("/api/ai/agent-actions") + + assert response.status_code == 403 diff --git a/backend/tests/test_crowdthreat_opt_in.py b/backend/tests/test_crowdthreat_opt_in.py new file mode 100644 index 0000000..f6d066c --- /dev/null +++ b/backend/tests/test_crowdthreat_opt_in.py @@ -0,0 +1,52 @@ +"""CrowdThreat ingestion is operator opt-in only.""" + + +class _CrowdThreatResponse: + status_code = 200 + + def json(self): + return { + "data": { + "threats": [ + { + "id": "ct-1", + "title": "Example report", + "location": { + "lng_lat": [12.5, 41.9], + "name": "Example place", + "country": {"name": "Italy"}, + }, + "category": {"id": 1, "name": "Security"}, + } + ] + } + } + + +def test_crowdthreat_disabled_by_default_does_not_call_upstream(monkeypatch): + from services.fetchers import _store, crowdthreat + + monkeypatch.delenv("CROWDTHREAT_ENABLED", raising=False) + monkeypatch.setitem(_store.latest_data, "crowdthreat", [{"id": "old"}]) + monkeypatch.setattr( + crowdthreat, + "fetch_with_curl", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("upstream called")), + ) + + crowdthreat.fetch_crowdthreat() + + assert _store.latest_data["crowdthreat"] == [] + + +def test_crowdthreat_opt_in_fetches_when_layer_is_enabled(monkeypatch): + from services.fetchers import _store, crowdthreat + + monkeypatch.setenv("CROWDTHREAT_ENABLED", "true") + monkeypatch.setitem(_store.active_layers, "crowdthreat", True) + monkeypatch.setattr(crowdthreat, "fetch_with_curl", lambda *args, **kwargs: _CrowdThreatResponse()) + + crowdthreat.fetch_crowdthreat() + + assert _store.latest_data["crowdthreat"][0]["id"] == "ct-1" + assert _store.latest_data["crowdthreat"][0]["source"] == "CrowdThreat" diff --git a/docker-compose.yml b/docker-compose.yml index d60b21b..3a7cebd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,11 +62,13 @@ services: image: ghcr.io/bigbodycobain/shadowbroker-frontend:latest container_name: shadowbroker-frontend ports: - - "${BIND:-127.0.0.1}:3000:3000" + - "${BIND:-127.0.0.1}:${FRONTEND_PORT:-3000}:3000" environment: # Points the Next.js server-side proxy at the backend container via Docker networking. # Change this if your backend runs on a different host or port. - BACKEND_URL=http://backend:8000 + # Lets the server-side proxy authenticate protected local-node API calls. + - ADMIN_KEY=${ADMIN_KEY:-} depends_on: backend: condition: service_healthy diff --git a/frontend/src/__tests__/mesh/messagesViewFirstContact.test.tsx b/frontend/src/__tests__/mesh/messagesViewFirstContact.test.tsx index 1ee4cae..385ee60 100644 --- a/frontend/src/__tests__/mesh/messagesViewFirstContact.test.tsx +++ b/frontend/src/__tests__/mesh/messagesViewFirstContact.test.tsx @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ buildMailboxClaims: vi.fn(async () => []), countDmMailboxes: vi.fn(async () => ({ ok: true, count: 0 })), ensureRegisteredDmKey: vi.fn(async () => ({ dhPubKey: 'local-dh', dhAlgo: 'X25519' })), - fetchDmPublicKey: vi.fn(async () => ({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' })), + fetchDmPublicKey: vi.fn(async () => ({ agent_id: '!sb_peer', dh_pub_key: 'peer-dh', dh_algo: 'X25519' })), pollDmMailboxes: vi.fn(async () => ({ ok: true, messages: [] })), sendDmMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })), sendOffLedgerConsentMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })), @@ -252,7 +252,7 @@ describe('MessagesView first-contact trust UX', () => { mocks.pollDmMailboxes.mockResolvedValue({ ok: true, messages: [] }); mocks.countDmMailboxes.mockResolvedValue({ ok: true, count: 0 }); mocks.ensureRegisteredDmKey.mockResolvedValue({ dhPubKey: 'local-dh', dhAlgo: 'X25519' }); - mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' }); + mocks.fetchDmPublicKey.mockResolvedValue({ agent_id: '!sb_peer', dh_pub_key: 'peer-dh', dh_algo: 'X25519' }); mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' }); mocks.canUseWormholeBootstrap.mockResolvedValue(false); mocks.exportWormholeDmInvite.mockResolvedValue({ @@ -334,8 +334,9 @@ describe('MessagesView first-contact trust UX', () => { await openComposeForRecipient('!sb_invited', 'hello to pinned peer'); expect(screen.queryByText('Unverified First Contact')).not.toBeInTheDocument(); - expect(await screen.findByText('ROOT LOCAL QUORUM')).toBeInTheDocument(); - expect(await screen.findByText(/Local quorum root rootabcd\.\.123456/i)).toBeInTheDocument(); + expect(screen.queryByText('ROOT LOCAL QUORUM')).not.toBeInTheDocument(); + expect(screen.queryByText(/Local quorum root rootabcd\.\.123456/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Fingerprint/i)).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled(); }); @@ -375,7 +376,34 @@ describe('MessagesView first-contact trust UX', () => { expect(screen.queryByText(/still warming up/i)).not.toBeInTheDocument(); }, 10000); - it('does not flatten witness policy not met into a generic witnessed root label', async () => { + it('repairs the local sending key before sending instead of surfacing backend key jargon', async () => { + contactsState = { + '!sb_pinned': { + alias: 'Pinned Peer', + blocked: false, + trust_level: 'invite_pinned', + dhPubKey: 'peer-dh', + remotePrekeyFingerprint: 'abcdef123456', + }, + }; + mocks.ensureRegisteredDmKey + .mockResolvedValueOnce({ ok: true, dhPubKey: '', dhAlgo: 'X25519', detail: 'Missing DH public key' }) + .mockResolvedValueOnce({ ok: true, dhPubKey: 'local-dh-repaired', dhAlgo: 'X25519' }); + mocks.sendDmMessage.mockResolvedValueOnce({ ok: true, transport: 'relay' }); + + renderMessagesView(); + await openComposeForRecipient('!sb_pinned', 'hello after repair'); + + fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' })); + + await waitFor(() => expect(mocks.ensureRegisteredDmKey).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(mocks.sendDmMessage).toHaveBeenCalled()); + expect(await screen.findByText(/Mail delivered to Pinned Peer/i)).toBeInTheDocument(); + expect(screen.queryByText(/Local DM key is unavailable/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Missing DH public key/i)).not.toBeInTheDocument(); + }); + + it('shows saved contacts without witness-policy implementation detail', async () => { contactsState = { '!sb_policy': { alias: 'Policy Peer', @@ -404,10 +432,39 @@ describe('MessagesView first-contact trust UX', () => { renderMessagesView(); fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); - expect(await screen.findByText(/Witness-policy root rootpoli\.\.123456/i)).toBeInTheDocument(); + expect(await screen.findByText('Saved Contact')).toBeInTheDocument(); + expect(screen.queryByText(/Witness-policy root rootpoli\.\.123456/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Witnessed root rootpoli\.\.123456/i)).not.toBeInTheDocument(); }); + it('hydrates Wormhole contacts on first load even when a local browser identity exists', async () => { + let wormholeIdentityResolved = false; + contactsState = { + '!sb_saved': { + alias: 'Saved Person', + blocked: false, + trust_level: 'invite_pinned', + invitePinnedPrekeyLookupHandle: 'handle-saved', + invitePinnedTrustFingerprint: 'savedfingerprint123456', + }, + }; + mocks.isWormholeSecureRequired.mockResolvedValue(true); + mocks.fetchWormholeIdentity.mockImplementation(async () => { + wormholeIdentityResolved = true; + return { node_id: '!sb_local', public_key: 'local-pub' }; + }); + mocks.hydrateWormholeContacts.mockImplementation(async () => + wormholeIdentityResolved ? contactsState : {}, + ); + + renderMessagesView(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + + expect(await screen.findByText('Saved Person')).toBeInTheDocument(); + expect(screen.queryByText(/No approved secure contacts yet/i)).not.toBeInTheDocument(); + expect(mocks.fetchWormholeIdentity).toHaveBeenCalled(); + }); + it('shows an import-invite shortcut for unpinned contacts in the contact list', async () => { contactsState = { '!sb_unpinned': { @@ -426,7 +483,7 @@ describe('MessagesView first-contact trust UX', () => { expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned'); }); - it('surfaces pending contact requests in the contact list with approve and deny actions', async () => { + it('surfaces pending contact requests in a top-level requests tab with approve and deny actions', async () => { localStorage.setItem( 'sb_infonet_mailbox_v1:!sb_local', JSON.stringify({ @@ -464,7 +521,7 @@ describe('MessagesView first-contact trust UX', () => { }); renderMessagesView(); - fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + fireEvent.click(await screen.findByRole('button', { name: /REQUESTS/i })); expect(await screen.findByText('Contact Requests')).toBeInTheDocument(); expect(await screen.findByText('1 pending')).toBeInTheDocument(); @@ -576,13 +633,13 @@ describe('MessagesView first-contact trust UX', () => { expect( await screen.findByText( - /Import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled\./i, + /This contact needs their full contact address once before messages can be sent/i, ), ).toBeInTheDocument(); expect(mocks.fetchDmPublicKey).not.toHaveBeenCalled(); }); - it('announces attested invite imports as INVITE PINNED', async () => { + it('announces attested invite imports as a saved contact', async () => { mocks.importWormholeDmInvite.mockResolvedValueOnce({ ok: true, peer_id: '!sb_attested', @@ -595,32 +652,34 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { + fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), { target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, }); fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); - expect( - await screen.findByText(/INVITE PINNED for !sb_attested \(invitefp\.\.tested\)\./i), - ).toBeInTheDocument(); + expect(await screen.findByText(/Contact saved: !sb_attested\./i)).toBeInTheDocument(); + expect(await screen.findByText('Saved Contact')).toBeInTheDocument(); + expect(screen.queryByText(/INVITE PINNED for/i)).not.toBeInTheDocument(); }); - it('generates and copies the full signed public address instead of the lookup handle', async () => { + it('automatically creates a share address and keeps copy actions simple', async () => { renderMessagesView(); - fireEvent.click(await screen.findByRole('button', { name: 'Generate Address' })); - - await waitFor(() => expect(mocks.writeClipboard).toHaveBeenCalled()); - const copied = String(mocks.writeClipboard.mock.calls[0][0] || ''); - expect(copied).toContain('"type": "shadowbroker.infonet.dm.invite"'); - expect(copied).toContain('"prekey_lookup_handle": "handle-123"'); - expect(copied).not.toBe('handle-123'); - expect(await screen.findByText(/Generated and copied/i)).toBeInTheDocument(); + expect(await screen.findByText(/Contact address ready/i)).toBeInTheDocument(); + expect(await screen.findByText('handle-123')).toBeInTheDocument(); expect(screen.getByText(/Signed invite ready/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Copy Short Address/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Copy Full Address/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Copy Short Address/i })); + + await waitFor(() => expect(mocks.writeClipboard).toHaveBeenCalledWith('handle-123')); + const copied = String(mocks.writeClipboard.mock.calls.at(-1)?.[0] || ''); + expect(copied).toBe('handle-123'); expect(screen.queryByText(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument(); }); - it('does not advertise legacy handle-only addresses as copyable public addresses', async () => { + it('does not advertise legacy handle-only addresses as copyable contact addresses', async () => { localStorage.setItem( 'sb_infonet_dm_addresses_v1:!sb_local', JSON.stringify({ @@ -641,25 +700,33 @@ describe('MessagesView first-contact trust UX', () => { renderMessagesView(); - expect(await screen.findByText(/Generate an address, then send it to someone/i)).toBeInTheDocument(); + expect(await screen.findByText(/Contact address ready/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText('Legacy handle')).toBeInTheDocument(); expect(screen.getByText('Address unavailable locally.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Copy' })).toBeDisabled(); + expect(screen.getAllByRole('button', { name: 'Copy Short' }).some((button) => !button.hasAttribute('disabled'))).toBe(true); + expect(screen.getAllByRole('button', { name: 'Copy Full' }).some((button) => button.hasAttribute('disabled'))).toBe(true); }); - it('explains raw lookup handles instead of showing a JSON parser error', async () => { + it('sends a contact request from a short address instead of requiring JSON', async () => { renderMessagesView(); fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { + fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), { target: { value: 'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67' }, }); + fireEvent.click(screen.getByRole('button', { name: 'Send Request' })); - expect(await screen.findByText(/only a short address ID/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Import Address' })).toBeDisabled(); + await waitFor(() => expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalled()); + expect(await screen.findByText(/Contact request sent to/i)).toBeInTheDocument(); + expect(mocks.fetchDmPublicKey).toHaveBeenCalledWith( + 'http://localhost:8000', + '', + 'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67', + ); + expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalled(); expect(screen.queryByText(/Unexpected number in JSON/i)).not.toBeInTheDocument(); expect(mocks.importWormholeDmInvite).not.toHaveBeenCalled(); }); @@ -675,7 +742,7 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - const addressField = screen.getByPlaceholderText(/Paste the full text copied/i); + const addressField = screen.getByPlaceholderText(/Paste a short address/i); fireEvent.paste(addressField, { clipboardData: { getData: () => signedAddress, @@ -687,7 +754,7 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'Advanced Details' })); - expect(screen.getByLabelText('Raw copied public address')).toHaveValue(signedAddress); + expect(screen.getByLabelText('Raw copied contact address')).toHaveValue(signedAddress); }); it('imports a copied address without waiting for secure mail warm-up', async () => { @@ -710,17 +777,113 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { + fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), { target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, }); fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); - expect(await screen.findByText(/INVITE PINNED for !sb_now \(invitefp-now\)\./i)).toBeInTheDocument(); + expect(await screen.findByText(/Contact saved: !sb_now\./i)).toBeInTheDocument(); expect(mocks.importWormholeDmInvite).toHaveBeenCalled(); expect(screen.queryByText(/Secure mail is still warming up/i)).not.toBeInTheDocument(); }); - it('announces compat invite imports as TOFU PINNED with backend detail', async () => { + it('saves pending-delivery contacts without showing prekey jargon', async () => { + mocks.importWormholeDmInvite.mockResolvedValueOnce({ + ok: true, + peer_id: '!sb_pending', + trust_fingerprint: 'invitefp-pending', + trust_level: 'invite_pinned', + pending_prekey: true, + detail: 'Contact saved.', + contact: { + alias: 'Pending Person', + blocked: false, + trust_level: 'invite_pinned', + invitePinnedPrekeyLookupHandle: 'handle-pending', + invitePinnedTrustFingerprint: 'invitefp-pending', + dhPubKey: '', + }, + }); + + renderMessagesView(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), { + target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); + + expect(await screen.findByText(/Contact saved: Pending Person\./i)).toBeInTheDocument(); + expect(await screen.findByText('Saved Contact')).toBeInTheDocument(); + expect(screen.queryByText(/prekey/i)).not.toBeInTheDocument(); + }); + + it('saves mail locally when a saved contact is not reachable yet', async () => { + contactsState = { + '!sb_pending': { + alias: 'Pending Person', + blocked: false, + trust_level: 'invite_pinned', + invitePinnedPrekeyLookupHandle: 'handle-pending', + invitePinnedTrustFingerprint: 'invitefp-pending', + dhPubKey: '', + }, + }; + mocks.fetchDmPublicKey.mockResolvedValueOnce({ agent_id: '!sb_pending', dh_pub_key: '', dh_algo: 'X25519' }); + + renderMessagesView(); + await openComposeForRecipient('!sb_pending', 'hello when ready'); + + fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' })); + + expect(await screen.findByText(/Mail is saved locally and will send automatically when the contact is reachable/i)).toBeInTheDocument(); + expect(mocks.sendOffLedgerConsentMessage).not.toHaveBeenCalled(); + expect(screen.queryByText(/delivery key has not reached/i)).not.toBeInTheDocument(); + }); + + it('removes an approved contact immediately from the visible contact list', async () => { + contactsState = { + '!sb_remove': { + alias: 'Remove Me', + blocked: false, + trust_level: 'invite_pinned', + invitePinnedTrustFingerprint: 'removefingerprint123456', + dhPubKey: 'peer-dh', + }, + }; + mocks.removeContact.mockImplementation((peerId: string) => { + delete contactsState[peerId]; + }); + + renderMessagesView(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + + expect(await screen.findByText('Remove Me')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + + expect(await screen.findByText(/Removed contact: Remove Me\./i)).toBeInTheDocument(); + expect(screen.queryByText('Remove Me')).not.toBeInTheDocument(); + }); + + it('explains unresolved address delivery without exposing backend jargon', async () => { + mocks.importWormholeDmInvite.mockRejectedValueOnce(new Error('peer prekey lookup unavailable')); + + renderMessagesView(); + fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); + expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), { + target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); + + expect(await screen.findByText(/This address is valid, but contact delivery is not ready on this node yet/i)).toBeInTheDocument(); + expect(screen.queryByText('peer prekey lookup unavailable')).not.toBeInTheDocument(); + expect(screen.queryByText(/sender prekey/i)).not.toBeInTheDocument(); + }); + + it('announces compat invite imports as a saved contact without backend detail', async () => { mocks.importWormholeDmInvite.mockResolvedValueOnce({ ok: true, peer_id: '!sb_compat', @@ -734,17 +897,16 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { + fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), { target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, }); fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); + expect(await screen.findByText(/Contact saved: !sb_compat\./i)).toBeInTheDocument(); + expect(screen.queryByText(/TOFU PINNED for/i)).not.toBeInTheDocument(); expect( - await screen.findByText(/TOFU PINNED for !sb_compat \(invitefp\.\.compat\)\./i), - ).toBeInTheDocument(); - expect( - screen.getByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i), - ).toBeInTheDocument(); + screen.queryByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i), + ).not.toBeInTheDocument(); }); it('surfaces stable root continuity breaks on invite re-import', async () => { @@ -783,7 +945,7 @@ describe('MessagesView first-contact trust UX', () => { fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), { + fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), { target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, }); fireEvent.click(screen.getByRole('button', { name: 'Import Address' })); diff --git a/frontend/src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts b/frontend/src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts index 39e1637..82e0006 100644 --- a/frontend/src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts +++ b/frontend/src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts @@ -1357,7 +1357,9 @@ describe('wormholeIdentityClient strict profile hints', () => { }), ); - expect(controlPlaneJson).toHaveBeenCalledWith('/api/wormhole/dm/root-health'); + expect(controlPlaneJson).toHaveBeenCalledWith('/api/wormhole/dm/root-health', { + requireAdminSession: false, + }); }); it('prepares the interactive lane through the configured wormhole runtime and bootstraps identity state', async () => { diff --git a/frontend/src/__tests__/proxy/proxyAdminKeyInjection.test.ts b/frontend/src/__tests__/proxy/proxyAdminKeyInjection.test.ts index e6d489a..006ee20 100644 --- a/frontend/src/__tests__/proxy/proxyAdminKeyInjection.test.ts +++ b/frontend/src/__tests__/proxy/proxyAdminKeyInjection.test.ts @@ -7,6 +7,7 @@ * - /api/tools/* (Sprint 1C addition) * - /api/wormhole/* (pre-existing, regression) * - /api/settings/* (pre-existing, regression) + * - /api/layers, /api/ais/feed, /api/ai/agent-actions * * Also verifies that: * - non-sensitive mesh paths (e.g. mesh/events) do NOT receive injected key @@ -272,6 +273,77 @@ describe('proxy admin-key injection coverage', () => { expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); }); + it('POST /api/layers with valid session injects X-Admin-Key', async () => { + const cookie = await mintSession(ADMIN_KEY); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost/api/layers', { + method: 'POST', + body: JSON.stringify({ layers: { aircraft: true } }), + headers: { cookie, 'Content-Type': 'application/json' }, + }); + const res = await proxyPost(req, { + params: Promise.resolve({ path: ['layers'] }), + }); + + expect(res.status).toBe(200); + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + + it('POST /api/ais/feed with valid session injects X-Admin-Key', async () => { + const cookie = await mintSession(ADMIN_KEY); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost/api/ais/feed', { + method: 'POST', + body: JSON.stringify({ msgs: [] }), + headers: { cookie, 'Content-Type': 'application/json' }, + }); + const res = await proxyPost(req, { + params: Promise.resolve({ path: ['ais', 'feed'] }), + }); + + expect(res.status).toBe(200); + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + + it('GET /api/ai/agent-actions with valid session injects X-Admin-Key', async () => { + const cookie = await mintSession(ADMIN_KEY); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true, actions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost/api/ai/agent-actions', { + method: 'GET', + headers: { cookie }, + }); + const res = await proxyGet(req, { + params: Promise.resolve({ path: ['ai', 'agent-actions'] }), + }); + + expect(res.status).toBe(200); + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + // ------------------------------------------------------------------------- // Non-sensitive mesh paths must NOT receive injected admin key // ------------------------------------------------------------------------- diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 074f99a..2f0df18 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -64,6 +64,9 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean { if (joined === 'refresh') return true; if (joined === 'debug-latest') return true; if (joined === 'system/update') return true; + if (joined === 'layers') return true; + if (joined === 'ais/feed') return true; + if (joined === 'ai/agent-actions') return true; if (pathSegments[0] === 'settings') return true; if (joined === 'mesh/infonet/ingest') return true; if (joined === 'mesh/meshtastic/send') return true; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ea196d0..dd6ab71 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -203,8 +203,8 @@ export default function Dashboard() { uap_sightings: true, // Biosurveillance wastewater: true, - // CrowdThreat - crowdthreat: true, + // CrowdThreat is operator opt-in only. + crowdthreat: false, // Shodan shodan_overlay: false, // AI Intel diff --git a/frontend/src/components/InfonetTerminal/MessagesView.tsx b/frontend/src/components/InfonetTerminal/MessagesView.tsx index 6277624..80e5e30 100644 --- a/frontend/src/components/InfonetTerminal/MessagesView.tsx +++ b/frontend/src/components/InfonetTerminal/MessagesView.tsx @@ -62,7 +62,7 @@ import { getDHAlgo, getNodeIdentity, hasSovereignty, - hydrateWormholeContacts, + hydrateWormholeContactsFromNode, purgeBrowserContactGraph, purgeBrowserSigningMaterial, removeContact, @@ -101,6 +101,7 @@ import { prepareWormholeInteractiveLane, issueWormholePairwiseAlias, openWormholeSenderSeal, + registerWormholeDmKey, renameWormholeDmInviteHandle, revokeWormholeDmInviteHandle, type WormholeDmAddressRecord, @@ -121,7 +122,7 @@ import { rootWitnessContinuityLabel, } from '@/mesh/contactTrustSummary'; -type ViewTab = 'mailbox' | 'compose' | 'contacts' | 'restricted'; +type ViewTab = 'mailbox' | 'compose' | 'requests' | 'contacts' | 'restricted'; type MailFolder = 'inbox' | 'sent' | 'junk' | 'spam' | 'trash'; type MailKind = 'mail' | 'request' | 'system'; @@ -340,6 +341,10 @@ function dmAddressShareText(address: LocalDmAddress): string { return String(address.inviteBlob || '').trim(); } +function dmAddressShortShareText(address: LocalDmAddress): string { + return String(address.handle || '').trim(); +} + function dmAddressStatusLabel(address: LocalDmAddress): string { return dmAddressShareText(address) ? 'Signed invite ready' : 'Legacy handle only'; } @@ -363,12 +368,12 @@ function parseDmInviteImportBlob(raw: string): Record { } catch (error) { if (isLikelyRawDmLookupHandle(raw)) { throw new Error( - 'That is a short address ID, not a contact address. Ask them to click Copy Address in Secure Messages and paste the full copied address here.', + 'That short address can be used to send a contact request. Paste it directly, or use Copy Full Address to save a contact without approval.', ); } if (error instanceof SyntaxError) { throw new Error( - 'Public address is not valid JSON. Paste the full signed Public Address copied from Secure Messages.', + 'That does not look like a contact address. Paste the address copied from Secure Messages.', ); } throw error; @@ -379,7 +384,7 @@ function inviteImportDisplayText(raw: string, hint: string): string { const trimmed = raw.trim(); if (!trimmed) return ''; if (isLikelyRawDmLookupHandle(trimmed)) { - return 'Short address ID pasted - use Copy Address instead.'; + return trimmed; } if (trimmed.startsWith('{') || trimmed.startsWith('[')) { return hint ? 'Copied address could not be read.' : 'Copied address received. Ready to import.'; @@ -434,9 +439,9 @@ function contactTrustSummary(contact: Contact): { label: string; tone: string; d if (!summary) return null; if (summary.transparencyConflict) { return { - label: 'HISTORY CONFLICT', + label: 'Needs Review', tone: 'border-red-500/30 text-red-300 bg-red-950/20', - detail: 'Prekey transparency history conflicted. Trust stays degraded until you acknowledge the changed fingerprint.', + detail: 'This contact changed identity details and should be reviewed before sensitive use.', }; } if (summary.state === 'invite_pinned') { @@ -444,7 +449,7 @@ function contactTrustSummary(contact: Contact): { label: string; tone: string; d contact.invitePinnedRootFingerprint || contact.remotePrekeyRootFingerprint || '', ); return { - label: 'INVITE PINNED', + label: 'Saved Contact', tone: 'border-emerald-500/30 text-emerald-300 bg-emerald-950/20', detail: `${summary.rootAttested ? `${rootTrustLabel(summary)} ${root} • ` : ''}Fingerprint ${shortFingerprint(contact.invitePinnedTrustFingerprint || contact.remotePrekeyFingerprint || '')}${contact.remotePrekeyLookupMode === 'legacy_agent_id' ? ' • legacy lookup' : ''}`, }; @@ -454,7 +459,7 @@ function contactTrustSummary(contact: Contact): { label: string; tone: string; d contact.invitePinnedRootFingerprint || contact.remotePrekeyRootFingerprint || '', ); return { - label: 'SAS VERIFIED', + label: 'Verified Contact', tone: 'border-cyan-500/30 text-cyan-300 bg-cyan-950/20', detail: `${summary.rootAttested ? `${rootTrustLabel(summary)} ${root} • ` : ''}Fingerprint ${shortFingerprint(contact.remotePrekeyFingerprint || '')}${contact.remotePrekeyLookupMode === 'legacy_agent_id' ? ' • legacy lookup' : ''}`, }; @@ -466,21 +471,21 @@ function contactTrustSummary(contact: Contact): { label: string; tone: string; d ) { const observedRoot = shortFingerprint(contact.remotePrekeyObservedRootFingerprint || ''); return { - label: summary.state === 'continuity_broken' ? 'CONTINUITY BROKEN' : 'REVERIFY', + label: summary.state === 'continuity_broken' ? 'Needs Review' : 'Reverify Contact', tone: 'border-red-500/30 text-red-300 bg-red-950/20', detail: `${summary.rootMismatch ? `Observed root ${observedRoot} • ` : ''}Observed ${shortFingerprint(contact.remotePrekeyObservedFingerprint || '')}${contact.remotePrekeyLookupMode === 'legacy_agent_id' ? ' • legacy lookup' : ''}`, }; } if (contact.remotePrekeyLookupMode === 'legacy_agent_id') { return { - label: 'LEGACY LOOKUP', + label: 'Saved Contact', tone: 'border-yellow-500/30 text-yellow-300 bg-yellow-950/20', - detail: 'This contact still bootstraps through direct agent ID lookup. Import a signed invite to tighten lookup privacy.', + detail: 'Added as a contact. A fresh copied address is recommended before sensitive use.', }; } if (summary.state === 'tofu_pinned') { return { - label: 'TOFU PINNED', + label: 'Saved Contact', tone: 'border-amber-500/30 text-amber-300 bg-amber-950/10', detail: `Fingerprint ${shortFingerprint(contact.remotePrekeyFingerprint || '')}${contact.remotePrekeyLookupMode === 'legacy_agent_id' ? ' • legacy lookup' : ''}`, }; @@ -527,6 +532,32 @@ function contactTrustNextStep( return null; } +function isDeliveryPendingContact(contact?: Contact): boolean { + if (!contact) return false; + const hasDeliveryKey = Boolean( + String(contact.dhPubKey || contact.invitePinnedDhPubKey || '').trim(), + ); + const hasInviteLookup = Boolean(String(contact.invitePinnedPrekeyLookupHandle || '').trim()); + return hasInviteLookup && !hasDeliveryKey; +} + +function preservePendingDeliveryContacts( + hydratedContacts: Record, + currentContacts: Record, + locallySavedContactIds: ReadonlySet = new Set(), +): Record { + const mergedContacts = { ...hydratedContacts }; + for (const [peerId, contact] of Object.entries(currentContacts)) { + if ( + !mergedContacts[peerId] && + (isDeliveryPendingContact(contact) || locallySavedContactIds.has(peerId)) + ) { + mergedContacts[peerId] = contact; + } + } + return mergedContacts; +} + function deadDropLaunchOptions(contact?: Contact): { showSas?: boolean } { const summary = getContactTrustSummary(contact); return { @@ -549,15 +580,68 @@ function normalizeMailError(message: string): string { return 'Secure mail is unavailable right now.'; } const lowered = detail.toLowerCase(); + if ( + lowered.includes('wormhole_required_for_sign_dm_poll') || + lowered.includes('wormhole_required_for_sign_dm_count') || + lowered.includes('wormhole_required_for_dm_poll') + ) { + return 'Secure mail is syncing in the background. You can still save messages; they will send automatically when ready.'; + } + if ( + lowered.includes('wormhole_required_for_sign_dm_message') || + lowered.includes('wormhole_required_for_dm_encrypt') || + lowered.includes('wormhole_required_for_dm_decrypt') || + lowered.includes('wormhole_required_for_') + ) { + return 'Secure mail is still connecting. Save the message now and it will send automatically when ready.'; + } if ( lowered.includes('transport tier insufficient') || lowered.includes('dm send requires private transport') ) { return 'Secure mail needs the Wormhole private lane online before it can sync or send.'; } + if ( + lowered.includes('delivery key has not reached') || + lowered.includes('prekey lookup unavailable') || + lowered.includes('prekey bundle not found') + ) { + return 'This contact is saved. Messages can be saved now and will send automatically when the contact is reachable.'; + } return detail; } +function shouldSaveMailForLater(message: string): boolean { + const lowered = String(message || '').toLowerCase(); + return ( + lowered.includes('wormhole_required_for_') || + lowered.includes('transport tier insufficient') || + lowered.includes('dm send requires private transport') || + lowered.includes('delivery key has not reached') || + lowered.includes('prekey lookup unavailable') || + lowered.includes('prekey bundle not found') || + lowered.includes('secure bootstrap path is unavailable') + ); +} + +function normalizeInviteImportError(message: string): string { + const detail = String(message || '').trim(); + const lower = detail.toLowerCase(); + if ( + lower.includes('peer prekey lookup unavailable') || + lower.includes('peer prekey lookup still preparing') || + lower.includes('prekey bundle missing signing key') || + lower.includes('prekey bundle not found') || + lower.includes('invite prekey bundle not found') + ) { + return [ + 'This address is valid, but contact delivery is not ready on this node yet.', + 'The contact can still be saved. Try sending again after the node finishes syncing, or ask them to share a fresh address from their online device.', + ].join(' '); + } + return detail || 'Address import failed.'; +} + async function decryptSenderSeal( senderSeal: string, candidateDhPub: string, @@ -692,11 +776,16 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro const [dmAddressBusy, setDmAddressBusy] = useState(''); const [dmAddressCopyStatus, setDmAddressCopyStatus] = useState(''); const [, setDmLaneWarmStatus] = useState(''); + const [contactsHydrationError, setContactsHydrationError] = useState(''); const [privateDelivery, setPrivateDelivery] = useState(null); const [privateDeliveryBusyId, setPrivateDeliveryBusyId] = useState(''); const inviteVideoRef = useRef(null); const dmLaneWarmRef = useRef | null>(null); const dmLaneBackgroundPrepStartedRef = useRef(false); + const locallySavedContactIdsRef = useRef>(new Set()); + const removedContactIdsRef = useRef>(new Set()); + const pendingDeliveryRetryRef = useRef>(new Set()); + const autoDmAddressStartedRef = useRef(false); const scopeId = identity?.nodeId || 'guest'; const qrScanAvailable = @@ -705,6 +794,36 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro Boolean((window as Window & { BarcodeDetector?: unknown }).BarcodeDetector) && Boolean(navigator.mediaDevices?.getUserMedia); + const loadBackendContacts = useCallback(async (): Promise> => { + try { + const hydratedContacts = await hydrateWormholeContactsFromNode(); + setContactsHydrationError(''); + return hydratedContacts; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error || ''); + setContactsHydrationError( + detail + ? `Secure contacts could not sync from this node. ${detail}` + : 'Secure contacts could not sync from this node.', + ); + return getContacts(); + } + }, []); + + const applyHydratedContacts = useCallback((hydratedContacts: Record) => { + setContacts((currentContacts) => { + const mergedContacts = preservePendingDeliveryContacts( + hydratedContacts, + currentContacts, + locallySavedContactIdsRef.current, + ); + for (const peerId of removedContactIdsRef.current) { + delete mergedContacts[peerId]; + } + return mergedContacts; + }); + }, []); + useEffect(() => { setMessages(loadMailbox(scopeId)); setDmAddresses(loadDmAddresses(scopeId)); @@ -747,15 +866,10 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro const syncIdentity = async () => { const localIdentity = getNodeIdentity(); - if (localIdentity && hasSovereignty()) { - const hydratedContacts = await hydrateWormholeContacts(true).catch(() => getContacts()); - if (!alive) return; - setContacts(hydratedContacts); - setIdentity(localIdentity); - return; - } - - if (secureRequired || !localIdentity) { + const secureNow = await isWormholeSecureRequired().catch(() => secureRequired); + if (!alive) return; + setSecureRequired(Boolean(secureNow)); + if (secureNow || !localIdentity) { try { let wormholeIdentity = await fetchWormholeIdentity().catch(() => null); if (!wormholeIdentity) { @@ -764,9 +878,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro purgeBrowserSigningMaterial(); purgeBrowserContactGraph(); await purgeBrowserDmState(); - const hydratedContacts = await hydrateWormholeContacts(true).catch(() => getContacts()); + const hydratedContacts = await loadBackendContacts(); if (!alive) return; - setContacts(hydratedContacts); + applyHydratedContacts(hydratedContacts); setIdentity({ publicKey: wormholeIdentity.public_key, privateKey: '', @@ -778,8 +892,16 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } } + if (localIdentity && hasSovereignty()) { + const hydratedContacts = await loadBackendContacts(); + if (!alive) return; + applyHydratedContacts(hydratedContacts); + setIdentity(localIdentity); + return; + } + if (!alive) return; - setContacts(getContacts()); + applyHydratedContacts(getContacts()); setIdentity(null); }; @@ -787,7 +909,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return () => { alive = false; }; - }, [secureRequired]); + }, [applyHydratedContacts, loadBackendContacts, secureRequired]); const dmLaneReady = wormholeTransportTier === 'private_control_only' || @@ -835,15 +957,15 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro const raw = inviteImportBlob.trim(); if (!raw) return ''; if (isLikelyRawDmLookupHandle(raw)) { - return 'This is only a short address ID. It cannot add a contact by itself. Ask them to click Copy Address and paste that full copied address here.'; + return ''; } if (!raw.startsWith('{') && !raw.startsWith('[')) { - return 'Paste the full copied public address. It should start with { and include shadowbroker.infonet.dm.invite.'; + return 'Paste a short address or the full copied contact address.'; } try { parseDmInviteImportBlob(raw); } catch { - return 'Copied address could not be read. Ask them to click Copy Address again and paste the full copied text.'; + return 'Copied address could not be read. Ask them to click Copy Full Address again and paste the full copied text.'; } return ''; }, [inviteImportBlob]); @@ -889,20 +1011,20 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro }, []); const syncSecureMailRuntime = useCallback(async () => { - const [secure, status, resolvedIdentity, hydratedContacts] = await Promise.all([ - isWormholeSecureRequired().catch(() => false), + const secure = await isWormholeSecureRequired().catch(() => false); + const [status, resolvedIdentity, hydratedContacts] = await Promise.all([ fetchWormholeStatus().catch(() => null), resolveMessagingIdentity(), - hydrateWormholeContacts(true).catch(() => getContacts()), + loadBackendContacts(), ]); setSecureRequired(Boolean(secure)); setWormholeReadyState(Boolean(status?.ready)); setWormholeTransportTier(String(status?.transport_tier || 'public_degraded')); setPrivateDelivery((status?.private_delivery as PrivateDeliverySummary) || null); - setContacts(hydratedContacts); + applyHydratedContacts(hydratedContacts); setIdentity(resolvedIdentity); return resolvedIdentity; - }, [resolveMessagingIdentity]); + }, [applyHydratedContacts, loadBackendContacts, resolveMessagingIdentity]); const ensureSecureMailLane = useCallback(async (statusLine: string): Promise => { if (dmLaneReady && wormholeReadyState && identity) { @@ -918,8 +1040,8 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setSecureRequired(Boolean(prepared.settingsEnabled)); setWormholeReadyState(Boolean(prepared.ready)); setWormholeTransportTier(String(prepared.transportTier || 'private_transitional')); - const hydratedContacts = await hydrateWormholeContacts(true).catch(() => getContacts()); - setContacts(hydratedContacts); + const hydratedContacts = await loadBackendContacts(); + applyHydratedContacts(hydratedContacts); setIdentity( prepared.identity ? { @@ -940,7 +1062,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro dmLaneWarmRef.current = null; }); return dmLaneWarmRef.current; - }, [dmLaneReady, identity, wormholeReadyState]); + }, [applyHydratedContacts, dmLaneReady, identity, loadBackendContacts, wormholeReadyState]); useEffect(() => { if (dmLaneReady) { @@ -1545,8 +1667,8 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setSyncing(true); setPollError(''); try { - const hydratedContacts = await hydrateWormholeContacts(true).catch(() => getContacts()); - setContacts(hydratedContacts); + const hydratedContacts = await loadBackendContacts(); + applyHydratedContacts(hydratedContacts); const claims = await buildMailboxClaims(hydratedContacts, activeIdentity); const pollPromise = pollDmMailboxes(API_BASE, activeIdentity, claims); const countPromise = includeCount @@ -1577,7 +1699,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return ensureSeedMail(sortMessages([...prev, ...dedupedIncoming])); }); } - setContacts(getContacts()); + applyHydratedContacts(getContacts()); } catch (error) { pollHasMoreRef.current = false; setPollError( @@ -1586,7 +1708,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } finally { setSyncing(false); } - }, [buildInboundMail, dmLaneReady, ensureSecureMailLane, identity, syncSecureMailRuntime, wormholeReadyState]); + }, [applyHydratedContacts, buildInboundMail, dmLaneReady, ensureSecureMailLane, identity, loadBackendContacts, syncSecureMailRuntime, wormholeReadyState]); useEffect(() => { if (!identity || !dmLaneReady) return; @@ -1633,6 +1755,147 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro [upsertLocalMessage], ); + const queuePendingDeliveryMail = useCallback( + (opts: { + senderId: string; + recipientId: string; + subject: string; + body: string; + reason?: string; + }) => { + const msgId = `pending_${Date.now()}_${opts.senderId.slice(-4)}`; + queueSentMail({ + msgId, + kind: 'mail', + senderId: opts.senderId, + recipientId: opts.recipientId, + subject: opts.subject || 'Secure Message', + body: opts.body, + timestamp: Math.floor(Date.now() / 1000), + transport: '', + deliveryClass: 'shared', + requestStatus: 'pending', + }); + setDraft({ + recipient: opts.recipientId, + subject: '', + body: '', + }); + setActiveTab('mailbox'); + setSelectedFolder('sent'); + setComposeError(''); + setComposeStatus( + `${displayNameForPeer(opts.recipientId, getContacts())} is saved. Mail is saved locally and will send automatically when the contact is reachable.`, + ); + }, + [queueSentMail], + ); + + const ensureLocalDmKey = useCallback(async (activeIdentity: NodeIdentity) => { + if (secureRequired || !activeIdentity.privateKey || activeIdentity.nodeId.startsWith('!sb_')) { + try { + const wormholeRegistration = await registerWormholeDmKey(); + const wormholeDhPub = String(wormholeRegistration.dh_pub_key || '').trim(); + if (wormholeRegistration.ok && wormholeDhPub) { + return { + registration: { + ok: true, + dhPubKey: wormholeDhPub, + dhAlgo: String(wormholeRegistration.dh_algo || 'X25519'), + acceptedSequence: Number( + wormholeRegistration.bundle_sequence || wormholeRegistration.sequence || 0, + ), + bundleFingerprint: String(wormholeRegistration.bundle_fingerprint || ''), + }, + myDhPub: wormholeDhPub, + }; + } + } catch { + // Fall through to the shared helper, which retries and gives us one more recovery path. + } + } + let registration = await ensureRegisteredDmKey(API_BASE, activeIdentity, { force: false }); + let myDhPub = String(registration.dhPubKey || '').trim(); + if (!myDhPub) { + registration = await ensureRegisteredDmKey(API_BASE, activeIdentity, { force: true }); + myDhPub = String(registration.dhPubKey || '').trim(); + } + if (!myDhPub) { + throw new Error( + registration.detail + ? `Secure mail is still preparing in the background. ${registration.detail}` + : 'Secure mail is still preparing in the background. Try again in a moment.', + ); + } + return { registration, myDhPub }; + }, [secureRequired]); + + const retryPendingDeliveryMail = useCallback( + async (activeIdentity: NodeIdentity, availableContacts: Record) => { + const pendingItems = messages.filter( + (item) => + item.folder === 'sent' && + item.direction === 'outbound' && + item.kind === 'mail' && + item.deliveryClass === 'shared' && + item.requestStatus === 'pending', + ); + if (pendingItems.length === 0) return; + + let localKeyReady = false; + for (const item of pendingItems) { + const contact = availableContacts[item.recipientId]; + const recipientDhPub = String(contact?.dhPubKey || '').trim(); + if (!recipientDhPub || pendingDeliveryRetryRef.current.has(item.id)) { + continue; + } + pendingDeliveryRetryRef.current.add(item.id); + try { + if (!localKeyReady) { + await ensureLocalDmKey(activeIdentity); + localKeyReady = true; + } + const recipientId = preferredDmPeerId(item.recipientId, contact); + const ciphertext = await ratchetEncryptDM( + item.recipientId, + recipientDhPub, + encodeMailPayload(item.subject, item.body), + ); + const recipientToken = await sharedMailboxToken(recipientId, recipientDhPub); + const sent = await sendDmMessage({ + apiBase: API_BASE, + identity: activeIdentity, + recipientId, + recipientDhPub, + ciphertext, + msgId: item.msgId, + timestamp: item.timestamp, + deliveryClass: 'shared', + recipientToken, + useSealedSender: true, + }); + if (sent.ok) { + upsertLocalMessage({ + ...item, + requestStatus: 'accepted', + transport: sent.transport || '', + }); + } + } catch { + // Keep the item pending. The next contact hydration or mailbox tick can retry. + } finally { + pendingDeliveryRetryRef.current.delete(item.id); + } + } + }, + [ensureLocalDmKey, messages, upsertLocalMessage], + ); + + useEffect(() => { + if (!identity) return; + void retryPendingDeliveryMail(identity, contacts); + }, [contacts, identity, retryPendingDeliveryMail]); + const handleComposeSubmit = useCallback(async () => { const recipient = draft.recipient.trim(); const subject = draft.subject.trim(); @@ -1665,16 +1928,17 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setComposeError(''); setComposeStatus(''); try { - const hydratedContacts = await hydrateWormholeContacts(true).catch(() => getContacts()); - setContacts(hydratedContacts); - const existingContact = hydratedContacts[recipient]; + const hydratedContacts = await loadBackendContacts(); + const mergedContacts = preservePendingDeliveryContacts(hydratedContacts, contacts); + setContacts(mergedContacts); + const existingContact = mergedContacts[recipient]; if (existingContact?.blocked) { throw new Error('Recipient is restricted on this install.'); } if (existingContact?.dhPubKey) { - await ensureRegisteredDmKey(API_BASE, activeIdentity, { force: false }); + await ensureLocalDmKey(activeIdentity); const recipientId = preferredDmPeerId(recipient, existingContact); const ciphertext = await ratchetEncryptDM( recipient, @@ -1728,23 +1992,23 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return; } - const registration = await ensureRegisteredDmKey(API_BASE, activeIdentity, { force: false }); - const myDhPub = String(registration.dhPubKey || '').trim(); - if (!myDhPub) { - throw new Error('Local DM key is unavailable.'); - } + const { registration, myDhPub } = await ensureLocalDmKey(activeIdentity); const recipientContact = getContacts()[recipient]; const lookupHandle = String(recipientContact?.invitePinnedPrekeyLookupHandle || '').trim(); if (!lookupHandle) { throw new Error( - 'Import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled.', + 'This contact needs their full contact address once before messages can be sent. Paste it in Contacts and the app will handle the rest.', ); } const targetKey = await fetchDmPublicKey(API_BASE, recipient, lookupHandle); if (!targetKey?.dh_pub_key) { - throw new Error( - 'Invite-scoped lookup failed for this contact. Re-import a signed invite and try again.', - ); + queuePendingDeliveryMail({ + senderId: activeIdentity.nodeId, + recipientId: recipient, + subject, + body, + }); + return; } const offerPlaintext = buildContactOfferMessage( myDhPub, @@ -1763,7 +2027,13 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro ciphertext = await encryptDM(offerPlaintext, sharedKey); } if (!ciphertext) { - throw new Error('Secure bootstrap path is unavailable for this contact request.'); + queuePendingDeliveryMail({ + senderId: activeIdentity.nodeId, + recipientId: recipient, + subject, + body, + }); + return; } const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`; const timestamp = Math.floor(Date.now() / 1000); @@ -1805,11 +2075,105 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setActiveTab('mailbox'); setSelectedFolder('sent'); } catch (error) { - setComposeError(normalizeMailError(error instanceof Error ? error.message : 'mail send failed')); + const message = error instanceof Error ? error.message : 'mail send failed'; + if (activeIdentity && contacts[recipient] && shouldSaveMailForLater(message)) { + queuePendingDeliveryMail({ + senderId: activeIdentity.nodeId, + recipientId: recipient, + subject, + body, + }); + } else { + setComposeError(normalizeMailError(message)); + } } finally { setBusy(false); } - }, [contacts, draft, identity, queueSentMail, secureRequired, syncSecureMailRuntime]); + }, [contacts, draft, ensureLocalDmKey, identity, loadBackendContacts, queuePendingDeliveryMail, queueSentMail, secureRequired, syncSecureMailRuntime]); + + const handleSendShortAddressRequest = useCallback( + async (lookupHandle: string) => { + const shortAddress = lookupHandle.trim(); + if (!shortAddress) { + setComposeError('Paste a short address first.'); + return; + } + setInviteBusy(true); + setComposeError(''); + setComposeStatus(''); + try { + let activeIdentity = identity; + if (!activeIdentity) { + activeIdentity = await syncSecureMailRuntime(); + } + if (!activeIdentity) { + throw new Error('Secure mail is still preparing your private identity.'); + } + const { registration, myDhPub } = await ensureLocalDmKey(activeIdentity); + const targetKey = await fetchDmPublicKey(API_BASE, '', shortAddress); + if (!targetKey?.dh_pub_key || !targetKey.agent_id) { + throw new Error('That address is not reachable yet. Ask them to copy their address again while their device is online.'); + } + const recipient = String(targetKey.agent_id).trim(); + const offerPlaintext = buildContactOfferMessage( + myDhPub, + registration.dhAlgo || getDHAlgo() || 'X25519', + ); + let ciphertext = ''; + if (await canUseWormholeBootstrap()) { + try { + ciphertext = await bootstrapEncryptAccessRequest(recipient, offerPlaintext); + } catch { + ciphertext = ''; + } + } + if (!ciphertext && !secureRequired) { + const sharedKey = await deriveSharedKey(String(targetKey.dh_pub_key)); + ciphertext = await encryptDM(offerPlaintext, sharedKey); + } + if (!ciphertext) { + throw new Error('Unable to build secure contact request.'); + } + const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`; + const timestamp = Math.floor(Date.now() / 1000); + const sent = await sendOffLedgerConsentMessage({ + apiBase: API_BASE, + identity: activeIdentity, + recipientId: recipient, + recipientDhPub: String(targetKey.dh_pub_key), + ciphertext, + msgId, + timestamp, + }); + if (!sent.ok) { + throw new Error(sent.detail || 'contact request failed'); + } + queueSentMail({ + msgId, + kind: 'system', + senderId: activeIdentity.nodeId, + recipientId: recipient, + subject: `Contact request to ${recipient}`, + body: 'A contact request was sent. If they approve it, secure mail can flow.', + timestamp, + transport: sent.transport || '', + deliveryClass: 'request', + requestStatus: 'pending', + }); + setInviteImportBlob(''); + setInviteImportAlias(''); + setInviteImportDetailsOpen(false); + setComposeStatus(`Contact request sent to ${shortHandle(recipient)}.`); + setActiveTab('mailbox'); + setSelectedFolder('sent'); + } catch (error) { + setComposeError(error instanceof Error ? error.message : 'contact request failed'); + } finally { + setInviteBusy(false); + } + }, + [ensureLocalDmKey, identity, queueSentMail, secureRequired, syncSecureMailRuntime], + ); const handleImportInvite = useCallback(async () => { const raw = inviteImportBlob.trim(); @@ -1818,16 +2182,14 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setComposeError('Paste a signed DM invite first.'); return; } - if (isLikelyRawDmLookupHandle(raw)) { - setComposeStatus(''); - setComposeError(''); - return; - } - setInviteBusy(true); setComposeError(''); setComposeStatus(''); try { + if (isLikelyRawDmLookupHandle(raw)) { + await handleSendShortAddressRequest(raw); + return; + } const parsed = parseDmInviteImportBlob(raw); const nestedInvite = parsed?.invite; const invite = @@ -1835,30 +2197,63 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro ? (nestedInvite as Record) : parsed; const result = await importWormholeDmInvite(invite, inviteImportAlias.trim()); - const hydratedContacts = await hydrateWormholeContacts(true).catch(() => getContacts()); - const importedContact = hydratedContacts[result.peer_id]; - const importedTrust = importedContact ? contactTrustSummary(importedContact) : null; - const importedTrustLabel = - importedTrust?.label || - (result.trust_level === 'invite_pinned' - ? 'INVITE PINNED' - : result.trust_level === 'tofu_pinned' - ? 'TOFU PINNED' - : 'INVITE IMPORTED'); - setContacts(hydratedContacts); + const hydratedContacts = await loadBackendContacts(); + const importedPeerId = String(result.peer_id || '').trim(); + const resultContact = + result.contact && typeof result.contact === 'object' && !Array.isArray(result.contact) + ? (result.contact as Partial) + : {}; + const invitePayload = + invite.payload && typeof invite.payload === 'object' && !Array.isArray(invite.payload) + ? (invite.payload as Record) + : {}; + const savedContact: Contact = { + ...(hydratedContacts[importedPeerId] || {}), + ...resultContact, + alias: String(resultContact.alias || inviteImportAlias.trim() || ''), + blocked: Boolean(resultContact.blocked), + trust_level: String(resultContact.trust_level || result.trust_level || 'invite_pinned'), + invitePinnedTrustFingerprint: String( + resultContact.invitePinnedTrustFingerprint || result.trust_fingerprint || '', + ), + remotePrekeyFingerprint: String( + resultContact.remotePrekeyFingerprint || result.trust_fingerprint || '', + ), + invitePinnedPrekeyLookupHandle: String( + resultContact.invitePinnedPrekeyLookupHandle || + invitePayload.prekey_lookup_handle || + '', + ), + dhPubKey: String(resultContact.dhPubKey || resultContact.invitePinnedDhPubKey || ''), + }; + const mergedContacts = importedPeerId + ? { + ...hydratedContacts, + [importedPeerId]: savedContact, + } + : hydratedContacts; + if (importedPeerId) { + removedContactIdsRef.current.delete(importedPeerId); + locallySavedContactIdsRef.current.add(importedPeerId); + } + const importedName = displayNameForPeer(importedPeerId, mergedContacts); + setContacts(mergedContacts); setInviteImportBlob(''); setInviteImportDetailsOpen(false); setInviteImportAlias(''); - setComposeStatus( - `${importedTrustLabel} for ${displayNameForPeer(result.peer_id, hydratedContacts)} (${shortFingerprint(result.trust_fingerprint)}).${result.detail ? ` ${result.detail}` : ''}`, - ); - void syncSecureMailRuntime(); + setComposeStatus(`Contact saved: ${importedName}.`); + if (result.pending_prekey) { + setActiveTab('contacts'); + } else { + setActiveTab('contacts'); + void syncSecureMailRuntime(); + } } catch (error) { setComposeStatus(''); const failure = getWormholeDmInviteImportErrorResult(error); if (failure?.peer_id) { - const hydratedContacts = await hydrateWormholeContacts(true).catch(() => getContacts()); - setContacts(hydratedContacts); + const hydratedContacts = await loadBackendContacts(); + applyHydratedContacts(hydratedContacts); const failedContact = hydratedContacts[failure.peer_id]; const failedTrustSummary = getContactTrustSummary(failedContact); if (failedTrustSummary?.state === 'continuity_broken' && failedTrustSummary.rootMismatch) { @@ -1868,11 +2263,11 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro return; } } - setComposeError(error instanceof Error ? error.message : 'invite import failed'); + setComposeError(normalizeInviteImportError(error instanceof Error ? error.message : 'invite import failed')); } finally { setInviteBusy(false); } - }, [inviteImportAlias, inviteImportBlob, syncSecureMailRuntime]); + }, [applyHydratedContacts, handleSendShortAddressRequest, inviteImportAlias, inviteImportBlob, loadBackendContacts, syncSecureMailRuntime]); const refreshDmAddressHandles = useCallback(async () => { try { @@ -1894,20 +2289,23 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro void refreshDmAddressHandles(); }, [identity, refreshDmAddressHandles]); - const handleGenerateDmAddress = useCallback(async () => { + const handleGenerateDmAddress = useCallback(async (options: { automatic?: boolean } = {}) => { + const automatic = Boolean(options.automatic); setDmAddressBusy('generate'); - setDmAddressCopyStatus(''); + if (!automatic) { + setDmAddressCopyStatus(''); + setComposeStatus(''); + } 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'); + throw new Error(exported.detail || 'Contact address could not be created.'); } const handle = inviteLookupHandle(exported.invite as unknown as Record); if (!handle) { - throw new Error('DM address did not include a lookup handle.'); + throw new Error('Contact address could not be created. Try again.'); } const inviteBlob = JSON.stringify( { @@ -1932,27 +2330,37 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro }; setDmAddresses((prev) => [record, ...prev.filter((item) => item.handle !== handle)].slice(0, 32)); setDmAddressLabel(''); - await navigator.clipboard?.writeText(dmAddressShareText(record)).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.`, - ); + if (automatic) { + setDmAddressCopyStatus(`Contact address ready: ${label}.`); + } else { + await navigator.clipboard?.writeText(dmAddressShortShareText(record)).catch(() => undefined); + setDmAddressCopyStatus(`Created and copied short address for ${label}.`); + } await refreshDmAddressHandles(); } catch (error) { - setComposeError(error instanceof Error ? error.message : 'DM address generation failed'); + if (!automatic) { + setComposeError(error instanceof Error ? error.message : 'Contact address could not be created.'); + } } finally { setDmAddressBusy(''); } }, [dmAddressLabel, refreshDmAddressHandles]); + useEffect(() => { + if (!identity || dmAddressBusy === 'generate' || activeDmAddresses.length > 0 || autoDmAddressStartedRef.current) { + return; + } + autoDmAddressStartedRef.current = true; + void handleGenerateDmAddress({ automatic: true }); + }, [activeDmAddresses.length, dmAddressBusy, handleGenerateDmAddress, identity]); + const handleCopyDmAddress = useCallback(async (address: LocalDmAddress) => { setDmAddressBusy(`copy:${address.handle}`); setDmAddressCopyStatus(''); try { const shareText = dmAddressShareText(address); if (!shareText) { - throw new Error('This saved address only has a legacy lookup handle. Generate a new public address.'); + throw new Error('This saved address is outdated. Create a new contact address.'); } await navigator.clipboard?.writeText(shareText); setDmAddressCopyStatus(`Copied ${address.label || shortHandle(address.handle)}.`); @@ -1965,6 +2373,25 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro } }, []); + const handleCopyDmShortAddress = useCallback(async (address: LocalDmAddress) => { + setDmAddressBusy(`copy-short:${address.handle}`); + setDmAddressCopyStatus(''); + try { + const shortAddress = dmAddressShortShareText(address); + if (!shortAddress) { + throw new Error('Short address is unavailable. Generate a new address.'); + } + await navigator.clipboard?.writeText(shortAddress); + setDmAddressCopyStatus(`Copied short address ${shortAddress}.`); + } 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}`); @@ -2078,7 +2505,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro const dhPubKey = String(registry?.dh_pub_key || mail.requestDhPubKey || '').trim(); const dhAlgo = String(registry?.dh_algo || mail.requestDhAlgo || 'X25519').trim(); if (!dhPubKey) { - throw new Error('Remote DM key is unavailable.'); + throw new Error('That contact is still syncing. Try again in a moment.'); } addContact(mail.senderId, dhPubKey, undefined, dhAlgo); @@ -2150,7 +2577,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro deliveryClass: 'request', requestStatus: 'accepted', }); - setContacts(getContacts()); + applyHydratedContacts(getContacts()); setComposeStatus( sent.queued || sent.private_transport_pending ? `Contact acceptance sealed locally for ${displayNameForPeer(mail.senderId, getContacts())}. Private delivery will release when the lane is ready.` @@ -2162,7 +2589,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro setBusy(false); } }, - [identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime], + [applyHydratedContacts, identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime], ); const handleDenyRequest = useCallback( @@ -2300,7 +2727,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
- My Public Address + My Contact Address
{primaryDmAddress ? ( @@ -2308,9 +2735,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
{primaryDmAddress.label || 'Default address'}
-
Address ID
+
Short Address
- {dmAddressDisplayId(primaryDmAddress)} + {dmAddressShortShareText(primaryDmAddress)}
@@ -2328,20 +2755,30 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
) : ( - 'Generate an address, then send it to someone so they can contact you.' + 'Your contact address is being prepared automatically. Share it with someone so they can message you.' )}
{primaryDmAddress && ( - + <> + + + )} - ))} + ].map((tab) => { + const badge = 'badge' in tab ? Number(tab.badge || 0) : 0; + return ( + + ); + })}
@@ -2606,7 +3057,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
{!wormholeReadyState || !dmLaneReady ? 'Private delivery route is still connecting. Sending now seals the message locally and releases it when the route is ready.' - : 'To message someone new, paste their public address in Contacts first. Existing contacts can be messaged from here.'} + : 'To message someone new, paste their contact address in Contacts first. Existing contacts can be messaged from here.'}
{privateDeliveryRows.length > 0 && ( @@ -2688,7 +3139,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
)} - {composeRecipient && !composeNeedsVerifiedFirstContact && composeTrustHint && ( + {composeRecipient && + !composeNeedsVerifiedFirstContact && + composeTrustHint?.severity === 'danger' && (
{composeTrustHint.detail}
- {composeTrustSummary?.detail && ( + {composeTrustSummary?.detail && composeTrustSummary.label !== 'Saved Contact' && (
{composeTrustSummary.detail}
@@ -2757,6 +3210,87 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
)} + {activeTab === 'requests' && ( +
+
+
+
+ + Contact Requests +
+
+ {pendingContactRequests.length} pending +
+
+ {pendingContactRequests.length === 0 ? ( +
No pending contact requests.
+ ) : ( +
+ {pendingContactRequests.map((request) => { + const unresolved = request.requestStatus === 'unresolved'; + return ( +
+
+
+
+ {displayNameForPeer(request.senderId, contacts)} +
+
+ {request.senderId} +
+
+ {unresolved ? 'Needs Sender Resolution' : 'Requested'} +
+
+ {messagePreview(request)} +
+
+ Received {formatTimestamp(request.timestamp)} +
+
+
+ + +
+
+ {unresolved && ( +
+ The request arrived through reduced sealed-sender metadata. It can be + dismissed now or approved after the sender is resolved. +
+ )} +
+ ); + })} +
+ )} +
+
+ )} + {activeTab === 'contacts' && (
@@ -2843,11 +3377,17 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
Approved Contacts
+ {contactsHydrationError && ( +
+ {contactsHydrationError} +
+ )} {activeContacts.length === 0 ? (
No approved secure contacts yet.
) : ( activeContacts.map(([peerId, contact]) => { - const trust = contactTrustSummary(contact); + const deliveryPending = isDeliveryPendingContact(contact); + const trust = deliveryPending ? null : contactTrustSummary(contact); const nextStep = contactTrustNextStep(contact); return (
@@ -2864,9 +3404,11 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro > {trust.label}
-
+ {trust.label !== 'Saved Contact' && ( +
{trust.detail} -
+
+ )} {nextStep && (
Next: {nextStep.label} • {nextStep.detail} @@ -2874,6 +3416,16 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro )} )} + {deliveryPending && ( + <> +
+ Saved Contact +
+
+ Contact is saved. Messages can be addressed to this person from this device. +
+ + )} {contact.sharedAlias && (
Shared alias: {contact.sharedAlias} @@ -2925,8 +3477,16 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
{dmAddressCopyStatus && ( @@ -2974,7 +3534,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro )}
{managedDmAddresses.length === 0 ? ( -
No public address yet. Generate one above.
+
Preparing your first contact address...
) : ( managedDmAddresses.map((address) => { const server = remoteDmHandles[address.handle]; @@ -3001,13 +3561,22 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
+
-
Address ID
+
Short Address
- {dmAddressDisplayId(address)} + {dmAddressShortShareText(address)}
@@ -3066,12 +3635,12 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
Share
- {shareText ? 'Copy sends the full signed address.' : 'Generate a new address.'} + {shareText ? 'Short address requests approval. Full address saves directly.' : 'Generate a new address.'}
- Revoking disables this public address for new first-contact requests. Existing approved + Revoking disables this contact address for new first-contact requests. Existing approved contacts remain contacts.
@@ -3088,8 +3657,8 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro 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. + Paste a short address to request contact access, or paste a full address to save + the person directly as a contact.
@@ -3104,7 +3673,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro />