From 5fb67f5b88f35fde5418cdc0bfa6b7c1d4ea5208 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 18 Jun 2026 18:21:39 +0200 Subject: [PATCH] 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. --- .../secubox-toolbox-ng/cmd/sbxmitm/machash.go | 119 ++++++++++++++++++ .../cmd/sbxmitm/machash_test.go | 118 +++++++++++++++++ .../testdata/machash-fixtures.json | 36 ++++++ .../testdata/wg-peers-fixture.json | 16 +++ 4 files changed, 289 insertions(+) create mode 100644 packages/secubox-toolbox-ng/cmd/sbxmitm/machash.go create mode 100644 packages/secubox-toolbox-ng/cmd/sbxmitm/machash_test.go create mode 100644 packages/secubox-toolbox-ng/testdata/machash-fixtures.json create mode 100644 packages/secubox-toolbox-ng/testdata/wg-peers-fixture.json diff --git a/packages/secubox-toolbox-ng/cmd/sbxmitm/machash.go b/packages/secubox-toolbox-ng/cmd/sbxmitm/machash.go new file mode 100644 index 00000000..88b6e921 --- /dev/null +++ b/packages/secubox-toolbox-ng/cmd/sbxmitm/machash.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: LicenseRef-CMSD-1.0 +// Copyright (c) 2026 CyberMind — Gérald Kerma +// +// 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": {"": {"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 +} diff --git a/packages/secubox-toolbox-ng/cmd/sbxmitm/machash_test.go b/packages/secubox-toolbox-ng/cmd/sbxmitm/machash_test.go new file mode 100644 index 00000000..da9cb9ea --- /dev/null +++ b/packages/secubox-toolbox-ng/cmd/sbxmitm/machash_test.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: LicenseRef-CMSD-1.0 +// Copyright (c) 2026 CyberMind — Gérald Kerma +// +// 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) + } +} diff --git a/packages/secubox-toolbox-ng/testdata/machash-fixtures.json b/packages/secubox-toolbox-ng/testdata/machash-fixtures.json new file mode 100644 index 00000000..dc45caa6 --- /dev/null +++ b/packages/secubox-toolbox-ng/testdata/machash-fixtures.json @@ -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" + } + ] +} diff --git a/packages/secubox-toolbox-ng/testdata/wg-peers-fixture.json b/packages/secubox-toolbox-ng/testdata/wg-peers-fixture.json new file mode 100644 index 00000000..c4ef9c55 --- /dev/null +++ b/packages/secubox-toolbox-ng/testdata/wg-peers-fixture.json @@ -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" + } + } +}