mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 18:06:21 +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 (
|
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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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