mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
User reported : report empty data for R3, cert test false-negative, splash
shows wrong level. Root cause = R3 mac_hash (sha256(wg_pubkey)) never
written to DB.
## Phase 6.H : R3 mac_hash propagation
### _common.py mac_hash_of() — WG-aware
For ip in 10.99.1.0/24 (WG peer subnet) returns sha256(peer_pubkey)[:16]
read from /var/lib/secubox/toolbox/wg-peers.json (cached, mtime-aware
auto-reload). For other IPs falls back to ARP→hash. Single change
cascades to ALL addons that use mac_hash_of (cookies, dpi, ja4,
soc_relay, avatar, inject_banner).
### local_store.py _client_hash() — same logic
local_store is the source-of-truth writer for the toolbox.db events
table. Has its own _hash_mac+_mac_of (no _common import), so duplicate
the WG-aware lookup as _client_hash() and replace all 5 call sites.
### /wg/profile/new — register peer in clients table
Upserts mac_hash = sha256(pubkey)[:16] with level='r3' so :
- store.get_client_level(wg_hash) returns 'r3' (was 'r1' default)
- admin webui /admin/clients/rich lists R3 peers
- _aggregate_session() finds the row (was returning empty session)
- dashboards display R3 badge correctly
Returns X-Client-Hash header so frontends can know their own hash.
### store.list_clients() — include level column
SQL forgot to SELECT level → admin counts showed 0 R3 even though
all peers had level=r3 in DB. One-word fix.
## Phase 6.G2 : cert test 2-step
Old single-probe test gave false negatives :
- User on kbin Internet direct → probe example.com OK → reported
'CA installed' but user wasn't even in tunnel
- User in tunnel → probe slow / favicon 404 → reported 'CA missing'
even when CA was fine
New /landing cert test does :
1. Try GET http://10.99.0.1:8088/qr/splash.png — only reachable
from inside the WG tunnel (10.99.0.1 is captive-internal, NAT
through wg-toolbox iface)
2. If reachable → try HTTPS probe (www.gstatic.com/generate_204).
If mitm CA installed, image loads; else cert verify fails.
3. Combine into clear verdict :
🌐 'Hors tunnel R3 — active WG d'abord'
🟢 'Tunnel R3 actif + CA R3 trusté ✓'
🔴 'Tunnel R3 actif mais CA R3 NON trusté — installe profil'
## Live verification
Backfilled 21 existing WG peers into clients table (level=r3) :
10.99.1.2..22 → wg_hash → level=r3
mac_hash_of('10.99.1.15') (Android) → 0fed072beea7ee69 ✓
mac_hash_of('10.99.1.18') (iPhone) → 3f988da842ae6806 ✓
store.get_client_level('0fed072beea7ee69') → 'r3' ✓
store.get_client_level('3f988da842ae6806') → 'r3' ✓
Events table will populate as phones surf via mitm-wg.
149 lines
4.8 KiB
Python
149 lines
4.8 KiB
Python
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
|
|
|
"""mitmproxy addons :: common helpers (fire-and-forget POST + MAC lookup)."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
try:
|
|
import httpx
|
|
except ImportError:
|
|
httpx = None
|
|
|
|
log = logging.getLogger("secubox.toolbox.addons")
|
|
_RE_MAC = re.compile(r"lladdr\s+([0-9a-f:]{17})", re.I)
|
|
_SALT_FILE = Path("/etc/secubox/secrets/toolbox-mac-salt")
|
|
_SALT_CACHE: str | None = None
|
|
|
|
|
|
def mac_of(ip: str) -> str | None:
|
|
try:
|
|
out = subprocess.run(
|
|
["ip", "neigh", "show", ip],
|
|
capture_output=True, text=True, timeout=1, check=False,
|
|
).stdout
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return None
|
|
m = _RE_MAC.search(out)
|
|
return m.group(1).lower() if m else None
|
|
|
|
|
|
def _salt() -> str:
|
|
"""Read MAC anonymization salt once, cache in-process."""
|
|
global _SALT_CACHE
|
|
if _SALT_CACHE is None:
|
|
try:
|
|
_SALT_CACHE = _SALT_FILE.read_text().strip()
|
|
except Exception as e:
|
|
log.warning("salt unavailable, MAC hashing disabled: %s", e)
|
|
_SALT_CACHE = ""
|
|
return _SALT_CACHE
|
|
|
|
|
|
def hash_mac(mac: str | None) -> str | None:
|
|
"""HMAC-SHA256 of MAC with salt + day rotation. 16-char hex digest.
|
|
|
|
Same algorithm as secubox_toolbox.mac.hash_mac and local_store._hash_mac
|
|
so events from all addons cross-reference the same mac_hash.
|
|
"""
|
|
if not mac:
|
|
return None
|
|
s = _salt()
|
|
if not s:
|
|
return None
|
|
key = (s + ":" + date.today().isoformat()).encode()
|
|
return hmac.new(key, mac.lower().encode(), hashlib.sha256).hexdigest()[:16]
|
|
|
|
|
|
# ── Phase 6 (#496) : WG peer identity (10.99.1.0/24) ───────────────
|
|
import json as _jsonmod
|
|
import hashlib as _hashlib
|
|
from pathlib import Path as _Path
|
|
|
|
_WG_PEERS_DB = _Path("/var/lib/secubox/toolbox/wg-peers.json")
|
|
_WG_PEERS_CACHE: dict[str, str] = {} # ip → wg_hash
|
|
_WG_PEERS_MTIME: float = 0.0
|
|
|
|
|
|
def _wg_hash_of(ip: str) -> str | None:
|
|
"""Map 10.99.1.X → sha256(peer_pubkey)[:16]. Cached, reloaded on mtime
|
|
change. R3 clients have NO ARP entry on captive subnet, so we identify
|
|
them by their WG public key (one peer → one IP, deterministic)."""
|
|
global _WG_PEERS_MTIME
|
|
try:
|
|
if not _WG_PEERS_DB.exists():
|
|
return None
|
|
mtime = _WG_PEERS_DB.stat().st_mtime
|
|
if mtime != _WG_PEERS_MTIME or not _WG_PEERS_CACHE:
|
|
data = _jsonmod.loads(_WG_PEERS_DB.read_text()).get("peers", {})
|
|
_WG_PEERS_CACHE.clear()
|
|
for pubkey, meta in data.items():
|
|
peer_ip = meta.get("ip")
|
|
if peer_ip:
|
|
_WG_PEERS_CACHE[peer_ip] = _hashlib.sha256(pubkey.encode()).hexdigest()[:16]
|
|
_WG_PEERS_MTIME = mtime
|
|
return _WG_PEERS_CACHE.get(ip)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def mac_hash_of(ip: str | None) -> str | None:
|
|
"""Resolve IP to a stable per-client identity hash.
|
|
|
|
- 10.99.1.0/24 (WG peer) → sha256(peer_pubkey)[:16] (Phase 6/R3)
|
|
- Captive subnet → HMAC(salt, MAC from ARP) (R0/R1/R2)
|
|
- Else → None
|
|
"""
|
|
if not ip:
|
|
return None
|
|
# Phase 6 : WG peers identified by pubkey hash, not ARP
|
|
if ip.startswith("10.99.1."):
|
|
return _wg_hash_of(ip)
|
|
raw = mac_of(ip)
|
|
return hash_mac(raw) if raw else None
|
|
|
|
|
|
def _resolve_socket_url(target: str) -> tuple[str, str]:
|
|
"""
|
|
Parse 'http+unix:///path/to.sock/route' → ('/path/to.sock', '/route').
|
|
"""
|
|
if target.startswith("http+unix://"):
|
|
rest = target[len("http+unix://"):]
|
|
slash_idx = rest.find("/", 1)
|
|
sock = rest[:slash_idx] if slash_idx > 0 else rest
|
|
path = rest[slash_idx:] if slash_idx > 0 else "/"
|
|
return sock, path
|
|
raise ValueError(f"unsupported target {target!r}")
|
|
|
|
|
|
async def fire_forget_post(target: str, payload: dict) -> None:
|
|
"""Fire-and-forget POST to a unix-socket'd endpoint. Never blocks the proxy flow."""
|
|
if httpx is None:
|
|
log.warning("httpx not available — addon POST skipped")
|
|
return
|
|
try:
|
|
sock, path = _resolve_socket_url(target)
|
|
transport = httpx.AsyncHTTPTransport(uds=sock)
|
|
async with httpx.AsyncClient(transport=transport, timeout=2) as client:
|
|
await client.post(f"http://localhost{path}", json=payload)
|
|
except Exception as e:
|
|
log.debug("addon POST to %s failed: %s", target, e)
|
|
|
|
|
|
def queue_async(coro) -> None:
|
|
"""Wrap coro in create_task at the mitmproxy event loop. Never raise."""
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
loop.create_task(coro)
|
|
except RuntimeError:
|
|
pass
|