From da09cf429e31c4d32c2bb794a2e53a54186d46fd Mon Sep 17 00:00:00 2001 From: anoracleofra-code Date: Thu, 26 Mar 2026 20:00:30 -0600 Subject: [PATCH] fix: cross-node gate decryption, UI text scaling, aircraft zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive gate envelope AES key from gate ID via HKDF so all nodes sharing a gate can decrypt each other's messages (was node-local) - Preserve gate_envelope/reply_to in chain payload normalization - Bump Wormhole modal text from 9-10px to 12-13px - Add aircraft icon zoom interpolation (0.8→2.0 across zoom 5-12) - Reduce Mesh Chat panel text sizes for tighter layout --- backend/main.py | 9 ++-- backend/services/mesh/mesh_crypto.py | 5 ++ backend/services/mesh/mesh_gate_mls.py | 48 ++++++++++++++++---- backend/services/mesh/mesh_protocol.py | 9 ++++ frontend/src/components/MaplibreViewer.tsx | 36 +++++++++------ frontend/src/components/MeshChat.tsx | 28 ++++++------ frontend/src/components/TopRightControls.tsx | 24 +++++----- frontend/src/components/map/MapMarkers.tsx | 8 ++-- 8 files changed, 111 insertions(+), 56 deletions(-) diff --git a/backend/main.py b/backend/main.py index 09133a5..dec990e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3825,11 +3825,10 @@ def _submit_gate_message_envelope(request: Request, gate_id: str, body: dict[str # — doing so would pre-advance the counter and cause append() to reject # the event as a replay, silently dropping the message. # - # The chain payload must match the signed payload exactly. The message - # was signed WITHOUT the `epoch` field (compose_encrypted_gate_message - # excludes it from the signing payload), so we must strip it here too — - # otherwise infonet.append() re-verifies the signature against a payload - # that includes epoch and gets a mismatch → "invalid signature". + # Strip `epoch` — the message was signed without it so including it + # would cause a signature mismatch. `gate_envelope` and `reply_to` + # are kept in the payload for cross-node decryption; signature + # verification in build_signature_payload() strips them automatically. chain_payload = {k: v for k, v in gate_payload.items() if k != "epoch"} chain_event_id = "" try: diff --git a/backend/services/mesh/mesh_crypto.py b/backend/services/mesh/mesh_crypto.py index 4ac2d8a..03a109d 100644 --- a/backend/services/mesh/mesh_crypto.py +++ b/backend/services/mesh/mesh_crypto.py @@ -83,6 +83,11 @@ def build_signature_payload( payload: dict[str, Any], ) -> str: normalized = normalize_payload(event_type, payload) + # gate_envelope and reply_to ride alongside the signed payload — they are + # added after the message is signed so must be excluded from verification. + if event_type == "gate_message": + for _unsig in ("gate_envelope", "reply_to"): + normalized.pop(_unsig, None) payload_json = canonical_json(normalized) return "|".join( [PROTOCOL_VERSION, NETWORK_ID, event_type, node_id, str(sequence), payload_json] diff --git a/backend/services/mesh/mesh_gate_mls.py b/backend/services/mesh/mesh_gate_mls.py index 86227d4..e0aab69 100644 --- a/backend/services/mesh/mesh_gate_mls.py +++ b/backend/services/mesh/mesh_gate_mls.py @@ -58,15 +58,36 @@ MLS_GATE_FORMAT = "mls1" _GATE_ENVELOPE_DOMAIN = "gate_persona" -def _gate_envelope_key() -> bytes: - """Return the 256-bit AES key for gate envelope encryption.""" - from services.mesh.mesh_secure_storage import _load_domain_key # type: ignore[attr-defined] - return _load_domain_key(_GATE_ENVELOPE_DOMAIN) +def _gate_envelope_key_shared(gate_id: str) -> bytes: + """Derive a 256-bit AES key from the gate ID via HKDF. + + Every node that knows the gate name derives the same key, enabling + cross-node decryption of gate_envelope payloads. + """ + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + from cryptography.hazmat.primitives import hashes + + ikm = gate_id.strip().lower().encode("utf-8") + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=b"shadowbroker-gate-envelope-v1", + info=b"gate_envelope_aes256gcm", + ).derive(ikm) + + +def _gate_envelope_key_legacy() -> bytes | None: + """Return the old node-local domain key, or None if unavailable.""" + try: + from services.mesh.mesh_secure_storage import _load_domain_key # type: ignore[attr-defined] + return _load_domain_key(_GATE_ENVELOPE_DOMAIN) + except Exception: + return None def _gate_envelope_encrypt(gate_id: str, plaintext: str) -> str: - """Encrypt plaintext under the gate domain key. Returns base64.""" - key = _gate_envelope_key() + """Encrypt plaintext under the shared gate-derived key. Returns base64.""" + key = _gate_envelope_key_shared(gate_id) nonce = _os.urandom(12) aad = f"gate_envelope|{gate_id}".encode("utf-8") ct = _AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), aad) @@ -74,15 +95,24 @@ def _gate_envelope_encrypt(gate_id: str, plaintext: str) -> str: def _gate_envelope_decrypt(gate_id: str, token: str) -> str | None: - """Decrypt a gate envelope token. Returns plaintext or None on failure.""" + """Decrypt a gate envelope token. Tries the shared key first, then + falls back to the legacy node-local key for old messages.""" try: raw = base64.b64decode(token) if len(raw) < 13: return None nonce, ct = raw[:12], raw[12:] - key = _gate_envelope_key() aad = f"gate_envelope|{gate_id}".encode("utf-8") - return _AESGCM(key).decrypt(nonce, ct, aad).decode("utf-8") + # Try shared (cross-node) key first + try: + return _AESGCM(_gate_envelope_key_shared(gate_id)).decrypt(nonce, ct, aad).decode("utf-8") + except Exception: + pass + # Fall back to legacy node-local key for pre-migration messages + legacy_key = _gate_envelope_key_legacy() + if legacy_key: + return _AESGCM(legacy_key).decrypt(nonce, ct, aad).decode("utf-8") + return None except Exception: return None # Self-echo plaintext cache: MLS cannot decrypt messages authored by the same diff --git a/backend/services/mesh/mesh_protocol.py b/backend/services/mesh/mesh_protocol.py index 4a1df8d..e854e3e 100644 --- a/backend/services/mesh/mesh_protocol.py +++ b/backend/services/mesh/mesh_protocol.py @@ -49,6 +49,15 @@ def normalize_gate_message_payload(payload: dict[str, Any]) -> dict[str, Any]: epoch = _safe_int(payload.get("epoch", 0), 0) if epoch > 0: normalized["epoch"] = epoch + # gate_envelope carries cross-node decryptable ciphertext — preserve it + # on-chain so receiving nodes can decrypt without MLS key exchange. + gate_envelope = str(payload.get("gate_envelope", "") or "").strip() + if gate_envelope: + normalized["gate_envelope"] = gate_envelope + # reply_to is a display-only parent message reference. + reply_to = str(payload.get("reply_to", "") or "").strip() + if reply_to: + normalized["reply_to"] = reply_to return normalized diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 5d7afe0..f993d56 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -3234,7 +3234,7 @@ const MaplibreViewer = ({ type="symbol" layout={{ 'icon-image': ['get', 'iconId'], - 'icon-size': 0.8, + 'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0], 'icon-allow-overlap': true, 'icon-rotate': ['get', 'rotation'], 'icon-rotation-alignment': 'map', @@ -3249,7 +3249,7 @@ const MaplibreViewer = ({ type="symbol" layout={{ 'icon-image': ['get', 'iconId'], - 'icon-size': 0.8, + 'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0], 'icon-allow-overlap': true, 'icon-rotate': ['get', 'rotation'], 'icon-rotation-alignment': 'map', @@ -3264,7 +3264,7 @@ const MaplibreViewer = ({ type="symbol" layout={{ 'icon-image': ['get', 'iconId'], - 'icon-size': 0.8, + 'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0], 'icon-allow-overlap': true, 'icon-rotate': ['get', 'rotation'], 'icon-rotation-alignment': 'map', @@ -3279,7 +3279,7 @@ const MaplibreViewer = ({ type="symbol" layout={{ 'icon-image': ['get', 'iconId'], - 'icon-size': 0.8, + 'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0], 'icon-allow-overlap': true, 'icon-rotate': ['get', 'rotation'], 'icon-rotation-alignment': 'map', @@ -3469,7 +3469,7 @@ const MaplibreViewer = ({ ['==', ['get', 'iconId'], 'svgPotusHeli'], ]} paint={{ - 'circle-radius': 18, + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 18, 8, 22, 12, 40], 'circle-color': 'transparent', 'circle-stroke-width': 2, 'circle-stroke-color': 'gold', @@ -3483,12 +3483,22 @@ const MaplibreViewer = ({ layout={{ 'icon-image': ['get', 'iconId'], 'icon-size': [ - 'case', - ['==', ['get', 'iconId'], 'svgPotusPlane'], - 1.3, - ['==', ['get', 'iconId'], 'svgPotusHeli'], - 1.3, - 0.8, + 'interpolate', ['linear'], ['zoom'], + 5, ['case', + ['==', ['get', 'iconId'], 'svgPotusPlane'], 1.3, + ['==', ['get', 'iconId'], 'svgPotusHeli'], 1.3, + 0.8, + ], + 8, ['case', + ['==', ['get', 'iconId'], 'svgPotusPlane'], 1.6, + ['==', ['get', 'iconId'], 'svgPotusHeli'], 1.6, + 1.0, + ], + 12, ['case', + ['==', ['get', 'iconId'], 'svgPotusPlane'], 2.6, + ['==', ['get', 'iconId'], 'svgPotusHeli'], 2.6, + 2.0, + ], ], 'icon-allow-overlap': true, 'icon-rotate': ['get', 'rotation'], @@ -3504,7 +3514,7 @@ const MaplibreViewer = ({ type="symbol" layout={{ 'icon-image': ['get', 'iconId'], - 'icon-size': 0.8, + 'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0], 'icon-allow-overlap': true, 'icon-rotate': ['get', 'rotation'], 'icon-rotation-alignment': 'map', @@ -3545,7 +3555,7 @@ const MaplibreViewer = ({ {/* HTML labels for UAVs (orange names) */} {uavGeoJSON && !selectedEntity && !isMapInteracting && data?.uavs && ( - + )} {/* HTML labels for earthquakes (yellow) - only show when zoomed in (~2000 miles = zoom ~5) */} diff --git a/frontend/src/components/MeshChat.tsx b/frontend/src/components/MeshChat.tsx index f234784..fd82b5a 100644 --- a/frontend/src/components/MeshChat.tsx +++ b/frontend/src/components/MeshChat.tsx @@ -3944,7 +3944,7 @@ const MeshChat = React.memo(function MeshChat({ setActiveTab(tab.key); if (tab.key === 'dms') setDmView('contacts'); }} - className={`flex-1 flex items-center justify-center gap-1 py-1.5 text-sm font-mono tracking-wider transition-colors ${ + className={`flex-1 flex items-center justify-center gap-1 py-1.5 text-[12px] font-mono tracking-wider transition-colors ${ activeTab === tab.key ? 'text-cyan-300 bg-cyan-950/50 font-bold border-b border-cyan-500/50' : 'text-[var(--text-muted)] hover:text-cyan-600 border-b border-cyan-900/20' @@ -4536,7 +4536,7 @@ const MeshChat = React.memo(function MeshChat({ value={meshRegion} onChange={(e) => setMeshRegion(e.target.value)} title="Meshtastic MQTT root" - className="bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] text-sm font-mono text-cyan-300 px-2 py-1 outline-none focus:border-cyan-700/50" + className="bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] text-[12px] font-mono text-cyan-300 px-2 py-1 outline-none focus:border-cyan-700/50" style={{ width: '132px' }} > {meshRoots.map((r) => ( @@ -4548,7 +4548,7 @@ const MeshChat = React.memo(function MeshChat({