feat: route nSite WebView traffic through Tor by default, per-site opt-out

nSites now load over Tor by default when Tor is active — closing the gap where
only blob fetches were Tor-routed while the site's own web traffic (fetch/img/
script) went out the system network and could leak the user's IP. Users can opt
a specific site out to the open web (e.g. a site that breaks or is slow over
Tor); the choice is remembered per site and makes everything for that site
direct (web + blobs).

- NappletHostActivity sets a process-wide WebView SOCKS proxy override
  (socks5://127.0.0.1:<torPort>) for website-mode nSites, or clears it for open
  web. Best-effort + on-device-verifiable: SOCKS-over-WebView support varies by
  WebView version, so it's isolated to applyWebViewProxy() and never breaks the
  site if unsupported.
- Top-bar onion (🧅 Tor / 🌐 open web) shows the routing and toggles it; the
  dialog explains the IP-privacy trade. Shown only for nSites when Tor is active.
- NappletNetworkRegistry: main-process per-site preference (coordinate-keyed,
  DataStore-backed, Tor-default). The launcher reads it; the broker persists the
  sandbox's toggle resolved through the launch token. The key-free sandbox never
  touches it.
- Toggling persists via a new MSG_SET_NETWORK_MODE IPC, then relaunches the host
  so the proxy + content server rebuild cleanly for the new mode.
- "Open web" also routes blob fetches direct (effective proxy -1); locked
  napplets always keep Tor for blobs (no toggle exposed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 23:10:12 +00:00
parent d0d1577bfe
commit 7eb0949e5c
7 changed files with 216 additions and 1 deletions
@@ -102,6 +102,16 @@ class NappletBrokerService : Service() {
}
private fun handleMessage(msg: Message): Boolean {
// The sandbox relays the user's per-site network choice; persist it against the trusted
// coordinate the launch token resolves to (the sandbox can't state its own coordinate).
if (msg.what == NappletIpc.MSG_SET_NETWORK_MODE) {
val data = msg.data ?: return true
val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) ?: return true
NappletNetworkRegistry.init(applicationContext)
NappletNetworkRegistry.set(session.identity.coordinate, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true))
return true
}
if (msg.what != NappletIpc.MSG_REQUEST) return false
val data = msg.data ?: return true
@@ -98,6 +98,11 @@ object NappletLauncher {
}
val launchToken = NappletLaunchRegistry.register(identity, declared)
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
NappletNetworkRegistry.init(context.applicationContext)
val useTor = if (websiteMode) NappletNetworkRegistry.useTor(identity.coordinate) else true
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { context.getString(it.labelRes()) }
@@ -115,6 +120,7 @@ object NappletLauncher {
putExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken)
putExtra(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
putExtra(NappletHostContract.EXTRA_WEBSITE_MODE, websiteMode)
putExtra(NappletHostContract.EXTRA_USE_TOR, useTor)
if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network")
/**
* Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default)
* or over the **open web**. Keyed by the site's coordinate (`author:identifier`), like the permission
* ledger, so the choice survives across launches and routine code updates.
*
* When Tor is active, nSites route through it by default; a user can opt a specific site out (e.g. a
* site that breaks or is unusably slow over Tor) from the sandbox top bar. "Open web" makes
* *everything* for that site direct — both its live web traffic and its blob fetches.
*
* An in-memory map is authoritative for the session ([NappletLauncher] reads it synchronously while
* building the launch intent) with write-through persistence. Absent = Tor (the safe default), so a
* site never silently starts on the open web before the on-disk preference has hydrated.
*
* Lives only in the **main process**: the launcher reads it, and [NappletBrokerService] writes it
* when the key-free `:napplet` sandbox relays a toggle over IPC. The sandbox never touches it.
*/
object NappletNetworkRegistry {
private const val OPEN_WEB = "OPEN"
private const val TOR = "TOR"
// coordinate -> useTor. Absent means "never chosen" -> Tor.
private val modes = ConcurrentHashMap<String, Boolean>()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
/** Binds the app context and hydrates the on-disk preferences into memory. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) ->
// putIfAbsent: never clobber a choice made in this session before hydration finished.
modes.putIfAbsent(key.name, value != OPEN_WEB)
}
}
}
/** Whether [coordinate] routes through Tor. Defaults to true (Tor) for any site never set. */
fun useTor(coordinate: String): Boolean = modes[coordinate] ?: true
/** Records [useTor] for [coordinate] in memory and persists it. */
fun set(
coordinate: String,
useTor: Boolean,
) {
modes[coordinate] = useTor
val ctx = appContext ?: return
scope.launch {
ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB }
}
}
}
@@ -55,6 +55,8 @@ import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
@@ -73,6 +75,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.util.concurrent.Executor
/**
* Hosts a napplet/nsite WebView in the isolated `:napplet` process — a process that holds **no**
@@ -107,6 +110,10 @@ class NappletHostActivity : ComponentActivity() {
// nSite "website mode": a normal web app (NIP-07 window.nostr + normal network), vs a locked napplet.
private var websiteMode: Boolean = false
// Per-site network choice: route this site's traffic through Tor (default) or over the open web.
// Applied to both the WebView proxy and the blob-fetch client; toggled from the top-bar onion.
private var useTor: Boolean = true
// NAP domain strings the shell advertises to the applet in the shell.init handshake.
private var declaredDomains: List<String> = emptyList()
@@ -196,13 +203,20 @@ class NappletHostActivity : ComponentActivity() {
val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH)
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier))
contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim, appOrigin, websiteMode)
// "Open web" for a site makes everything direct — both its blob fetches (here) and its live
// web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port.
val effectiveProxy = if (useTor) proxyPort else -1
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, websiteMode)
// Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main
// thread concurrently with the index probe below (which runs on IO) — instead of serially after
// it. Binding the broker early overlaps too. The WebView is attached once the probe succeeds.
webView = WebView(this)
hardenWebView(webView)
// Route the WebView's own (off-origin) traffic through Tor for an nSite, unless this site was
// opted out to the open web. Set process-wide before any page navigation; the shell + blobs are
// served from cache via shouldInterceptRequest, so only the site's external requests hit this.
if (websiteMode) applyWebViewProxy(effectiveProxy)
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
WebViewCompat.addWebMessageListener(
webView,
@@ -328,6 +342,7 @@ class NappletHostActivity : ComponentActivity() {
author = intent.getStringExtra(NappletHostContract.EXTRA_AUTHOR).orEmpty()
identifier = intent.getStringExtra(NappletHostContract.EXTRA_IDENTIFIER).orEmpty()
websiteMode = intent.getBooleanExtra(NappletHostContract.EXTRA_WEBSITE_MODE, false)
useTor = intent.getBooleanExtra(NappletHostContract.EXTRA_USE_TOR, true)
title = intent.getStringExtra(NappletHostContract.EXTRA_TITLE).orEmpty()
proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1)
launchToken = intent.getStringExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty()
@@ -390,6 +405,25 @@ class NappletHostActivity : ComponentActivity() {
webView.webViewClient = NappletWebViewClient()
}
/**
* Routes this process's WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears any
* override so the site loads over the open web. Process-global (this `:napplet` process hosts only
* applet/site WebViews) and best-effort: a device whose WebView can't honor a SOCKS proxy falls back
* to direct — verified on-device, since SOCKS-over-WebView support varies by WebView version.
*/
private fun applyWebViewProxy(port: Int) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return
val executor = Executor { it.run() }
runCatching {
if (port > 0) {
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build()
ProxyController.getInstance().setProxyOverride(config, executor) {}
} else {
ProxyController.getInstance().clearProxyOverride(executor) {}
}
}.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) }
}
/** Serves only the trusted shell and the manifest's verified blobs; everything else 404s. */
private inner class NappletWebViewClient : WebViewClient() {
override fun shouldInterceptRequest(
@@ -638,6 +672,21 @@ class NappletHostActivity : ComponentActivity() {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
},
)
// nSite network indicator + toggle: onion = Tor (private), globe = open web (IP visible). Only
// shown when this is a website-mode nSite AND Tor is active — otherwise there is nothing to
// route through. Its own click handler opens the network dialog without firing the access sheet.
if (websiteMode && proxyPort > 0) {
bar.addView(
TextView(this).apply {
text = if (useTor) "🧅" else "🌐"
textSize = 18f
setPadding(0, 0, dp(12), 0)
isClickable = true
setOnClickListener { showNetworkDialog() }
contentDescription = getString(if (useTor) R.string.napplet_net_tor_desc else R.string.napplet_net_open_desc)
},
)
}
bar.addView(
TextView(this).apply {
text = "" // circled info
@@ -648,6 +697,40 @@ class NappletHostActivity : ComponentActivity() {
return bar
}
/**
* Explains the site's current network routing and offers to switch it. Switching persists the
* per-site choice (via the broker, which owns the preference) and relaunches this screen so the new
* routing applies cleanly from [onCreate] — the proxy and content server are rebuilt for the new mode.
*/
private fun showNetworkDialog() {
val titleRes = if (useTor) R.string.napplet_net_tor_title else R.string.napplet_net_open_title
val messageRes = if (useTor) R.string.napplet_net_tor_message else R.string.napplet_net_open_message
val switchRes = if (useTor) R.string.napplet_net_switch_open else R.string.napplet_net_switch_tor
AlertDialog
.Builder(this)
.setTitle(getString(titleRes, barTitle()))
.setMessage(getString(messageRes))
.setPositiveButton(getString(switchRes)) { _, _ -> setNetworkMode(!useTor) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
/** Persists the new routing choice in the main process, then relaunches this screen to apply it. */
private fun setNetworkMode(newUseTor: Boolean) {
val msg =
Message.obtain(null, NappletIpc.MSG_SET_NETWORK_MODE).apply {
data =
Bundle().apply {
putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken)
putBoolean(NappletIpc.KEY_NETWORK_USE_TOR, newUseTor)
}
}
sendToBroker(msg)
useTor = newUseTor
intent.putExtra(NappletHostContract.EXTRA_USE_TOR, newUseTor)
recreate()
}
private fun buildDivider(): View =
View(this).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1))
@@ -59,6 +59,13 @@ object NappletHostContract {
*/
const val EXTRA_WEBSITE_MODE = "napplet_website_mode"
/**
* Whether this site's traffic routes through Tor (true, the default when Tor is active) or over the
* open web (false). The main process resolves the per-site preference; the sandbox applies it to
* both the WebView proxy and the blob-fetch client. Always true for locked napplets.
*/
const val EXTRA_USE_TOR = "napplet_use_tor"
/**
* FQN of the main-process broker service (in `:amethyst`). The sandbox binds it by name so it
* needs no compile-time reference to `:amethyst`. Must match the manifest `<service>` declaration.
@@ -40,9 +40,19 @@ object NappletIpc {
*/
const val MSG_PUSH = 3
/**
* Host → broker: persist this nSite's network-routing choice (Tor vs open web). Carries
* [KEY_LAUNCH_TOKEN] (so the broker resolves the trusted coordinate) and [KEY_NETWORK_USE_TOR].
* The sandbox never persists anything itself; the main process owns the preference.
*/
const val MSG_SET_NETWORK_MODE = 4
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
/** Boolean: route this site through Tor (true) or over the open web (false). */
const val KEY_NETWORK_USE_TOR = "networkUseTor"
/**
* Opaque per-launch token. The sandbox cannot be trusted to state its own identity, so it sends
* only this token; the broker resolves it through [NappletLaunchRegistry] (main process) back to
@@ -14,6 +14,16 @@
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
<string name="napplet_action_paid">“%1$s” made a payment</string>
<!-- nSite network routing (Tor vs open web) -->
<string name="napplet_net_tor_desc">This site loads over Tor. Tap to change.</string>
<string name="napplet_net_open_desc">This site loads over the open web. Tap to change.</string>
<string name="napplet_net_tor_title">“%1$s” loads over Tor</string>
<string name="napplet_net_tor_message">This site\'s traffic is routed through Tor, so it can\'t see your IP address. Some sites are slow or broken over Tor — you can switch this site to the open web. Your choice is remembered for this site.</string>
<string name="napplet_net_open_title">“%1$s” loads over the open web</string>
<string name="napplet_net_open_message">This site loads directly, so it (and the servers it contacts) can see your IP address. Switch it back to Tor to keep your IP private. Your choice is remembered for this site.</string>
<string name="napplet_net_switch_open">Use open web</string>
<string name="napplet_net_switch_tor">Use Tor</string>
<!-- Loading / unavailable screens -->
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>