Compare commits

...

2 Commits

Author SHA1 Message Date
79c6166181 fix(toolbox): 🧅 Tor indicator on the REAL injected banner (bundle) (ref #683)
The live page banner is the client-side stream-inject bundle (bundle.py:
"SecuBox · LEVEL · 🛰️ trackers · 🍪 cookies · report ▸ · ✕"), not the
server-side inject_banner chip I'd added earlier. Added tor_mode to the
decision bundle + a "🧅 Tor" span in the shared banner render() (_BANNER_CORE,
used by both the loader and the #662 inline/service-worker path).

Verified live on kbin: /__toolbox/bundle → tor_mode:true; loader.js + inline
both carry the 🧅 span. toolbox 2.7.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:13:13 +02:00
55955867af feat(clients): persistent WG identity (APK) + Tor status in webext/APK (ref #683)
APK (0.4.0):
- FIX lost-referrer: persist the WG profile in app-internal filesDir and REUSE
  it. /wg/profile/new mints a fresh keypair each call and onboarding runs every
  boot, so the device kept re-keying → new sha256(pubkey) identity → stats reset
  each reboot. Now one stable identity across reboot/reconnect/restart.
  (Reinstall still wipes filesDir; allowBackup stays off for CSPN.)
- Silent root onboarding (CA system-store + native WG) already runs on boot
  (#538/#558); it now provisions the STABLE profile.
- Surfaces 🧅 kbin Tor-egress status after onboarding.

webext (0.1.5):
- popup shows a 🧅 Tor indicator (exit anonymised) next to the R3 dot,
  via the new public /wg/tor-status endpoint.

toolbox (2.7.5):
- public, kbin-safe GET /wg/tor-status {tor_mode,running,bootstrap,exit_ip}
  (mirrors /wg/r3-check; /admin/tor/* stays admin-gated). Verified live on kbin.

13 toolbox tests green. .xpi 0.1.5 built; .apk builds via build-android-apk.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:03:15 +02:00
13 changed files with 176 additions and 10 deletions

View File

@ -12,8 +12,8 @@ android {
applicationId = "in.secubox.toolbox"
minSdk = 26
targetSdk = 34
versionCode = 3
versionName = "0.3.0"
versionCode = 4
versionName = "0.4.0"
}
buildTypes {

View File

@ -88,12 +88,23 @@ fun OnboardApp() {
busy = false; status = "Borne injoignable — vérifie le réseau."
} else {
step = Step.RootAuto
val onb = RootOnboard(api, ctx.cacheDir)
val onb = RootOnboard(api, ctx.cacheDir, ctx.filesDir)
val out = withContext(Dispatchers.IO) {
onb.runSilent { line -> scope.launch(Dispatchers.Main) { rootLog.add(line) } }
}
busy = false
onTunnel = out.verified
// #683 — surface kbin Tor egress status (anonymised exit) if on.
rootLog.add(withContext(Dispatchers.IO) {
val t = api.torStatus()
when {
t == null -> "• Statut Tor : indisponible"
!t.optBoolean("tor_mode", false) -> "• Mode Tor : inactif"
t.optBoolean("running", false) ->
"🧅 Mode Tor ACTIF — sortie anonymisée${t.optString("exit_ip", "").let { if (it.isNotBlank() && it != "null") " ($it)" else "" }}"
else -> "🧅 Mode Tor activé — tunnel Tor en démarrage…"
}
})
when {
out.verified -> step = Step.Done
out.wgViaApp -> { step = Step.ImportProfile

View File

@ -50,7 +50,7 @@ class OnboardService : Service() {
kotlinx.coroutines.delay(2000)
}
if (!ok) return
RootOnboard(api, cacheDir).runSilent { /* headless: no UI log */ }
RootOnboard(api, cacheDir, filesDir).runSilent { /* headless: no UI log */ }
}
private fun buildNotification(): Notification {

View File

@ -9,7 +9,14 @@ import java.io.File
import java.security.MessageDigest
import java.security.cert.CertificateFactory
class RootOnboard(private val api: ToolboxApi, private val cacheDir: File) {
class RootOnboard(
private val api: ToolboxApi,
private val cacheDir: File,
// #683: app-internal storage for the STABLE WG identity (survives reboot).
// Defaults to cacheDir so older call sites still compile, but real callers
// pass filesDir so the identity persists instead of churning each boot.
private val filesDir: File = cacheDir,
) {
/** A line appended to the on-screen log during the silent run. */
fun interface Logger { fun log(line: String) }
@ -123,8 +130,10 @@ class RootOnboard(private val api: ToolboxApi, private val cacheDir: File) {
log.log("• Noyau sans module WireGuard — bascule sur l'app WireGuard")
return false
}
log.log("• Génération du profil WireGuard…")
val conf = api.downloadProfile(cacheDir).readText()
log.log("• Profil WireGuard (identité stable)…")
// #683: reuse the persisted keypair so the device keeps ONE identity
// across reboots (no more stats reset to a fresh empty hash each boot).
val conf = api.persistentProfile(filesDir).readText()
val wg = parse(conf) ?: run { log.log("✗ profil illisible"); return false }
val iface = "wg-village3b"
val r = RootShell.runScript(

View File

@ -51,6 +51,41 @@ class ToolboxApi(rawHost: String) {
fun downloadCa(cacheDir: File): File = download("/wg/ca.crt", "village3b-ca.crt", cacheDir)
fun downloadProfile(cacheDir: File): File = download("/wg/profile/new", "village3b-toolbox.conf", cacheDir)
/**
* The device's STABLE WireGuard identity (#683 lost-referrer fix).
*
* `/wg/profile/new` mints a FRESH keypair on every call. The onboarding
* runs on every boot, so calling it each time gave the device a NEW pubkey
* new sha256(pubkey) identity hash its stats/social history reset to an
* empty bucket on every reboot/reconnect. Here we fetch a peer ONCE and
* persist the .conf in app-internal `filesDir` (survives reboots, unlike the
* evictable cacheDir). Every later call reuses the SAME keypair SAME
* identity the device keeps one continuous history.
*
* Survives reboot/reconnect/app-restart. (Reinstall still wipes filesDir;
* cross-reinstall persistence would need allowBackup kept off for CSPN.)
*/
fun persistentProfile(filesDir: File): File {
val stored = File(filesDir, "identity-wg.conf")
if (stored.exists() && stored.length() > 0L &&
stored.readText().contains("PrivateKey", ignoreCase = true)) {
return stored
}
val fresh = download("/wg/profile/new", "identity-wg.conf.tmp", filesDir)
fresh.copyTo(stored, overwrite = true)
fresh.delete()
return stored
}
/** kbin Tor egress status for the client UI (read-only, kbin-safe). */
fun torStatus(): JSONObject? {
val c = open("/wg/tor-status")
return try {
if (c.responseCode !in 200..299) null
else JSONObject(c.inputStream.bufferedReader().readText())
} catch (_: Exception) { null } finally { c.disconnect() }
}
/** R3 tunnel status. Returns (onTunnel, peerIp?). */
fun r3Check(): Pair<Boolean, String?> {
val c = open("/wg/r3-check")

View File

@ -65,6 +65,17 @@ async function r3Check(host) {
}
}
// #683 — kbin Tor egress status (public, kbin-safe endpoint).
async function torStatus(host) {
try {
const resp = await fetch(`${baseUrl(host)}/wg/tor-status`, { credentials: "omit" });
if (!resp.ok) return { tor_mode: false };
return await resp.json();
} catch (_) {
return { tor_mode: false };
}
}
// graph: the per-session cartographie JSON. Throws on HTTP error so the
// caller can show "token expired — re-pair".
async function graph(host, token, since) {
@ -133,6 +144,7 @@ const SbxApi = {
setConfig,
pair,
r3Check,
torStatus,
graph,
wipe,
ghost,

View File

@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "SecuBox ToolBoX — Cartographie sociale",
"version": "0.1.4",
"version": "0.1.5",
"description": "Surface the SecuBox R3 toolbox live tracker analysis (cartographie sociale) in your browser: live badge, per-session trackers, mini Round-Eye graph, RGPD wipe + PDF report.",
"browser_specific_settings": {
"gecko": {

View File

@ -10,6 +10,7 @@
<body>
<header>
<span class="logo">👁️ VILLAGE3B</span>
<span id="tordot" class="r3 off" title="Mode Tor" style="display:none">🧅</span>
<span id="r3dot" class="r3 off" title="État du tunnel R3">R3</span>
</header>

View File

@ -111,6 +111,21 @@ async function load() {
dot.title = r.tunnel ? `Tunnel R3 actif (${r.peer_ip || "?"})` : "Hors tunnel R3";
});
// #683 — Tor egress indicator (only visible when kbin Tor mode is on)
api.torStatus(cfg.host).then((t) => {
const dot = $("tordot");
if (!dot) return;
if (t && t.tor_mode) {
dot.style.display = "";
dot.className = "r3 " + (t.running ? "on" : "off");
dot.title = t.running
? `Mode Tor actif — sortie anonymisée${t.exit_ip ? " (" + t.exit_ip + ")" : ""}`
: "Mode Tor activé — démarrage du tunnel…";
} else {
dot.style.display = "none";
}
});
if (!cfg.token) {
$("host").value = cfg.host;
show("pair");

View File

@ -1,3 +1,23 @@
secubox-toolbox (2.7.6-1~bookworm1) bookworm; urgency=medium
* fix(#683): the 🧅 Tor indicator now appears on the ACTUAL injected banner.
The live page banner is the client-side stream-inject bundle (bundle.py:
"SecuBox · LEVEL · 🛰️ trackers · 🍪 cookies · report ▸ · ✕"), NOT the
server-rendered inject_banner chip I'd added earlier. Added `tor_mode` to the
decision bundle + a "🧅 Tor" span in the banner render() (loader + inline
paths) so a consenting client sees their exit is anonymised.
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 17:40:00 +0200
secubox-toolbox (2.7.5-1~bookworm1) bookworm; urgency=medium
* feat(#683): public kbin-safe GET /wg/tor-status ({tor_mode, running,
bootstrap, exit_ip}) so the clients (webext popup + Android app) can show a
🧅 Tor-egress indicator. Mirrors /wg/r3-check (reachable on every vhost incl.
public kbin; the /admin/tor/* controls stay admin-gated).
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 17:00:00 +0200
secubox-toolbox (2.7.4-1~bookworm1) bookworm; urgency=medium
* fix(#683): Tor mode no longer torifies the box's OWN services. kbin/admin

View File

@ -500,6 +500,23 @@ async def change_level(request: Request):
status_code=303)
@router.get("/wg/tor-status")
async def wg_tor_status() -> dict:
"""kbin Tor egress status for the clients (#683). Public + read-only, so it
is reachable on the kbin vhost (unlike the admin-gated /admin/tor/*). The
webext popup + Android app poll this to show the 🧅 indicator + exit IP."""
from .filters import get_filters
from . import tor_ctl
f = get_filters(force=False)
st = tor_ctl.status()
return {
"tor_mode": bool(f.get("tor_mode", False)),
"running": bool(st.get("running", False)),
"bootstrap": int(st.get("bootstrap", 0) or 0),
"exit_ip": _tor_exit_ip_cached(),
}
@router.get("/wg/r3-check")
async def wg_r3_check(request: Request):
"""Phase 7 (#498) — same-origin HTTPS probe for the R3 verification

View File

@ -72,6 +72,15 @@ def _report_url(client_id: str, is_wg: bool) -> str:
return REPORT_URL_CAPTIVE
def _tor_mode() -> bool:
"""kbin Tor egress on? (#683) Read from filters; fail-safe to off."""
try:
from .filters import get_filters
return bool(get_filters().get("tor_mode", False))
except Exception:
return False
def build_bundle(client_id: str, is_wg: bool = False) -> dict:
"""Build the per-client cosmetic decision bundle (pure given inputs + pin file)."""
return {
@ -81,6 +90,7 @@ def build_bundle(client_id: str, is_wg: bool = False) -> dict:
"pin": _read_pin(),
"report_url": _report_url(client_id, is_wg),
"tracker_patterns": TRACKER_PATTERNS,
"tor_mode": _tor_mode(),
"ts": int(time.time()),
}
@ -157,8 +167,12 @@ _BANNER_CORE = r"""
// #662 — 🔓 proof: the engine relaxed this page's CSP to inject this banner.
var cspProof = (csp === "1")
? "<span title=\"CSP contourné par SecuBox (démonstration)\">🔓</span>" : "";
// #683 — 🧅 kbin Tor mode: this session's exit is anonymised via Tor.
var tor = b.tor_mode
? "<span title=\"Sortie anonymisée via Tor\" style=\"color:#9E76FF;font-weight:bold\">🧅 Tor</span>" : "";
bar.innerHTML = "<b style=\"color:#148C66\">SecuBox</b>"
+ cspProof
+ tor
+ "<span>" + esc((b.level || "r1").toUpperCase()) + "</span>"
+ "<span>🛰️ " + trk + " trackers</span>"
+ "<span>🍪 " + ck + " cookies</span>"

View File

@ -160,5 +160,37 @@ def test_nft_tunnel_failclosed_invariants():
assert "meta nfproto ipv6" in text and "drop" in text
# only the worker uid is torified (not a blanket rule)
assert text.count('meta skuid "secubox-toolbox"') >= 4
# loopback + local subnets are exempted (no plumbing breakage)
assert "10.99.0.0/16" in text and "10.100.0.0/16" in text
# own-services exemption: the reconciler-populated set must exist and be
# consulted before the redirect/drop (so the box reaches itself directly)
assert "set tor_exempt" in text
assert text.count("ip daddr @tor_exempt return") >= 2
def test_bundle_banner_has_tor_indicator(tmp_path, monkeypatch):
"""The LIVE injected banner is the stream-inject bundle (bundle.py), not the
server-side inject_banner chip. Its render() must show the 🧅 span and the
decision bundle must carry tor_mode."""
import importlib
monkeypatch.setenv("SECUBOX_FILTERS_PATH", str(tmp_path / "filters.json"))
import secubox_toolbox.filters as f
importlib.reload(f)
f.set_filters({"tor_mode": True})
import secubox_toolbox.bundle as b
importlib.reload(b)
assert b.build_bundle("abc", True)["tor_mode"] is True
assert b.build_bundle("abc", True) is not None
# the banner render() (shared by loader + inline) emits the 🧅 span
assert "b.tor_mode" in b.LOADER_JS
assert "\U0001F9C5" in b.LOADER_JS # 🧅
def test_reconcile_populates_exempt_and_excludes_automap():
"""The reconciler must fill tor_exempt with loopback + own public IP and
must NOT exempt the Tor automap range (10.192/10) or transparent proxy breaks."""
import pathlib
sh = (pathlib.Path(__file__).resolve().parents[1]
/ "sbin" / "secubox-toolbox-tor-reconcile").read_text()
assert "tor_exempt" in sh and "127.0.0.0/8" in sh
assert "api.ipify.org" in sh # own public IP detected direct
assert "scope link" in sh # board-local subnets
assert "10.19" in sh # explicit automap-range guard