refactor(napplets): extract sandbox runtime into :nappletHost module

The `:napplet` sandbox runtime (NappletHostActivity, NappletContentServer,
NappletIpc, NappletKeyActions) now lives in a new :nappletHost Android library
that depends only on :commons + :quartz — NEVER :amethyst. So the sandbox code
is compile-time incapable of importing Amethyst.instance / LocalCache / Account,
turning the "two-process, no secrets in the sandbox" rule from a convention into
a build-graph guarantee.

- New module + NappletHostContract (Intent-extra keys + broker service FQN), so
  the launcher (amethyst) and activity (module) share the launch contract with no
  dependency cycle. The activity binds the broker by class name.
- Capability labels for the "what it can access" sheet are resolved by the
  launcher (which has app resources) and passed in, so the module needs no
  capability string resources. Host-only strings moved into the module.
- amethyst depends on :nappletHost; the broker-side (NappletBrokerService,
  gateways, NappletLaunchRegistry) stays in :amethyst. Manifest declares the
  activity by FQN (keeps @style/Theme.Amethyst resolvable).
- Docs updated (CLAUDE.md + security plan).

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 20:45:19 +00:00
parent 55cbb72a99
commit 4218a7c1af
17 changed files with 174 additions and 64 deletions
+4
View File
@@ -95,6 +95,10 @@ Application class (there is no per-process Application in the manifest) in **bot
(`NappletHostActivity`, declared `android:process=":napplet"`). It holds **no**
account or keys; `Amethyst.onCreate()` early-returns here so `Amethyst.instance`
is **left unset** (touching it throws `UninitializedPropertyAccessException`).
The sandbox runtime lives in its own module **`:nappletHost`** (depends only on
`:commons` + `:quartz`, **never** `:amethyst`) so it *cannot* import
`Amethyst`/`LocalCache`/`Account` — the broker-side (signer, gateways, registry)
stays in `:amethyst` and the two halves talk over Messenger IPC.
Consequences — don't get caught assuming one process:
+1
View File
@@ -334,6 +334,7 @@ dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":nestsClient"))
implementation(project(":nappletHost"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
@@ -58,6 +58,13 @@ and what remains as future work.
new *signed* manifest and own the app — expected Nostr trust model. Aggregate `x` hash
is enforced when present (only *recommended* by the spec); per-path hashes always
protect.
- **Sandbox isolation is now structural.** The `:napplet` runtime
(`NappletHostActivity`, content server, IPC, key actions) lives in its own
`:nappletHost` module that depends only on `:commons` + `:quartz` — so it is
*compile-time incapable* of importing `Amethyst`/`LocalCache`/`Account`. The
broker-side (signer, gateways, `NappletLaunchRegistry`) stays in `:amethyst`;
the activity binds the broker service by class-name string and the two halves
communicate only over Messenger IPC.
- **Launch-token lifecycle.** Tokens are capped (LRU, 128) rather than explicitly
unregistered on sandbox close (the sandbox is a separate process and can't reach the
main-process registry). A long-backgrounded napplet whose token was evicted would need
+1 -1
View File
@@ -404,7 +404,7 @@
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
<activity
android:name=".napplet.NappletHostActivity"
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostActivity"
android:process=":napplet"
android:exported="false"
android:autoRemoveFromRecents="true"
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
import com.vitorpamplona.amethyst.ui.screen.AccountState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -23,9 +23,12 @@ package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
@@ -38,27 +41,6 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
* declared capabilities, and a display title. No account state crosses into the sandbox process.
*/
object NappletLauncher {
const val EXTRA_PATHS = "napplet_paths"
const val EXTRA_HASHES = "napplet_hashes"
const val EXTRA_SERVERS = "napplet_servers"
const val EXTRA_AUTHOR = "napplet_author"
const val EXTRA_IDENTIFIER = "napplet_identifier"
const val EXTRA_AGGREGATE_HASH = "napplet_aggregate_hash"
const val EXTRA_TITLE = "napplet_title"
/** Bare NAP capability domains the manifest declared (empty for a plain nsite). */
const val EXTRA_REQUIRES = "napplet_requires"
/**
* Unguessable token for this launch. The sandbox relays it to the broker, which resolves it back
* to the trusted identity + declared capabilities via [NappletLaunchRegistry] — the sandbox never
* carries (and so can never forge) its own coordinate.
*/
const val EXTRA_LAUNCH_TOKEN = "napplet_launch_token"
/** SOCKS proxy port to route blob fetches through, or -1 for a direct connection. */
const val EXTRA_PROXY_PORT = "napplet_proxy_port"
/** Opens a NIP-5D napplet, forwarding its declared capabilities to the broker. */
fun launch(
context: Context,
@@ -107,18 +89,22 @@ object NappletLauncher {
val declared = resolveRequiredCapabilities(requires).capabilities.toSet()
val launchToken = NappletLaunchRegistry.register(identity, declared)
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = requires.mapNotNull { NappletCapability.fromNapDomain(it) }.map { context.getString(it.labelRes()) }
val intent =
Intent(context, NappletHostActivity::class.java).apply {
putExtra(EXTRA_PATHS, ArrayList(paths.map { it.path }))
putExtra(EXTRA_HASHES, ArrayList(paths.map { it.hash }))
putExtra(EXTRA_SERVERS, ArrayList(allServers))
putExtra(EXTRA_AUTHOR, authorPubKey)
putExtra(EXTRA_IDENTIFIER, identifier)
putExtra(EXTRA_AGGREGATE_HASH, aggregateHash)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_REQUIRES, ArrayList(requires))
putExtra(EXTRA_LAUNCH_TOKEN, launchToken)
putExtra(EXTRA_PROXY_PORT, proxyPort)
putExtra(NappletHostContract.EXTRA_PATHS, ArrayList(paths.map { it.path }))
putExtra(NappletHostContract.EXTRA_HASHES, ArrayList(paths.map { it.hash }))
putExtra(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
putExtra(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
putExtra(NappletHostContract.EXTRA_IDENTIFIER, identifier)
putExtra(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash)
putExtra(NappletHostContract.EXTRA_TITLE, title)
putExtra(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
putExtra(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
putExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken)
putExtra(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
-9
View File
@@ -674,17 +674,8 @@
<string name="napplet_permissions_revoke">Revoke</string>
<string name="napplet_untitled">Untitled nApplet</string>
<string name="napplet_none_found">No nApplets found yet.</string>
<string name="napplet_invalid">Invalid nApplet.</string>
<string name="napplet_webview_too_old">This device\'s WebView is too old to run nApplets safely.</string>
<string name="napplet_fallback_title">nApplet %1$s…</string>
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
<string name="napplet_chrome_access_title">What “%1$s” can access</string>
<string name="napplet_chrome_keys_safe">It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets.</string>
<string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string>
<string name="napplet_chrome_permissions_desc">What this app can access</string>
<string name="napplet_action_published">“%1$s” published a note as you</string>
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
<string name="napplet_action_paid">“%1$s” made a payment</string>
<!-- Napplet capability names -->
<string name="napplet_cap_shell">Shell</string>
+1
View File
@@ -99,6 +99,7 @@ ksp = "2.3.9"
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activityCompose" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" }
+38
View File
@@ -0,0 +1,38 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
}
android {
namespace = "com.vitorpamplona.amethyst.napplethost"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
dependencies {
// The sandbox runtime depends ONLY on the protocol/contract (commons) and Nostr resolution
// (quartz) — never on :amethyst. This makes it impossible for the `:napplet` process code to
// reach for Amethyst.instance / LocalCache / Account (which don't exist in that process).
implementation(project(":commons"))
implementation(project(":quartz"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity)
implementation(libs.androidx.webkit)
implementation(libs.okhttp)
}
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.napplethost
import android.util.Log
import android.webkit.WebResourceRequest
@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.napplethost
import android.app.AlertDialog
import android.content.ComponentName
@@ -52,11 +52,10 @@ import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.amethyst.napplethost.R
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import org.json.JSONObject
@@ -88,6 +87,9 @@ class NappletHostActivity : ComponentActivity() {
// NAP domain strings the shell advertises to the applet in the shell.init handshake.
private var declaredDomains: List<String> = emptyList()
// Pre-localized capability labels for the "what it can access" sheet (resolved by the launcher).
private var capabilityLabels: List<String> = emptyList()
// Correlation id for id-less fire-and-forget messages so they still reach the broker.
private var fireSeq = 0
@@ -175,7 +177,10 @@ class NappletHostActivity : ComponentActivity() {
::onShellMessage,
)
bindService(Intent(this, NappletBrokerService::class.java), brokerConnection, BIND_AUTO_CREATE)
// Bind the main-process broker by explicit class name (same APK) rather than a compile-time
// class reference, so this sandbox module needs no dependency on :amethyst.
val brokerIntent = Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS)
bindService(brokerIntent, brokerConnection, BIND_AUTO_CREATE)
webView.loadUrl(NappletWebContract.SHELL_URL)
}
@@ -223,18 +228,19 @@ class NappletHostActivity : ComponentActivity() {
}
private fun readManifestExtras(): Boolean {
val pathList = intent.getStringArrayListExtra(NappletLauncher.EXTRA_PATHS) ?: return false
val hashList = intent.getStringArrayListExtra(NappletLauncher.EXTRA_HASHES) ?: return false
val pathList = intent.getStringArrayListExtra(NappletHostContract.EXTRA_PATHS) ?: return false
val hashList = intent.getStringArrayListExtra(NappletHostContract.EXTRA_HASHES) ?: return false
if (pathList.size != hashList.size || pathList.isEmpty()) return false
for (i in pathList.indices) paths.add(PathTag(pathList[i], hashList[i]))
servers.addAll(intent.getStringArrayListExtra(NappletLauncher.EXTRA_SERVERS) ?: emptyList())
author = intent.getStringExtra(NappletLauncher.EXTRA_AUTHOR).orEmpty()
title = intent.getStringExtra(NappletLauncher.EXTRA_TITLE).orEmpty()
proxyPort = intent.getIntExtra(NappletLauncher.EXTRA_PROXY_PORT, -1)
launchToken = intent.getStringExtra(NappletLauncher.EXTRA_LAUNCH_TOKEN).orEmpty()
servers.addAll(intent.getStringArrayListExtra(NappletHostContract.EXTRA_SERVERS) ?: emptyList())
author = intent.getStringExtra(NappletHostContract.EXTRA_AUTHOR).orEmpty()
title = intent.getStringExtra(NappletHostContract.EXTRA_TITLE).orEmpty()
proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1)
launchToken = intent.getStringExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty()
capabilityLabels = intent.getStringArrayListExtra(NappletHostContract.EXTRA_CAP_LABELS) ?: emptyList()
val requires = intent.getStringArrayListExtra(NappletLauncher.EXTRA_REQUIRES) ?: emptyList()
val requires = intent.getStringArrayListExtra(NappletHostContract.EXTRA_REQUIRES) ?: emptyList()
val resolved = resolveRequiredCapabilities(requires)
// shell is always available; the rest are the declared domains advertised to the applet in the
// handshake. (The broker enforces the authoritative set from the launch token, not this list.)
@@ -437,18 +443,13 @@ class NappletHostActivity : ComponentActivity() {
setBackgroundColor(resolveThemeColor(android.R.attr.textColorPrimary) and 0x22FFFFFF)
}
/** Lists, in plain language, exactly which capability domains this napplet was launched with. */
/** Lists, in plain language, exactly which capabilities this napplet was launched with. */
private fun showAccessDialog() {
val caps =
declaredDomains
.filter { it != "shell" }
.mapNotNull { NappletCapability.fromNapDomain(it) }
.map { getString(it.labelRes()) }
val body =
if (caps.isEmpty()) {
if (capabilityLabels.isEmpty()) {
getString(R.string.napplet_chrome_static_site)
} else {
caps.joinToString("\n") { "• $it" } + "\n\n" + getString(R.string.napplet_chrome_keys_safe)
capabilityLabels.joinToString("\n") { "• $it" } + "\n\n" + getString(R.string.napplet_chrome_keys_safe)
}
AlertDialog
.Builder(this)
@@ -0,0 +1,61 @@
/*
* 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.napplethost
/**
* Intent-extra contract for launching [NappletHostActivity]. Lives in the sandbox module so the
* activity (here) and the launcher (in `:amethyst`) share the keys without a dependency cycle —
* `:amethyst` depends on `:nappletHost`, never the other way around.
*/
object NappletHostContract {
const val EXTRA_PATHS = "napplet_paths"
const val EXTRA_HASHES = "napplet_hashes"
const val EXTRA_SERVERS = "napplet_servers"
const val EXTRA_AUTHOR = "napplet_author"
const val EXTRA_IDENTIFIER = "napplet_identifier"
const val EXTRA_AGGREGATE_HASH = "napplet_aggregate_hash"
const val EXTRA_TITLE = "napplet_title"
/** Bare NAP capability domains the manifest declared (empty for a plain nSite). */
const val EXTRA_REQUIRES = "napplet_requires"
/**
* Pre-localized capability labels for the "what it can access" sheet, resolved by the launcher
* (which has the app's resources) so the sandbox module needs no capability string resources.
*/
const val EXTRA_CAP_LABELS = "napplet_cap_labels"
/**
* Unguessable token for this launch. The sandbox relays it to the broker, which resolves it back
* to the trusted identity + declared capabilities — the sandbox never carries (and so can never
* forge) its own coordinate.
*/
const val EXTRA_LAUNCH_TOKEN = "napplet_launch_token"
/** SOCKS proxy port to route blob fetches through, or -1 for a direct connection. */
const val EXTRA_PROXY_PORT = "napplet_proxy_port"
/**
* 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.
*/
const val BROKER_SERVICE_CLASS = "com.vitorpamplona.amethyst.napplet.NappletBrokerService"
}
@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.napplethost
/**
* The Messenger wire contract between the untrusted `:napplet` process (the WebView host) and
@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.napplethost
import android.view.KeyEvent
import java.util.concurrent.ConcurrentHashMap
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Sandbox host (`:napplet` process) UI. Kept here so the sandbox module needs no app resources. -->
<string name="napplet_invalid">Invalid nApplet.</string>
<string name="napplet_webview_too_old">This device\'s WebView is too old to run nApplets safely.</string>
<string name="napplet_untitled">Untitled nApplet</string>
<!-- Trusted chrome (top bar + live action notices) -->
<string name="napplet_chrome_access_title">What “%1$s” can access</string>
<string name="napplet_chrome_keys_safe">It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets.</string>
<string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string>
<string name="napplet_chrome_permissions_desc">What this app can access</string>
<string name="napplet_action_published">“%1$s” published a note as you</string>
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
<string name="napplet_action_paid">“%1$s” made a payment</string>
</resources>
+1
View File
@@ -31,6 +31,7 @@ dependencyResolutionManagement {
rootProject.name = "Amethyst"
include(":amethyst")
include(":nappletHost")
include(":benchmark")
include(":quartz")
include(":geode")