Compare commits

...

3 Commits

Author SHA1 Message Date
CyberMind
c870b6362b
Merge pull request #668 from CyberMind-FR/feat/662-phase5prep-pkg
Some checks are pending
License Headers / check (push) Waiting to run
feat(#662 Phase 5-prep): wire Decide+jar+anonymize+poison into handlers + DARK debian package
2026-06-18 18:06:31 +02:00
de15a18c30 feat(#662 Phase 5-prep B): debian packaging for sbxmitm (DISABLED, dark)
New packages/secubox-toolbox-ng/debian/ producing the secubox-toolbox-ng
binary package (Architecture: arm64):
  - control: Maintainer Gerald KERMA; B-D golang-go; Depends
    only (static CGO_ENABLED=0 binary → no shlib deps). Compat 13 via
    debhelper-compat build-dep (debhelper rejects compat both ways).
  - changelog: 0.1.0-1~bookworm1.
  - rules: dh; GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOPROXY=off go build
    (pure stdlib, offline). dh_installsystemd --no-enable --no-start so the
    unit is shipped but NEVER enabled/started.
  - secubox-toolbox-ng-worker@.service: systemd template mirroring the Python
    mitm-wg worker@ but running sbxmitm on 127.0.0.1:809%i (distinct from the
    Python 808%i so both fleets coexist during cutover). Reads the ca-wg CA.
    DISABLED BY DESIGN — header documents Phase-6-cutover-only enablement.
  - postinst: daemon-reload only; explicitly NO enable/start; NO nft.

Built locally for arm64: dpkg-deb verified — ships /usr/sbin/sbxmitm (arm64
static ELF) + the disabled template; postinst contains ZERO deb-systemd-helper
enable lines. .gitignore extended for in-tree build artifacts. DARK: install
changes no runtime behaviour (no service start, no DNAT, no live-R3 wiring).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:03:40 +02:00
f65af3355c feat(#662 Phase 5-prep A): wire ported policy+jar into proxy handlers
Replace the hardcoded action() short-circuit in handleConnect with the
ported Decide(host,sni) + always-on anonymize + Set-Cookie poison:

  - allow/own-infra  → clean MITM (anonymize only, NO block/poison)
  - splice           → raw passthrough (unchanged)
  - block            → 204 (unchanged)
  - mitm + tracker   → poison tracking-id Set-Cookies via the HMAC jar

New pure, unit-testable helpers (privacy.go):
  - anonymizeRequest(http.Header): drop operator/carrier + re-id headers
    (mirrors privacy_guard._STRIP), pin DNT:1 + Sec-GPC:1.
  - isTrackingCookieName / poisonSetCookies: replace tracking-id cookie
    values with fakeID(clientHash,host,name,jarKey); attrs preserved,
    benign cookies untouched, fail-closed-to-clean when no key/clientHash.
  - Policy.isTracker / Policy.shouldPoison: poison ONLY on MITM'd tracker
    flows, never on allow/own-infra (same dark safety as the block path).
  - clientHashFromConn: PoC peer-IP stub, TODO(#662 P6) mac_hash via
    SO_ORIGINAL_DST + WG-peer map.

writeResponse (util.go) preserves multi-valued Set-Cookie headers.
Poison gated behind --poison (default on) AND a loaded --jar-key.
DARK: nothing wired to live R3. +8 tests (14→22), all green; vet clean;
arm64 cross-build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:00:26 +02:00
12 changed files with 617 additions and 4 deletions

View File

@ -1,3 +1,12 @@
/sbxmitm /sbxmitm
*.test *.test
cmd/sbxmitm/sbxmitm cmd/sbxmitm/sbxmitm
# Debian build artifacts (rules builds the binary + go caches in-tree)
/_gocache/
/_gopath/
/debian/.debhelper/
/debian/files
/debian/*.substvars
/debian/secubox-toolbox-ng/
/debian/debhelper-build-stamp
/debian/*.debhelper.log

View File

@ -179,6 +179,8 @@ type Proxy struct {
ca *CA ca *CA
pol *Policy pol *Policy
jaSink func(string) // JA4 observations (logged; a sidecar in prod) jaSink func(string) // JA4 observations (logged; a sidecar in prod)
jarKey []byte // anti-track HMAC fake-identity seed (nil → poison off)
poison bool // master gate: poison tracker Set-Cookies (default on when jarKey present)
} }
func (px *Proxy) serverTLSConfig() *tls.Config { func (px *Proxy) serverTLSConfig() *tls.Config {
@ -210,7 +212,11 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
defer client.Close() defer client.Close()
io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n") io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n")
if px.pol.action(host) == "splice" { // Decide once on (host, sni). For the CONNECT PoC the SNI is the CONNECT
// host; the transparent engine will splice on the real ClientHello SNI.
verdict := px.pol.Decide(host, host)
if verdict == "splice" {
// passthrough: raw TCP to upstream, no TLS interception (tls_splice). // passthrough: raw TCP to upstream, no TLS interception (tls_splice).
up, err := net.DialTimeout("tcp", r.URL.Host, 10*time.Second) up, err := net.DialTimeout("tcp", r.URL.Host, 10*time.Second)
if err != nil { if err != nil {
@ -235,11 +241,22 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
} }
req.URL.Scheme, req.URL.Host = "https", r.URL.Host req.URL.Scheme, req.URL.Host = "https", r.URL.Host
if px.pol.action(host) == "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)
return return
} }
// ── verdict ∈ {"allow","mitm"} → intercept normally ──────────────────────
//
// allow → own-infra / allowlist: clean MITM, apply NO block/poison.
// mitm → intercept + apply the response handlers (poison if a tracker).
//
// 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
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}
req.RequestURI = "" req.RequestURI = ""
@ -249,18 +266,35 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
// Poison: only on MITM'd tracker flows (never on allow/own-infra), and only
// when the jar key is loaded. Replaces tracking-id Set-Cookie values with a
// stable fabricated persona; benign cookies pass through untouched.
if verdict == "mitm" && px.poison && len(px.jarKey) > 0 && px.pol.shouldPoison(host) {
if sc := resp.Header.Values("Set-Cookie"); len(sc) > 0 {
poisoned := poisonSetCookies(sc, clientHash, host, px.jarKey)
resp.Header.Del("Set-Cookie")
for _, c := range poisoned {
resp.Header.Add("Set-Cookie", c)
}
}
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") { if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
body = px.pol.injectMarker(body) body = px.pol.injectMarker(body)
} }
hdr := map[string]string{"Content-Type": resp.Header.Get("Content-Type")} writeResponse(tconn, resp, body)
writeRaw(tconn, resp.StatusCode, resp.Status, hdr, body)
} }
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")
addr := flag.String("listen", ":8090", "CONNECT proxy listen addr") addr := flag.String("listen", ":8090", "CONNECT proxy listen addr")
jarKeyPath := flag.String("jar-key", "/etc/secubox/secrets/privacy-jar.key",
"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)")
flag.Parse() flag.Parse()
ca, err := loadCA(*caCert, *caKey) ca, err := loadCA(*caCert, *caKey)
if err != nil { if err != nil {
@ -274,10 +308,18 @@ func main() {
log.Fatalf("policy load: %v", err) log.Fatalf("policy load: %v", err)
} }
pol.Inject = []byte("<!-- sbx-ng banner -->") pol.Inject = []byte("<!-- sbx-ng banner -->")
// Anti-track jar seed: best-effort (like the Python _jar_key). Absent/empty
// → loadJarKey returns nil → poison stays off even if --poison is set.
jarKey := loadJarKey(*jarKeyPath)
if *poison && len(jarKey) == 0 {
log.Printf("poison requested but jar key %s absent/empty → poison OFF", *jarKeyPath)
}
px := &Proxy{ px := &Proxy{
ca: ca, ca: ca,
pol: pol, pol: pol,
jaSink: func(s string) { log.Printf("ja4 %s", s) }, jaSink: func(s string) { log.Printf("ja4 %s", s) },
jarKey: jarKey,
poison: *poison,
} }
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 {

View File

@ -0,0 +1,47 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// Gate tests for the poison emission (#662 Phase 5-prep, Part A): poison only
// fires on MITM'd TRACKER flows, never on allow/own-infra flows. This is the
// same safety envelope as anti-track — own-infra/allowlist flows stay clean.
package main
import (
"path/filepath"
"testing"
)
// TestShouldPoisonGate: a tracker host MITM'd → poison; an own-infra/allowlisted
// host → never poison (even though both are intercepted = "mitm" verb).
func TestShouldPoisonGate(t *testing.T) {
pf, dir := loadParityFile(t)
cfgPath := func(rel string) string { return filepath.Join(dir, filepath.FromSlash(rel)) }
pol, err := LoadPolicy(PolicyOpts{
AllowPath: cfgPath(pf.Config.AdAllowlist),
LearnedPath: cfgPath(pf.Config.LearnedTrackers),
SpliceSeedPath: cfgPath(pf.Config.SpliceSeed),
SpliceLearnPath: cfgPath(pf.Config.SpliceLearned),
PureTrackersPath: cfgPath(pf.Config.PureTrackers),
FortknoxSites: pf.Config.FortknoxSites,
SelfDomains: pf.Config.SelfDomains,
})
if err != nil {
t.Fatal(err)
}
cases := map[string]bool{
// tracker hosts → poison eligible (a tracker we'd otherwise block, but
// once MITM'd we poison rather than blunt-block).
"ads.doubleclick.net": true,
"adnxs.com": true,
// own-infra + allowlisted + benign → NEVER poison.
"hub.secubox.in": false,
"analytics.example-allowed.com": false,
"news.example.com": false,
}
for host, want := range cases {
if got := pol.shouldPoison(host); got != want {
t.Errorf("shouldPoison(%q)=%v want %v", host, got, want)
}
}
}

View File

@ -0,0 +1,179 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: toolbox-ng :: always-on anonymize + Set-Cookie poison wiring
// (#662 Phase 5-prep, Part A)
//
// These helpers wire the ported policy (policy.go) + HMAC fake-identity jar
// (jar.go) into the MITM response path. They mirror the INTENT of the Python
// privacy_guard._anonymize and privacy.fake_id poison (mitmproxy_addons/
// privacy_guard.py, secubox_toolbox/privacy.py) — best-effort privacy hygiene,
// NOT byte-identical to the Python request-Cookie path. The jar values
// themselves ARE byte-exact (proven in jar_test.go).
//
// Safety envelope (DARK, like anti-track): poison only acts on MITM'd TRACKER
// flows. allow/own-infra flows are left CLEAN — never poisoned, never blocked.
//
// Pure standard library — no external modules.
package main
import (
"net"
"net/http"
"strings"
)
// ── anonymize: always-on hygiene ─────────────────────────────────────────────
// anonymizeStrip mirrors privacy_guard._STRIP / protective_mode._STRIP: the
// operator/carrier + re-identification REQUEST headers we drop on every MITM'd
// flow. Lower-cased for case-insensitive matching against canonicalised keys.
var anonymizeStrip = []string{
"msisdn", "x-msisdn", "x-up-calling-line-id", "x-up-subno",
"x-nokia-msisdn", "x-acr", "x-vf-acr", "x-amobee-1", "x-amobee-2",
"tm-user-id", "x-wap-profile", "x-wap-msisdn", "x-network-info",
"x-forwarded-for", "forwarded", "x-real-ip", "via",
}
// anonymizeRequest applies always-on privacy hygiene to a MITM'd request:
// drop the operator/tracking headers above, then pin DNT:1 + Sec-GPC:1 (the
// opt-out signals). Mirrors privacy_guard._anonymize. Minimal + best-effort:
// it never errors and is safe to call on every intercepted request.
//
// NOTE: unlike the Python spoof path we do NOT drop Cookie/Referer here —
// anonymize is the universally-safe hygiene layer; cookie neutralisation is the
// poison layer (poisonSetCookies), gated behind the tracker classification.
func anonymizeRequest(h http.Header) {
for _, name := range anonymizeStrip {
// http.Header.Del canonicalises the key; our list is lower-case but Del
// matches case-insensitively via CanonicalMIMEHeaderKey.
h.Del(name)
}
h.Set("DNT", "1")
h.Set("Sec-GPC", "1")
}
// ── poison: response Set-Cookie value replacement ────────────────────────────
// trackingCookieNames is the set of exact cookie names we treat as tracking
// identifiers worth poisoning (lower-cased). These map onto the shapes the jar
// (_shape in jar.go) knows how to forge plausibly.
var trackingCookieNames = map[string]bool{
"_fbp": true, "_fbc": true, "_gid": true, "_gcl_au": true,
"uid": true, "uuid": true, "_pk_id": true, "_pk_ses": true,
"__qca": true, "muid": true, "ide": true, "fr": true,
"_uetvid": true, "_uetsid": true, "anid": true, "nid": true,
}
// isTrackingCookieName reports whether a Set-Cookie name looks like a tracking
// identifier we should poison. Prefix rule: any "_ga*" cookie (GA + GA4
// per-property _ga_<id>) is a tracking id; otherwise an exact-match against
// trackingCookieNames. Benign session/CSRF cookies (sessionid, csrftoken, …)
// are NOT matched, so they pass through untouched.
func isTrackingCookieName(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
if n == "" {
return false
}
if strings.HasPrefix(n, "_ga") {
return true
}
return trackingCookieNames[n]
}
// poisonSetCookies rewrites the response Set-Cookie header lines for a MITM'd
// tracker flow: for each cookie whose NAME is a tracking id, the value is
// replaced with the jar fakeID(clientHash, host, name, key) while ALL cookie
// attributes (Path, Domain, Max-Age, Secure, HttpOnly, SameSite, …) are
// preserved verbatim. Non-tracking cookies are returned byte-identical.
//
// Gating (caller's responsibility too, but defensive here): if the jar key is
// absent OR fakeID returns !ok (empty clientHash / tracker), the cookie is left
// UNCHANGED — we never emit a malformed cookie, and we never invent a fake
// where we lack the seed. This keeps the poison fail-closed-to-clean.
//
// This is the emission half of the jar; the classification half (is this a
// tracker flow at all) is Policy.shouldPoison, applied by the wiring before
// this is ever called — poison NEVER touches allow/own-infra flows.
func poisonSetCookies(setCookies []string, clientHash, host string, key []byte) []string {
if len(setCookies) == 0 {
return setCookies
}
out := make([]string, len(setCookies))
for i, sc := range setCookies {
out[i] = poisonOneSetCookie(sc, clientHash, host, key)
}
return out
}
// poisonOneSetCookie rewrites a single Set-Cookie line. The line shape is
// `name=value; Attr1; Attr2=...`; we split off the first `;` to isolate the
// name=value pair, replace value if name is a tracking id and a fake mints,
// then re-attach the (unchanged) attribute tail.
func poisonOneSetCookie(sc, clientHash, host string, key []byte) string {
semi := strings.IndexByte(sc, ';')
pair := sc
tail := ""
if semi >= 0 {
pair = sc[:semi]
tail = sc[semi:] // includes the leading ';'
}
eq := strings.IndexByte(pair, '=')
if eq < 0 {
return sc // attribute-only / malformed → leave untouched
}
name := strings.TrimSpace(pair[:eq])
if !isTrackingCookieName(name) {
return sc
}
fake, ok := fakeID(clientHash, host, name, key)
if !ok {
return sc // no jar key / no clientHash → leave clean (fail-closed)
}
return name + "=" + fake + tail
}
// ── tracker classification + poison gate ─────────────────────────────────────
// isTracker mirrors the tracker classification used by the block decision
// (privacy.is_tracker / ad_ghost): _AD_HOST regex OR host/registrable in the
// learned-trackers set. Reused here so poison fires on exactly the hosts the
// engine already considers trackers.
func (p *Policy) isTracker(host string) bool {
return p.blockedByAd(host)
}
// shouldPoison reports whether a MITM'd flow to host should have its tracking
// Set-Cookies poisoned. TRUE only for tracker hosts that are NOT own-infra /
// allowlisted — own-infra flows are left clean (same dark safety as the block
// path). The caller additionally requires a loaded jar key.
func (p *Policy) shouldPoison(host string) bool {
if p.allowed(host) {
return false // own-infra / allowlist → never poison
}
return p.isTracker(host)
}
// ── client identity ──────────────────────────────────────────────────────────
// 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.
//
// TODO(#662 Phase 6): wire mac_hash via SO_ORIGINAL_DST + WG-peer map.
func clientHashFromConn(conn net.Conn) string {
if conn == nil {
return ""
}
host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
return conn.RemoteAddr().String()
}
return host
}

View File

@ -0,0 +1,152 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// Unit tests for the always-on anonymize hygiene + the Set-Cookie poison
// emission wired into the MITM response path (#662 Phase 5-prep, Part A).
//
// These exercise the PURE helpers (anonymizeRequest / poisonSetCookies /
// isTrackingCookieName) so the wiring is testable without standing up a full
// proxy. The behaviour mirrors the Python privacy_guard._anonymize and the
// privacy.fake_id poison intent (see comments in privacy.go) — best-effort
// hygiene, not byte-identical to the request-Cookie path.
package main
import (
"net/http"
"testing"
)
// TestAnonymizeRequestStripsOperatorHeaders: the operator/carrier + re-id
// headers are dropped, and DNT:1 + Sec-GPC:1 are pinned (mirrors
// privacy_guard._anonymize / protective_mode spoof header hygiene).
func TestAnonymizeRequestStripsOperatorHeaders(t *testing.T) {
h := http.Header{}
h.Set("X-MSISDN", "33612345678")
h.Set("X-ACR", "carrier-acr-token")
h.Set("X-Up-Calling-Line-Id", "33612345678")
h.Set("X-Wap-Profile", "http://wap.example/ua.xml")
h.Set("X-Forwarded-For", "10.0.0.7")
h.Set("Via", "1.1 carrier-proxy")
h.Set("User-Agent", "Mozilla/5.0") // must survive
anonymizeRequest(h)
for _, k := range []string{
"X-Msisdn", "X-Acr", "X-Up-Calling-Line-Id", "X-Wap-Profile",
"X-Forwarded-For", "Via",
} {
if v := h.Get(k); v != "" {
t.Errorf("anonymizeRequest left %s=%q (should be stripped)", k, v)
}
}
if h.Get("User-Agent") != "Mozilla/5.0" {
t.Errorf("anonymizeRequest clobbered a benign header: User-Agent=%q", h.Get("User-Agent"))
}
if h.Get("DNT") != "1" {
t.Errorf("DNT not pinned: %q", h.Get("DNT"))
}
if h.Get("Sec-GPC") != "1" {
t.Errorf("Sec-GPC not pinned: %q", h.Get("Sec-GPC"))
}
}
// TestAnonymizeRequestPinsSignalsWhenAbsent: DNT/Sec-GPC are asserted even when
// no operator headers were present (always-on hygiene).
func TestAnonymizeRequestPinsSignalsWhenAbsent(t *testing.T) {
h := http.Header{}
anonymizeRequest(h)
if h.Get("DNT") != "1" || h.Get("Sec-GPC") != "1" {
t.Fatalf("opt-out signals not pinned on a clean request: DNT=%q GPC=%q",
h.Get("DNT"), h.Get("Sec-GPC"))
}
}
// TestIsTrackingCookieName: known tracking-id cookie names are recognised;
// benign session/CSRF cookies are not.
func TestIsTrackingCookieName(t *testing.T) {
track := []string{"_ga", "_GA_ABC123", "_fbp", "_gid", "uid", "uuid", "_pk_id", "__qca", "_gcl_au"}
for _, n := range track {
if !isTrackingCookieName(n) {
t.Errorf("isTrackingCookieName(%q)=false, want true", n)
}
}
benign := []string{"sessionid", "csrftoken", "XSRF-TOKEN", "PHPSESSID", "cart", "lang"}
for _, n := range benign {
if isTrackingCookieName(n) {
t.Errorf("isTrackingCookieName(%q)=true, want false", n)
}
}
}
// TestPoisonSetCookiesReplacesTrackingValue: a tracking Set-Cookie has its value
// replaced by the jar fakeID (attributes preserved), while a non-tracking cookie
// is left byte-identical.
func TestPoisonSetCookiesReplacesTrackingValue(t *testing.T) {
key := []byte("test-jar-seed-key-0123456789abcdef")
const ch = "203.0.113.9"
const host = "ads.doubleclick.net"
in := []string{
"_ga=GA1.2.111.222; Path=/; Domain=.doubleclick.net; Max-Age=63072000",
"sessionid=abc123; Path=/; HttpOnly",
}
out := poisonSetCookies(in, ch, host, key)
if len(out) != 2 {
t.Fatalf("poisonSetCookies returned %d cookies, want 2", len(out))
}
// The _ga value must be the jar fakeID and the attributes preserved.
want, ok := fakeID(ch, host, "_ga", key)
if !ok {
t.Fatal("fakeID returned !ok for _ga")
}
wantCookie := "_ga=" + want + "; Path=/; Domain=.doubleclick.net; Max-Age=63072000"
if out[0] != wantCookie {
t.Errorf("poisoned _ga = %q\n want %q", out[0], wantCookie)
}
if out[0] == in[0] {
t.Error("tracking cookie value was NOT changed")
}
// The benign cookie must be untouched.
if out[1] != in[1] {
t.Errorf("non-tracking cookie altered: %q != %q", out[1], in[1])
}
}
// TestPoisonSetCookiesNoKeyLeavesUnchanged: with no jar key (key present-gate),
// nothing is poisoned (fail-closed-to-clean: we never emit a broken cookie).
func TestPoisonSetCookiesNoKeyLeavesUnchanged(t *testing.T) {
in := []string{"_ga=GA1.2.1.2; Path=/"}
out := poisonSetCookies(in, "1.2.3.4", "ads.doubleclick.net", nil)
if len(out) != 1 || out[0] != in[0] {
t.Fatalf("poisonSetCookies with nil key altered output: %v", out)
}
}
// TestPoisonSetCookiesNoClientHashLeavesUnchanged: empty clientHash → fakeID !ok
// → cookie left as-is.
func TestPoisonSetCookiesNoClientHashLeavesUnchanged(t *testing.T) {
key := []byte("test-jar-seed-key-0123456789abcdef")
in := []string{"_ga=GA1.2.1.2; Path=/"}
out := poisonSetCookies(in, "", "ads.doubleclick.net", key)
if len(out) != 1 || out[0] != in[0] {
t.Fatalf("poisonSetCookies with empty clientHash altered output: %v", out)
}
}
// TestPoisonSetCookiesDeterministic: same (client,host,name) → same fake value
// across calls ('rémanent' jar — proven byte-exact in jar_test.go; here we just
// assert the wiring keeps it stable).
func TestPoisonSetCookiesDeterministic(t *testing.T) {
key := []byte("test-jar-seed-key-0123456789abcdef")
in := []string{"uid=real-user-7; Path=/"}
a := poisonSetCookies(in, "9.9.9.9", "adnxs.com", key)
b := poisonSetCookies(in, "9.9.9.9", "adnxs.com", key)
if a[0] != b[0] {
t.Fatalf("poison not deterministic: %q != %q", a[0], b[0])
}
if a[0] == in[0] {
t.Fatal("uid (tracking) cookie not poisoned")
}
}

View File

@ -7,10 +7,39 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http"
) )
func newReader(c net.Conn) *bufio.Reader { return bufio.NewReader(c) } func newReader(c net.Conn) *bufio.Reader { return bufio.NewReader(c) }
// writeResponse serializes an http.Response (status + headers + body) onto a
// (TLS) conn, preserving MULTI-VALUED headers (notably Set-Cookie, which the
// poison path rewrites per-cookie). Hop-by-hop framing headers are dropped and
// replaced with an explicit Content-Length + Connection: close, because we send
// the fully-buffered body.
func writeResponse(c io.Writer, resp *http.Response, body []byte) {
status := resp.Status
if status == "" {
status = fmt.Sprintf("%d", resp.StatusCode)
}
fmt.Fprintf(c, "HTTP/1.1 %s\r\n", status)
for k, vals := range resp.Header {
switch http.CanonicalHeaderKey(k) {
case "Content-Length", "Transfer-Encoding", "Connection":
continue // we set framing ourselves
}
for _, v := range vals {
fmt.Fprintf(c, "%s: %s\r\n", k, v)
}
}
fmt.Fprintf(c, "Content-Length: %d\r\n", len(body))
fmt.Fprintf(c, "Connection: close\r\n")
io.WriteString(c, "\r\n")
if len(body) > 0 {
c.Write(body)
}
}
// writeRaw writes a minimal HTTP/1.1 response onto a (TLS) conn. // writeRaw writes a minimal HTTP/1.1 response onto a (TLS) conn.
func writeRaw(c io.Writer, code int, status string, headers map[string]string, body []byte) { func writeRaw(c io.Writer, code int, status string, headers map[string]string, body []byte) {
if status == "" { if status == "" {

View File

@ -0,0 +1,9 @@
secubox-toolbox-ng (0.1.0-1~bookworm1) bookworm; urgency=medium
* Initial packaging of the Go MITM engine migration target (#662 Phase 5-prep).
Ships /usr/sbin/sbxmitm + a DISABLED systemd template unit
(secubox-toolbox-ng-worker@.service). DARK by design: the unit is not
enabled or started, no nft DNAT, no live-R3 wiring — enabled only at the
Phase 6 cutover.
-- Gerald KERMA <devel@cybermind.fr> Wed, 18 Jun 2026 22:00:00 +0200

View File

@ -0,0 +1,22 @@
Source: secubox-toolbox-ng
Section: net
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13), golang-go (>= 2:1.22~)
Standards-Version: 4.6.2
Homepage: https://cybermind.fr/secubox
Rules-Requires-Root: no
Package: secubox-toolbox-ng
Architecture: arm64
Depends: ${misc:Depends}
Description: SecuBox-Deb — Go MITM engine (migration target, DARK)
Multi-core Go re-implementation of the R3 toolbox MITM engine (#662),
ported off the GIL-bound Python mitmproxy worker fleet. Ships the
standalone sbxmitm binary plus a DISABLED systemd template unit.
.
This package is the Phase-6-cutover migration target. The unit is NOT
enabled or started by the maintainer scripts — the live R3 tunnel keeps
running on the Python workers until the cutover is performed manually.
Installing this package changes NO runtime behaviour (no service start,
no nft DNAT).

View File

@ -0,0 +1,27 @@
#!/bin/sh
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: toolbox-ng — postinst
#
# DARK by design (#662 Phase 5-prep):
# - DO reload the systemd unit catalogue so the template is known.
# - DO NOT enable or start secubox-toolbox-ng-worker@.service — this is the
# Phase-6 cutover target; the live R3 tunnel keeps running on the Python
# workers until the operator performs the cutover manually.
# - DO NOT touch nftables (no DNAT, no live-R3 rewiring).
set -e
case "$1" in
configure)
if [ -d /run/systemd/system ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
# Intentionally NO `systemctl enable --now`. See the unit header and
# debian/changelog: enabled only at the Phase 6 cutover.
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,44 @@
#!/usr/bin/make -f
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: toolbox-ng — Go MITM engine (migration target, DARK)
#
# The binary is pure-stdlib (no go.sum, no external modules), so it
# cross-compiles offline with GOPROXY=off. CI cross-builds for arm64;
# this rules file does the same with `GOOS=linux GOARCH=arm64 go build`.
export DH_VERBOSE = 1
# Build the static arm64 binary offline (stdlib only — no network, no go.sum).
export GOOS = linux
export GOARCH = arm64
export CGO_ENABLED = 0
export GOFLAGS = -mod=mod
export GOPROXY = off
# Keep the Go build/module cache inside the build tree (sandbox-friendly).
export GOCACHE = $(CURDIR)/_gocache
export GOPATH = $(CURDIR)/_gopath
%:
dh $@
override_dh_auto_build:
go build -trimpath -ldflags=-s -o sbxmitm ./cmd/sbxmitm
# No Go unit tests at package-build time (run in CI on the host arch; the
# arm64 cross-binary cannot execute its tests here).
override_dh_auto_test:
override_dh_auto_install:
install -d debian/secubox-toolbox-ng/usr/sbin
install -m 0755 sbxmitm debian/secubox-toolbox-ng/usr/sbin/sbxmitm
override_dh_auto_clean:
rm -f sbxmitm
rm -rf _gocache _gopath
# DARK: install the unit file into the catalogue but DO NOT enable or start it.
# This is the Phase-6 cutover target; the live R3 tunnel stays on the Python
# workers until the operator enables it manually. The postinst still reloads the
# unit catalogue so `systemctl` knows the template exists.
override_dh_installsystemd:
dh_installsystemd --no-enable --no-start --name=secubox-toolbox-ng-worker@

View File

@ -0,0 +1,52 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: toolbox-ng — Go MITM engine worker template (#662)
#
# ── DISABLED BY DESIGN (DARK) ────────────────────────────────────────────────
# This is the Phase-6 CUTOVER MIGRATION TARGET. It is NOT enabled or started by
# the package (postinst does not `systemctl enable --now`). The live R3 tunnel
# keeps running on the Python mitmproxy workers
# (secubox-toolbox-mitm-wg-worker@{1..4}, ports 8081-8084) until the cutover is
# performed manually.
#
# Mirrors the Python worker@ fanout: each %i ∈ {1..4} listens on 127.0.0.1:809%i
# (distinct from the Python 808%i ports so both fleets can coexist during a
# side-by-side cutover validation). Enable ONLY at Phase 6:
#
# systemctl enable --now secubox-toolbox-ng-worker@{1,2,3,4}.service
# # then re-point the nft DNAT at 809%i and retire the Python workers
#
# Rollback: disable these, re-point DNAT at the Python 808%i workers.
[Unit]
Description=SecuBox ToolBoX-NG Go MITM worker %i (migration target, port 809%i)
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/662
After=network.target
[Service]
Type=simple
User=secubox-toolbox
Group=secubox-toolbox
# Reuse the EXISTING ca-wg CA (R3 clients already trust it — no re-enroll).
# The anti-track jar key is best-effort: absent → poison stays off.
ExecStart=/usr/sbin/sbxmitm \
--listen 127.0.0.1:809%i \
--ca-cert /etc/secubox/toolbox/ca-wg/ca.pem \
--ca-key /etc/secubox/toolbox/ca-wg/key.pem \
--jar-key /etc/secubox/secrets/privacy-jar.key
Restart=on-failure
RestartSec=5
# Hardening (mirrors the Python worker envelope).
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadOnlyPaths=/etc/secubox
MemoryHigh=100M
MemoryMax=128M
TasksMax=128
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1 @@
3.0 (native)