fieldwitness/src/soosef/fieldkit/killswitch.py
Aaron D. Lee 2629aabcc5
Some checks failed
CI / typecheck (push) Waiting to run
CI / lint (push) Has been cancelled
Fix 12 security findings from adversarial audit
CRITICAL:
- #1+#2: Consistency proof verification no longer a stub — implements
  actual hash chain reconstruction from proof hashes, rejects proofs
  that don't reconstruct to the expected root. GossipNode._verify_consistency
  now calls verify_consistency_proof() instead of just checking sizes.
- #3: Remove passphrase.lower() from KDF — was silently discarding
  case entropy from mixed-case passphrases. Passphrases are now
  case-sensitive as users would expect.
- #4: Federation gossip now applies record_filter (trust store check)
  on every received record before appending to the log. Untrusted
  attestor fingerprints are rejected with a warning.
- #5: Killswitch disables all logging BEFORE activation to prevent
  audit log from recording killswitch activity that could survive an
  interrupted purge. Audit log destruction moved to position 4 (right
  after keys + flask secret, before other data).

HIGH:
- #6: CSRF exemption narrowed from entire dropbox blueprint to only
  the upload view function. Admin routes retain CSRF protection.
- #7: /health endpoint returns only {"status":"ok"} to anonymous
  callers. Full operational report requires authentication.
- #8: Metadata stripping now reconstructs image from pixel data only
  (Image.new + putdata), stripping XMP, IPTC, and ICC profiles — not
  just EXIF.
- #9: Same as #6 (CSRF scope fix).

MEDIUM:
- #11: Receipt HMAC key changed from public upload token to server-side
  secret key, making valid receipts unforgeable by the source or anyone
  who captured the upload URL.
- #12: Docker CMD no longer defaults to --no-https. HTTPS with
  self-signed cert is the default; --no-https requires explicit opt-in.
- #14: shred return code now checked — non-zero exit falls through to
  the zero-overwrite fallback instead of silently succeeding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:31:03 -04:00

243 lines
7.8 KiB
Python

"""
Emergency data destruction — the killswitch.
Ordered destruction to maximize what's gone before any interruption.
Priority: keys first (most sensitive), then data, then logs.
On SSDs/flash where shred is less effective, key destruction is the real win —
without keys, the encrypted data is unrecoverable.
"""
from __future__ import annotations
import enum
import logging
import platform
import shutil
import subprocess
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
import soosef.paths as paths
logger = logging.getLogger(__name__)
class PurgeScope(enum.Enum):
"""What to destroy."""
KEYS_ONLY = "keys_only" # Just key material
ALL = "all" # Everything
@dataclass
class PurgeResult:
"""Report of what was destroyed."""
steps_completed: list[str] = field(default_factory=list)
steps_failed: list[tuple[str, str]] = field(default_factory=list)
fully_purged: bool = False
def _secure_delete_file(path: Path) -> None:
"""Overwrite and delete a file. Best-effort on flash storage."""
if not path.exists():
return
if platform.system() == "Linux":
try:
result = subprocess.run(
["shred", "-u", "-z", "-n", "3", str(path)],
timeout=30,
capture_output=True,
)
if result.returncode == 0:
return
# shred failed (permissions, read-only FS, etc.) — fall through to overwrite
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
# Fallback: overwrite with zeros then delete
size = path.stat().st_size
with open(path, "wb") as f:
f.write(b"\x00" * size)
f.flush()
path.unlink()
def _secure_delete_dir(path: Path) -> None:
"""Recursively secure-delete all files in a directory, then remove it."""
if not path.exists():
return
for child in path.rglob("*"):
if child.is_file():
_secure_delete_file(child)
shutil.rmtree(path, ignore_errors=True)
def execute_purge(scope: PurgeScope = PurgeScope.ALL, reason: str = "manual") -> PurgeResult:
"""
Execute emergency purge.
Destruction order is intentional — keys go first because they're
the smallest and most critical. Even if the purge is interrupted
after step 1, the remaining data is cryptographically useless.
"""
result = PurgeResult()
# Disable all logging BEFORE activation to prevent the audit log
# from recording killswitch activity that could survive an interrupted purge.
logging.disable(logging.CRITICAL)
steps: list[tuple[str, Callable]] = [
("destroy_identity_keys", lambda: _secure_delete_dir(paths.IDENTITY_DIR)),
("destroy_channel_key", lambda: _secure_delete_file(paths.CHANNEL_KEY_FILE)),
("destroy_flask_secret", lambda: _secure_delete_file(paths.INSTANCE_DIR / ".secret_key")),
]
if scope == PurgeScope.ALL:
steps.extend(
[
# Audit log destroyed EARLY — before other data — to minimize
# forensic evidence if the purge is interrupted.
("destroy_audit_log", lambda: _secure_delete_file(paths.AUDIT_LOG)),
("destroy_auth_db", lambda: _secure_delete_file(paths.AUTH_DB)),
("destroy_attestation_log", lambda: _secure_delete_dir(paths.ATTESTATIONS_DIR)),
("destroy_chain_data", lambda: _secure_delete_dir(paths.CHAIN_DIR)),
("destroy_temp_files", lambda: _secure_delete_dir(paths.TEMP_DIR)),
("destroy_config", lambda: _secure_delete_file(paths.CONFIG_FILE)),
("clear_journald", _clear_system_logs),
("deep_forensic_scrub", _deep_forensic_scrub),
("uninstall_package", _uninstall_package),
]
)
for name, action in steps:
try:
action()
result.steps_completed.append(name)
logger.info("Purge step completed: %s", name)
except Exception as e:
result.steps_failed.append((name, str(e)))
logger.error("Purge step failed: %s%s", name, e)
result.fully_purged = len(result.steps_failed) == 0
return result
def _clear_system_logs() -> None:
"""Best-effort clearing of system journal entries for soosef."""
if platform.system() != "Linux":
return
try:
subprocess.run(
["journalctl", "--vacuum-time=1s", "--unit=soosef*"],
timeout=10,
capture_output=True,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
def _uninstall_package() -> None:
"""Best-effort self-uninstall of the soosef pip package."""
try:
subprocess.run(
[sys.executable, "-m", "pip", "uninstall", "-y", "soosef"],
timeout=30,
capture_output=True,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
def _deep_forensic_scrub() -> None:
"""Best-effort removal of all forensic traces of SooSeF installation.
Targets:
- Python __pycache__ and .pyc files for soosef/stegasoo/verisoo
- pip dist-info directories
- pip download cache
- Shell history entries containing 'soosef'
"""
import glob
import site
# Scrub __pycache__ and dist-info in site-packages
for site_dir in site.getsitepackages() + [site.getusersitepackages()]:
if not isinstance(site_dir, str):
continue
site_path = Path(site_dir)
if not site_path.exists():
continue
for pattern in ["soosef*", "stegasoo*", "verisoo*"]:
for match in site_path.glob(pattern):
try:
if match.is_dir():
shutil.rmtree(match)
else:
_secure_delete_file(match)
except OSError:
pass
# Scrub pip cache
pip_cache = Path.home() / ".cache" / "pip"
if pip_cache.exists():
for pattern in ["*soosef*", "*stegasoo*", "*verisoo*"]:
for match in pip_cache.rglob(pattern):
try:
if match.is_dir():
shutil.rmtree(match)
else:
match.unlink()
except OSError:
pass
# Scrub shell history (best-effort, rewrite without soosef lines)
for hist_file in [".bash_history", ".zsh_history", ".local/share/fish/fish_history"]:
hist_path = Path.home() / hist_file
if hist_path.exists():
try:
lines = hist_path.read_text().splitlines()
cleaned = [l for l in lines if "soosef" not in l.lower()]
hist_path.write_text("\n".join(cleaned) + "\n")
except OSError:
pass
# ── Hardware GPIO killswitch ─────────────────────────────────────────
try:
from gpiozero import Button
HAS_GPIO = True
except ImportError:
HAS_GPIO = False
def watch_hardware_button(
pin: int = 17,
hold_seconds: float = 5.0,
callback: Callable | None = None,
) -> None:
"""
Monitor GPIO pin for physical killswitch button.
Requires holding for hold_seconds to prevent accidental trigger.
"""
if not HAS_GPIO:
logger.warning("gpiozero not available — hardware killswitch disabled")
return
def _on_held():
logger.warning("Hardware killswitch button held for %ss — firing", hold_seconds)
if callback:
callback()
else:
execute_purge(PurgeScope.ALL, reason="hardware_button")
btn = Button(pin, hold_time=hold_seconds)
btn.when_held = _on_held
logger.info("Hardware killswitch armed on GPIO pin %d (hold %ss to trigger)", pin, hold_seconds)