Compare commits

..

No commits in common. "223f81ac636d6227db7f87cb9766f2f15c903f40" and "c870b6362b54f0fa57cee5b0ed651e954656a9af" have entirely different histories.

10 changed files with 11 additions and 1198 deletions

View File

@ -1,119 +0,0 @@
// 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

@ -1,118 +0,0 @@
// 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

@ -22,7 +22,6 @@ package main
import ( import (
"bytes" "bytes"
"context"
"crypto" "crypto"
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
@ -235,44 +234,12 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tconn.Close() defer tconn.Close()
// Shared post-TLS pipeline. CONNECT dials upstream by the request URL host
// (req.URL.Host set inside), so dialHost is "" → mitmPipeline derives it.
px.mitmPipeline(tconn, client, host, verdict, "")
}
// mitmPipeline runs the shared post-TLS-handshake MITM logic used by BOTH the
// CONNECT path (handleConnect) and the transparent path (handleTransparent):
// read the decrypted request, apply the verdict, anonymize, proxy upstream,
// poison tracker Set-Cookies, inject into HTML, and write the response back over
// tconn. Factored out so the two accept paths never drift.
//
// - tconn : the TLS-terminated client connection (forged leaf).
// - rawClient : the underlying client net.Conn (for the per-client identity).
// - host : the decision host (CONNECT host / transparent SNI). Also the
// Host/SNI used for the upstream request and TLS verification.
// - verdict : the already-Decided action ∈ {allow, mitm, block}.
// - dialHost : upstream "ip:port" to FORCE-dial at the TCP layer. "" →
// CONNECT semantics: dial by req.URL.Host (the request URL / host). Non-""
// → transparent: TCP-connect the captured original-dst while doing TLS with
// ServerName=host and verifying the cert against host (not the bare IP).
func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict, dialHost string) {
br := newReader(tconn) br := newReader(tconn)
req, err := http.ReadRequest(br) req, err := http.ReadRequest(br)
if err != nil { if err != nil {
return return
} }
req.URL.Scheme = "https" req.URL.Scheme, req.URL.Host = "https", r.URL.Host
if req.URL.Host == "" {
req.URL.Host = host
}
// Transparent: the upstream request must carry the SNI host (for Host header,
// SNI, and cert verification); the actual TCP dial is pinned to the captured
// original-dst by transparentTransport. We do NOT put the bare ip:port in
// req.URL.Host (that would make http.Client verify the cert against the IP).
if dialHost != "" && host != "" {
req.URL.Host = host
}
if verdict == "block" { if verdict == "block" {
writeRaw(tconn, 204, "No Content", map[string]string{"X-SecuBox-Ng": "blocked"}, nil) writeRaw(tconn, 204, "No Content", map[string]string{"X-SecuBox-Ng": "blocked"}, nil)
@ -287,16 +254,11 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
// Always-on hygiene: anonymize the request on EVERY MITM'd flow (incl. // Always-on hygiene: anonymize the request on EVERY MITM'd flow (incl.
// allow — stripping operator headers + asserting opt-out is universally // allow — stripping operator headers + asserting opt-out is universally
// safe and never touches own-infra correctness). // safe and never touches own-infra correctness).
clientHash := clientHashFromConn(rawClient) // mac_hash-aware (WG persona) clientHash := clientHashFromConn(client) // PoC: peer IP — TODO(#662 P6): mac_hash
anonymizeRequest(req.Header) anonymizeRequest(req.Header)
// proxy upstream, inject into HTML bodies. // proxy upstream, inject into HTML bodies.
up := &http.Client{Timeout: 30 * time.Second} up := &http.Client{Timeout: 30 * time.Second}
if dialHost != "" {
// Transparent: pin the TCP dial to the captured original-dst, do TLS with
// ServerName=host, verify the cert against host (verification stays ON).
up.Transport = transparentTransport(dialHost, host)
}
req.RequestURI = "" req.RequestURI = ""
resp, err := up.Do(req) resp, err := up.Do(req)
if err != nil { if err != nil {
@ -325,26 +287,6 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
writeResponse(tconn, resp, body) writeResponse(tconn, resp, body)
} }
// transparentTransport builds a per-request http.Transport for the transparent
// path: it TCP-dials the captured original-dst (ip:port) for EVERY connection
// regardless of req.URL.Host, while performing TLS with ServerName=sni and
// verifying the cert against that name — so a transparently-redirected upstream
// is reached at the real captured IP yet validated by hostname, NOT the bare IP
// (which would always mismatch the cert). Cert verification stays ON
// (no InsecureSkipVerify). Pure stdlib so it builds on all GOOS.
func transparentTransport(dialAddr, sni string) *http.Transport {
d := &net.Dialer{Timeout: 10 * time.Second}
return &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return d.DialContext(ctx, network, dialAddr)
},
TLSClientConfig: &tls.Config{ServerName: sni},
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ForceAttemptHTTP2: false,
}
}
func main() { func main() {
caCert := flag.String("ca-cert", "/etc/secubox/toolbox/ca-wg/ca.pem", "CA cert PEM") caCert := flag.String("ca-cert", "/etc/secubox/toolbox/ca-wg/ca.pem", "CA cert PEM")
caKey := flag.String("ca-key", "/etc/secubox/toolbox/ca-wg/key.pem", "CA key PEM") caKey := flag.String("ca-key", "/etc/secubox/toolbox/ca-wg/key.pem", "CA key PEM")
@ -353,8 +295,6 @@ func main() {
"anti-track HMAC fake-identity seed (poison disabled if absent)") "anti-track HMAC fake-identity seed (poison disabled if absent)")
poison := flag.Bool("poison", true, poison := flag.Bool("poison", true,
"poison tracking Set-Cookies on MITM'd tracker flows (needs --jar-key; never touches allow/own-infra)") "poison tracking Set-Cookies on MITM'd tracker flows (needs --jar-key; never touches allow/own-infra)")
transparent := flag.Bool("transparent", false,
"transparent mode: accept nft-DNAT'd conns + recover SO_ORIGINAL_DST (live R3); default is the CONNECT proxy PoC")
flag.Parse() flag.Parse()
ca, err := loadCA(*caCert, *caKey) ca, err := loadCA(*caCert, *caKey)
if err != nil { if err != nil {
@ -381,15 +321,6 @@ func main() {
jarKey: jarKey, jarKey: jarKey,
poison: *poison, poison: *poison,
} }
if *transparent {
// Transparent R3 mode: raw accept loop, each conn carries its pre-DNAT
// destination via SO_ORIGINAL_DST (recovered in handleTransparent). The
// accept loop lives in runTransparent — linux-tagged, with a non-linux
// stub so the package still builds (and `darwin go build`) off-target.
runTransparent(px, *addr)
return
}
srv := &http.Server{Addr: *addr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := &http.Server{Addr: *addr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect { if r.Method == http.MethodConnect {
px.handleConnect(w, r) px.handleConnect(w, r)
@ -397,6 +328,6 @@ func main() {
} }
http.Error(w, "CONNECT only (PoC)", 405) http.Error(w, "CONNECT only (PoC)", 405)
})} })}
log.Printf("sbxmitm CONNECT PoC listening on %s (CA %s)", *addr, *caCert) log.Printf("sbxmitm PoC listening on %s (CA %s)", *addr, *caCert)
log.Fatal(srv.ListenAndServe()) log.Fatal(srv.ListenAndServe())
} }

View File

@ -159,36 +159,21 @@ func (p *Policy) shouldPoison(host string) bool {
// clientHashFromConn returns the per-client identity used to mint the stable // clientHashFromConn returns the per-client identity used to mint the stable
// fake persona (jar fakeID first arg). // fake persona (jar fakeID first arg).
// //
// It mirrors the Python privacy_guard._client_hash → _common.mac_hash_of(peer_ip) // PoC / CONNECT path: this is the peer IP string. A real TRANSPARENT R3 deploy
// for the WireGuard R3 path: the peer IP is resolved to the WG persona hash // MUST replace this with the mac_hash the Python addon uses
// (sha256(peer_pubkey)[:16]) by macHashOf. For 10.99.1.0/24 WG peers that hash // (privacy_guard._client_hash → _common.mac_hash_of(peer_ip)), resolved via the
// is byte-identical to the Python engine (proven in machash_test.go ↔ // SO_ORIGINAL_DST original-destination socket option and the WireGuard-peer →
// test_machash_parity.py), so a flow's fake persona is stable across the Go and // MAC map. Using the raw peer IP here is NOT identity-stable across NAT/DHCP
// Python engines and across restarts. // and is intentionally a Phase-6-cutover TODO, not a shipped behaviour.
// //
// macHashOf returns "" for any IP it cannot resolve (non-WG peers, the captive // TODO(#662 Phase 6): wire mac_hash via SO_ORIGINAL_DST + WG-peer map.
// 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 { func clientHashFromConn(conn net.Conn) string {
if conn == nil { if conn == nil {
return "" return ""
} }
host, _, err := net.SplitHostPort(conn.RemoteAddr().String()) host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil { if err != nil {
host = conn.RemoteAddr().String() return conn.RemoteAddr().String()
}
if mh := macHashOf(host); mh != "" {
return mh
} }
return host return host
} }

View File

@ -1,390 +0,0 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
//go:build linux
// SecuBox-Deb :: toolbox-ng :: transparent SO_ORIGINAL_DST accept path
// (#662 Phase 6 prep)
//
// The live R3 engine runs transparent: nft DNAT redirects the client's TCP SYN
// to this worker, which recovers the ORIGINAL destination via
// getsockopt(SOL_IP, SO_ORIGINAL_DST) (IPv4) or
// getsockopt(SOL_IPV6, IP6T_SO_ORIGINAL_DST=80) (IPv6). This is a SECOND listen
// mode behind --transparent; the CONNECT PoC (main.go handleConnect) is left
// EXACTLY as-is.
//
// This is DARK — never wired to live traffic yet. The pure parser (parseOrigDst)
// is unit-tested; the syscall glue (origDst) and end-to-end transparent capture
// can only be exercised behind a real nft DNAT redirect, validated at Phase 5
// shadow on the board, NOT in unit tests.
//
// Pure standard library — syscall + net + crypto/tls; no external modules.
package main
import (
"bytes"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"log"
"net"
"syscall"
"unsafe"
)
// SO_ORIGINAL_DST is the Netfilter getsockopt that returns the pre-DNAT
// destination sockaddr. Same value (80) for IPv4 (SOL_IP) and IPv6
// (SOL_IPV6, where it is named IP6T_SO_ORIGINAL_DST).
const soOriginalDst = 80
// parseOrigDst decodes a raw sockaddr blob (as returned by getsockopt
// SO_ORIGINAL_DST) into host + port. It is PURE — no syscall — so it is fully
// unit-testable offline.
//
// IPv4 sockaddr_in (16 bytes): [0:2]=family (AF_INET=2, host byte order),
// [2:4]=port (BIG-endian / network order), [4:8]=4-byte address.
// IPv6 sockaddr_in6 (≥24 bytes): [0:2]=family (AF_INET6=10), [2:4]=port (BE),
// [4:8]=flowinfo, [8:24]=16-byte address.
//
// The family field is host byte order in the kernel; on x86/arm64 (little-end)
// AF_INET=2 lands in the low byte. We accept the family if EITHER the LE or BE
// 16-bit read matches the expected constant, so the parser is endianness-robust
// across architectures.
func parseOrigDst(raw []byte) (host string, port int, err error) {
if len(raw) < 4 {
return "", 0, fmt.Errorf("sockaddr too short: %d bytes", len(raw))
}
famLE := binary.LittleEndian.Uint16(raw[0:2])
famBE := binary.BigEndian.Uint16(raw[0:2])
p := int(binary.BigEndian.Uint16(raw[2:4])) // port is network order
switch {
case famLE == syscall.AF_INET || famBE == syscall.AF_INET:
if len(raw) < 8 {
return "", 0, fmt.Errorf("sockaddr_in too short: %d bytes", len(raw))
}
ip := net.IPv4(raw[4], raw[5], raw[6], raw[7])
return ip.String(), p, nil
case famLE == syscall.AF_INET6 || famBE == syscall.AF_INET6:
if len(raw) < 24 {
return "", 0, fmt.Errorf("sockaddr_in6 too short: %d bytes", len(raw))
}
ip := make(net.IP, 16)
copy(ip, raw[8:24])
return ip.String(), p, nil
default:
return "", 0, fmt.Errorf("unknown sockaddr family: LE=%d BE=%d", famLE, famBE)
}
}
// origDst recovers the pre-DNAT original destination of a transparently
// redirected TCP connection via getsockopt(SO_ORIGINAL_DST). v4 vs v6 is chosen
// by the local address family. stdlib-only (syscall.Syscall6 on the raw fd via
// SyscallConn). Linux-only by build tag.
func origDst(conn *net.TCPConn) (host string, port int, err error) {
level := syscall.SOL_IP
if la, ok := conn.LocalAddr().(*net.TCPAddr); ok && la.IP.To4() == nil && la.IP != nil {
level = syscall.SOL_IPV6
}
rc, err := conn.SyscallConn()
if err != nil {
return "", 0, err
}
// A sockaddr_in6 is 28 bytes; size the buffer for the larger of the two.
buf := make([]byte, 28)
size := uint32(len(buf))
var goErr error
ctrlErr := rc.Control(func(fd uintptr) {
_, _, errno := syscall.Syscall6(
syscall.SYS_GETSOCKOPT,
fd,
uintptr(level),
uintptr(soOriginalDst),
uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(&size)),
0,
)
if errno != 0 {
goErr = errno
}
})
if ctrlErr != nil {
return "", 0, ctrlErr
}
if goErr != nil {
return "", 0, goErr
}
return parseOrigDst(buf[:size])
}
// ── ClientHello SNI peek (no decryption) ─────────────────────────────────────
// recordingReader tees every byte it reads off the underlying reader into an
// in-memory buffer, so the exact bytes consumed during the ClientHello peek can
// be re-fed to either the upstream (splice) or a tls.Server (mitm/allow/block).
type recordingReader struct {
r io.Reader
buf bytes.Buffer
}
func (rr *recordingReader) Read(p []byte) (int, error) {
n, err := rr.r.Read(p)
if n > 0 {
rr.buf.Write(p[:n])
}
return n, err
}
// prefixConn is a net.Conn whose Read drains an internal prefix buffer (the
// bytes already peeked off the wire) before delegating to the underlying conn;
// every other net.Conn method delegates straight through. This re-presents the
// recorded ClientHello bytes to a tls.Server / upstream that must see the
// original handshake.
type prefixConn struct {
prefix []byte
off int
net.Conn
}
func (pc *prefixConn) Read(p []byte) (int, error) {
if pc.off < len(pc.prefix) {
n := copy(p, pc.prefix[pc.off:])
pc.off += n
return n, nil
}
return pc.Conn.Read(p)
}
// peekClientHello reads exactly the first TLS record (the ClientHello) off conn
// WITHOUT consuming it from the caller's perspective: the bytes are recorded so
// they can be replayed. It returns the recorded record bytes (the full set of
// bytes read off the wire, which equals the first TLS record) for replay.
func peekClientHello(conn net.Conn) (record []byte, err error) {
rr := &recordingReader{r: conn}
// TLS record header: type(1) + version(2) + length(2).
hdr := make([]byte, 5)
if _, err := io.ReadFull(rr, hdr); err != nil {
return rr.buf.Bytes(), err
}
recLen := int(binary.BigEndian.Uint16(hdr[3:5]))
// Sanity cap: a ClientHello must fit in a single record (max 16KiB payload).
if recLen < 0 || recLen > (1<<14) {
return rr.buf.Bytes(), fmt.Errorf("clienthello record length out of range: %d", recLen)
}
if _, err := io.ReadFull(rr, make([]byte, recLen)); err != nil {
return rr.buf.Bytes(), err
}
return rr.buf.Bytes(), nil
}
// sniFromClientHello extracts the SNI host_name from a raw TLS ClientHello
// record. It is PURE (no I/O) and defensive: every slice is bounds-checked and
// any malformed/short input or absent SNI returns ("", false) — it never panics.
//
// Record framing parsed here:
//
// record header : type=0x16 (handshake) | version(2) | length(2)
// handshake hdr : type=0x01 (ClientHello) | length(3)
// body : client_version(2) | random(32) |
// session_id_len(1) + session_id |
// cipher_suites_len(2) + cipher_suites |
// compression_len(1) + compression_methods |
// extensions_len(2) + extensions
// extension : ext_type(2) | ext_len(2) + ext_data
// server_name : list_len(2) | name_type(1)=0 | name_len(2) + host
func sniFromClientHello(record []byte) (string, bool) {
// record header (5) — type 0x16 handshake.
if len(record) < 5 || record[0] != 0x16 {
return "", false
}
recLen := int(binary.BigEndian.Uint16(record[3:5]))
body := record[5:]
if len(body) < recLen {
return "", false
}
body = body[:recLen]
// handshake header (4) — type 0x01 ClientHello + 3-byte length.
if len(body) < 4 || body[0] != 0x01 {
return "", false
}
hsLen := int(body[1])<<16 | int(body[2])<<8 | int(body[3])
hs := body[4:]
if len(hs) < hsLen {
return "", false
}
hs = hs[:hsLen]
// client_version(2) + random(32).
if len(hs) < 34 {
return "", false
}
p := hs[34:]
// session_id: len(1) + data.
if len(p) < 1 {
return "", false
}
sidLen := int(p[0])
p = p[1:]
if len(p) < sidLen {
return "", false
}
p = p[sidLen:]
// cipher_suites: len(2) + data.
if len(p) < 2 {
return "", false
}
csLen := int(binary.BigEndian.Uint16(p[0:2]))
p = p[2:]
if len(p) < csLen {
return "", false
}
p = p[csLen:]
// compression_methods: len(1) + data.
if len(p) < 1 {
return "", false
}
cmLen := int(p[0])
p = p[1:]
if len(p) < cmLen {
return "", false
}
p = p[cmLen:]
// extensions: len(2) + entries.
if len(p) < 2 {
return "", false
}
extLen := int(binary.BigEndian.Uint16(p[0:2]))
p = p[2:]
if len(p) < extLen {
return "", false
}
ext := p[:extLen]
for len(ext) >= 4 {
etype := binary.BigEndian.Uint16(ext[0:2])
elen := int(binary.BigEndian.Uint16(ext[2:4]))
ext = ext[4:]
if len(ext) < elen {
return "", false
}
data := ext[:elen]
ext = ext[elen:]
if etype != 0x0000 { // server_name
continue
}
// server_name_list: list_len(2) + entries.
if len(data) < 2 {
return "", false
}
listLen := int(binary.BigEndian.Uint16(data[0:2]))
list := data[2:]
if len(list) < listLen {
return "", false
}
list = list[:listLen]
// First entry: name_type(1) + name_len(2) + host.
if len(list) < 3 {
return "", false
}
nameType := list[0]
nameLen := int(binary.BigEndian.Uint16(list[1:3]))
list = list[3:]
if nameType != 0x00 || len(list) < nameLen { // 0 = host_name
return "", false
}
return string(list[:nameLen]), true
}
return "", false
}
// ── transparent accept path ──────────────────────────────────────────────────
// runTransparent runs the transparent (SO_ORIGINAL_DST) accept loop: listen on
// addr, and for each nft-DNAT'd connection recover its pre-DNAT destination and
// dispatch to handleTransparent. Linux-only (build-tagged).
func runTransparent(px *Proxy, addr string) {
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("transparent listen: %v", err)
}
log.Printf("sbxmitm TRANSPARENT listening on %s", addr)
for {
conn, err := ln.Accept()
if err != nil {
log.Printf("accept: %v", err)
continue
}
go px.handleTransparent(conn)
}
}
// handleTransparent serves one transparently-redirected client connection:
// 1. recover the pre-DNAT original destination via SO_ORIGINAL_DST,
// 2. PEEK the ClientHello off the raw conn without consuming it,
// 3. parse the SNI and Decide WITHOUT decrypting,
// 4. splice → raw TCP passthrough to the ORIGINAL dst, replaying the peeked
// ClientHello first; NEVER terminate TLS (cert-pinned/own-infra safe),
// 5. allow/mitm/block → NOW tls.Server over the replayable conn (so the TLS
// server still sees the original ClientHello) and run the shared pipeline.
func (px *Proxy) handleTransparent(client net.Conn) {
defer client.Close()
tcp, ok := client.(*net.TCPConn)
if !ok {
return // transparent mode only accepts raw TCP conns
}
dstHost, dstPort, err := origDst(tcp)
if err != nil {
return // no original-dst (not DNAT'd) → drop; nothing safe to do
}
dialAddr := net.JoinHostPort(dstHost, fmt.Sprintf("%d", dstPort))
// Peek the ClientHello WITHOUT decrypting. The recorded bytes are replayed
// to whatever we hand the conn to next (upstream for splice, tls.Server
// otherwise) so the original handshake is preserved byte-for-byte.
hello, perr := peekClientHello(client)
if perr != nil {
return // could not read a ClientHello → nothing safe to do
}
sni, _ := sniFromClientHello(hello)
decisionHost := sni
if decisionHost == "" {
decisionHost = dstHost // no SNI → fall back to the captured dst IP
}
verdict := px.pol.Decide(decisionHost, sni)
if verdict == "splice" {
// Passthrough: raw TCP to the REAL captured destination, never the SNI,
// NEVER terminating TLS. Replay the peeked ClientHello to the upstream
// first, then pipe raw bytes both directions over the raw client conn.
up, derr := net.Dial("tcp", dialAddr)
if derr != nil {
return
}
defer up.Close()
if _, werr := up.Write(hello); werr != nil {
return
}
go func() { _, _ = io.Copy(up, client) }()
_, _ = io.Copy(client, up)
return
}
// allow / mitm / block → re-present the peeked ClientHello to a tls.Server
// over a replayable conn, then run the shared pipeline dialling the captured
// original-dst (NOT the SNI).
replay := &prefixConn{prefix: hello, Conn: client}
tconn := tls.Server(replay, px.serverTLSConfig())
if err := tconn.Handshake(); err != nil {
return
}
defer tconn.Close()
px.mitmPipeline(tconn, client, decisionHost, verdict, dialAddr)
}

View File

@ -1,33 +0,0 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
//go:build !linux
// SecuBox-Deb :: toolbox-ng :: transparent mode non-linux stub (#662).
//
// SO_ORIGINAL_DST recovery is Netfilter-specific (Linux-only). The real
// transparent accept path lives in transparent.go behind //go:build linux. This
// stub lets the package still compile (and `GOOS=darwin go build ./...`) on
// non-linux: invoking transparent mode there is a hard error, never silently
// degraded. handleTransparent is stubbed too in case it is referenced.
package main
import (
"log"
"net"
)
// runTransparent is the non-linux counterpart of the linux accept loop: it
// refuses to start, because transparent SO_ORIGINAL_DST capture requires Linux.
func runTransparent(px *Proxy, addr string) {
_ = px
_ = addr
log.Fatal("transparent mode requires linux (SO_ORIGINAL_DST)")
}
// handleTransparent is a non-linux stub; it can never be reached because
// runTransparent log.Fatals first. Present so any reference still links.
func (px *Proxy) handleTransparent(client net.Conn) {
_ = client
log.Fatal("transparent mode requires linux (SO_ORIGINAL_DST)")
}

View File

@ -1,304 +0,0 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
//go:build linux
// Tests for the transparent SO_ORIGINAL_DST sockaddr parser (#662 Phase 6 prep).
//
// Only the PURE parser (parseOrigDst) is unit-tested here: it decodes a raw
// sockaddr byte blob with no syscall, so it is fully covered offline. The real
// getsockopt(SO_ORIGINAL_DST) glue (origDst) cannot be exercised without an nft
// DNAT redirect in the kernel — end-to-end transparent capture is validated at
// Phase 5 shadow on the board, NOT in unit tests (documented in transparent.go).
package main
import (
"bytes"
"encoding/binary"
"io"
"net"
"testing"
"time"
)
// mkSockaddrIn4 builds a 16-byte sockaddr_in: family(2 host-order) + port(BE) +
// 4-byte addr + 8 pad. familyLE controls whether the 2 family bytes are written
// little-endian (low byte first, the x86/arm64 host order) or big-endian, so we
// can prove parseOrigDst tolerates both.
func mkSockaddrIn4(family uint16, port uint16, a, b, c, d byte, familyLE bool) []byte {
buf := make([]byte, 16)
if familyLE {
binary.LittleEndian.PutUint16(buf[0:2], family)
} else {
binary.BigEndian.PutUint16(buf[0:2], family)
}
binary.BigEndian.PutUint16(buf[2:4], port) // port is always network order
buf[4], buf[5], buf[6], buf[7] = a, b, c, d
return buf
}
// mkSockaddrIn6 builds a 28-byte sockaddr_in6: family(2) + port(BE) +
// flowinfo(4) + 16-byte addr + scope_id(4).
func mkSockaddrIn6(family uint16, port uint16, addr [16]byte, familyLE bool) []byte {
buf := make([]byte, 28)
if familyLE {
binary.LittleEndian.PutUint16(buf[0:2], family)
} else {
binary.BigEndian.PutUint16(buf[0:2], family)
}
binary.BigEndian.PutUint16(buf[2:4], port)
copy(buf[8:24], addr[:])
return buf
}
func TestParseOrigDstIPv4(t *testing.T) {
cases := []struct {
name string
raw []byte
wantHost string
wantPort int
}{
{"le-family", mkSockaddrIn4(2, 443, 93, 184, 216, 34, true), "93.184.216.34", 443},
{"be-family", mkSockaddrIn4(2, 8080, 10, 99, 1, 10, false), "10.99.1.10", 8080},
{"high-port", mkSockaddrIn4(2, 65535, 1, 2, 3, 4, true), "1.2.3.4", 65535},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
host, port, err := parseOrigDst(tc.raw)
if err != nil {
t.Fatalf("parseOrigDst: %v", err)
}
if host != tc.wantHost || port != tc.wantPort {
t.Fatalf("parseOrigDst = %q:%d want %q:%d", host, port, tc.wantHost, tc.wantPort)
}
})
}
}
func TestParseOrigDstIPv6(t *testing.T) {
// 2606:2800:220:1:248:1893:25c8:1946 (example.com-ish), port 443.
addr := [16]byte{0x26, 0x06, 0x28, 0x00, 0x02, 0x20, 0x00, 0x01,
0x02, 0x48, 0x18, 0x93, 0x25, 0xc8, 0x19, 0x46}
for _, le := range []bool{true, false} {
raw := mkSockaddrIn6(10, 443, addr, le)
host, port, err := parseOrigDst(raw)
if err != nil {
t.Fatalf("parseOrigDst(le=%v): %v", le, err)
}
want := "2606:2800:220:1:248:1893:25c8:1946"
if host != want || port != 443 {
t.Fatalf("parseOrigDst(le=%v) = %q:%d want %q:443", le, host, port, want)
}
}
}
func TestParseOrigDstPortBigEndian(t *testing.T) {
// Port 0x01BB = 443; assert it is read big-endian (network order), not the
// host-order 0xBB01 = 47873.
raw := mkSockaddrIn4(2, 0x01BB, 8, 8, 8, 8, true)
_, port, err := parseOrigDst(raw)
if err != nil {
t.Fatal(err)
}
if port != 443 {
t.Fatalf("port = %d want 443 (big-endian decode)", port)
}
}
func TestParseOrigDstErrors(t *testing.T) {
cases := []struct {
name string
raw []byte
}{
{"empty", nil},
{"unknown-family-4", make([]byte, 4)}, // all-zero family=0 → unknown-family branch
{"too-short-v4", mkV4Short()}, // valid AF_INET family but 4≤len<8 → sockaddr_in <8 guard
{"too-short-v6", mkV6Short()}, // AF_INET6 but < 24 bytes
{"unknown-family", mkSockaddrIn4(7, 443, 1, 2, 3, 4, true)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := parseOrigDst(tc.raw); err == nil {
t.Fatalf("parseOrigDst(%s) = nil err, want error", tc.name)
}
})
}
}
// mkV6Short returns an AF_INET6 blob truncated before the 16-byte address.
func mkV6Short() []byte {
buf := make([]byte, 10) // family + port + flowinfo + 2 bytes of addr
binary.LittleEndian.PutUint16(buf[0:2], 10)
binary.BigEndian.PutUint16(buf[2:4], 443)
return buf
}
// mkV4Short returns a blob with a valid AF_INET family byte but a total length
// in [4,8): it passes the >=4 length check and matches the AF_INET case, so it
// exercises parseOrigDst's sockaddr_in `<8` guard (not the unknown-family path).
func mkV4Short() []byte {
buf := make([]byte, 6) // family(2) + port(2) but no full 4-byte address
binary.LittleEndian.PutUint16(buf[0:2], 2) // AF_INET
binary.BigEndian.PutUint16(buf[2:4], 443)
return buf
}
// ── sniFromClientHello ───────────────────────────────────────────────────────
// mkClientHello hand-assembles a minimal but structurally-valid TLS
// ClientHello record. If withSNI is true a server_name extension carrying
// `sni` (a single host_name entry) is appended; otherwise NO extensions are
// emitted (extensions length 0).
//
// Record layout assembled here (see sniFromClientHello for the parser):
//
// record header : type=0x16 (handshake) | version 0x0303 | record_len(2)
// handshake : type=0x01 (ClientHello) | hs_len(3)
// body : client_version 0x0303 | random(32) |
// session_id_len=0 |
// cipher_suites_len(2)=2 | cipher 0x002f |
// compression_len=1 | method 0x00 |
// extensions_len(2) | [ server_name ext ]
// server_name : ext_type 0x0000 | ext_len(2) |
// list_len(2) | name_type 0x00 | name_len(2) | host bytes
func mkClientHello(sni string, withSNI bool) []byte {
body := []byte{0x03, 0x03} // client_version TLS1.2
body = append(body, make([]byte, 32)...) // random (zeros)
body = append(body, 0x00) // session_id_len = 0
// cipher_suites: length 2, one suite TLS_RSA_WITH_AES_128_CBC_SHA (0x002f)
body = append(body, 0x00, 0x02, 0x00, 0x2f)
// compression_methods: length 1, method null (0x00)
body = append(body, 0x01, 0x00)
var exts []byte
if withSNI {
host := []byte(sni)
var sn []byte
sn = append(sn, 0x00) // name_type = host_name
sn = append(sn, byte(len(host)>>8), byte(len(host))) // name_len(2)
sn = append(sn, host...)
var list []byte
list = append(list, byte(len(sn)>>8), byte(len(sn))) // server_name_list len(2)
list = append(list, sn...)
exts = append(exts, 0x00, 0x00) // ext_type = server_name
exts = append(exts, byte(len(list)>>8), byte(len(list))) // ext_len(2)
exts = append(exts, list...)
}
body = append(body, byte(len(exts)>>8), byte(len(exts))) // extensions_len(2)
body = append(body, exts...)
// handshake header: type 0x01 + 3-byte length
hs := []byte{0x01, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
hs = append(hs, body...)
// record header: type 0x16 + version 0x0303 + 2-byte length
rec := []byte{0x16, 0x03, 0x03, byte(len(hs) >> 8), byte(len(hs))}
rec = append(rec, hs...)
return rec
}
func TestSNIFromClientHello(t *testing.T) {
// Sanity: the hand-assembled blob parses with our own parser.
good := mkClientHello("example.com", true)
if sni, ok := sniFromClientHello(good); !ok || sni != "example.com" {
t.Fatalf("sniFromClientHello(valid) = %q,%v want example.com,true", sni, ok)
}
cases := []struct {
name string
rec []byte
wantSNI string
wantOK bool
}{
{"with-sni", mkClientHello("secubox.in", true), "secubox.in", true},
{"no-sni-ext", mkClientHello("", false), "", false},
{"nil", nil, "", false},
{"empty", []byte{}, "", false},
{"non-handshake-record", []byte{0x17, 0x03, 0x03, 0x00, 0x05, 1, 2, 3, 4, 5}, "", false},
{"truncated-header", []byte{0x16, 0x03}, "", false},
// valid record header claiming length 100 but body truncated.
{"truncated-body", []byte{0x16, 0x03, 0x03, 0x00, 0x64, 0x01, 0x00, 0x00}, "", false},
// truncate a known-good blob mid-extensions.
{"truncated-good", good[:len(good)-3], "", false},
{"not-clienthello-hs", func() []byte {
b := mkClientHello("x.example", true)
b[5] = 0x02 // handshake type ServerHello, not ClientHello
return b
}(), "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sni, ok := sniFromClientHello(tc.rec)
if ok != tc.wantOK || sni != tc.wantSNI {
t.Fatalf("sniFromClientHello = %q,%v want %q,%v", sni, ok, tc.wantSNI, tc.wantOK)
}
})
}
}
func TestSNIFromClientHelloNoPanic(t *testing.T) {
// Fuzz-ish: every truncation of a valid blob must return cleanly, never panic.
good := mkClientHello("example.com", true)
for i := 0; i <= len(good); i++ {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("panic on good[:%d]: %v", i, r)
}
}()
_, _ = sniFromClientHello(good[:i])
}()
}
}
// ── prefixConn (replayable client conn) ──────────────────────────────────────
// fakeConn adapts an io.ReadWriteCloser to net.Conn for prefixConn tests.
type fakeConn struct{ io.ReadWriteCloser }
func (fakeConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (fakeConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (fakeConn) SetDeadline(time.Time) error { return nil }
func (fakeConn) SetReadDeadline(time.Time) error { return nil }
func (fakeConn) SetWriteDeadline(time.Time) error { return nil }
type rwc struct {
*bytes.Reader
w *bytes.Buffer
}
func (r rwc) Write(p []byte) (int, error) { return r.w.Write(p) }
func (rwc) Close() error { return nil }
func TestPrefixConnReplaysBufferedThenLive(t *testing.T) {
live := bytes.NewReader([]byte("LIVE-DATA"))
wbuf := &bytes.Buffer{}
underlying := fakeConn{rwc{Reader: live, w: wbuf}}
pc := &prefixConn{prefix: []byte("PEEKED"), Conn: underlying}
got, err := io.ReadAll(pc)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != "PEEKEDLIVE-DATA" {
t.Fatalf("prefixConn read = %q want PEEKEDLIVE-DATA", got)
}
// Writes delegate straight through to the underlying conn.
if _, err := pc.Write([]byte("OUT")); err != nil {
t.Fatalf("write: %v", err)
}
if wbuf.String() != "OUT" {
t.Fatalf("underlying write = %q want OUT", wbuf.String())
}
}
func TestPrefixConnEmptyPrefix(t *testing.T) {
live := bytes.NewReader([]byte("ONLY-LIVE"))
underlying := fakeConn{rwc{Reader: live, w: &bytes.Buffer{}}}
pc := &prefixConn{Conn: underlying}
got, _ := io.ReadAll(pc)
if string(got) != "ONLY-LIVE" {
t.Fatalf("prefixConn read = %q want ONLY-LIVE", got)
}
}

View File

@ -1,36 +0,0 @@
{
"_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

@ -1,16 +0,0 @@
{
"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"
}
}
}

View File

@ -1,87 +0,0 @@
# 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