feat(toolbox-ng): wire mac_hash into clientHashFromConn + Python parity (ref #662)

clientHashFromConn now resolves the peer IP via macHashOf (WG persona hash,
byte-identical to Python for 10.99.1.0/24), falling back to the raw peer IP for
non-WG/test conns so poison stays deterministic. Updated the TODO block: WG
mac_hash wiring DONE; remaining gap is only the transparent original-dst
plumbing (Deliverable 2) and the intentionally-out-of-scope R0-R2 ARP path.

test_machash_parity.py drives _common.mac_hash_of on the SAME fixtures; both
engines agree. Anti-rig verified on the Python side too.
This commit is contained in:
CyberMind-FR 2026-06-18 18:21:45 +02:00
parent 5fb67f5b88
commit 67e85ba4dd
2 changed files with 110 additions and 8 deletions

View File

@ -159,21 +159,36 @@ func (p *Policy) shouldPoison(host string) bool {
// clientHashFromConn returns the per-client identity used to mint the stable
// fake persona (jar fakeID first arg).
//
// PoC / CONNECT path: this is the peer IP string. A real TRANSPARENT R3 deploy
// MUST replace this with the mac_hash the Python addon uses
// (privacy_guard._client_hash → _common.mac_hash_of(peer_ip)), resolved via the
// SO_ORIGINAL_DST original-destination socket option and the WireGuard-peer →
// MAC map. Using the raw peer IP here is NOT identity-stable across NAT/DHCP
// and is intentionally a Phase-6-cutover TODO, not a shipped behaviour.
// It mirrors the Python privacy_guard._client_hash → _common.mac_hash_of(peer_ip)
// for the WireGuard R3 path: the peer IP is resolved to the WG persona hash
// (sha256(peer_pubkey)[:16]) by macHashOf. For 10.99.1.0/24 WG peers that hash
// is byte-identical to the Python engine (proven in machash_test.go ↔
// test_machash_parity.py), so a flow's fake persona is stable across the Go and
// Python engines and across restarts.
//
// TODO(#662 Phase 6): wire mac_hash via SO_ORIGINAL_DST + WG-peer map.
// macHashOf returns "" for any IP it cannot resolve (non-WG peers, the captive
// R0-R2 ARP path which is out of scope for this R3 engine, missing WG DB). In
// that case we fall back to the raw peer IP so non-WG / test conns still get a
// deterministic seed and poison remains functional — the fallback value is just
// not cross-engine-stable, which is acceptable for non-R3 traffic.
//
// DONE(#662): mac_hash wiring for the WG path. Remaining gaps, intentionally NOT
// addressed here:
// - the transparent original-dst plumbing that feeds the *real* peer IP into
// this function lives in transparent.go (handleTransparent); the CONNECT PoC
// still sees the proxy-hop peer IP.
// - the R0-R2 captive-subnet ARP/HMAC branch of _common.mac_hash_of is out of
// scope (this engine is WG-only — see machash.go macHashOf).
func clientHashFromConn(conn net.Conn) string {
if conn == nil {
return ""
}
host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
return conn.RemoteAddr().String()
host = conn.RemoteAddr().String()
}
if mh := macHashOf(host); mh != "" {
return mh
}
return host
}

View File

@ -0,0 +1,87 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Cross-engine mac_hash (WG persona identity) parity harness — Python side
(#662 Phase 6 prep).
Loads the SAME ``machash-fixtures.json`` + ``wg-peers-fixture.json`` the Go core
uses (``../secubox-toolbox-ng/testdata``), points ``_common._WG_PEERS_DB`` at the
fixture WG DB (NOT the real ``/var/lib/secubox/toolbox/wg-peers.json``), resets
the WG cache, and asserts ``_common.mac_hash_of`` == each fixture's ``expected``.
Python is the source of truth: the ``expected`` values were GENERATED by
``sha256(pubkey.encode()).hexdigest()[:16]`` (the very algorithm
``_common._wg_hash_of`` runs). The Go side (machash_test.go) must reproduce them
byte-for-byte. Both files reading identical inputs is what makes the parity
meaningful (and non-circular).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from mitmproxy_addons import _common
_HERE = os.path.dirname(os.path.abspath(__file__))
# tests/ → packages/secubox-toolbox → packages → packages/secubox-toolbox-ng
_NG_TESTDATA = os.path.normpath(
os.path.join(_HERE, "..", "..", "secubox-toolbox-ng", "testdata"))
_FIXTURES = os.path.join(_NG_TESTDATA, "machash-fixtures.json")
def _load():
with open(_FIXTURES, encoding="utf-8") as f:
return json.load(f)
@pytest.fixture
def wg_env(monkeypatch):
"""Point _common at the fixture WG DB and reset the mtime cache so the
override is (re)read. Mirrors exactly the (path, cache, mtime) surface the
Go wgHashOf reads (wgPeersPath + resetWGCache)."""
data = _load()
wg_path = os.path.join(_NG_TESTDATA, data["wg_peers_file"].replace("/", os.sep))
monkeypatch.setattr(_common, "_WG_PEERS_DB", Path(wg_path))
monkeypatch.setattr(_common, "_WG_PEERS_MTIME", 0.0)
_common._WG_PEERS_CACHE.clear()
return data
def test_machash_parity(wg_env):
failures = []
for fx in wg_env["fixtures"]:
# _common returns None where Go returns ""; normalise None → "".
got = _common.mac_hash_of(fx["ip"]) or ""
if got != fx["expected"]:
failures.append(
f"mac_hash_of({fx['ip']!r})={got!r} want {fx['expected']!r}"
f" ({fx.get('why')})")
assert not failures, "Python↔fixture mac_hash parity mismatches:\n" + "\n".join(failures)
def test_machash_coverage(wg_env):
# The fixtures must exercise the discriminating cases, else parity is vacuous.
resolved = subnet_miss = off_subnet = empty = False
for fx in wg_env["fixtures"]:
ip, exp = fx["ip"], fx["expected"]
if ip == "":
empty = True
elif exp != "":
resolved = True
elif ip.startswith("10.99.1."):
subnet_miss = True
else:
off_subnet = True
assert resolved and subnet_miss and off_subnet and empty, (
f"coverage incomplete: resolved={resolved} subnet_miss={subnet_miss} "
f"off_subnet={off_subnet} empty={empty}")
def test_machash_missing_db_fail_open(wg_env):
# A missing WG DB fails open to None (best-effort), never raises.
_common._WG_PEERS_DB = Path("/nonexistent/secubox/wg-peers.json")
_common._WG_PEERS_MTIME = 0.0
_common._WG_PEERS_CACHE.clear()
assert _common.mac_hash_of("10.99.1.10") is None