From 313396559e1446c5133b7369eacdfed6f70c23dd Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Mon, 14 Sep 2026 09:57:50 -0300 Subject: [PATCH] Close clearnet leak for Tor users during the startup settings race Profile fetches and boot-time network callbacks could dial relays directly before the async settings load finished: Amber.settings starts as the data-class default (torMode = DISABLED), so the relay and Coil factories read 'Tor off' and picked the direct OkHttp client. The fail-closed SOCKS placeholder never got consulted. Seen in adb logs: kind-0 profile REQs went out over clearnet TLS a full ten seconds before the built-in Tor SOCKS port existed. Fix, at the factory decision point: - Preset the proxy synchronously from plain prefs in Application.onCreate: ORBOT gets the configured port, BUILTIN the fail-closed placeholder (SOCKS 127.0.0.1:, refused until Tor binds), DISABLED stays direct. - Add Amber.isSettingsLoaded, set when reloadApp() assigns the loaded settings. Until then both factories route through the proxy client instead of trusting the DISABLED default, so early dials (network callbacks, NotificationSubscription / ProfileSubscription / BunkerRequestUtils) hit the placeholder and retry rather than leak. Verified on device: cold start with BUILTIN Tor shows pre-Tor dials refused and every profile REQ flowing after the SOCKS port comes up. --- .../java/com/greenart7c3/nostrsigner/Amber.kt | 31 +++++++++++++++++-- .../nostrsigner/LocalPreferences.kt | 24 ++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt index 863716ab..8a33ebba 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt @@ -117,8 +117,25 @@ class Amber : var settings: AmberSettings = AmberSettings() + /** + * False until the first async settings load ([LocalPreferences.reloadApp]) lands. Until + * then [settings] is the data-class default, whose torMode (DISABLED) is a guess, not a + * decision — any dial in that window must route through the proxy client (preset from + * plain prefs in [onCreate]) instead of trusting the default and going to the clearnet. + */ + @Volatile var isSettingsLoaded = false + val factory = OkHttpWebSocket.Builder { url -> - val useProxy = if (isPrivateIp(url.url)) false else settings.torMode != TorMode.DISABLED + val useProxy = when { + isPrivateIp(url.url) -> false + // Settings load asynchronously; until they land, torMode's default (DISABLED) + // is a guess, not a decision. Route through the proxy client — preset from + // plain prefs in onCreate — so a BUILTIN/ORBOT user's pre-Tor dials (network + // callbacks, profile fetches) hit the fail-closed placeholder instead of the + // clearnet. + !isSettingsLoaded -> true + else -> settings.torMode != TorMode.DISABLED + } HttpClientManager.getHttpClient(useProxy) } @@ -354,6 +371,12 @@ class Amber : } instance = this + // Before anything can dial: route the proxy client through the user's SOCKS + // setup (or the fail-closed placeholder for BUILTIN) read synchronously from + // plain prefs. Dials in the window before the async settings load — network + // callbacks, profile fetches — pick this up via the factory's !isSettingsLoaded + // gate; without it that window leaks clearnet traffic for Tor users. + LocalPreferences.presetProxyFromPrefs(this) stats.createNotificationChannel() Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope)) @@ -654,7 +677,11 @@ class Amber : .build() val coilCallFactory = okhttp3.Call.Factory { request -> val url = request.url.toString() - val useProxy = if (isPrivateIp(url)) false else settings.torMode != TorMode.DISABLED + val useProxy = when { + isPrivateIp(url) -> false + !isSettingsLoaded -> true + else -> settings.torMode != TorMode.DISABLED + } HttpClientManager.getHttpClient(useProxy).newCall(request) } return ImageLoader.Builder(context) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt index 6ebffb86..3fcdb449 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt @@ -244,6 +244,7 @@ object LocalPreferences { accountCache.clear() warmAccountCache(context) context.settings = loadSettingsFromEncryptedStorage(context) + context.isSettingsLoaded = true context.settings.language?.let { AppCompatDelegate.setApplicationLocales( LocaleListCompat.forLanguageTags(it), @@ -251,6 +252,29 @@ object LocalPreferences { } } + /** + * Synchronously presets the OkHttp proxy from plain (unencrypted) prefs, before any + * dial can happen. The full settings load is async; until it lands, the relay/Coil + * factories treat torMode as unknown and route through the proxy client — which only + * protects Tor users if that client already carries the SOCKS proxy (fail-closed + * placeholder for BUILTIN, the configured port for ORBOT). + */ + fun presetProxyFromPrefs(context: Context) { + val prefs = sharedPrefs(context) + val torMode = try { + TorMode.valueOf(prefs.getString(SettingsKeys.TOR_MODE.key, TorMode.DISABLED.name)!!) + } catch (_: IllegalArgumentException) { + TorMode.DISABLED + } + when (torMode) { + TorMode.ORBOT -> HttpClientManager.setDefaultProxyOnPort( + prefs.getInt(SettingsKeys.PROXY_PORT.key, 9050), + ) + TorMode.BUILTIN -> HttpClientManager.setDefaultProxyOnPort(TorManager.socksPort.value) + TorMode.DISABLED -> {} + } + } + fun getStartServiceOnBoot(context: Context): Boolean = sharedPrefs(context).getBoolean(SettingsKeys.START_SERVICE_ON_BOOT.key, true) fun loadSettingsFromEncryptedStorage(context: Context = Amber.instance): AmberSettings {