espilon-source/tools/C3PO/cli/cli.py
Eun0us 8b6c1cd53d ε - ChaCha20-Poly1305 AEAD + HKDF crypto upgrade + C3PO rewrite + docs
Crypto:
- Replace broken ChaCha20 (static nonce) with ChaCha20-Poly1305 AEAD
- HKDF-SHA256 key derivation from per-device factory NVS master keys
- Random 12-byte nonce per message (ESP32 hardware RNG)
- crypto_init/encrypt/decrypt API with mbedtls legacy (ESP-IDF v5.3.2)
- Custom partition table with factory NVS (fctry at 0x10000)

Firmware:
- crypto.c full rewrite, messages.c device_id prefix + AEAD encrypt
- crypto_init() at boot with esp_restart() on failure
- Fix command_t initializations across all modules (sub/help fields)
- Clean CMakeLists dependencies for ESP-IDF v5.3.2

C3PO (C2):
- Rename tools/c2 + tools/c3po -> tools/C3PO
- Per-device CryptoContext with HKDF key derivation
- KeyStore (keys.json) for master key management
- Transport parses device_id:base64(...) wire format

Tools:
- New tools/provisioning/provision.py for factory NVS key generation
- Updated flasher with mbedtls config for v5.3.2

Docs:
- Update all READMEs for new crypto, C3PO paths, provisioning
- Update roadmap, architecture diagrams, security sections
- Update CONTRIBUTING.md project structure
2026-02-10 21:28:45 +01:00

485 lines
19 KiB
Python

import readline
import os
import time
from typing import Optional
from utils.display import Display
from cli.help import HelpManager
from core.transport import Transport
from proto.c2_pb2 import Command
from streams.udp_receiver import UDPReceiver
from streams.config import (
UDP_HOST, UDP_PORT, IMAGE_DIR, MULTILAT_AUTH_TOKEN,
WEB_HOST, WEB_PORT, DEFAULT_USERNAME, DEFAULT_PASSWORD, FLASK_SECRET_KEY
)
from web.server import UnifiedWebServer
from web.mlat import MlatEngine
DEV_MODE = True
class CLI:
def __init__(self, registry, commands, groups, transport: Transport):
self.registry = registry
self.commands = commands
self.groups = groups
self.transport = transport
self.help_manager = HelpManager(commands, DEV_MODE)
self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}}
# Separate server instances
self.web_server: Optional[UnifiedWebServer] = None
self.udp_receiver: Optional[UDPReceiver] = None
self.mlat_engine = MlatEngine()
# Honeypot dashboard components (created on web start)
self.hp_store = None
self.hp_commander = None
self.hp_alerts = None
self.hp_geo = None
readline.parse_and_bind("tab: complete")
readline.set_completer(self._complete)
# ================= TAB COMPLETION =================
def _complete(self, text, state):
buffer = readline.get_line_buffer()
parts = buffer.split()
options = []
if len(parts) == 1:
options = ["send", "list", "modules", "group", "help", "clear", "exit", "active_commands", "web", "camera"]
elif parts[0] == "send":
if len(parts) == 2: # Completing target (device ID, 'all', 'group')
options = ["all", "group"] + self.registry.ids()
elif len(parts) == 3 and parts[1] == "group": # Completing group name after 'send group'
options = list(self.groups.all_groups().keys())
elif (len(parts) == 3 and parts[1] != "group") or (len(parts) == 4 and parts[1] == "group"): # Completing command name
options = self.commands.list()
elif parts[0] == "web":
if len(parts) == 2:
options = ["start", "stop", "status"]
elif parts[0] == "camera":
if len(parts) == 2:
options = ["start", "stop", "status"]
elif parts[0] == "group":
if len(parts) == 2: # Completing group action
options = ["add", "remove", "list", "show"]
elif parts[1] == "add" and len(parts) >= 3: # Completing device IDs for 'group add'
# Suggest available device IDs that are not already in the group being added to
group_name = parts[2] if len(parts) > 2 else ""
current_group_members = self.groups.get(group_name) if group_name else []
all_device_ids = set(self.registry.ids())
options = sorted(list(all_device_ids - set(current_group_members)))
elif parts[1] in ("remove", "show") and len(parts) == 3: # Completing group names for 'group remove/show'
options = list(self.groups.all_groups().keys())
elif parts[1] == "remove" and len(parts) >= 4: # Completing device IDs for 'group remove'
group_name = parts[2]
options = self.groups.get(group_name)
matches = [o for o in options if o.startswith(text)]
return matches[state] if state < len(matches) else None
# ================= MAIN LOOP =================
def loop(self):
while True:
cmd = input(Display.cli_prompt()).strip()
if not cmd:
continue
if cmd == "exit":
return
self.execute_command(cmd)
def execute_command(self, cmd: str):
"""Execute a command string. Used by both CLI loop and TUI."""
if not cmd:
return
parts = cmd.split()
action = parts[0]
if action == "help":
self.help_manager.show(parts[1:])
return
if action == "exit":
return
if action == "clear":
os.system("cls" if os.name == "nt" else "clear")
return
if action == "list":
self._handle_list()
return
if action == "modules":
self.help_manager.show_modules()
return
if action == "group":
self._handle_group(parts[1:])
return
if action == "send":
self._handle_send(parts)
return
if action == "active_commands":
self._handle_active_commands()
return
if action == "web":
self._handle_web(parts[1:])
return
if action == "camera":
self._handle_camera(parts[1:])
return
Display.error("Unknown command")
# ================= HANDLERS =================
def _handle_list(self):
now = time.time()
active_devices = self.registry.all()
if not active_devices:
Display.system_message("No devices currently connected.")
return
Display.system_message("Connected Devices:")
Display.print_table_header(["ID", "IP Address", "Status", "Connected For", "Last Seen"])
for d in active_devices:
connected_for = Display.format_duration(now - d.connected_at)
last_seen_duration = Display.format_duration(now - d.last_seen)
Display.print_table_row([d.id, d.address[0], d.status, connected_for, last_seen_duration])
def _handle_send(self, parts):
if len(parts) < 3:
Display.error("Usage: send <id|all|group> <command> [args...]")
return
target_specifier = parts[1]
command_parts = parts[2:]
devices_to_target = []
target_description = ""
# Resolve devices based on target_specifier
if target_specifier == "all":
devices_to_target = self.registry.all()
target_description = "all connected devices"
elif target_specifier == "group":
if len(command_parts) < 2:
Display.error("Usage: send group <name> <command> [args...]")
return
group_name = command_parts[0]
command_parts = command_parts[1:]
group_members_ids = self.groups.get(group_name)
if not group_members_ids:
Display.error(f"Group '{group_name}' not found or is empty.")
return
active_group_devices = []
for esp_id in group_members_ids:
dev = self.registry.get(esp_id)
if dev:
active_group_devices.append(dev)
else:
Display.device_event(esp_id, f"Device in group '{group_name}' is not currently connected.")
if not active_group_devices:
Display.error(f"No active devices found in group '{group_name}'.")
return
devices_to_target = active_group_devices
target_description = f"group '{group_name}' ({', '.join([d.id for d in devices_to_target])})"
else:
dev = self.registry.get(target_specifier)
if dev:
devices_to_target.append(dev)
target_description = f"device '{target_specifier}'"
else:
Display.error(f"Device '{target_specifier}' not found.")
return
if not devices_to_target:
Display.error("No target devices resolved for sending command.")
return
# Build Command
cmd_name = command_parts[0]
argv = command_parts[1:]
request_id_base = f"req-{int(time.time())}"
Display.system_message(f"Sending command '{cmd_name}' to {target_description}...")
for i, d in enumerate(devices_to_target):
cmd = Command()
cmd.device_id = d.id
cmd.command_name = cmd_name
cmd.argv.extend(argv)
request_id = f"{request_id_base}-{i}"
cmd.request_id = request_id
Display.command_sent(d.id, cmd_name, request_id)
self.transport.send_command(d.sock, cmd, d.id)
self.active_commands[request_id] = {
"device_id": d.id,
"command_name": cmd_name,
"start_time": time.time(),
"status": "running",
"output": []
}
def handle_command_response(self, request_id: str, device_id: str, payload: str, eof: bool):
if request_id in self.active_commands:
command_info = self.active_commands[request_id]
command_info["output"].append(payload)
if eof:
command_info["status"] = "completed"
Display.command_response(request_id, device_id, f"Command completed in {Display.format_duration(time.time() - command_info['start_time'])}")
# Optionally print full output here if not already streamed
# Display.command_response(request_id, device_id, "\n".join(command_info["output"]))
del self.active_commands[request_id]
else:
# For streaming output, Display.command_response already prints each line
pass
else:
Display.device_event(device_id, f"Received response for unknown command {request_id}: {payload}")
def _handle_group(self, parts):
if not parts:
Display.error("Usage: group <add|remove|list|show>")
return
cmd = parts[0]
if cmd == "add" and len(parts) >= 3:
group = parts[1]
added_devices = []
for esp_id in parts[2:]:
if self.registry.get(esp_id): # Only add if device exists
self.groups.add_device(group, esp_id)
added_devices.append(esp_id)
else:
Display.device_event(esp_id, "Device not found, skipping group add.")
if added_devices:
Display.system_message(f"Group '{group}' updated. Added: {', '.join(added_devices)}")
else:
Display.system_message(f"No valid devices to add to group '{group}'.")
elif cmd == "remove" and len(parts) >= 3:
group = parts[1]
removed_devices = []
for esp_id in parts[2:]:
if esp_id in self.groups.get(group):
self.groups.remove_device(group, esp_id)
removed_devices.append(esp_id)
else:
Display.device_event(esp_id, f"Device not in group '{group}', skipping remove.")
if removed_devices:
Display.system_message(f"Group '{group}' updated. Removed: {', '.join(removed_devices)}")
else:
Display.system_message(f"No specified devices found in group '{group}' to remove.")
elif cmd == "list":
all_groups = self.groups.all_groups()
if not all_groups:
Display.system_message("No groups defined.")
return
Display.system_message("Defined Groups:")
for g, members in all_groups.items():
Display.system_message(f" {g}: {', '.join(members) if members else 'No members'}")
elif cmd == "show" and len(parts) == 2:
group_name = parts[1]
members = self.groups.get(group_name)
if members:
Display.system_message(f"Members of group '{group_name}': {', '.join(members)}")
else:
Display.system_message(f"Group '{group_name}' not found or empty.")
else:
Display.error("Invalid group command usage. See 'help group' for details.")
def _handle_active_commands(self):
if not self.active_commands:
Display.system_message("No commands are currently active.")
return
Display.system_message("Active Commands:")
Display.print_table_header(["Request ID", "Device ID", "Command", "Status", "Elapsed Time"])
now = time.time()
for req_id, cmd_info in self.active_commands.items():
elapsed_time = Display.format_duration(now - cmd_info["start_time"])
Display.print_table_row([
req_id,
cmd_info["device_id"],
cmd_info["command_name"],
cmd_info["status"],
elapsed_time
])
def _handle_web(self, parts):
"""Handle web server commands (frontend + multilateration API)."""
if not parts:
Display.error("Usage: web <start|stop|status>")
return
cmd = parts[0]
if cmd == "start":
if self.web_server and self.web_server.is_running:
Display.system_message("Web server is already running.")
return
# Initialize honeypot dashboard components
try:
from hp_dashboard import HpStore, HpCommander, HpAlertEngine, HpGeoLookup
if not self.hp_store:
self.hp_geo = HpGeoLookup()
self.hp_store = HpStore(geo_lookup=self.hp_geo)
if not self.hp_alerts:
self.hp_alerts = HpAlertEngine()
self.hp_alerts.set_store(self.hp_store)
if not self.hp_commander:
self.hp_commander = HpCommander(
get_transport=lambda: self.transport,
get_registry=lambda: self.registry,
)
# Wire into transport for event/response routing
self.transport.hp_store = self.hp_store
self.transport.hp_commander = self.hp_commander
Display.system_message("Honeypot dashboard enabled (alerts + geo active)")
except ImportError:
Display.system_message("Honeypot dashboard not available (hp_dashboard not found)")
self.web_server = UnifiedWebServer(
host=WEB_HOST,
port=WEB_PORT,
image_dir=IMAGE_DIR,
username=DEFAULT_USERNAME,
password=DEFAULT_PASSWORD,
secret_key=FLASK_SECRET_KEY,
device_registry=self.registry,
mlat_engine=self.mlat_engine,
multilat_token=MULTILAT_AUTH_TOKEN,
camera_receiver=self.udp_receiver,
hp_store=self.hp_store,
hp_commander=self.hp_commander,
hp_alerts=self.hp_alerts,
hp_geo=self.hp_geo,
)
if self.web_server.start():
Display.system_message(f"Web server started at {self.web_server.get_url()}")
else:
Display.error("Web server failed to start")
elif cmd == "stop":
if not self.web_server or not self.web_server.is_running:
Display.system_message("Web server is not running.")
return
self.web_server.stop()
Display.system_message("Web server stopped.")
self.web_server = None
elif cmd == "status":
Display.system_message("Web Server Status:")
if self.web_server and self.web_server.is_running:
Display.system_message(f" Status: Running")
Display.system_message(f" URL: {self.web_server.get_url()}")
else:
Display.system_message(f" Status: Stopped")
# MLAT stats
Display.system_message("MLAT Engine:")
state = self.mlat_engine.get_state()
Display.system_message(f" Mode: {state.get('coord_mode', 'gps').upper()}")
Display.system_message(f" Scanners: {state['scanners_count']}")
if state['target']:
pos = state['target']['position']
if 'lat' in pos:
Display.system_message(f" Target: ({pos['lat']:.6f}, {pos['lon']:.6f})")
else:
Display.system_message(f" Target: ({pos['x']:.2f}m, {pos['y']:.2f}m)")
else:
Display.system_message(f" Target: Not calculated")
else:
Display.error("Invalid web command. Use: start, stop, status")
def _handle_camera(self, parts):
"""Handle camera UDP receiver commands."""
if not parts:
Display.error("Usage: camera <start|stop|status>")
return
cmd = parts[0]
if cmd == "start":
if self.udp_receiver and self.udp_receiver.is_running:
Display.system_message("Camera UDP receiver is already running.")
return
self.udp_receiver = UDPReceiver(
host=UDP_HOST,
port=UDP_PORT,
image_dir=IMAGE_DIR,
device_registry=self.registry
)
if self.udp_receiver.start():
Display.system_message(f"Camera UDP receiver started on {UDP_HOST}:{UDP_PORT}")
# Update web server if running
if self.web_server and self.web_server.is_running:
self.web_server.set_camera_receiver(self.udp_receiver)
Display.system_message("Web server updated with camera receiver")
else:
Display.error("Camera UDP receiver failed to start")
elif cmd == "stop":
if not self.udp_receiver or not self.udp_receiver.is_running:
Display.system_message("Camera UDP receiver is not running.")
return
self.udp_receiver.stop()
Display.system_message("Camera UDP receiver stopped.")
self.udp_receiver = None
# Update web server
if self.web_server and self.web_server.is_running:
self.web_server.set_camera_receiver(None)
elif cmd == "status":
Display.system_message("Camera UDP Receiver Status:")
if self.udp_receiver and self.udp_receiver.is_running:
stats = self.udp_receiver.get_stats()
Display.system_message(f" Status: Running on {UDP_HOST}:{UDP_PORT}")
Display.system_message(f" Packets received: {stats['packets_received']}")
Display.system_message(f" Frames decoded: {stats['frames_received']}")
Display.system_message(f" Decode errors: {stats['decode_errors']}")
Display.system_message(f" Invalid tokens: {stats['invalid_tokens']}")
Display.system_message(f" Active cameras: {stats['active_cameras']}")
else:
Display.system_message(f" Status: Stopped")
else:
Display.error("Invalid camera command. Use: start, stop, status")