From 4218a7c1af4e96ce6c8596e5615c4639668eca0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 20:45:19 +0000 Subject: [PATCH] refactor(napplets): extract sandbox runtime into :nappletHost module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .claude/CLAUDE.md | 4 ++ amethyst/build.gradle.kts | 1 + .../2026-06-22-napplet-nsite-security.md | 7 +++ amethyst/src/main/AndroidManifest.xml | 2 +- .../amethyst/napplet/NappletBrokerService.kt | 1 + .../amethyst/napplet/NappletLauncher.kt | 48 ++++++--------- amethyst/src/main/res/values/strings.xml | 9 --- gradle/libs.versions.toml | 1 + nappletHost/build.gradle.kts | 38 ++++++++++++ nappletHost/src/main/AndroidManifest.xml | 2 + .../napplethost}/NappletContentServer.kt | 2 +- .../napplethost}/NappletHostActivity.kt | 41 +++++++------ .../napplethost/NappletHostContract.kt | 61 +++++++++++++++++++ .../amethyst/napplethost}/NappletIpc.kt | 2 +- .../napplethost}/NappletKeyActions.kt | 2 +- nappletHost/src/main/res/values/strings.xml | 16 +++++ settings.gradle.kts | 1 + 17 files changed, 174 insertions(+), 64 deletions(-) create mode 100644 nappletHost/build.gradle.kts create mode 100644 nappletHost/src/main/AndroidManifest.xml rename {amethyst/src/main/java/com/vitorpamplona/amethyst/napplet => nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost}/NappletContentServer.kt (99%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/napplet => nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost}/NappletHostActivity.kt (92%) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/napplet => nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost}/NappletIpc.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/napplet => nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost}/NappletKeyActions.kt (99%) create mode 100644 nappletHost/src/main/res/values/strings.xml diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7394c3a74b..f992e42cc9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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: diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index b0104042ba..c8c3a72587 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -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) diff --git a/amethyst/plans/2026-06-22-napplet-nsite-security.md b/amethyst/plans/2026-06-22-napplet-nsite-security.md index 619dd9922e..d306f037bf 100644 --- a/amethyst/plans/2026-06-22-napplet-nsite-security.md +++ b/amethyst/plans/2026-06-22-napplet-nsite-security.md @@ -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 diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index d346d9868c..a450afeba5 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -404,7 +404,7 @@ Revoke Untitled nApplet No nApplets found yet. - Invalid nApplet. - This device\'s WebView is too old to run nApplets safely. nApplet %1$s… - What “%1$s” can access - It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets. - Static site — it has no special access to your account. - What this app can access - “%1$s” published a note as you - “%1$s” uploaded a file - “%1$s” made a payment Shell diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87fea3c9f0..7b12776aa6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/nappletHost/build.gradle.kts b/nappletHost/build.gradle.kts new file mode 100644 index 0000000000..ddc19cd17c --- /dev/null +++ b/nappletHost/build.gradle.kts @@ -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) +} diff --git a/nappletHost/src/main/AndroidManifest.xml b/nappletHost/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b2d3ea1235 --- /dev/null +++ b/nappletHost/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletContentServer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletContentServer.kt rename to nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt index 3f4b7a4935..ebe48889dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletContentServer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt rename to nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index 9237c60e2c..c2680c13fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -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 = emptyList() + // Pre-localized capability labels for the "what it can access" sheet (resolved by the launcher). + private var capabilityLabels: List = 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) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt new file mode 100644 index 0000000000..829586610d --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt @@ -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 `` declaration. + */ + const val BROKER_SERVICE_CLASS = "com.vitorpamplona.amethyst.napplet.NappletBrokerService" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt rename to nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt index d391d69bec..f4e4e98439 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletKeyActions.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletKeyActions.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletKeyActions.kt rename to nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletKeyActions.kt index c708711c0c..698f37de56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletKeyActions.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletKeyActions.kt @@ -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 diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml new file mode 100644 index 0000000000..56a48a19a0 --- /dev/null +++ b/nappletHost/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + + Invalid nApplet. + This device\'s WebView is too old to run nApplets safely. + Untitled nApplet + + + What “%1$s” can access + It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets. + Static site — it has no special access to your account. + What this app can access + “%1$s” published a note as you + “%1$s” uploaded a file + “%1$s” made a payment + diff --git a/settings.gradle.kts b/settings.gradle.kts index f30ae000ba..023ac563fe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,7 @@ dependencyResolutionManagement { rootProject.name = "Amethyst" include(":amethyst") +include(":nappletHost") include(":benchmark") include(":quartz") include(":geode")