From ab2ca779984ceacc7b0a29e4485edb900bcf94b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 18:46:10 +0000 Subject: [PATCH] fix(napplets): harden the sandbox trust boundary (identity, private lists, visibility) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three security hardenings from the nsite/napplet review: 1. Launch-token identity binding. The broker no longer trusts the identity + declared capabilities sent on each IPC message from the (less-trusted) :napplet process. The main process now mints a random token at launch (NappletLaunchRegistry), hands only that to the sandbox, and resolves it back to the trusted identity/declared set. A compromised sandbox can act only as the napplet it was launched as — closing cross-napplet coordinate spoofing (storage + permission ledger). 2. Stop leaking private lists. identity.getMutes/getBlocked read the decrypted flow, which includes the user's PRIVATE mutes/blocks. Return only the events' public tags (MuteListEvent.publicMutes / PeopleListEvent.publicUsersIdSet). 3. Make grants visible + anti-phishing chrome. A persistent trusted sandbox bar (shield + name + tap for "what it can access") the applet can't draw over, and a live toast when a granted publish/upload/payment runs — so an allow-always grant can't act silently. Also keeps the host out from under the system bars. Findings + residual risks (session-scoped grants, per-origin resource consent) tracked in amethyst/plans/2026-06-22-napplet-nsite-security.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../2026-06-22-napplet-nsite-security.md | 64 ++++++++ .../amethyst/napplet/NappletBrokerService.kt | 29 ++-- .../amethyst/napplet/NappletHostActivity.kt | 144 +++++++++++++++--- .../amethyst/napplet/NappletIpc.kt | 15 +- .../amethyst/napplet/NappletLaunchRegistry.kt | 76 +++++++++ .../amethyst/napplet/NappletLauncher.kt | 16 ++ .../napplet/gateways/AccountIdentityReader.kt | 19 ++- amethyst/src/main/res/values/strings.xml | 9 ++ 8 files changed, 322 insertions(+), 50 deletions(-) create mode 100644 amethyst/plans/2026-06-22-napplet-nsite-security.md create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt diff --git a/amethyst/plans/2026-06-22-napplet-nsite-security.md b/amethyst/plans/2026-06-22-napplet-nsite-security.md new file mode 100644 index 0000000000..619dd9922e --- /dev/null +++ b/amethyst/plans/2026-06-22-napplet-nsite-security.md @@ -0,0 +1,64 @@ +# Napplet / nsite security review (2026-06-22) + +A review of the attack surface for NIP-5A static sites (nsites) and NIP-5D napplets, +the protections in place, and the residual risks — with what was fixed in this pass +and what remains as future work. + +## Trust model (what holds) + +- **Keys never enter the sandbox.** The `:napplet` process holds no account/signer. + Signing happens only in the main process (`signer` fixes `pubkey`, host clock + prevents backdating). *"Even a full WebView/renderer escape into this process yields + no secret."* +- **Content integrity.** Every blob is sha256-verified against the **signed** manifest + before serving (`StaticSiteResolver`); cache is content-addressed + re-verified, so a + poisoned/stale cache can't be served. nsites launch with **zero** capabilities. +- **Network containment.** App CSP `connect-src 'none'`; egress only via the brokered, + consent-gated `resource.bytes`, Tor-routed. Bridge is origin-restricted + main-frame. +- **IPC binding** is `exported=false` + same-UID (`onBind` UID check). Payments always + prompt per-use with the amount. + +## Fixed in this pass + +1. **Cross-napplet identity/storage spoofing (was: per-message identity).** The broker + used to trust `author`/`identifier`/`declared` sent on **every** IPC message from the + `:napplet` process. A WebView→native escape could forge another napplet's coordinate + and read/act as it. Now the **main process** mints a random launch token + (`NappletLaunchRegistry`), hands only that token to the sandbox, and the broker + resolves it back to the trusted identity + declared set. A compromised sandbox can act + as nothing but the napplet it was launched as (it holds only its own token). + +2. **Private mute/block leak via `identity.getMutes`/`getBlocked`.** These read + `muteList.flow` / `blockPeopleList.flow`, which contain **decrypted private** entries. + Now they read the events' **public** tags only (`MuteListEvent.publicMutes()`, + `PeopleListEvent.publicUsersIdSet()`). + +3. **Silent "allow-always" actions + UI-redress.** Added persistent **trusted chrome** + (a sandbox bar the applet can't draw over: shield + name + tap-to-see "what it can + access") and a **live toast** when a granted RELAY/UPLOAD/VALUE op runs — so an + allow-always grant can't act completely silently. Also fixes the host drawing under + the status/navigation bars (edge-to-edge insets). + +## Residual risks / future work + +- **Coarse, persistent grants.** Non-payment capabilities persist as ALLOW_ALWAYS. A + consented napplet can still, thereafter, publish as you (RELAY), read your social graph + (IDENTITY), and make arbitrary network calls (RESOURCE) without re-prompting. The new + chrome + toasts make this *visible*, but the model is still allow-forever. **Proposed:** + a session-scoped grant ("Allow while open", cleared on close) in `GrantState` + + consent dialog, defaulted for RESOURCE/RELAY; and per-origin consent for cross-origin + `resource.bytes` https fetches. +- **`resource.bytes` as exfil channel.** Once RESOURCE is allow-always, the applet can + encode data into arbitrary https URLs through the Tor proxy. Tor hides the IP, not the + payload. Tie to the per-origin/session proposal above. +- **`'unsafe-inline'` in app `script-src`.** Required to inject the shim; the applet is + the author's own (content sha256-pinned), so it's not an escalation. `connect-src + 'none'` remains the real boundary. Residual, accepted. +- **Trust pivots on the author key.** A compromised author key lets an attacker push a + 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. +- **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 + relaunch. Acceptable; revisit if it bites. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index e5a8c3cfb2..2b5afa1677 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -34,8 +34,6 @@ import android.os.RemoteException import android.util.Log import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.napplet.NappletBroker -import com.vitorpamplona.amethyst.commons.napplet.NappletCapability -import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson @@ -110,15 +108,19 @@ class NappletBrokerService : Service() { val requestId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - val identity = - NappletIdentity( - authorPubKey = data.getString(NappletIpc.KEY_AUTHOR).orEmpty(), - identifier = data.getString(NappletIpc.KEY_IDENTIFIER).orEmpty(), - aggregateHash = data.getString(NappletIpc.KEY_AGGREGATE_HASH), - ) - val declared = parseDeclared(data.getString(NappletIpc.KEY_DECLARED)) - val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" + + // Resolve the launch token to the trusted identity + declared set. The sandbox never states + // its own coordinate, so a compromised :napplet process can only ever act as the napplet it + // was launched as (it holds only its own token). An unknown token = no session; refuse. + val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) + if (session == null) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session."))) + return true + } + val identity = session.identity + val declared = session.declared + scope.launch { // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply // decision (it stays wire-identical with the future desktop host). This service only supplies @@ -141,13 +143,6 @@ class NappletBrokerService : Service() { return true } - private fun parseDeclared(value: String?): Set = - value - ?.split(',') - ?.mapNotNull { name -> runCatching { NappletCapability.valueOf(name.trim()) }.getOrNull() } - ?.toSet() - ?: emptySet() - /** * The broker for the *currently* signed-in account, cached and rebuilt only when the account * changes (reference identity). The gateways capture the account and read its flows live, so a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index c14af1c110..9237c60e2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.napplet +import android.app.AlertDialog import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection @@ -31,14 +32,20 @@ import android.os.Looper import android.os.Message import android.os.Messenger import android.util.Log +import android.util.TypedValue +import android.view.Gravity import android.view.KeyEvent +import android.view.View import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.LinearLayout +import android.widget.TextView import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.webkit.JavaScriptReplyProxy @@ -46,6 +53,7 @@ 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 @@ -72,11 +80,10 @@ class NappletHostActivity : ComponentActivity() { private val paths = mutableListOf() private val servers = mutableListOf() private var author: String = "" - private var identifier: String = "" - private var aggregateHash: String? = null - // Capability names (comma-separated) the manifest declared; the broker refuses anything else. - private var declared: String = "" + // Opaque token the broker resolves to this launch's trusted identity + declared capabilities. + // The sandbox never carries its own coordinate, so a compromised :napplet process can't forge one. + private var launchToken: String = "" // NAP domain strings the shell advertises to the applet in the shell.init handshake. private var declaredDomains: List = emptyList() @@ -140,15 +147,25 @@ class NappletHostActivity : ComponentActivity() { contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim) webView = WebView(this) - setContentView(webView) - // Activities are edge-to-edge by default on recent Android; pad the WebView by the system bar - // and display-cutout insets so the applet's own content isn't drawn under the status/nav bars. - ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets -> + hardenWebView(webView) + + // Persistent trusted chrome: a sandbox bar the applet can't draw over (anti-phishing) showing + // the napplet's name and a tap-to-see "what it can access". Below it, the applet's WebView. + val root = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(buildSandboxBar()) + addView(buildDivider()) + addView(webView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) + } + setContentView(root) + // Activities are edge-to-edge by default on recent Android; pad by the system bar and + // display-cutout insets so neither the chrome nor the applet draws under the system bars. + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) view.setPadding(bars.left, bars.top, bars.right, bars.bottom) insets } - hardenWebView(webView) // Origin-restricted bridge: only the trusted shell page (main frame) can reach native. WebViewCompat.addWebMessageListener( @@ -213,18 +230,17 @@ class NappletHostActivity : ComponentActivity() { 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() - identifier = intent.getStringExtra(NappletLauncher.EXTRA_IDENTIFIER).orEmpty() - aggregateHash = intent.getStringExtra(NappletLauncher.EXTRA_AGGREGATE_HASH) title = intent.getStringExtra(NappletLauncher.EXTRA_TITLE).orEmpty() proxyPort = intent.getIntExtra(NappletLauncher.EXTRA_PROXY_PORT, -1) + launchToken = intent.getStringExtra(NappletLauncher.EXTRA_LAUNCH_TOKEN).orEmpty() val requires = intent.getStringArrayListExtra(NappletLauncher.EXTRA_REQUIRES) ?: emptyList() val resolved = resolveRequiredCapabilities(requires) - declared = resolved.capabilities.joinToString(",") { it.name } - // shell is always available; the rest are the declared domains the broker will honor. + // 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.) declaredDomains = (listOf("shell") + resolved.capabilities.map { it.name.lowercase() }).distinct() - return author.isNotEmpty() + return author.isNotEmpty() && launchToken.isNotEmpty() } private var title: String = "" @@ -323,10 +339,7 @@ class NappletHostActivity : ComponentActivity() { Bundle().apply { putString(NappletIpc.KEY_REQUEST_ID, id) putString(NappletIpc.KEY_PAYLOAD, raw) - putString(NappletIpc.KEY_AUTHOR, author) - putString(NappletIpc.KEY_IDENTIFIER, identifier) - putString(NappletIpc.KEY_AGGREGATE_HASH, aggregateHash) - putString(NappletIpc.KEY_DECLARED, declared) + putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken) } } @@ -362,6 +375,7 @@ class NappletHostActivity : ComponentActivity() { val actionId = result.optString("actionId") if (actionId.isNotEmpty()) keyActions.register(actionId, result.optString("binding").ifEmpty { null }) } + notifyIfSensitive(result) bridgeReplyProxy?.postMessage(result.toString()) } // A subscription push (relay.event/relay.eose) is keyed by subId, not a request id; forward verbatim. @@ -374,6 +388,100 @@ class NappletHostActivity : ComponentActivity() { return true } + // ---- trusted sandbox chrome ---- + + private fun barTitle(): String = title.ifBlank { getString(R.string.napplet_untitled) } + + /** The always-visible bar: a shield, the napplet's name, and an info affordance to see its access. */ + private fun buildSandboxBar(): View { + val onSurface = resolveThemeColor(android.R.attr.textColorPrimary) + val bar = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) + setPadding(dp(14), dp(10), dp(14), dp(10)) + isClickable = true + setOnClickListener { showAccessDialog() } + contentDescription = getString(R.string.napplet_chrome_permissions_desc) + } + bar.addView( + TextView(this).apply { + text = "🛡" // shield + setPadding(0, 0, dp(10), 0) + }, + ) + bar.addView( + TextView(this).apply { + text = barTitle() + setTextColor(onSurface) + textSize = 16f + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + }, + ) + bar.addView( + TextView(this).apply { + text = "ⓘ" // circled info + setTextColor(onSurface) + textSize = 18f + }, + ) + return bar + } + + private fun buildDivider(): View = + View(this).apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1)) + setBackgroundColor(resolveThemeColor(android.R.attr.textColorPrimary) and 0x22FFFFFF) + } + + /** Lists, in plain language, exactly which capability domains 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()) { + getString(R.string.napplet_chrome_static_site) + } else { + caps.joinToString("\n") { "• $it" } + "\n\n" + getString(R.string.napplet_chrome_keys_safe) + } + AlertDialog + .Builder(this) + .setTitle(getString(R.string.napplet_chrome_access_title, barTitle())) + .setMessage(body) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + /** + * Surfaces an "allow always" capability acting on the user's behalf, so a granted RELAY/UPLOAD/VALUE + * op can never run completely silently. Read-only ops (identity/storage/resource) don't toast. + */ + private fun notifyIfSensitive(result: JSONObject) { + if (!result.optBoolean("ok")) return + val message = + when (result.optString("type")) { + "relay.publish.result", "relay.publishEncrypted.result" -> getString(R.string.napplet_action_published, barTitle()) + "upload.upload.result" -> getString(R.string.napplet_action_uploaded, barTitle()) + "value.payInvoice.result" -> getString(R.string.napplet_action_paid, barTitle()) + else -> return + } + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun resolveThemeColor(attr: Int): Int { + val tv = TypedValue() + theme.resolveAttribute(attr, tv, true) + return if (tv.resourceId != 0) ContextCompat.getColor(this, tv.resourceId) else tv.data + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + companion object { private const val TAG = "NappletHostActivity" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt index 487ce7bd6b..d391d69bec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt @@ -43,13 +43,10 @@ object NappletIpc { const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" - // Applet identity (the ledger coordinate) travels with every request — the host cannot be - // trusted to have applied any policy, so the broker re-derives everything from these. - const val KEY_AUTHOR = "author" - const val KEY_IDENTIFIER = "identifier" - const val KEY_AGGREGATE_HASH = "aggregateHash" - - /** Capability names (comma-separated) the manifest's `requires` resolved to. The broker - * refuses any request outside this set, regardless of consent state. */ - const val KEY_DECLARED = "declared" + /** + * 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 + * the trusted identity + declared capability set the launch was registered with. + */ + const val KEY_LAUNCH_TOKEN = "launchToken" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt new file mode 100644 index 0000000000..2b619b39ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -0,0 +1,76 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import java.security.SecureRandom + +/** + * Main-process registry that binds a sandbox launch to its **trusted** identity + declared capability + * set, addressed by an unguessable launch token. + * + * Why: the broker (main process) must know *which* napplet a brokered request belongs to, but the + * request travels up from the sandboxed `:napplet` process, which is the very thing we don't fully + * trust (a WebView/renderer escape runs code there). If the identity rode along inside each IPC + * message, a compromised sandbox could simply claim another napplet's coordinate and read/act as it. + * + * Instead, [NappletLauncher] (main process) mints a random token here at launch time, hands only that + * token to the sandbox, and the broker resolves it back to the identity it was registered with. The + * sandbox only ever holds *its own* token, so even a fully compromised `:napplet` process can act as + * nothing but the napplet it was launched as. + * + * Both the launcher and [NappletBrokerService] run in the main process, so they share this singleton. + */ +object NappletLaunchRegistry { + data class Session( + val identity: NappletIdentity, + val declared: Set, + ) + + // Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The + // active napplet always re-touches its token, so only stale sessions are ever evicted. + private const val MAX_SESSIONS = 128 + private val sessions = + object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > MAX_SESSIONS + } + private val secureRandom = SecureRandom() + + @Synchronized + fun register( + identity: NappletIdentity, + declared: Set, + ): String { + val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey() + sessions[token] = Session(identity, declared) + return token + } + + @Synchronized + fun resolve(token: String?): Session? = token?.let { sessions[it] } + + @Synchronized + fun unregister(token: String?) { + token?.let { sessions.remove(it) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 7a8265db4b..95b758c78d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import android.content.Intent import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag @@ -47,6 +49,13 @@ object NappletLauncher { /** 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" @@ -92,6 +101,12 @@ object NappletLauncher { }.getOrNull().orEmpty() val allServers = (servers + authorBlossomServers).distinct() + // Mint the launch token in the (trusted) main process: the broker resolves the sandbox's + // requests back to THIS identity + declared set, regardless of anything the sandbox sends. + val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash) + val declared = resolveRequiredCapabilities(requires).capabilities.toSet() + val launchToken = NappletLaunchRegistry.register(identity, declared) + val intent = Intent(context, NappletHostActivity::class.java).apply { putExtra(EXTRA_PATHS, ArrayList(paths.map { it.path })) @@ -102,6 +117,7 @@ object NappletLauncher { putExtra(EXTRA_AGGREGATE_HASH, aggregateHash) putExtra(EXTRA_TITLE, title) putExtra(EXTRA_REQUIRES, ArrayList(requires)) + putExtra(EXTRA_LAUNCH_TOKEN, launchToken) putExtra(EXTRA_PROXY_PORT, proxyPort) if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt index 861f17cfcd..369a7b95c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt @@ -58,15 +58,22 @@ class AccountIdentityReader( "getFollows" -> jsonStringArray(account.kind3FollowList.flow.value.authors) "getMutes" -> jsonStringArray( - account.muteList.flow.value - .filterIsInstance() - .map { it.pubKey }, + // PUBLIC mutes only — the decrypted flow (muteList.flow) also holds the user's + // private mutes, which a napplet must never see. Read the event's plaintext tags. + account.muteList + .getMuteList() + ?.publicMutes() + ?.filterIsInstance() + ?.map { it.pubKey } + .orEmpty(), ) "getBlocked" -> jsonStringArray( - account.blockPeopleList.flow.value - .filterIsInstance() - .map { it.pubKey }, + // PUBLIC blocks only, for the same reason as getMutes. + account.blockPeopleList + .getBlockList() + ?.publicUsersIdSet() + .orEmpty(), ) "getRelays" -> relaysJson() "getList" -> listJson(argument) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index dc07d6e262..b458edf0e7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -675,6 +675,15 @@ 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 Identity