mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 11:08:33 +00:00
Compare commits
8 Commits
c870b6362b
...
223f81ac63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
223f81ac63 | ||
| bf022f618f | |||
| 9df984c73f | |||
| 5acfdb17c6 | |||
| 364b8c4a30 | |||
| ba933a6ec3 | |||
| 67e85ba4dd | |||
| 5fb67f5b88 |
119
packages/secubox-toolbox-ng/cmd/sbxmitm/machash.go
Normal file
119
packages/secubox-toolbox-ng/cmd/sbxmitm/machash.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
//
|
||||
// SecuBox-Deb :: toolbox-ng :: WG persona identity (mac_hash) (#662 Phase 6 prep)
|
||||
//
|
||||
// Byte-exact port of the Python WG-peer identity resolver
|
||||
// (packages/secubox-toolbox/mitmproxy_addons/_common.py: _wg_hash_of /
|
||||
// mac_hash_of). Python is the source of truth; this mirrors it exactly, proven
|
||||
// by the cross-engine parity harness (testdata/wg-peers-fixture.json +
|
||||
// testdata/machash-fixtures.json + machash_test.go ↔ tests/test_machash_parity.py).
|
||||
//
|
||||
// R3 clients reach this transparent engine over WireGuard on 10.99.1.0/24 and
|
||||
// have NO ARP entry on the captive subnet, so they are identified by their WG
|
||||
// public key (one peer → one IP, deterministic): ip → sha256(pubkey)[:16].
|
||||
//
|
||||
// Pure standard library — no external modules, no go.sum.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// wgPeersPath is the on-disk WG peer DB, mirroring _common._WG_PEERS_DB. It is a
|
||||
// package-level var (not a const) so tests can repoint it at a fixture.
|
||||
var wgPeersPath = "/var/lib/secubox/toolbox/wg-peers.json"
|
||||
|
||||
// wgPeer mirrors the per-pubkey metadata object in wg-peers.json. Only "ip" is
|
||||
// consumed here (other fields are ignored, like the Python meta.get("ip")).
|
||||
type wgPeer struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// wgPeersDB mirrors the file shape: {"peers": {"<pubkey>": {"ip": "..."}}}.
|
||||
type wgPeersDB struct {
|
||||
Peers map[string]wgPeer `json:"peers"`
|
||||
}
|
||||
|
||||
// WG peer cache, mtime-keyed and reloaded only on mtime change — exactly like
|
||||
// the Python _WG_PEERS_CACHE / _WG_PEERS_MTIME globals. Guarded by a mutex: the
|
||||
// Go proxy is genuinely concurrent (Python relied on the GIL), so the cache map
|
||||
// and mtime MUST NOT be read/written without holding wgMu.
|
||||
var (
|
||||
wgMu sync.Mutex
|
||||
wgCache map[string]string // ip → sha256(pubkey)[:16]
|
||||
wgMtime int64 // last loaded file mtime (UnixNano), 0 = unloaded
|
||||
)
|
||||
|
||||
// resetWGCache clears the in-process WG cache so the next wgHashOf reload reads
|
||||
// wgPeersPath afresh. Used by tests after repointing wgPeersPath; mirrors the
|
||||
// Python parity test resetting _WG_PEERS_CACHE/_WG_PEERS_MTIME.
|
||||
func resetWGCache() {
|
||||
wgMu.Lock()
|
||||
wgCache = nil
|
||||
wgMtime = 0
|
||||
wgMu.Unlock()
|
||||
}
|
||||
|
||||
// wgHashOf maps a WG peer IP (10.99.1.X) to sha256(peer_pubkey)[:16]. Mirrors
|
||||
// _common._wg_hash_of EXACTLY: mtime-cached, reloaded only when the file mtime
|
||||
// changes (or the cache is empty); ANY error (missing file, bad JSON, stat
|
||||
// failure) → "" (best-effort, fail-open to empty, never panics). Returns "" for
|
||||
// an IP not present in the DB. The cache is mutex-guarded for concurrency.
|
||||
func wgHashOf(ip string) string {
|
||||
wgMu.Lock()
|
||||
defer wgMu.Unlock()
|
||||
|
||||
fi, err := os.Stat(wgPeersPath)
|
||||
if err != nil {
|
||||
return "" // missing file / unreadable → fail-open (Python: not exists → None)
|
||||
}
|
||||
mtime := fi.ModTime().UnixNano()
|
||||
if mtime != wgMtime || wgCache == nil {
|
||||
raw, err := os.ReadFile(wgPeersPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var db wgPeersDB
|
||||
if err := json.Unmarshal(raw, &db); err != nil {
|
||||
return "" // bad JSON → fail-open (Python: except → None)
|
||||
}
|
||||
fresh := make(map[string]string, len(db.Peers))
|
||||
for pubkey, meta := range db.Peers {
|
||||
if meta.IP != "" {
|
||||
sum := sha256.Sum256([]byte(pubkey))
|
||||
fresh[meta.IP] = hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
}
|
||||
wgCache = fresh
|
||||
wgMtime = mtime
|
||||
}
|
||||
return wgCache[ip] // missing key → "" (Python: cache.get(ip) → None)
|
||||
}
|
||||
|
||||
// macHashOf resolves an IP to a stable per-client persona identity hash.
|
||||
// Mirrors _common.mac_hash_of, but scoped to the R3 transparent engine:
|
||||
//
|
||||
// - empty ip → ""
|
||||
// - 10.99.1.0/24 (WG peer) → wgHashOf(ip) = sha256(peer_pubkey)[:16]
|
||||
// - else → ""
|
||||
//
|
||||
// The Python mac_hash_of has a third branch for the captive subnet
|
||||
// (R0/R1/R2): hash_mac(mac_of(ip)) = HMAC(salt, ARP MAC). That ARP/HMAC path is
|
||||
// INTENTIONALLY out of scope here — R3 clients arrive over WireGuard and have no
|
||||
// ARP entry on the captive subnet, so this engine is WG-only. Off-subnet IPs
|
||||
// therefore resolve to "" (the caller falls back to the raw peer IP).
|
||||
func macHashOf(ip string) string {
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(ip, "10.99.1.") {
|
||||
return wgHashOf(ip)
|
||||
}
|
||||
return "" // R0-R2 ARP/HMAC path out of scope for the R3 transparent engine
|
||||
}
|
||||
118
packages/secubox-toolbox-ng/cmd/sbxmitm/machash_test.go
Normal file
118
packages/secubox-toolbox-ng/cmd/sbxmitm/machash_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
//
|
||||
// Cross-engine mac_hash (WG persona identity) parity harness — Go side
|
||||
// (#662 Phase 6 prep).
|
||||
//
|
||||
// Loads testdata/machash-fixtures.json + the SAME testdata/wg-peers-fixture.json
|
||||
// the Python side reads, points wgPeersPath at the fixture, and asserts
|
||||
// macHashOf(ip) == each fixture's expected. The Python side
|
||||
// (../secubox-toolbox/tests/test_machash_parity.py) monkeypatches
|
||||
// _common._WG_PEERS_DB to the SAME fixture and drives _common.mac_hash_of; both
|
||||
// must agree → the WG persona hash is byte-exact across engines. Python is the
|
||||
// source of truth: the expected values were GENERATED by sha256(pubkey)[:16] in
|
||||
// Python, never hand-computed in Go (non-circular parity).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type machashFixture struct {
|
||||
IP string `json:"ip"`
|
||||
Expected string `json:"expected"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
type machashFile struct {
|
||||
WGPeersFile string `json:"wg_peers_file"`
|
||||
Fixtures []machashFixture `json:"fixtures"`
|
||||
}
|
||||
|
||||
func loadMachashFile(t *testing.T) (machashFile, string) {
|
||||
t.Helper()
|
||||
dir := testdataDir(t) // shared with policy_test.go (cmd/sbxmitm → ../../testdata)
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "machash-fixtures.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read machash fixtures: %v", err)
|
||||
}
|
||||
var mf machashFile
|
||||
if err := json.Unmarshal(raw, &mf); err != nil {
|
||||
t.Fatalf("parse machash fixtures: %v", err)
|
||||
}
|
||||
if len(mf.Fixtures) == 0 {
|
||||
t.Fatal("no machash fixtures")
|
||||
}
|
||||
return mf, dir
|
||||
}
|
||||
|
||||
// withWGFixture points wgPeersPath at the fixture and resets the cache so the
|
||||
// override is (re)read, restoring the original path afterwards. Mirrors exactly
|
||||
// the (path, cache) surface the Python _wg_hash_of reads.
|
||||
func withWGFixture(t *testing.T, mf machashFile, dir string) {
|
||||
t.Helper()
|
||||
orig := wgPeersPath
|
||||
wgPeersPath = filepath.Join(dir, mf.WGPeersFile)
|
||||
resetWGCache()
|
||||
t.Cleanup(func() {
|
||||
wgPeersPath = orig
|
||||
resetWGCache()
|
||||
})
|
||||
}
|
||||
|
||||
// TestMacHashParity: macHashOf == Python-generated expected for every fixture.
|
||||
func TestMacHashParity(t *testing.T) {
|
||||
mf, dir := loadMachashFile(t)
|
||||
withWGFixture(t, mf, dir)
|
||||
for _, fx := range mf.Fixtures {
|
||||
got := macHashOf(fx.IP)
|
||||
if got != fx.Expected {
|
||||
t.Errorf("macHashOf(%q)=%q want %q (%s)", fx.IP, got, fx.Expected, fx.Why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMacHashCoverage: the fixtures must exercise the discriminating cases, else
|
||||
// "parity" is vacuous. We need at least one resolved WG peer (non-empty), one
|
||||
// in-subnet miss (empty), one off-subnet IP (empty), and the empty ip (empty).
|
||||
func TestMacHashCoverage(t *testing.T) {
|
||||
mf, dir := loadMachashFile(t)
|
||||
withWGFixture(t, mf, dir)
|
||||
var sawResolved, sawSubnetMiss, sawOffSubnet, sawEmpty bool
|
||||
for _, fx := range mf.Fixtures {
|
||||
switch {
|
||||
case fx.IP == "":
|
||||
sawEmpty = true
|
||||
case fx.Expected != "":
|
||||
sawResolved = true
|
||||
case len(fx.IP) >= 8 && fx.IP[:8] == "10.99.1.":
|
||||
sawSubnetMiss = true
|
||||
default:
|
||||
sawOffSubnet = true
|
||||
}
|
||||
}
|
||||
if !sawResolved || !sawSubnetMiss || !sawOffSubnet || !sawEmpty {
|
||||
t.Fatalf("machash coverage incomplete: resolved=%v subnetMiss=%v offSubnet=%v empty=%v",
|
||||
sawResolved, sawSubnetMiss, sawOffSubnet, sawEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWGCacheReload: wgHashOf reflects the file's content; after pointing at a
|
||||
// missing path it fails open to "" (best-effort, never panics).
|
||||
func TestWGCacheReload(t *testing.T) {
|
||||
mf, dir := loadMachashFile(t)
|
||||
withWGFixture(t, mf, dir)
|
||||
// A resolved peer from the fixture returns non-empty.
|
||||
if got := wgHashOf("10.99.1.10"); got == "" {
|
||||
t.Fatal("wgHashOf(10.99.1.10) empty — fixture not loaded")
|
||||
}
|
||||
// Repoint at a missing file → reload → fail-open to "".
|
||||
wgPeersPath = filepath.Join(dir, "does-not-exist.json")
|
||||
resetWGCache()
|
||||
if got := wgHashOf("10.99.1.10"); got != "" {
|
||||
t.Fatalf("wgHashOf with missing file = %q want \"\"", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
|
|
@ -234,12 +235,44 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
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)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
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" {
|
||||
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.
|
||||
// allow — stripping operator headers + asserting opt-out is universally
|
||||
// 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)
|
||||
|
||||
// proxy upstream, inject into HTML bodies.
|
||||
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 = ""
|
||||
resp, err := up.Do(req)
|
||||
if err != nil {
|
||||
|
|
@ -287,6 +325,26 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|||
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() {
|
||||
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")
|
||||
|
|
@ -295,6 +353,8 @@ func main() {
|
|||
"anti-track HMAC fake-identity seed (poison disabled if absent)")
|
||||
poison := flag.Bool("poison", true,
|
||||
"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()
|
||||
ca, err := loadCA(*caCert, *caKey)
|
||||
if err != nil {
|
||||
|
|
@ -321,6 +381,15 @@ func main() {
|
|||
jarKey: jarKey,
|
||||
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) {
|
||||
if r.Method == http.MethodConnect {
|
||||
px.handleConnect(w, r)
|
||||
|
|
@ -328,6 +397,6 @@ func main() {
|
|||
}
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,21 +159,36 @@ func (p *Policy) shouldPoison(host string) bool {
|
|||
// clientHashFromConn returns the per-client identity used to mint the stable
|
||||
// fake persona (jar fakeID first arg).
|
||||
//
|
||||
// PoC / CONNECT path: this is the peer IP string. A real TRANSPARENT R3 deploy
|
||||
// MUST replace this with the mac_hash the Python addon uses
|
||||
// (privacy_guard._client_hash → _common.mac_hash_of(peer_ip)), resolved via the
|
||||
// SO_ORIGINAL_DST original-destination socket option and the WireGuard-peer →
|
||||
// MAC map. Using the raw peer IP here is NOT identity-stable across NAT/DHCP
|
||||
// and is intentionally a Phase-6-cutover TODO, not a shipped behaviour.
|
||||
// It mirrors the Python privacy_guard._client_hash → _common.mac_hash_of(peer_ip)
|
||||
// for the WireGuard R3 path: the peer IP is resolved to the WG persona hash
|
||||
// (sha256(peer_pubkey)[:16]) by macHashOf. For 10.99.1.0/24 WG peers that hash
|
||||
// is byte-identical to the Python engine (proven in machash_test.go ↔
|
||||
// test_machash_parity.py), so a flow's fake persona is stable across the Go and
|
||||
// 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 {
|
||||
if conn == nil {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
if err != nil {
|
||||
return conn.RemoteAddr().String()
|
||||
host = conn.RemoteAddr().String()
|
||||
}
|
||||
if mh := macHashOf(host); mh != "" {
|
||||
return mh
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
|
|
|||
390
packages/secubox-toolbox-ng/cmd/sbxmitm/transparent.go
Normal file
390
packages/secubox-toolbox-ng/cmd/sbxmitm/transparent.go
Normal 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)
|
||||
}
|
||||
33
packages/secubox-toolbox-ng/cmd/sbxmitm/transparent_stub.go
Normal file
33
packages/secubox-toolbox-ng/cmd/sbxmitm/transparent_stub.go
Normal 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)")
|
||||
}
|
||||
304
packages/secubox-toolbox-ng/cmd/sbxmitm/transparent_test.go
Normal file
304
packages/secubox-toolbox-ng/cmd/sbxmitm/transparent_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
36
packages/secubox-toolbox-ng/testdata/machash-fixtures.json
vendored
Normal file
36
packages/secubox-toolbox-ng/testdata/machash-fixtures.json
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"_doc": "Cross-engine mac_hash (WG persona identity) parity fixtures (#662 Phase 6 prep). Go core (machash_test.go, macHashOf with wgPeersPath pointed at wg-peers-fixture.json) and Python (_common.mac_hash_of with _WG_PEERS_DB monkeypatched to the SAME wg-peers-fixture.json) load THIS file and MUST agree. Python is the source of truth: expected = sha256(pubkey.encode()).hexdigest()[:16], generated by Python, never Go-authored. The R0-R2 ARP/HMAC path is intentionally out of scope for the R3 transparent engine (WG-only); off-subnet IPs expect empty.",
|
||||
"wg_peers_file": "wg-peers-fixture.json",
|
||||
"fixtures": [
|
||||
{
|
||||
"ip": "10.99.1.10",
|
||||
"expected": "7d790156855ebeef",
|
||||
"why": "WG peer phone-gk2 -> sha256(pubkey)[:16]"
|
||||
},
|
||||
{
|
||||
"ip": "10.99.1.11",
|
||||
"expected": "6f3663aa06e871c4",
|
||||
"why": "WG peer laptop-admin -> sha256(pubkey)[:16]"
|
||||
},
|
||||
{
|
||||
"ip": "10.99.1.12",
|
||||
"expected": "1db566f7c72180f0",
|
||||
"why": "WG peer tablet-lab -> sha256(pubkey)[:16]"
|
||||
},
|
||||
{
|
||||
"ip": "10.99.1.250",
|
||||
"expected": "",
|
||||
"why": "WG subnet but no peer entry -> empty"
|
||||
},
|
||||
{
|
||||
"ip": "192.168.1.5",
|
||||
"expected": "",
|
||||
"why": "off-subnet (R0-R2 ARP path out of scope in R3) -> empty"
|
||||
},
|
||||
{
|
||||
"ip": "",
|
||||
"expected": "",
|
||||
"why": "empty ip -> empty"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
packages/secubox-toolbox-ng/testdata/wg-peers-fixture.json
vendored
Normal file
16
packages/secubox-toolbox-ng/testdata/wg-peers-fixture.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"peers": {
|
||||
"aL3kF2pQ9rZxT7vN1wB4cD6eH8jM0sU2yX5zA7bC1E=": {
|
||||
"ip": "10.99.1.10",
|
||||
"name": "phone-gk2"
|
||||
},
|
||||
"bM4lG3qR0sAyU8wO2xC5dE7fI9kN1tV3zY6aB8cD2F=": {
|
||||
"ip": "10.99.1.11",
|
||||
"name": "laptop-admin"
|
||||
},
|
||||
"cN5mH4rS1tBzV9xP3yD6eF8gJ0lO2uW4aZ7bC9dE3G=": {
|
||||
"ip": "10.99.1.12",
|
||||
"name": "tablet-lab"
|
||||
}
|
||||
}
|
||||
}
|
||||
87
packages/secubox-toolbox/tests/test_machash_parity.py
Normal file
87
packages/secubox-toolbox/tests/test_machash_parity.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user