Files
bigbrother/modules/connectivity/ble_emergency.py
T
Cobra ffd384f64b Strip OPSEC tool identity fingerprints
Replace all sensor.* logger namespaces with __name__ (generic module
identifiers instead of discoverable 'sensor.*' prefixes).

Change hardcoded 'bb' API user to 'admin' in config and code defaults.

Change hardcoded relay_user 'bb' to 'operator' — prevents network
profiling from exposing tool identity via SSH config.

Fixes #457, #458, #459
2026-04-08 22:18:35 -04:00

448 lines
16 KiB
Python

#!/usr/bin/env python3
"""BLE emergency module — GATT server for local emergency access.
Provides a Bluetooth Low Energy GATT server for last-resort local access
when all network connectivity is lost. PSK-authenticated commands over
a custom GATT service allow the operator within ~10m to check status,
issue kill commands, reboot, or retrieve the current IP.
Disabled by default — must be explicitly enabled in config.
"""
import hashlib
import hmac
import json
import logging
import os
import secrets
import subprocess
import threading
import time
from typing import Optional
from modules.base import BaseModule
logger = logging.getLogger(__name__)
# Custom BLE service/characteristic UUIDs (random, non-standard)
SERVICE_UUID = "bb000001-1337-4000-8000-000000000001"
CMD_CHAR_UUID = "bb000002-1337-4000-8000-000000000002" # Write: send command
RESP_CHAR_UUID = "bb000003-1337-4000-8000-000000000003" # Read/Notify: response
# Max BLE MTU for response
BLE_MTU = 512
ADV_TIMEOUT = 0 # 0 = advertise indefinitely
class BLEEmergency(BaseModule):
"""BLE GATT server for emergency local access with PSK auth."""
name = "ble_emergency"
module_type = "connectivity"
priority = -50
requires_root = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._psk: Optional[str] = None
self._advertising = False
self._gatt_thread: Optional[threading.Thread] = None
self._hci_device = "hci0"
self._device_name: Optional[str] = None
self._nonce: Optional[str] = None
self._authenticated_sessions: set = set()
self._last_response: str = ""
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
ble_cfg = self.config.get("connectivity", {}).get("ble_emergency", {})
# Disabled by default
if not ble_cfg.get("enabled", False):
logger.info("BLE emergency module disabled (set ble_emergency.enabled=true)")
self.state.set_module_status(self.name, "disabled",
extra={"reason": "not_enabled"})
return
self._psk = ble_cfg.get("psk")
if not self._psk:
logger.error("BLE emergency PSK not configured")
self.state.set_module_status(self.name, "error")
return
self._hci_device = ble_cfg.get("hci_device", "hci0")
self._device_name = ble_cfg.get("device_name")
# If no device name, try to match MAC profile for consistency
if not self._device_name:
mac_hostname = self.state.get(
"mac_manager", "dhcp_hostname", default=None
)
self._device_name = mac_hostname if mac_hostname else "LE-Device"
if not self._check_bluetooth_available():
logger.error("Bluetooth hardware not available")
self.state.set_module_status(self.name, "error")
return
if not self.start_gatt():
self.state.set_module_status(self.name, "error")
return
self._running = True
self._pid = os.getpid()
self._start_time = time.time()
self.state.set_module_status(self.name, "running", pid=self._pid)
logger.info("BLE emergency GATT server active (device: %s)", self._device_name)
def stop(self) -> None:
if not self._running:
return
self.stop_gatt()
self._running = False
self._advertising = False
self.state.set_module_status(self.name, "stopped")
logger.info("BLE emergency stopped")
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"advertising": self._advertising,
"hci_device": self._hci_device,
"device_name": self._device_name,
"authenticated_sessions": len(self._authenticated_sessions),
}
def configure(self, config: dict) -> None:
self.config.update(config)
def health_check(self) -> bool:
if not self._running:
return False
return self.is_advertising()
# ------------------------------------------------------------------
# GATT server operations
# ------------------------------------------------------------------
def start_gatt(self) -> bool:
"""Start BLE advertising and GATT server."""
# Enable BLE adapter
if not self._enable_adapter():
return False
# Set device name
if not self._set_device_name():
logger.warning("Could not set BLE device name")
# Start advertising
if not self._start_advertising():
return False
# Start GATT command listener in background thread
self._nonce = secrets.token_hex(16)
self._gatt_thread = threading.Thread(
target=self._gatt_listen_loop, daemon=True, name="sensor-ble-gatt",
)
self._gatt_thread.start()
self._advertising = True
return True
def stop_gatt(self) -> None:
"""Stop GATT server and BLE advertising."""
self._advertising = False
self._stop_advertising()
if self._gatt_thread and self._gatt_thread.is_alive():
self._gatt_thread.join(timeout=3.0)
self._authenticated_sessions.clear()
def is_advertising(self) -> bool:
"""Check if BLE advertising is active."""
try:
result = subprocess.run(
["hciconfig", self._hci_device],
capture_output=True, text=True, timeout=5,
)
return "UP RUNNING" in result.stdout
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
# ------------------------------------------------------------------
# BLE adapter management
# ------------------------------------------------------------------
def _check_bluetooth_available(self) -> bool:
"""Check if Bluetooth hardware is present."""
try:
result = subprocess.run(
["hciconfig", self._hci_device],
capture_output=True, timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def _enable_adapter(self) -> bool:
"""Power on the BLE adapter."""
try:
subprocess.run(
["hciconfig", self._hci_device, "up"],
check=True, capture_output=True, timeout=10,
)
# Enable LE mode
subprocess.run(
["hciconfig", self._hci_device, "leadv", "0"],
capture_output=True, timeout=5,
)
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
logger.error("Failed to enable BLE adapter: %s", exc)
return False
def _set_device_name(self) -> bool:
"""Set the BLE device name via bluetoothctl."""
try:
proc = subprocess.run(
["bluetoothctl", "--", "system-alias", self._device_name],
capture_output=True, timeout=5,
)
return proc.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def _start_advertising(self) -> bool:
"""Start BLE advertising."""
try:
# Set advertising data
subprocess.run(
["hciconfig", self._hci_device, "leadv", "0"],
capture_output=True, timeout=5,
)
# Also use btmgmt for more control if available
subprocess.run(
["btmgmt", "--index", self._hci_device.replace("hci", ""),
"power", "on"],
capture_output=True, timeout=5,
)
subprocess.run(
["btmgmt", "--index", self._hci_device.replace("hci", ""),
"le", "on"],
capture_output=True, timeout=5,
)
subprocess.run(
["btmgmt", "--index", self._hci_device.replace("hci", ""),
"connectable", "on"],
capture_output=True, timeout=5,
)
subprocess.run(
["btmgmt", "--index", self._hci_device.replace("hci", ""),
"advertising", "on"],
capture_output=True, timeout=5,
)
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
return False
def _stop_advertising(self) -> None:
"""Stop BLE advertising."""
try:
subprocess.run(
["hciconfig", self._hci_device, "noleadv"],
capture_output=True, timeout=5,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
pass
# ------------------------------------------------------------------
# GATT command processing
# ------------------------------------------------------------------
def _gatt_listen_loop(self) -> None:
"""Listen for GATT characteristic writes via bluetoothctl/gatttool.
This loop uses a simple file-based IPC approach: commands are written
to a well-known path by the BLE GATT callback, and this loop processes
them. In production, this would use dbus-python to register a proper
GATT application.
"""
cmd_fifo = "/tmp/.bb_ble_cmd"
resp_fifo = "/tmp/.bb_ble_resp"
# Create FIFOs for IPC
for fifo in (cmd_fifo, resp_fifo):
try:
if os.path.exists(fifo):
os.unlink(fifo)
os.mkfifo(fifo, 0o600)
except OSError as exc:
logger.error("Cannot create BLE FIFO %s: %s", fifo, exc)
return
logger.debug("BLE GATT listener ready on FIFOs")
while self._advertising and self._running:
try:
# Non-blocking read from command FIFO
fd = os.open(cmd_fifo, os.O_RDONLY | os.O_NONBLOCK)
try:
data = os.read(fd, 4096)
finally:
os.close(fd)
if data:
cmd_str = data.decode(errors='replace').strip()
response = self._process_command(cmd_str)
self._last_response = response
# Write response to response FIFO
try:
fd = os.open(resp_fifo, os.O_WRONLY | os.O_NONBLOCK)
try:
os.write(fd, response.encode()[:BLE_MTU])
finally:
os.close(fd)
except OSError:
pass
except OSError:
pass # No data ready
time.sleep(0.5)
# Cleanup FIFOs
for fifo in (cmd_fifo, resp_fifo):
try:
os.unlink(fifo)
except OSError:
pass
def _process_command(self, raw_cmd: str) -> str:
"""Process an authenticated BLE command.
Command format: <hmac_hex>:<command>
HMAC is computed over: nonce + command using the PSK.
"""
if ":" not in raw_cmd:
return '{"error": "invalid format"}'
provided_hmac, command = raw_cmd.split(":", 1)
command = command.strip().lower()
# Verify HMAC
if not self._verify_hmac(provided_hmac, command):
logger.warning("BLE auth failed for command: %s", command)
return '{"error": "auth_failed"}'
# Rotate nonce after successful auth
old_nonce = self._nonce
self._nonce = secrets.token_hex(16)
# Execute command
if command == "status":
return self._cmd_status()
elif command == "kill":
return self._cmd_kill()
elif command == "reboot":
return self._cmd_reboot()
elif command == "get_ip":
return self._cmd_get_ip()
elif command == "get_nonce":
# Return new nonce for next command (unauthenticated)
return json.dumps({"nonce": self._nonce})
else:
return json.dumps({"error": "unknown_command", "commands": [
"status", "kill", "reboot", "get_ip", "get_nonce"
]})
def _verify_hmac(self, provided_hmac: str, command: str) -> bool:
"""Verify HMAC authentication for a BLE command."""
if not self._psk or not self._nonce:
return False
message = f"{self._nonce}:{command}".encode()
expected = hmac.new(
self._psk.encode(), message, hashlib.sha256
).hexdigest()
return hmac.compare_digest(provided_hmac.lower(), expected.lower())
# ------------------------------------------------------------------
# BLE command handlers
# ------------------------------------------------------------------
def _cmd_status(self) -> str:
"""Return implant status summary."""
status_data = {
"ok": True,
"uptime": time.time() - self._start_time if self._start_time else 0,
"nonce": self._nonce,
}
# Get connectivity status from state
all_status = self.state.get_all_module_status()
connectivity_modules = {}
for name, info in all_status.items():
if name in ("wireguard", "tailscale", "cellular_backup"):
connectivity_modules[name] = info.get("status", "unknown")
status_data["connectivity"] = connectivity_modules
return json.dumps(status_data)
def _cmd_kill(self) -> str:
"""Trigger kill switch via event bus."""
logger.warning("Kill switch triggered via BLE emergency")
self.bus.emit("KILL_SWITCH",
{"source": "ble_emergency", "reason": "operator_ble_command"},
source_module=self.name)
return json.dumps({"ok": True, "action": "kill_switch_triggered"})
def _cmd_reboot(self) -> str:
"""Trigger system reboot."""
logger.warning("Reboot triggered via BLE emergency")
def _delayed_reboot():
time.sleep(2.0)
try:
subprocess.run(["reboot"], timeout=5)
except Exception:
pass
threading.Thread(target=_delayed_reboot, daemon=True).start()
return json.dumps({"ok": True, "action": "reboot_in_2s"})
def _cmd_get_ip(self) -> str:
"""Return all IP addresses on the system."""
ips = {}
try:
result = subprocess.run(
["ip", "-4", "-o", "addr", "show"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
import re
for line in result.stdout.strip().splitlines():
match = re.search(r'(\S+)\s+inet\s+(\d+\.\d+\.\d+\.\d+)', line)
if match:
iface = match.group(1)
ip = match.group(2)
if iface != "lo":
ips[iface] = ip
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return json.dumps({"ips": ips, "nonce": self._nonce})