mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 16:37:04 +00:00
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:
parent
c870b6362b
commit
5fb67f5b88
119
packages/secubox-toolbox-ng/cmd/sbxmitm/machash.go
Normal file
119
packages/secubox-toolbox-ng/cmd/sbxmitm/machash.go
Normal 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
|
||||
}
|
||||
118
packages/secubox-toolbox-ng/cmd/sbxmitm/machash_test.go
Normal file
118
packages/secubox-toolbox-ng/cmd/sbxmitm/machash_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
36
packages/secubox-toolbox-ng/testdata/machash-fixtures.json
vendored
Normal file
36
packages/secubox-toolbox-ng/testdata/machash-fixtures.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
packages/secubox-toolbox-ng/testdata/wg-peers-fixture.json
vendored
Normal file
16
packages/secubox-toolbox-ng/testdata/wg-peers-fixture.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user