feat(toolbox-ng): port WG persona mac_hash to Go with cross-engine parity (ref #662)

Port _common._wg_hash_of / mac_hash_of to cmd/sbxmitm/machash.go: WG peers on
10.99.1.0/24 resolve to sha256(peer_pubkey)[:16], mtime-cached behind a mutex
(Go is concurrent; Python relied on the GIL). Off-subnet / R0-R2 ARP path is
out of scope for the R3 transparent engine; any error fails open to "".

Parity fixtures (testdata/wg-peers-fixture.json + machash-fixtures.json) carry
Python-authored expected values; machash_test.go asserts macHashOf matches.
Anti-rig verified: [:16]->[:15] fails the test.
This commit is contained in:
CyberMind-FR 2026-06-18 18:21:39 +02:00
parent c870b6362b
commit 5fb67f5b88
4 changed files with 289 additions and 0 deletions

View File

@ -0,0 +1,119 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: toolbox-ng :: WG persona identity (mac_hash) (#662 Phase 6 prep)
//
// Byte-exact port of the Python WG-peer identity resolver
// (packages/secubox-toolbox/mitmproxy_addons/_common.py: _wg_hash_of /
// mac_hash_of). Python is the source of truth; this mirrors it exactly, proven
// by the cross-engine parity harness (testdata/wg-peers-fixture.json +
// testdata/machash-fixtures.json + machash_test.go ↔ tests/test_machash_parity.py).
//
// R3 clients reach this transparent engine over WireGuard on 10.99.1.0/24 and
// have NO ARP entry on the captive subnet, so they are identified by their WG
// public key (one peer → one IP, deterministic): ip → sha256(pubkey)[:16].
//
// Pure standard library — no external modules, no go.sum.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"strings"
"sync"
)
// wgPeersPath is the on-disk WG peer DB, mirroring _common._WG_PEERS_DB. It is a
// package-level var (not a const) so tests can repoint it at a fixture.
var wgPeersPath = "/var/lib/secubox/toolbox/wg-peers.json"
// wgPeer mirrors the per-pubkey metadata object in wg-peers.json. Only "ip" is
// consumed here (other fields are ignored, like the Python meta.get("ip")).
type wgPeer struct {
IP string `json:"ip"`
}
// wgPeersDB mirrors the file shape: {"peers": {"<pubkey>": {"ip": "..."}}}.
type wgPeersDB struct {
Peers map[string]wgPeer `json:"peers"`
}
// WG peer cache, mtime-keyed and reloaded only on mtime change — exactly like
// the Python _WG_PEERS_CACHE / _WG_PEERS_MTIME globals. Guarded by a mutex: the
// Go proxy is genuinely concurrent (Python relied on the GIL), so the cache map
// and mtime MUST NOT be read/written without holding wgMu.
var (
wgMu sync.Mutex
wgCache map[string]string // ip → sha256(pubkey)[:16]
wgMtime int64 // last loaded file mtime (UnixNano), 0 = unloaded
)
// resetWGCache clears the in-process WG cache so the next wgHashOf reload reads
// wgPeersPath afresh. Used by tests after repointing wgPeersPath; mirrors the
// Python parity test resetting _WG_PEERS_CACHE/_WG_PEERS_MTIME.
func resetWGCache() {
wgMu.Lock()
wgCache = nil
wgMtime = 0
wgMu.Unlock()
}
// wgHashOf maps a WG peer IP (10.99.1.X) to sha256(peer_pubkey)[:16]. Mirrors
// _common._wg_hash_of EXACTLY: mtime-cached, reloaded only when the file mtime
// changes (or the cache is empty); ANY error (missing file, bad JSON, stat
// failure) → "" (best-effort, fail-open to empty, never panics). Returns "" for
// an IP not present in the DB. The cache is mutex-guarded for concurrency.
func wgHashOf(ip string) string {
wgMu.Lock()
defer wgMu.Unlock()
fi, err := os.Stat(wgPeersPath)
if err != nil {
return "" // missing file / unreadable → fail-open (Python: not exists → None)
}
mtime := fi.ModTime().UnixNano()
if mtime != wgMtime || wgCache == nil {
raw, err := os.ReadFile(wgPeersPath)
if err != nil {
return ""
}
var db wgPeersDB
if err := json.Unmarshal(raw, &db); err != nil {
return "" // bad JSON → fail-open (Python: except → None)
}
fresh := make(map[string]string, len(db.Peers))
for pubkey, meta := range db.Peers {
if meta.IP != "" {
sum := sha256.Sum256([]byte(pubkey))
fresh[meta.IP] = hex.EncodeToString(sum[:])[:16]
}
}
wgCache = fresh
wgMtime = mtime
}
return wgCache[ip] // missing key → "" (Python: cache.get(ip) → None)
}
// macHashOf resolves an IP to a stable per-client persona identity hash.
// Mirrors _common.mac_hash_of, but scoped to the R3 transparent engine:
//
// - empty ip → ""
// - 10.99.1.0/24 (WG peer) → wgHashOf(ip) = sha256(peer_pubkey)[:16]
// - else → ""
//
// The Python mac_hash_of has a third branch for the captive subnet
// (R0/R1/R2): hash_mac(mac_of(ip)) = HMAC(salt, ARP MAC). That ARP/HMAC path is
// INTENTIONALLY out of scope here — R3 clients arrive over WireGuard and have no
// ARP entry on the captive subnet, so this engine is WG-only. Off-subnet IPs
// therefore resolve to "" (the caller falls back to the raw peer IP).
func macHashOf(ip string) string {
if ip == "" {
return ""
}
if strings.HasPrefix(ip, "10.99.1.") {
return wgHashOf(ip)
}
return "" // R0-R2 ARP/HMAC path out of scope for the R3 transparent engine
}

View File

@ -0,0 +1,118 @@
// 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 — Go side
// (#662 Phase 6 prep).
//
// Loads testdata/machash-fixtures.json + the SAME testdata/wg-peers-fixture.json
// the Python side reads, points wgPeersPath at the fixture, and asserts
// macHashOf(ip) == each fixture's expected. The Python side
// (../secubox-toolbox/tests/test_machash_parity.py) monkeypatches
// _common._WG_PEERS_DB to the SAME fixture and drives _common.mac_hash_of; both
// must agree → the WG persona hash is byte-exact across engines. Python is the
// source of truth: the expected values were GENERATED by sha256(pubkey)[:16] in
// Python, never hand-computed in Go (non-circular parity).
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
type machashFixture struct {
IP string `json:"ip"`
Expected string `json:"expected"`
Why string `json:"why"`
}
type machashFile struct {
WGPeersFile string `json:"wg_peers_file"`
Fixtures []machashFixture `json:"fixtures"`
}
func loadMachashFile(t *testing.T) (machashFile, string) {
t.Helper()
dir := testdataDir(t) // shared with policy_test.go (cmd/sbxmitm → ../../testdata)
raw, err := os.ReadFile(filepath.Join(dir, "machash-fixtures.json"))
if err != nil {
t.Fatalf("read machash fixtures: %v", err)
}
var mf machashFile
if err := json.Unmarshal(raw, &mf); err != nil {
t.Fatalf("parse machash fixtures: %v", err)
}
if len(mf.Fixtures) == 0 {
t.Fatal("no machash fixtures")
}
return mf, dir
}
// withWGFixture points wgPeersPath at the fixture and resets the cache so the
// override is (re)read, restoring the original path afterwards. Mirrors exactly
// the (path, cache) surface the Python _wg_hash_of reads.
func withWGFixture(t *testing.T, mf machashFile, dir string) {
t.Helper()
orig := wgPeersPath
wgPeersPath = filepath.Join(dir, mf.WGPeersFile)
resetWGCache()
t.Cleanup(func() {
wgPeersPath = orig
resetWGCache()
})
}
// TestMacHashParity: macHashOf == Python-generated expected for every fixture.
func TestMacHashParity(t *testing.T) {
mf, dir := loadMachashFile(t)
withWGFixture(t, mf, dir)
for _, fx := range mf.Fixtures {
got := macHashOf(fx.IP)
if got != fx.Expected {
t.Errorf("macHashOf(%q)=%q want %q (%s)", fx.IP, got, fx.Expected, fx.Why)
}
}
}
// TestMacHashCoverage: the fixtures must exercise the discriminating cases, else
// "parity" is vacuous. We need at least one resolved WG peer (non-empty), one
// in-subnet miss (empty), one off-subnet IP (empty), and the empty ip (empty).
func TestMacHashCoverage(t *testing.T) {
mf, dir := loadMachashFile(t)
withWGFixture(t, mf, dir)
var sawResolved, sawSubnetMiss, sawOffSubnet, sawEmpty bool
for _, fx := range mf.Fixtures {
switch {
case fx.IP == "":
sawEmpty = true
case fx.Expected != "":
sawResolved = true
case len(fx.IP) >= 8 && fx.IP[:8] == "10.99.1.":
sawSubnetMiss = true
default:
sawOffSubnet = true
}
}
if !sawResolved || !sawSubnetMiss || !sawOffSubnet || !sawEmpty {
t.Fatalf("machash coverage incomplete: resolved=%v subnetMiss=%v offSubnet=%v empty=%v",
sawResolved, sawSubnetMiss, sawOffSubnet, sawEmpty)
}
}
// TestWGCacheReload: wgHashOf reflects the file's content; after pointing at a
// missing path it fails open to "" (best-effort, never panics).
func TestWGCacheReload(t *testing.T) {
mf, dir := loadMachashFile(t)
withWGFixture(t, mf, dir)
// A resolved peer from the fixture returns non-empty.
if got := wgHashOf("10.99.1.10"); got == "" {
t.Fatal("wgHashOf(10.99.1.10) empty — fixture not loaded")
}
// Repoint at a missing file → reload → fail-open to "".
wgPeersPath = filepath.Join(dir, "does-not-exist.json")
resetWGCache()
if got := wgHashOf("10.99.1.10"); got != "" {
t.Fatalf("wgHashOf with missing file = %q want \"\"", got)
}
}

View File

@ -0,0 +1,36 @@
{
"_doc": "Cross-engine mac_hash (WG persona identity) parity fixtures (#662 Phase 6 prep). Go core (machash_test.go, macHashOf with wgPeersPath pointed at wg-peers-fixture.json) and Python (_common.mac_hash_of with _WG_PEERS_DB monkeypatched to the SAME wg-peers-fixture.json) load THIS file and MUST agree. Python is the source of truth: expected = sha256(pubkey.encode()).hexdigest()[:16], generated by Python, never Go-authored. The R0-R2 ARP/HMAC path is intentionally out of scope for the R3 transparent engine (WG-only); off-subnet IPs expect empty.",
"wg_peers_file": "wg-peers-fixture.json",
"fixtures": [
{
"ip": "10.99.1.10",
"expected": "7d790156855ebeef",
"why": "WG peer phone-gk2 -> sha256(pubkey)[:16]"
},
{
"ip": "10.99.1.11",
"expected": "6f3663aa06e871c4",
"why": "WG peer laptop-admin -> sha256(pubkey)[:16]"
},
{
"ip": "10.99.1.12",
"expected": "1db566f7c72180f0",
"why": "WG peer tablet-lab -> sha256(pubkey)[:16]"
},
{
"ip": "10.99.1.250",
"expected": "",
"why": "WG subnet but no peer entry -> empty"
},
{
"ip": "192.168.1.5",
"expected": "",
"why": "off-subnet (R0-R2 ARP path out of scope in R3) -> empty"
},
{
"ip": "",
"expected": "",
"why": "empty ip -> empty"
}
]
}

View File

@ -0,0 +1,16 @@
{
"peers": {
"aL3kF2pQ9rZxT7vN1wB4cD6eH8jM0sU2yX5zA7bC1E=": {
"ip": "10.99.1.10",
"name": "phone-gk2"
},
"bM4lG3qR0sAyU8wO2xC5dE7fI9kN1tV3zY6aB8cD2F=": {
"ip": "10.99.1.11",
"name": "laptop-admin"
},
"cN5mH4rS1tBzV9xP3yD6eF8gJ0lO2uW4aZ7bC9dE3G=": {
"ip": "10.99.1.12",
"name": "tablet-lab"
}
}
}