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
519 lines
19 KiB
Python
519 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Epsilon Multi-Device Flasher
|
||
Automates building and flashing ESP32 devices with custom configurations.
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import subprocess
|
||
import string
|
||
import random
|
||
import argparse
|
||
from typing import List, Optional
|
||
from dataclasses import dataclass
|
||
|
||
|
||
# Configuration
|
||
CONFIG_FILE = "devices.json"
|
||
SDKCONFIG_FILENAME = "sdkconfig.defaults"
|
||
|
||
|
||
@dataclass
|
||
class Device:
|
||
"""Represents an ESP32 device configuration"""
|
||
device_id: str
|
||
port: str
|
||
srv_ip: str
|
||
srv_port: int = 2626
|
||
|
||
# Network configuration
|
||
network_mode: str = "wifi" # "wifi" or "gprs"
|
||
wifi_ssid: Optional[str] = None
|
||
wifi_pass: Optional[str] = None
|
||
gprs_apn: Optional[str] = "sl2sfr"
|
||
|
||
# Optional settings
|
||
hostname: Optional[str] = None
|
||
|
||
# Modules
|
||
module_network: bool = True
|
||
module_recon: bool = False
|
||
module_fakeap: bool = False
|
||
|
||
# Recon settings
|
||
recon_camera: bool = False
|
||
recon_ble_trilat: bool = False
|
||
|
||
# Security (master key provisioned separately via provision.py)
|
||
|
||
def __post_init__(self):
|
||
"""Generate hostname if not provided"""
|
||
if not self.hostname:
|
||
self.hostname = self._generate_hostname()
|
||
|
||
# Validate network mode
|
||
if self.network_mode not in ["wifi", "gprs"]:
|
||
raise ValueError(f"Invalid network_mode: {self.network_mode}. Must be 'wifi' or 'gprs'")
|
||
|
||
# Validate WiFi mode has credentials
|
||
if self.network_mode == "wifi" and (not self.wifi_ssid or not self.wifi_pass):
|
||
raise ValueError("WiFi mode requires wifi_ssid and wifi_pass")
|
||
|
||
@staticmethod
|
||
def _generate_hostname() -> str:
|
||
"""Generate a realistic device hostname"""
|
||
hostnames = [
|
||
"iPhone",
|
||
"Android",
|
||
f"DESKTOP-{''.join([random.choice(string.digits + string.ascii_uppercase) for _ in range(7)])}",
|
||
|
||
# iPhones
|
||
"iPhone-15-pro-max", "iPhone-15-pro", "iPhone-15", "iPhone-15-plus",
|
||
"iPhone-14-pro-max", "iPhone-14-pro", "iPhone-14", "iPhone-14-plus",
|
||
"iPhone-se-3rd-gen", "iPhone-13-pro-max",
|
||
|
||
# Samsung
|
||
"galaxy-s24-ultra", "galaxy-s24", "galaxy-z-fold5", "galaxy-z-flip5", "galaxy-a55",
|
||
|
||
# Xiaomi
|
||
"xiaomi-14-ultra", "xiaomi-14", "redmi-note-13-pro-plus", "redmi-note-13-5g", "poco-f6-pro",
|
||
|
||
# OnePlus
|
||
"oneplus-12", "oneplus-12r", "oneplus-11", "oneplus-nord-3", "oneplus-nord-ce-3-lite",
|
||
|
||
# Google
|
||
"pixel-8-pro", "pixel-8", "pixel-7a", "pixel-fold", "pixel-6a",
|
||
|
||
# Motorola
|
||
"moto-edge-50-ultra", "moto-g-stylus-5g-2024", "moto-g-power-2024",
|
||
"razr-50-ultra", "moto-e32",
|
||
|
||
# Sony
|
||
"xperia-1-vi", "xperia-10-vi", "xperia-5-v", "xperia-l5", "xperia-pro-i",
|
||
|
||
# Oppo
|
||
"oppo-find-x6-pro", "oppo-reno9-pro", "oppo-a78", "oppo-f21-pro", "oppo-a17",
|
||
|
||
# Vivo
|
||
"vivo-x90-pro-plus", "vivo-x90-pro", "vivo-y35", "vivo-y75", "vivo-v29e",
|
||
|
||
# Realme
|
||
"realme-11-pro-plus", "realme-10x", "realme-9i", "realme-c33", "realme-11x",
|
||
|
||
# Asus
|
||
"rog-phone-8-pro", "zenfone-10", "rog-phone-7", "rog-phone-6d", "asus-zenfone-9",
|
||
|
||
# Lenovo
|
||
"lenovo-legion-y90", "lenovo-k14-note", "lenovo-k14-plus", "lenovo-tab-m10",
|
||
|
||
# Honor
|
||
"honor-90", "honor-x8a", "honor-70-pro", "honor-magic5-pro", "honor-x7a",
|
||
|
||
# Huawei
|
||
"huawei-p60-pro", "huawei-p50-pro", "huawei-mate-50-pro", "huawei-mate-xs-2", "huawei-nova-11",
|
||
|
||
# LG
|
||
"lg-wing", "lg-velvet", "lg-g8x-thinQ", "lg-v60-thinQ", "lg-k92-5g"
|
||
]
|
||
return random.choice(hostnames)
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: dict):
|
||
"""Create Device from dictionary"""
|
||
return cls(
|
||
device_id=data["device_id"],
|
||
port=data["port"],
|
||
srv_ip=data["srv_ip"],
|
||
srv_port=data.get("srv_port", 2626),
|
||
network_mode=data.get("network_mode", "wifi"),
|
||
wifi_ssid=data.get("wifi_ssid"),
|
||
wifi_pass=data.get("wifi_pass"),
|
||
gprs_apn=data.get("gprs_apn", "sl2sfr"),
|
||
hostname=data.get("hostname"),
|
||
module_network=data.get("module_network", True),
|
||
module_recon=data.get("module_recon", False),
|
||
module_fakeap=data.get("module_fakeap", False),
|
||
recon_camera=data.get("recon_camera", False),
|
||
recon_ble_trilat=data.get("recon_ble_trilat", False)
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"{self.device_id} ({self.network_mode.upper()}) on {self.port}"
|
||
|
||
|
||
class FirmwareBuilder:
|
||
"""Handles firmware building for ESP32 devices"""
|
||
|
||
def __init__(self, project_path: str):
|
||
self.project_path = project_path
|
||
self.sdkconfig_path = os.path.join(self.project_path, SDKCONFIG_FILENAME)
|
||
self.build_dir = os.path.join(self.project_path, "build")
|
||
self.firmware_dir = os.path.join(self.project_path, "firmware")
|
||
os.makedirs(self.firmware_dir, exist_ok=True)
|
||
|
||
def generate_sdkconfig(self, device: Device):
|
||
"""Generate sdkconfig.defaults for a specific device"""
|
||
|
||
# Backup existing config
|
||
if os.path.exists(self.sdkconfig_path):
|
||
backup_path = f"{self.sdkconfig_path}.bak"
|
||
with open(self.sdkconfig_path, "r") as src, open(backup_path, "w") as dst:
|
||
dst.write(src.read())
|
||
|
||
# Generate new config
|
||
config_lines = []
|
||
|
||
# Device ID
|
||
config_lines.append(f'CONFIG_DEVICE_ID="{device.device_id}"')
|
||
|
||
# Network Mode
|
||
if device.network_mode == "wifi":
|
||
config_lines.append("CONFIG_NETWORK_WIFI=y")
|
||
config_lines.append("CONFIG_NETWORK_GPRS=n")
|
||
config_lines.append(f'CONFIG_WIFI_SSID="{device.wifi_ssid}"')
|
||
config_lines.append(f'CONFIG_WIFI_PASS="{device.wifi_pass}"')
|
||
else: # gprs
|
||
config_lines.append("CONFIG_NETWORK_WIFI=n")
|
||
config_lines.append("CONFIG_NETWORK_GPRS=y")
|
||
config_lines.append(f'CONFIG_GPRS_APN="{device.gprs_apn}"')
|
||
|
||
# Server
|
||
config_lines.append(f'CONFIG_SERVER_IP="{device.srv_ip}"')
|
||
config_lines.append(f'CONFIG_SERVER_PORT={device.srv_port}')
|
||
|
||
# Security (master key provisioned via provision.py into factory NVS)
|
||
|
||
# Modules
|
||
config_lines.append(f'CONFIG_MODULE_NETWORK={"y" if device.module_network else "n"}')
|
||
config_lines.append(f'CONFIG_MODULE_RECON={"y" if device.module_recon else "n"}')
|
||
config_lines.append(f'CONFIG_MODULE_FAKEAP={"y" if device.module_fakeap else "n"}')
|
||
|
||
# Recon settings (only if module enabled)
|
||
if device.module_recon:
|
||
config_lines.append(f'CONFIG_RECON_MODE_CAMERA={"y" if device.recon_camera else "n"}')
|
||
config_lines.append(f'CONFIG_RECON_MODE_BLE_TRILAT={"y" if device.recon_ble_trilat else "n"}')
|
||
|
||
# Crypto: ChaCha20-Poly1305 + HKDF (mbedtls legacy API, ESP-IDF v5.3)
|
||
config_lines.append("CONFIG_MBEDTLS_CHACHA20_C=y")
|
||
config_lines.append("CONFIG_MBEDTLS_POLY1305_C=y")
|
||
config_lines.append("CONFIG_MBEDTLS_CHACHAPOLY_C=y")
|
||
config_lines.append("CONFIG_MBEDTLS_HKDF_C=y")
|
||
|
||
# Partition table (custom with factory NVS)
|
||
config_lines.append("CONFIG_PARTITION_TABLE_CUSTOM=y")
|
||
config_lines.append('CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"')
|
||
config_lines.append("CONFIG_LWIP_IPV4_NAPT=y")
|
||
config_lines.append("CONFIG_LWIP_IPV4_NAPT_PORTMAP=y")
|
||
config_lines.append("CONFIG_LWIP_IP_FORWARD=y")
|
||
config_lines.append(f'CONFIG_LWIP_LOCAL_HOSTNAME="{device.hostname}"')
|
||
|
||
# Bluetooth (for BLE trilateration)
|
||
if device.recon_ble_trilat:
|
||
config_lines.append("CONFIG_BT_ENABLED=y")
|
||
config_lines.append("CONFIG_BT_BLUEDROID_ENABLED=y")
|
||
config_lines.append("CONFIG_BT_BLE_ENABLED=y")
|
||
|
||
# Write config
|
||
with open(self.sdkconfig_path, "w") as f:
|
||
f.write("\n".join(config_lines) + "\n")
|
||
|
||
print(f"✅ Generated sdkconfig.defaults for {device.device_id}")
|
||
|
||
def build(self, device: Device) -> Optional[str]:
|
||
"""Build firmware for a specific device"""
|
||
print(f"\n{'='*60}")
|
||
print(f"🔧 Building firmware for: {device}")
|
||
print(f"{'='*60}")
|
||
|
||
# Generate config
|
||
self.generate_sdkconfig(device)
|
||
|
||
# Remove old sdkconfig to force reconfiguration
|
||
sdkconfig = os.path.join(self.project_path, "sdkconfig")
|
||
if os.path.exists(sdkconfig):
|
||
os.remove(sdkconfig)
|
||
print("🗑️ Removed old sdkconfig")
|
||
|
||
# Build
|
||
try:
|
||
print("⚙️ Running idf.py build...")
|
||
result = subprocess.run(
|
||
["bash", "-c", f". $HOME/esp-idf/export.sh > /dev/null 2>&1 && idf.py -C {self.project_path} -D SDKCONFIG_DEFAULTS={SDKCONFIG_FILENAME} build 2>&1"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=300 # 5 minutes timeout
|
||
)
|
||
|
||
if result.returncode != 0:
|
||
print(f"❌ Build failed for {device.device_id}")
|
||
print("Error output:")
|
||
print(result.stdout[-2000:] if len(result.stdout) > 2000 else result.stdout)
|
||
return None
|
||
|
||
except subprocess.TimeoutExpired:
|
||
print(f"❌ Build timeout for {device.device_id}")
|
||
return None
|
||
except Exception as e:
|
||
print(f"❌ Build error for {device.device_id}: {e}")
|
||
return None
|
||
|
||
# Find binary
|
||
bin_name = "epsilon_bot.bin"
|
||
bin_path = os.path.join(self.build_dir, bin_name)
|
||
|
||
if not os.path.exists(bin_path):
|
||
print(f"❌ Binary not found: {bin_path}")
|
||
return None
|
||
|
||
# Copy to firmware directory with device ID
|
||
output_bin = os.path.join(self.firmware_dir, f"{device.device_id}.bin")
|
||
subprocess.run(["cp", bin_path, output_bin], check=True)
|
||
|
||
print(f"✅ Firmware saved: {output_bin}")
|
||
return output_bin
|
||
|
||
|
||
class Flasher:
|
||
"""Handles flashing ESP32 devices"""
|
||
|
||
def __init__(self, project_path: str):
|
||
self.project_path = project_path
|
||
self.build_dir = os.path.join(project_path, "build")
|
||
|
||
def flash(self, device: Device, bin_path: str):
|
||
"""Flash a device with compiled firmware"""
|
||
print(f"\n{'='*60}")
|
||
print(f"🚀 Flashing: {device}")
|
||
print(f"{'='*60}")
|
||
|
||
# Locate required files
|
||
bootloader = os.path.join(self.build_dir, "bootloader", "bootloader.bin")
|
||
partitions = os.path.join(self.build_dir, "partition_table", "partition-table.bin")
|
||
|
||
# Check all files exist
|
||
if not os.path.exists(bootloader):
|
||
print(f"❌ Missing bootloader: {bootloader}")
|
||
return False
|
||
|
||
if not os.path.exists(partitions):
|
||
print(f"❌ Missing partition table: {partitions}")
|
||
return False
|
||
|
||
if not os.path.exists(bin_path):
|
||
print(f"❌ Missing application binary: {bin_path}")
|
||
return False
|
||
|
||
print(f"📁 Bootloader: {bootloader}")
|
||
print(f"📁 Partitions: {partitions}")
|
||
print(f"📁 Application: {bin_path}")
|
||
print(f"🔌 Port: {device.port}")
|
||
|
||
# Flash
|
||
try:
|
||
subprocess.run([
|
||
"esptool.py",
|
||
"--chip", "esp32",
|
||
"--port", device.port,
|
||
"--baud", "460800",
|
||
"write_flash", "-z",
|
||
"0x1000", bootloader,
|
||
"0x8000", partitions,
|
||
"0x10000", bin_path
|
||
], check=True)
|
||
|
||
print(f"✅ Successfully flashed {device.device_id}")
|
||
return True
|
||
|
||
except subprocess.CalledProcessError as e:
|
||
print(f"❌ Flash failed for {device.device_id}: {e}")
|
||
return False
|
||
except Exception as e:
|
||
print(f"❌ Unexpected error flashing {device.device_id}: {e}")
|
||
return False
|
||
|
||
|
||
def load_devices_from_config(config_path: str) -> tuple[str, List[Device]]:
|
||
"""Load devices from JSON config file"""
|
||
if not os.path.exists(config_path):
|
||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||
|
||
with open(config_path, "r") as f:
|
||
data = json.load(f)
|
||
|
||
project_path = data.get("project")
|
||
if not project_path:
|
||
raise ValueError("Missing 'project' field in config")
|
||
|
||
devices_data = data.get("devices", [])
|
||
devices = [Device.from_dict(d) for d in devices_data]
|
||
|
||
return project_path, devices
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Epsilon ESP32 Multi-Device Flasher",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
# Flash all devices from config file
|
||
python flash.py --config devices.json
|
||
|
||
# Flash a single device manually (WiFi)
|
||
python flash.py --manual \\
|
||
--project /home/user/epsilon/espilon_bot \\
|
||
--device-id abc12345 \\
|
||
--port /dev/ttyUSB0 \\
|
||
--srv-ip 192.168.1.100 \\
|
||
--wifi-ssid MyWiFi \\
|
||
--wifi-pass MyPassword
|
||
|
||
# Flash a single device manually (GPRS)
|
||
python flash.py --manual \\
|
||
--project /home/user/epsilon/espilon_bot \\
|
||
--device-id def67890 \\
|
||
--port /dev/ttyUSB1 \\
|
||
--srv-ip 203.0.113.10 \\
|
||
--network-mode gprs \\
|
||
--gprs-apn sl2sfr
|
||
|
||
# Build only (no flash)
|
||
python flash.py --config devices.json --build-only
|
||
"""
|
||
)
|
||
|
||
# Mode selection
|
||
mode_group = parser.add_mutually_exclusive_group(required=True)
|
||
mode_group.add_argument("--config", type=str, help="Path to devices.json config file")
|
||
mode_group.add_argument("--manual", action="store_true", help="Manual device configuration")
|
||
|
||
# Manual device configuration
|
||
parser.add_argument("--project", type=str, help="Path to epsilon_bot project directory")
|
||
parser.add_argument("--device-id", type=str, help="Device ID (8 hex chars)")
|
||
parser.add_argument("--port", type=str, help="Serial port (e.g., /dev/ttyUSB0)")
|
||
parser.add_argument("--srv-ip", type=str, help="C2 server IP address")
|
||
parser.add_argument("--srv-port", type=int, default=2626, help="C2 server port (default: 2626)")
|
||
|
||
# Network configuration
|
||
parser.add_argument("--network-mode", choices=["wifi", "gprs"], default="wifi", help="Network mode")
|
||
parser.add_argument("--wifi-ssid", type=str, help="WiFi SSID (required for WiFi mode)")
|
||
parser.add_argument("--wifi-pass", type=str, help="WiFi password (required for WiFi mode)")
|
||
parser.add_argument("--gprs-apn", type=str, default="sl2sfr", help="GPRS APN (default: sl2sfr)")
|
||
|
||
# Optional settings
|
||
parser.add_argument("--hostname", type=str, help="Device hostname (random if not specified)")
|
||
# Crypto keys are now provisioned separately via provision.py
|
||
|
||
# Modules
|
||
parser.add_argument("--enable-recon", action="store_true", help="Enable recon module")
|
||
parser.add_argument("--enable-fakeap", action="store_true", help="Enable fake AP module")
|
||
parser.add_argument("--enable-camera", action="store_true", help="Enable camera reconnaissance")
|
||
parser.add_argument("--enable-ble-trilat", action="store_true", help="Enable BLE trilateration")
|
||
|
||
# Actions
|
||
parser.add_argument("--build-only", action="store_true", help="Build firmware without flashing")
|
||
parser.add_argument("--flash-only", action="store_true", help="Flash existing firmware without rebuilding")
|
||
|
||
args = parser.parse_args()
|
||
|
||
# Load devices
|
||
if args.config:
|
||
# Load from config file
|
||
project_path, devices = load_devices_from_config(args.config)
|
||
print(f"📋 Loaded {len(devices)} device(s) from {args.config}")
|
||
print(f"📂 Project: {project_path}")
|
||
|
||
else:
|
||
# Manual device
|
||
required = ["project", "device_id", "port", "srv_ip"]
|
||
missing = [f for f in required if not getattr(args, f.replace("-", "_"))]
|
||
if missing:
|
||
parser.error(f"--manual mode requires: {', '.join(f'--{f}' for f in missing)}")
|
||
|
||
# Validate WiFi requirements
|
||
if args.network_mode == "wifi" and (not args.wifi_ssid or not args.wifi_pass):
|
||
parser.error("WiFi mode requires --wifi-ssid and --wifi-pass")
|
||
|
||
project_path = args.project
|
||
device = Device(
|
||
device_id=args.device_id,
|
||
port=args.port,
|
||
srv_ip=args.srv_ip,
|
||
srv_port=args.srv_port,
|
||
network_mode=args.network_mode,
|
||
wifi_ssid=args.wifi_ssid,
|
||
wifi_pass=args.wifi_pass,
|
||
gprs_apn=args.gprs_apn,
|
||
hostname=args.hostname,
|
||
module_network=True,
|
||
module_recon=args.enable_recon,
|
||
module_fakeap=args.enable_fakeap,
|
||
recon_camera=args.enable_camera,
|
||
recon_ble_trilat=args.enable_ble_trilat
|
||
)
|
||
devices = [device]
|
||
print(f"📋 Manual device configuration: {device}")
|
||
|
||
# Validate project path
|
||
if not os.path.exists(project_path):
|
||
print(f"❌ Project path not found: {project_path}")
|
||
return 1
|
||
|
||
# Initialize tools
|
||
builder = FirmwareBuilder(project_path)
|
||
flasher = Flasher(project_path)
|
||
|
||
# Process each device
|
||
success_count = 0
|
||
fail_count = 0
|
||
|
||
for i, device in enumerate(devices, 1):
|
||
print(f"\n{'#'*60}")
|
||
print(f"# Device {i}/{len(devices)}: {device.device_id}")
|
||
print(f"{'#'*60}")
|
||
|
||
try:
|
||
# Build
|
||
if not args.flash_only:
|
||
bin_path = builder.build(device)
|
||
if not bin_path:
|
||
print(f"⚠️ Skipping flash for {device.device_id} (build failed)")
|
||
fail_count += 1
|
||
continue
|
||
else:
|
||
# Use existing binary
|
||
bin_path = os.path.join(builder.firmware_dir, f"{device.device_id}.bin")
|
||
if not os.path.exists(bin_path):
|
||
print(f"❌ Firmware not found: {bin_path}")
|
||
fail_count += 1
|
||
continue
|
||
|
||
# Flash
|
||
if not args.build_only:
|
||
if flasher.flash(device, bin_path):
|
||
success_count += 1
|
||
else:
|
||
fail_count += 1
|
||
else:
|
||
print(f"ℹ️ Build-only mode, skipping flash")
|
||
success_count += 1
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error processing {device.device_id}: {e}")
|
||
fail_count += 1
|
||
|
||
# Summary
|
||
print(f"\n{'='*60}")
|
||
print(f"📊 SUMMARY")
|
||
print(f"{'='*60}")
|
||
print(f"✅ Success: {success_count}/{len(devices)}")
|
||
print(f"❌ Failed: {fail_count}/{len(devices)}")
|
||
print(f"{'='*60}\n")
|
||
|
||
return 0 if fail_count == 0 else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
exit(main())
|