mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
fix(napplets): harden the sandbox trust boundary (identity, private lists, visibility)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
@@ -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.
|
||||
@@ -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<NappletCapability> =
|
||||
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
|
||||
|
||||
@@ -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<PathTag>()
|
||||
private val servers = mutableListOf<String>()
|
||||
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<String> = 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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<NappletCapability>,
|
||||
)
|
||||
|
||||
// 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<String, Session>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<String, Session>) = size > MAX_SESSIONS
|
||||
}
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
@Synchronized
|
||||
fun register(
|
||||
identity: NappletIdentity,
|
||||
declared: Set<NappletCapability>,
|
||||
): 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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+13
-6
@@ -58,15 +58,22 @@ class AccountIdentityReader(
|
||||
"getFollows" -> jsonStringArray(account.kind3FollowList.flow.value.authors)
|
||||
"getMutes" ->
|
||||
jsonStringArray(
|
||||
account.muteList.flow.value
|
||||
.filterIsInstance<UserTag>()
|
||||
.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<UserTag>()
|
||||
?.map { it.pubKey }
|
||||
.orEmpty(),
|
||||
)
|
||||
"getBlocked" ->
|
||||
jsonStringArray(
|
||||
account.blockPeopleList.flow.value
|
||||
.filterIsInstance<UserTag>()
|
||||
.map { it.pubKey },
|
||||
// PUBLIC blocks only, for the same reason as getMutes.
|
||||
account.blockPeopleList
|
||||
.getBlockList()
|
||||
?.publicUsersIdSet()
|
||||
.orEmpty(),
|
||||
)
|
||||
"getRelays" -> relaysJson()
|
||||
"getList" -> listJson(argument)
|
||||
|
||||
@@ -675,6 +675,15 @@
|
||||
<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>
|
||||
<string name="napplet_cap_identity">Identity</string>
|
||||
|
||||
Reference in New Issue
Block a user