fix: cross-node gate decryption, UI text scaling, aircraft zoom

- 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
This commit is contained in:
anoracleofra-code
2026-03-26 20:00:30 -06:00
parent c6fc47c2c5
commit da09cf429e
8 changed files with 111 additions and 56 deletions
+4 -5
View File
@@ -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:
+5
View File
@@ -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]
+39 -9
View File
@@ -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
+9
View File
@@ -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
+23 -13
View File
@@ -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 && (
<UavLabels uavs={data.uavs} inView={inView} />
<UavLabels uavs={data.uavs} inView={inView} zoom={zoom} />
)}
{/* HTML labels for earthquakes (yellow) - only show when zoomed in (~2000 miles = zoom ~5) */}
+14 -14
View File
@@ -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({
<select
value={meshChannel}
onChange={(e) => setMeshChannel(e.target.value)}
className="flex-1 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] text-sm font-mono text-green-400 px-2 py-1 outline-none focus:border-cyan-700/50"
className="flex-1 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] text-[12px] font-mono text-green-400 px-2 py-1 outline-none focus:border-cyan-700/50"
>
{meshChannels.map((ch) => (
<option key={ch} value={ch}>
@@ -4561,7 +4561,7 @@ const MeshChat = React.memo(function MeshChat({
<div className="flex items-center gap-1">
<button
onClick={() => setMeshView('channel')}
className={`px-2 py-0.5 text-[13px] font-mono tracking-wider border transition-colors ${
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
meshView === 'channel'
? 'border-green-500/40 text-green-300 bg-green-950/30'
: 'border-[var(--border-primary)]/40 text-[var(--text-muted)] hover:text-green-300'
@@ -4571,7 +4571,7 @@ const MeshChat = React.memo(function MeshChat({
</button>
<button
onClick={() => setMeshView('inbox')}
className={`px-2 py-0.5 text-[13px] font-mono tracking-wider border transition-colors ${
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
meshView === 'inbox'
? 'border-amber-500/40 text-amber-300 bg-amber-950/20'
: 'border-[var(--border-primary)]/40 text-[var(--text-muted)] hover:text-amber-300'
@@ -4580,31 +4580,31 @@ const MeshChat = React.memo(function MeshChat({
INBOX
</button>
</div>
<div className="text-[12px] font-mono text-[var(--text-muted)] truncate">
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
{publicMeshAddress ? `ADDR ${publicMeshAddress.toUpperCase()}` : 'NO PUBLIC MESH ADDRESS'}
</div>
</div>
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
{meshView === 'channel' && filteredMeshMessages.length === 0 && (
<div className="text-sm font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
No messages from {meshRegion} / {meshChannel}
</div>
)}
{meshView === 'inbox' && (
<>
{!publicMeshAddress && (
<div className="text-sm font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
Create or load a public mesh identity to see direct Meshtastic traffic.
</div>
)}
{publicMeshAddress && meshInboxMessages.length === 0 && (
<div className="text-sm font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
No public direct messages addressed to {publicMeshAddress.toUpperCase()} yet.
</div>
)}
{meshInboxMessages.map((m, i) => (
<div key={`${m.timestamp}-${i}`} className="py-0.5 leading-[1.65]">
<div className="flex items-start gap-1.5 text-sm font-mono">
<div className="flex items-start gap-1.5 text-[12px] font-mono">
<button
onClick={(e) => handleSenderClick(m.from, e, 'meshtastic')}
className="text-amber-300 shrink-0 hover:text-amber-200 hover:underline cursor-pointer"
@@ -4612,14 +4612,14 @@ const MeshChat = React.memo(function MeshChat({
{displayPublicMeshSender(m.from)}
</button>
<div className="flex-1 min-w-0">
<div className="text-[12px] text-amber-200/70 mb-0.5">
<div className="text-[10px] text-amber-200/70 mb-0.5">
TO {publicMeshAddress.toUpperCase()}
</div>
<div className="break-words whitespace-pre-wrap text-amber-100/90">
{m.text}
</div>
</div>
<span className="text-[var(--text-muted)] shrink-0 text-[13px]">
<span className="text-[var(--text-muted)] shrink-0 text-[11px]">
{timeAgo(
typeof m.timestamp === 'number'
? m.timestamp
@@ -4634,7 +4634,7 @@ const MeshChat = React.memo(function MeshChat({
{meshView === 'channel' &&
filteredMeshMessages.map((m, i) => (
<div key={`${m.timestamp}-${i}`} className="py-0.5 leading-[1.65]">
<div className="flex gap-1.5 text-sm font-mono">
<div className="flex gap-1.5 text-[12px] font-mono">
<button
onClick={(e) => handleSenderClick(m.from, e, 'meshtastic')}
className="text-green-400 shrink-0 hover:text-green-300 hover:underline cursor-pointer"
@@ -4646,7 +4646,7 @@ const MeshChat = React.memo(function MeshChat({
>
{m.text}
</span>
<span className="text-[var(--text-muted)] shrink-0 text-[13px]">
<span className="text-[var(--text-muted)] shrink-0 text-[11px]">
{timeAgo(
typeof m.timestamp === 'number'
? m.timestamp
+12 -12
View File
@@ -876,13 +876,13 @@ export default function TopRightControls({
onClick={closeTerminalLauncher}
className="absolute inset-0 bg-black/70 backdrop-blur-[2px]"
/>
<div className="relative z-[1201] w-full max-w-[560px] border border-cyan-700/40 bg-[var(--bg-primary)]/96 backdrop-blur-sm shadow-[0_0_32px_rgba(0,255,255,0.12)]">
<div className="relative z-[1201] w-full max-w-[640px] border border-cyan-700/40 bg-[var(--bg-primary)]/96 backdrop-blur-sm shadow-[0_0_32px_rgba(0,255,255,0.12)]">
<div className="flex items-center justify-between px-4 py-3 border-b border-cyan-900/30">
<div>
<div className="text-[10px] font-mono tracking-[0.24em] text-cyan-300">
<div className="text-[13px] font-mono tracking-[0.24em] text-cyan-300">
INFONET TERMINAL
</div>
<div className={`mt-1 text-[9px] font-mono ${terminalStatusTone}`}>
<div className={`mt-1 text-[11px] font-mono ${terminalStatusTone}`}>
{terminalStatusLabel} {terminalTransportTier}
</div>
</div>
@@ -892,26 +892,26 @@ export default function TopRightControls({
className="text-[var(--text-muted)] hover:text-cyan-300 transition-colors"
title="Close terminal launcher"
>
<X size={13} />
<X size={16} />
</button>
</div>
<div className="px-5 py-5 space-y-4">
<div className="border border-cyan-500/20 bg-cyan-950/10 px-4 py-4 text-[10px] font-mono text-cyan-100 leading-[1.8]">
<div className="border border-cyan-500/20 bg-cyan-950/10 px-4 py-4 text-[13px] font-mono text-cyan-100 leading-[1.8]">
{terminalPrivateReady
? 'Enter the Wormhole-facing terminal and sync with the obfuscated Infonet commons?'
: 'The terminal runs through Wormhole for obfuscated gates, inbox, and experimental comms.'}
<div className="mt-2 text-[9px] text-cyan-200/70 normal-case tracking-normal">
<div className="mt-2 text-[12px] text-cyan-200/70 normal-case tracking-normal">
{terminalPrivateReady
? 'Your obfuscated identity is already provisioned. Entering now keeps the obfuscated lane separate from the public node sync path.'
: 'This turns Wormhole on and opens the obfuscated lane. If you already have a Wormhole identity, it reuses it. If you do not, it bootstraps one once and then keeps using it.'}
</div>
</div>
{terminalLaunchError && (
<div className="border border-amber-500/40 bg-amber-950/20 px-4 py-3 text-[9px] font-mono text-amber-200 leading-[1.7]">
<div className="border border-amber-500/40 bg-amber-950/20 px-4 py-3 text-[12px] font-mono text-amber-200 leading-[1.7]">
{terminalLaunchError}
</div>
)}
<div className="border border-cyan-500/20 bg-black/30 px-4 py-4 text-[9px] font-mono text-slate-200 leading-[1.85]">
<div className="border border-cyan-500/20 bg-black/30 px-4 py-4 text-[12px] font-mono text-slate-200 leading-[1.85]">
<div className="text-cyan-300 tracking-[0.18em]">BEFORE YOU ENTER:</div>
<ul className="mt-3 space-y-2 list-disc pl-5">
<li>The terminal is for Wormhole, gates, and experimental mail.</li>
@@ -919,7 +919,7 @@ export default function TopRightControls({
<li>Mesh remains the public perimeter. Wormhole is the obfuscated commons.</li>
</ul>
</div>
<div className="border border-amber-500/20 bg-amber-950/10 px-4 py-3 text-[9px] font-mono text-amber-200/80 leading-[1.85]">
<div className="border border-amber-500/20 bg-amber-950/10 px-4 py-3 text-[12px] font-mono text-amber-200/80 leading-[1.85]">
<div className="text-amber-300 tracking-[0.18em]">WORMHOLE CLEANUP:</div>
<div className="mt-2">
Closing the Infonet terminal will shut down Wormhole automatically. If you force-close
@@ -934,7 +934,7 @@ export default function TopRightControls({
type="button"
onClick={() => void activateWormholeAndLaunchTerminal()}
disabled={terminalLaunchBusy}
className="px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 hover:bg-cyan-950/35 disabled:opacity-50 text-[11px] font-mono text-cyan-300 tracking-[0.16em]"
className="px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 hover:bg-cyan-950/35 disabled:opacity-50 text-[13px] font-mono text-cyan-300 tracking-[0.16em]"
>
{terminalLaunchBusy
? 'ENTERING...'
@@ -949,7 +949,7 @@ export default function TopRightControls({
onMeshChatNavigate?.('meshtastic');
}}
disabled={terminalLaunchBusy}
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 disabled:opacity-50 text-[11px] font-mono text-[var(--text-muted)] tracking-[0.16em]"
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 disabled:opacity-50 text-[13px] font-mono text-[var(--text-muted)] tracking-[0.16em]"
>
GO TO MESH
</button>
@@ -957,7 +957,7 @@ export default function TopRightControls({
type="button"
onClick={closeTerminalLauncher}
disabled={terminalLaunchBusy}
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 disabled:opacity-50 text-[11px] font-mono text-[var(--text-muted)] tracking-[0.16em]"
className="px-4 py-3 border border-[var(--border-primary)] hover:border-cyan-500/40 disabled:opacity-50 text-[13px] font-mono text-[var(--text-muted)] tracking-[0.16em]"
>
CANCEL
</button>
+5 -3
View File
@@ -106,7 +106,7 @@ export function TrackedFlightLabels({
style={{
...LABEL_BASE,
color: labelColor,
fontSize: '10px',
fontSize: `${Math.max(10, Math.min(16, 10 + (zoom - 5) * 1.2))}px`,
textShadow: LABEL_SHADOW_EXTRA,
whiteSpace: 'nowrap',
}}
@@ -212,9 +212,11 @@ export function TrackedYachtLabels({ ships, inView, interpShip }: TrackedYachtLa
interface UavLabelsProps {
uavs: UAV[];
inView: (lat: number, lng: number) => boolean;
zoom?: number;
}
export function UavLabels({ uavs, inView }: UavLabelsProps) {
export function UavLabels({ uavs, inView, zoom = 5 }: UavLabelsProps) {
const labelSize = `${Math.max(10, Math.min(16, 10 + (zoom - 5) * 1.2))}px`;
return (
<>
{uavs.map((uav, i) => {
@@ -234,7 +236,7 @@ export function UavLabels({ uavs, inView }: UavLabelsProps) {
style={{
...LABEL_BASE,
color: '#ff8c00',
fontSize: '10px',
fontSize: labelSize,
textShadow: LABEL_SHADOW_EXTRA,
whiteSpace: 'nowrap',
}}