Compare commits

...

8 Commits

Author SHA1 Message Date
CyberMind
223f81ac63
Merge pull request #669 from CyberMind-FR/feat/662-transparent-machash
Some checks are pending
License Headers / check (push) Waiting to run
feat(#662 Phase 6-prep): transparent SO_ORIGINAL_DST accept + mac_hash persona (DARK)
2026-06-18 18:41:17 +02:00
bf022f618f fix(toolbox-ng): transparent upstream must verify cert against SNI, not bare IP (ref #662)
The transparent mitm/allow path set req.URL.Host = ip:port, so http.Client
TLS-dialed the captured original-dst and verified the cert against the bare IP
→ guaranteed SNI/cert-name mismatch. Add a per-request transparentTransport
whose DialContext pins the TCP dial to the captured ip:port for every
connection while TLSClientConfig.ServerName = the SNI host, so the upstream is
reached at the real IP yet the cert is verified by hostname. req.URL.Host now
carries the SNI host (correct Host header + SNI); verification stays ON (no
InsecureSkipVerify). The CONNECT path is unchanged (dialHost == "" → it still
dials by req.URL.Host exactly as before).
2026-06-18 18:37:35 +02:00
9df984c73f fix(toolbox-ng): transparent splice must not decrypt — peek+replay ClientHello (ref #662)
handleTransparent previously forged a cert and terminated TLS BEFORE Decide, so
a splice host was already MITM'd and the splice branch then io.Copy'd decrypted
plaintext into a cleartext dial — a broken relay of a host policy says to pass
through untouched (cert-pinned apps, own media infra).

Now: peek the ClientHello off the raw conn without consuming it (recordingReader
tees the bytes), parse SNI with a new pure stdlib sniFromClientHello (fully
bounds-checked, never panics), and Decide on the peeked SNI with NO decryption.
splice → dial the ORIGINAL dst, replay the buffered ClientHello upstream, pipe
raw TCP both ways, NEVER tls.Server. allow/mitm/block → re-present the buffered
ClientHello to tls.Server via a prefixConn (Read drains the prefix then
delegates) and run the shared pipeline as before.

Adds table tests for sniFromClientHello (hand-assembled ClientHello with/without
SNI, non-handshake, truncated, not-ClientHello → ("",false)), a no-panic
truncation sweep, and prefixConn replay tests.
2026-06-18 18:37:13 +02:00
5acfdb17c6 fix(toolbox-ng): non-linux build regression in transparent dispatch (ref #662)
main.go (untagged) referenced px.handleTransparent + the transparent accept
loop unconditionally, so the linux-only transparent.go made `GOOS=darwin
go build ./...` fail. Move the accept loop into a linux-tagged runTransparent
helper and add a non-linux transparent_stub.go that log.Fatals; main.go now
calls runTransparent only when --transparent. Verified GOOS=linux/arm64,
linux/amd64 and darwin all build.
2026-06-18 18:36:46 +02:00
364b8c4a30 feat(toolbox-ng): transparent SO_ORIGINAL_DST accept path (build only, DARK) (ref #662)
Add cmd/sbxmitm/transparent.go (//go:build linux): parseOrigDst decodes a raw
sockaddr_in/sockaddr_in6 blob (endianness-robust family, big-endian port) into
host:port — PURE, fully unit-tested. origDst recovers the pre-DNAT destination
via getsockopt(SO_ORIGINAL_DST=80) using syscall.Syscall6 on the raw fd
(stdlib-only). handleTransparent recovers origDst, terminates TLS by SNI,
splices raw TCP to the REAL captured dst or runs mitmPipeline dialling it.

transparent_test.go table-tests parseOrigDst (v4/v6, both family endiannesses,
BE port, short-blob errors). End-to-end getsockopt capture needs nft DNAT and
is validated at Phase 5 shadow on the board, not in unit tests (documented).
2026-06-18 18:24:57 +02:00
ba933a6ec3 refactor(toolbox-ng): extract shared post-TLS MITM pipeline + add --transparent flag (ref #662)
Factor handleConnect's post-handshake logic (read request, apply verdict,
anonymize, proxy upstream, poison, inject, write) into mitmPipeline so the
CONNECT and transparent accept paths can't drift. dialHost param lets the
transparent path dial the captured original-dst instead of the SNI. Add a
--transparent bool flag: when set, a raw net.Listen accept loop dispatches each
conn to handleTransparent; default keeps the CONNECT http.Server EXACTLY.
CONNECT path + its tests unchanged.
2026-06-18 18:24:57 +02:00
67e85ba4dd 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.
2026-06-18 18:21:45 +02:00
5fb67f5b88 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.
2026-06-18 18:21:39 +02:00
10 changed files with 1198 additions and 11 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

@ -22,6 +22,7 @@ package main
import ( import (
"bytes" "bytes"
"context"
"crypto" "crypto"
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
@ -234,12 +235,44 @@ 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, req.URL.Host = "https", r.URL.Host req.URL.Scheme = "https"
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)
@ -254,11 +287,16 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// 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(client) // PoC: peer IP — TODO(#662 P6): mac_hash clientHash := clientHashFromConn(rawClient) // mac_hash-aware (WG persona)
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 {
@ -287,6 +325,26 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
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")
@ -295,6 +353,8 @@ 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 {
@ -321,6 +381,15 @@ 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)
@ -328,6 +397,6 @@ func main() {
} }
http.Error(w, "CONNECT only (PoC)", 405) http.Error(w, "CONNECT only (PoC)", 405)
})} })}
log.Printf("sbxmitm PoC listening on %s (CA %s)", *addr, *caCert) log.Printf("sbxmitm CONNECT PoC listening on %s (CA %s)", *addr, *caCert)
log.Fatal(srv.ListenAndServe()) log.Fatal(srv.ListenAndServe())
} }

View File

@ -159,21 +159,36 @@ 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).
// //
// PoC / CONNECT path: this is the peer IP string. A real TRANSPARENT R3 deploy // It mirrors the Python privacy_guard._client_hash → _common.mac_hash_of(peer_ip)
// MUST replace this with the mac_hash the Python addon uses // for the WireGuard R3 path: the peer IP is resolved to the WG persona hash
// (privacy_guard._client_hash → _common.mac_hash_of(peer_ip)), resolved via the // (sha256(peer_pubkey)[:16]) by macHashOf. For 10.99.1.0/24 WG peers that hash
// SO_ORIGINAL_DST original-destination socket option and the WireGuard-peer → // is byte-identical to the Python engine (proven in machash_test.go ↔
// MAC map. Using the raw peer IP here is NOT identity-stable across NAT/DHCP // test_machash_parity.py), so a flow's fake persona is stable across the Go and
// and is intentionally a Phase-6-cutover TODO, not a shipped behaviour. // 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 { 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 {
return conn.RemoteAddr().String() host = conn.RemoteAddr().String()
}
if mh := macHashOf(host); mh != "" {
return mh
} }
return host return host
} }

View File

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

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

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

@ -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"
}
}
}

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