Align napplet host with current NIP-5D and NAPs

This commit is contained in:
sandwich
2026-08-04 19:59:22 +01:00
parent d3bd7a45b9
commit 4f6d6acefd
37 changed files with 1537 additions and 473 deletions
@@ -165,27 +165,17 @@ object FavoriteAppLauncher {
return when (event) { return when (event) {
is RootNappletEvent -> is RootNappletEvent ->
NappletLauncher.buildLaunchParams( NappletLauncher.buildLaunchParams(
context, context = context,
event.paths(), manifest = event,
event.servers(), authorPubKey = event.pubKey,
event.pubKey, identifier = "",
"",
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: "Napplet",
event.requires(),
HostProfile.NAPPLET,
) )
is NamedNappletEvent -> is NamedNappletEvent ->
NappletLauncher.buildLaunchParams( NappletLauncher.buildLaunchParams(
context, context = context,
event.paths(), manifest = event,
event.servers(), authorPubKey = event.pubKey,
event.pubKey, identifier = event.identifier(),
event.identifier(),
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: event.identifier(),
event.requires(),
HostProfile.NAPPLET,
) )
is RootSiteEvent -> is RootSiteEvent ->
NappletLauncher.buildLaunchParams( NappletLauncher.buildLaunchParams(
@@ -57,7 +57,27 @@ class DataStoreNappletStorage(
key: String, key: String,
value: String, value: String,
) { ) {
dataStore.edit { it[keyOf(coordinate, key)] = value } dataStore.edit { preferences ->
val prefix = prefixOf(coordinate)
val target = keyOf(coordinate, key)
val currentBytes =
preferences
.asMap()
.entries
.asSequence()
.filter { it.key.name.startsWith(prefix) }
.sumOf { (storedKey, storedValue) ->
storedKey.name
.removePrefix(prefix)
.encodeToByteArray()
.size +
((storedValue as? String)?.encodeToByteArray()?.size ?: 0)
}
val replacedBytes = key.encodeToByteArray().size + (preferences[target]?.encodeToByteArray()?.size ?: 0)
val proposedBytes = currentBytes - replacedBytes + key.encodeToByteArray().size + value.encodeToByteArray().size
require(proposedBytes <= MAX_STORAGE_BYTES) { "Napplet storage quota exceeded." }
preferences[target] = value
}
} }
override suspend fun remove( override suspend fun remove(
@@ -87,4 +107,9 @@ class DataStoreNappletStorage(
coordinate: String, coordinate: String,
key: String, key: String,
) = stringPreferencesKey(prefixOf(coordinate) + key) ) = stringPreferencesKey(prefixOf(coordinate) + key)
companion object {
/** NAP-STORAGE's recommended per-napplet UTF-8 quota. */
const val MAX_STORAGE_BYTES = 512 * 1024
}
} }
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/** /**
* The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
@@ -93,7 +95,11 @@ class NappletBrokerService : Service() {
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the // Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
// requesting surface's launch token, so a surface's REQs always target the account it acts as. // requesting surface's launch token, so a surface's REQs always target the account it acts as.
private val liveSubscriptions = NappletLiveSubscriptions() private val liveSubscriptions = NappletLiveSubscriptions(scope)
// NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id.
// Cancelling removes the job before it can emit a late terminal envelope to the sandbox.
private val resourceRequests = ConcurrentHashMap<String, Job>()
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes. // The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) } private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
@@ -117,7 +123,7 @@ class NappletBrokerService : Service() {
override fun onDestroy() { override fun onDestroy() {
liveSubscriptions.closeAll() liveSubscriptions.closeAll()
identityWatch.stop() identityWatch.stopAll()
// Every applet/browser surface has unbound, so the "session" the user granted for is over. // Every applet/browser surface has unbound, so the "session" the user granted for is over.
// The ledger and the broker cache are now app-wide singletons that outlive this service, so // The ledger and the broker cache are now app-wide singletons that outlive this service, so
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime // their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
@@ -280,15 +286,23 @@ class NappletBrokerService : Service() {
// Resolve the launch token to the trusted identity + declared set. The sandbox never states // 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 // 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. // 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)) val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
val session = NappletLaunchRegistry.resolve(launchToken)
if (session == null) { if (session == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session."))) reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
return true return true
} }
val identity = session.identity val identity = session.identity
val declared = session.declared val declared = session.declared
val resourceRequestKey = "$launchToken\u0000$requestId"
if (requestType == "resource.cancel") {
resourceRequests.remove(resourceRequestKey)?.cancel()
return true
}
val tracksResourceRequest = requestType == "resource.bytes" || requestType == "resource.bytesMany"
scope.launch { val requestJob =
scope.launch(start = if (tracksResourceRequest) CoroutineStart.LAZY else CoroutineStart.DEFAULT) {
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply // 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 // decision (it stays wire-identical with the future desktop host). This service only supplies
// the broker, the Messenger transport, and the live relay subscription each Outcome implies. // the broker, the Messenger transport, and the live relay subscription each Outcome implies.
@@ -301,18 +315,29 @@ class NappletBrokerService : Service() {
} }
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
is NappletRequestRouter.Outcome.Ignore -> {} is NappletRequestRouter.Outcome.Ignore -> {}
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) is NappletRequestRouter.Outcome.Reply -> {
reply(replyTo, requestId, outcome.payload)
// NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup
// snapshot succeeds, the runtime owns identity.changed delivery for this
// trusted launch token until the broker service closes.
if (requestType == "identity.getPublicKey" && outcome.payload.contains("\"ok\":true") && launchToken != null) {
identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) }
}
}
is NappletRequestRouter.Outcome.OpenSubscription -> is NappletRequestRouter.Outcome.OpenSubscription ->
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) }
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw) is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
} }
} }
if (tracksResourceRequest) {
resourceRequests.put(resourceRequestKey, requestJob)?.cancel()
requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) }
requestJob.start()
}
return true return true
} }
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
@StringRes @StringRes
fun NappletCapability.labelRes(): Int = fun NappletCapability.labelRes(): Int =
when (this) { when (this) {
NappletCapability.SHELL -> R.string.napplet_cap_shell
NappletCapability.IDENTITY -> R.string.napplet_cap_identity NappletCapability.IDENTITY -> R.string.napplet_cap_identity
NappletCapability.KEYS -> R.string.napplet_cap_keys NappletCapability.KEYS -> R.string.napplet_cap_keys
NappletCapability.RELAY -> R.string.napplet_cap_relay NappletCapability.RELAY -> R.string.napplet_cap_relay
@@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int =
@StringRes @StringRes
fun NappletCapability.descriptionRes(): Int = fun NappletCapability.descriptionRes(): Int =
when (this) { when (this) {
NappletCapability.SHELL -> R.string.napplet_cap_shell_desc
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
@@ -294,9 +294,10 @@ class NappletConsentSummary(
context.getString(R.string.napplet_consent_pay_no_amount) context.getString(R.string.napplet_consent_pay_no_amount)
} }
} }
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource) NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany ->
context.getString(R.string.napplet_consent_resource)
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload) is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown. // Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> "" is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
} }
} }
@@ -34,21 +34,22 @@ import kotlinx.coroutines.launch
* value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key * value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key
* (or `""` when no account is signed in) to the caller-supplied sink. * (or `""` when no account is signed in) to the caller-supplied sink.
* *
* One watch at a time per host binding; [start] replaces any prior one. Reached only after the * Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's
* router confirmed the applet declared the IDENTITY capability. * streams. A watch starts only after that surface successfully obtains its public-key snapshot.
*/ */
class NappletIdentityWatch( class NappletIdentityWatch(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val pubKey: (boundPubKey: String) -> Flow<String>, private val pubKey: (boundPubKey: String) -> Flow<String>,
) { ) {
private var job: Job? = null private val jobs = mutableMapOf<String, Job>()
fun start( fun start(
watchId: String,
boundPubKey: String, boundPubKey: String,
push: (String) -> Unit, push: (String) -> Unit,
) { ) {
stop() if (jobs.containsKey(watchId)) return
job = jobs[watchId] =
scope.launch { scope.launch {
pubKey(boundPubKey) pubKey(boundPubKey)
.distinctUntilChanged() .distinctUntilChanged()
@@ -57,8 +58,8 @@ class NappletIdentityWatch(
} }
} }
fun stop() { fun stopAll() {
job?.cancel() jobs.values.forEach { it.cancel() }
job = null jobs.clear()
} }
} }
@@ -76,7 +76,7 @@ object NappletLaunchRegistry {
accountPubKey: HexKey, accountPubKey: HexKey,
): String { ): String {
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey() val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
sessions[token] = Session(identity, declared, accountPubKey) sessions[token] = Session(identity.copy(instanceId = token), declared, accountPubKey)
return token return token
} }
@@ -24,14 +24,18 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@@ -49,20 +53,18 @@ object NappletLauncher {
manifest: NappletManifest, manifest: NappletManifest,
authorPubKey: HexKey, authorPubKey: HexKey,
identifier: String, identifier: String,
) = launch( ) {
context = context, val event = manifest as? Event
paths = manifest.paths(), if (event?.verify() != true || event.pubKey != authorPubKey) {
servers = manifest.servers(), Log.w(TAG, "Refusing NIP-5D manifest that failed signature/author verification")
authorPubKey = authorPubKey, return
identifier = identifier, }
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(), buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) }
title = manifest.title() ?: identifier.ifBlank { "Napplet" }, }
requires = manifest.requires(),
)
/** /**
* Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite — * Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified
* the broker then refuses every capability, so the site renders as inert static content. * manifest overload so raw callers cannot bypass signature/author validation.
*/ */
fun launch( fun launch(
context: Context, context: Context,
@@ -73,12 +75,26 @@ object NappletLauncher {
aggregateHash: HexKey?, aggregateHash: HexKey?,
title: String, title: String,
requires: List<String>, requires: List<String>,
// nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The // Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must
// broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless // use the signature-checking manifest overload above.
// of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET]. profile: HostProfile,
profile: HostProfile = HostProfile.NAPPLET, ) {
if (profile != HostProfile.WEBSITE) {
Log.w(TAG, "Refusing raw NIP-5D launch without a verified manifest")
return
}
val params =
runCatching { buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) }
.onFailure { Log.w(TAG, "Refusing invalid ${profile.name.lowercase()} launch", it) }
.getOrNull()
?: return
openHost(context, params)
}
private fun openHost(
context: Context,
params: Bundle,
) { ) {
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
val intent = val intent =
Intent(context, NappletHostActivity::class.java).apply { Intent(context, NappletHostActivity::class.java).apply {
putExtras(params) putExtras(params)
@@ -105,6 +121,29 @@ object NappletLauncher {
requires: List<String>, requires: List<String>,
profile: HostProfile, profile: HostProfile,
): Bundle { ): Bundle {
require(profile == HostProfile.WEBSITE) { "NIP-5D launch parameters require a verified manifest." }
return buildLaunchParamsTrusted(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
}
private fun buildLaunchParamsTrusted(
context: Context,
paths: List<PathTag>,
servers: List<String>,
authorPubKey: HexKey,
identifier: String,
aggregateHash: HexKey?,
title: String,
requires: List<String>,
profile: HostProfile,
): Bundle {
val effectiveAggregateHash =
if (profile == HostProfile.NAPPLET) {
requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) {
"NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate."
}
} else {
aggregateHash
}
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
// Augment the manifest's servers with the author's published Blossom list (kind:10063), if // Augment the manifest's servers with the author's published Blossom list (kind:10063), if
@@ -118,7 +157,7 @@ object NappletLauncher {
// Mint the launch token in the (trusted) main process: the broker resolves the sandbox's // 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. // requests back to THIS identity + declared set, regardless of anything the sandbox sends.
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash) val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = effectiveAggregateHash)
val declared = profile.declaredCapabilities(requires) val declared = profile.declaredCapabilities(requires)
// Bound to the account launching it, so the surface keeps signing as that account even if the // Bound to the account launching it, so the surface keeps signing as that account even if the
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints). // user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
@@ -156,7 +195,7 @@ object NappletLauncher {
putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers)) putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey) putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
putString(NappletHostContract.EXTRA_IDENTIFIER, identifier) putString(NappletHostContract.EXTRA_IDENTIFIER, identifier)
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash) putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash)
putString(NappletHostContract.EXTRA_TITLE, title) putString(NappletHostContract.EXTRA_TITLE, title)
putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires)) putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels)) putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
@@ -170,4 +209,34 @@ object NappletLauncher {
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current()) putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
} }
} }
/** Signature-checking entry point for embedded NIP-5D surfaces. */
fun buildLaunchParams(
context: Context,
manifest: NappletManifest,
authorPubKey: HexKey,
identifier: String,
): Bundle? {
val event = manifest as? Event
if (event?.verify() != true || event.pubKey != authorPubKey) {
Log.w(TAG, "Refusing embedded NIP-5D manifest that failed signature/author verification")
return null
}
return runCatching {
buildLaunchParamsTrusted(
context = context,
paths = manifest.paths(),
servers = manifest.servers(),
authorPubKey = authorPubKey,
identifier = identifier,
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
requires = manifest.requires(),
profile = HostProfile.NAPPLET,
)
}.onFailure { Log.w(TAG, "Refusing invalid embedded NIP-5D launch", it) }
.getOrNull()
}
private const val TAG = "NappletLauncher"
} }
@@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -44,7 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger
* signatures still came from the old one. [open] is reached only after the broker authorized the * signatures still came from the old one. [open] is reached only after the broker authorized the
* subscription (RELAY consent). * subscription (RELAY consent).
*/ */
class NappletLiveSubscriptions { class NappletLiveSubscriptions(
private val scope: CoroutineScope,
) {
private val liveSubs = ConcurrentHashMap<String, LiveSub>() private val liveSubs = ConcurrentHashMap<String, LiveSub>()
private val liveSeq = AtomicInteger(0) private val liveSeq = AtomicInteger(0)
@@ -53,6 +59,20 @@ class NappletLiveSubscriptions {
val client: INostrClient, val client: INostrClient,
) { ) {
val eoseSent = AtomicBoolean(false) val eoseSent = AtomicBoolean(false)
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
var deliveryJob: Job? = null
}
private sealed interface Delivery {
data class RelayEvent(
val event: Event,
) : Delivery
data object Eose : Delivery
data class Closed(
val reason: String,
) : Delivery
} }
/** /**
@@ -77,6 +97,20 @@ class NappletLiveSubscriptions {
// can't collide with the subscription it's replacing. // can't collide with the subscription it's replacing.
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client) val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
liveSubs[nappletSubId] = sub liveSubs[nappletSubId] = sub
sub.deliveryJob =
scope.launch {
for (delivery in sub.deliveries) {
if (liveSubs[nappletSubId] !== sub) break
when (delivery) {
is Delivery.RelayEvent ->
NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let {
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
}
Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId))
is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason))
}
}
}
val listener = val listener =
object : SubscriptionListener { object : SubscriptionListener {
@@ -85,7 +119,9 @@ class NappletLiveSubscriptions {
isLive: Boolean, isLive: Boolean,
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event)) ) {
sub.deliveries.trySend(Delivery.RelayEvent(event))
}
// A subscription fans out to several relays; collapse their EOSEs into the single // A subscription fans out to several relays; collapse their EOSEs into the single
// relay.eose the SDK expects (fired when the first relay finishes its stored events). // relay.eose the SDK expects (fired when the first relay finishes its stored events).
@@ -93,14 +129,16 @@ class NappletLiveSubscriptions {
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId)) if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose)
} }
override fun onClosed( override fun onClosed(
message: String, message: String,
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message)) ) {
sub.deliveries.trySend(Delivery.Closed(message))
}
} }
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) } runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
@@ -109,12 +147,18 @@ class NappletLiveSubscriptions {
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */ /** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
fun close(nappletSubId: String) { fun close(nappletSubId: String) {
val sub = liveSubs.remove(nappletSubId) ?: return val sub = liveSubs.remove(nappletSubId) ?: return
sub.deliveries.close()
sub.deliveryJob?.cancel()
runCatching { sub.client.unsubscribe(sub.clientSubId) } runCatching { sub.client.unsubscribe(sub.clientSubId) }
} }
/** Tears down every open subscription (service teardown). */ /** Tears down every open subscription (service teardown). */
fun closeAll() { fun closeAll() {
liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } } liveSubs.values.forEach { sub ->
sub.deliveries.close()
sub.deliveryJob?.cancel()
runCatching { sub.client.unsubscribe(sub.clientSubId) }
}
liveSubs.clear() liveSubs.clear()
} }
} }
@@ -0,0 +1,75 @@
/*
* 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.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */
internal object NappletRelayCleartext {
suspend fun forDelivery(
event: Event,
signer: NostrSigner,
): Event? = forDelivery(event, signer.pubKey, signer::decrypt)
internal suspend fun forDelivery(
event: Event,
userPubKey: HexKey,
decrypt: suspend (String, HexKey) -> String,
): Event? {
if (!isEncrypted(event)) return event
val peer =
when {
event.pubKey == userPubKey -> event.recipientPubKey()
event.isAddressedTo(userPubKey) -> event.pubKey
else -> null
} ?: return null
val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null
// NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and
// signature fields so callers can still correlate it, while making clear that this object
// must never be republished as a signed event after its content projection has changed.
return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig)
}
internal fun isEncrypted(event: Event): Boolean =
event is PrivateDmEvent ||
EncryptedInfo.isNIP04(event.content) ||
isNip44V2(event.content)
private fun isNip44V2(content: String): Boolean =
content.length >= MIN_NIP44_V2_LENGTH &&
runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess
private fun Event.recipientPubKey(): HexKey? =
tags.firstNotNullOfOrNull { tag ->
tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" }
}
private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey }
private const val MIN_NIP44_V2_LENGTH = 132
}
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator
import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletConsentSummary
import com.vitorpamplona.amethyst.napplet.NappletNotificationStore import com.vitorpamplona.amethyst.napplet.NappletNotificationStore
import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext
import com.vitorpamplona.amethyst.napplet.buildConnectInfo import com.vitorpamplona.amethyst.napplet.buildConnectInfo
import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -265,7 +266,8 @@ class AccountNappletGateways(
.distinctBy { it.id } .distinctBy { it.id }
.sortedByDescending { it.createdAt } .sortedByDescending { it.createdAt }
val limit = filters.mapNotNull { it.limit }.maxOrNull() val limit = filters.mapNotNull { it.limit }.maxOrNull()
return limit?.let { merged.take(it) } ?: merged val limited = limit?.let { merged.take(it) } ?: merged
return limited.mapNotNull { NappletRelayCleartext.forDelivery(it, account.signer) }
} }
/** /**
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
import android.util.Base64 import android.util.Base64
import com.vitorpamplona.amethyst.commons.napplet.NappletResource import com.vitorpamplona.amethyst.commons.napplet.NappletResource
import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -39,9 +40,21 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.CookieJar
import okhttp3.Dns
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.ByteArrayOutputStream
import java.io.InterruptedIOException
import java.net.InetAddress
import java.net.URLDecoder import java.net.URLDecoder
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.util.concurrent.TimeUnit
/** /**
* Fetches a resource URL on an applet's behalf — the applet has no direct network * Fetches a resource URL on an applet's behalf — the applet has no direct network
@@ -63,40 +76,99 @@ class NappletResourceFetcher(
private val account: Account, private val account: Account,
private val httpClient: (useProxy: Boolean) -> OkHttpClient, private val httpClient: (useProxy: Boolean) -> OkHttpClient,
) { ) {
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */ /** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */
suspend fun fetch( suspend fun fetch(
url: String, url: String,
coordinate: String, coordinate: String,
): NappletResource? = ): NappletResourceResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise.
NappletNetworkRegistry.awaitReady()
val client = httpClient(NappletNetworkRegistry.useTor(coordinate))
when { when {
url.startsWith("data:") -> decodeDataUrl(url) url.startsWith("data:") -> decodeDataUrl(url)
url.startsWith("https://") -> { url.startsWith("nostr:") ->
runCatching { resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.")
url.startsWith("https://") || url.startsWith("blossom:") -> {
// Route like the applet's own page: locked napplets stay on Tor. The derived
// client removes ambient cookies/auth and validates DNS before every hop.
NappletNetworkRegistry.awaitReady()
val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate)))
if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client)
}
else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.")
}
}
private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient =
baseClient
.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.cache(null)
.cookieJar(CookieJar.NO_COOKIES)
.authenticator(Authenticator.NONE)
.proxyAuthenticator(Authenticator.NONE)
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.dns(
Dns { hostname ->
baseClient.dns.lookup(hostname).also { addresses ->
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
throw BlockedResourceException("Resolved address is not public.")
}
}
},
).addNetworkInterceptor { chain ->
chain.proceed(
chain
.request()
.newBuilder()
.removeHeader("Authorization")
.removeHeader("Cookie")
.removeHeader("Proxy-Authorization")
.build(),
)
}.build()
private fun fetchHttps(
url: String,
client: OkHttpClient,
): NappletResourceResult {
var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.")
repeat(MAX_REDIRECTS + 1) { hop ->
try {
client client
.newCall( .newCall(
Request Request
.Builder() .Builder()
.url(url) .url(current)
.get() .get()
.build(), .build(),
).execute() ).execute()
.use { r -> .use { response ->
if (!r.isSuccessful) return@withContext null if (response.isRedirect) {
val body = r.body.bytes() if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
val type = r.header("Content-Type") ?: "application/octet-stream" val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.")
NappletResource(body, type) current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.")
return@repeat
} }
}.getOrNull() if (response.code == 404) return failure(ERROR_NOT_FOUND)
if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.")
if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE)
return classify(body)
} }
url.startsWith("blossom:") -> fetchBlossom(url, client) } catch (e: BlockedResourceException) {
url.startsWith("nostr:") -> resolveNostr(url) return failure(ERROR_BLOCKED, e.message)
else -> null } catch (_: InterruptedIOException) {
return failure(ERROR_TIMEOUT)
} catch (_: Exception) {
return failure(ERROR_NETWORK)
} }
} }
return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
}
private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) }
private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() }
/** /**
* Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed` * Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed`
@@ -158,62 +230,176 @@ class NappletResourceFetcher(
private fun fetchBlossom( private fun fetchBlossom(
url: String, url: String,
client: OkHttpClient, client: OkHttpClient,
): NappletResource? { ): NappletResourceResult {
val hash = if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
url val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase()
.removePrefix("blossom://") if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
.removePrefix("blossom:")
.substringBefore('/')
.substringBefore('?')
.trim()
.lowercase()
if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null
val servers = val servers =
account.blossomServers account.blossomServers
.getBlossomServersList() .getBlossomServersList()
?.servers() ?.servers()
.orEmpty() .orEmpty()
var sawHashMismatch = false
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
val bytes = when (val fetched = fetchHttps(candidate, client)) {
runCatching { is NappletResourceResult.Success -> {
client if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) {
.newCall( sawHashMismatch = true
Request continue
.Builder()
.url(candidate)
.get()
.build(),
).execute()
.use { r ->
if (r.isSuccessful) r.body.bytes() else null
} }
}.getOrNull() ?: continue return fetched
if (StaticSiteResolver.verify(bytes, hash)) { }
return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream") is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched
} }
} }
return null if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.")
return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.")
} }
/** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */ /** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */
private fun decodeDataUrl(url: String): NappletResource? { private fun decodeDataUrl(url: String): NappletResourceResult {
val comma = url.indexOf(',') val comma = url.indexOf(',')
if (comma < 0) return null if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.")
val meta = url.substring("data:".length, comma) val meta = url.substring("data:".length, comma)
val data = url.substring(comma + 1) val data = url.substring(comma + 1)
if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE)
val isBase64 = meta.endsWith(";base64") val isBase64 = meta.endsWith(";base64")
val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" } val declaredType =
meta
.removeSuffix(";base64")
.substringBefore(';')
.ifEmpty { "text/plain" }
.lowercase()
val bytes = val bytes =
if (isBase64) { if (isBase64) {
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull()
?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.")
} else { } else {
URLDecoder.decode(data, "UTF-8").encodeToByteArray() runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull()
?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.")
}
if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
return classify(bytes, declaredType)
}
private fun classify(
bytes: ByteArray,
declaredType: String? = null,
): NappletResourceResult {
if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.")
val sniffed = sniffContentType(bytes)
val type =
when {
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
declaredType == "application/json" && isJson(bytes) -> "application/json"
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
else -> null
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
return success(NappletResource(bytes, type))
}
private fun looksLikeSvg(bytes: ByteArray): Boolean {
val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase()
return prefix.contains("<svg")
}
private fun isJson(bytes: ByteArray): Boolean = runCatching { Json.parseToJsonElement(bytes.decodeToString()) }.isSuccess
private fun isPlainText(bytes: ByteArray): Boolean =
runCatching {
Charsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
}.isSuccess && bytes.none { it == 0.toByte() }
private fun success(resource: NappletResource): NappletResourceResult = NappletResourceResult.Success(resource)
private fun failure(
error: String,
message: String? = null,
): NappletResourceResult = NappletResourceResult.Failure(error, message)
private fun readBounded(input: java.io.InputStream): ByteArray? {
input.use { source ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(8 * 1024)
var total = 0
while (true) {
val read = source.read(buffer)
if (read < 0) break
total += read
if (total > MAX_RESOURCE_BYTES) return null
output.write(buffer, 0, read)
}
return output.toByteArray()
} }
return NappletResource(bytes, contentType)
} }
companion object { companion object {
internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true
internal fun isPublicAddress(address: InetAddress): Boolean {
if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) {
return false
}
val bytes = address.address
if (bytes.size == 4) {
val first = bytes[0].toInt() and 0xff
val second = bytes[1].toInt() and 0xff
// Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify.
if (first == 0 || first >= 224) return false
if (first == 100 && second in 64..127) return false
if (first == 192 && second == 0) return false
if (first == 198 && second in 18..19) return false
if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false
if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false
} else if (bytes.size == 16) {
val first = bytes[0].toInt() and 0xff
if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local
if (
first == 0x20 &&
(bytes[1].toInt() and 0xff) == 0x01 &&
(bytes[2].toInt() and 0xff) == 0x0d &&
(bytes[3].toInt() and 0xff) == 0xb8
) {
return false // 2001:db8::/32 documentation range
}
}
return true
}
private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L
private const val FETCH_TIMEOUT_SECONDS = 30L
private const val MAX_REDIRECTS = 5
private const val MIME_PREFIX_BYTES = 8 * 1024
private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024
private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:"
const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024
private const val ERROR_INVALID_REQUEST = "invalid-request"
private const val ERROR_NOT_FOUND = "not-found"
private const val ERROR_BLOCKED = "blocked-by-policy"
private const val ERROR_TIMEOUT = "timeout"
private const val ERROR_TOO_LARGE = "too-large"
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
private const val ERROR_DECODE_FAILED = "decode-failed"
private const val ERROR_NETWORK = "network-error"
private val SHA256 = Regex("^[0-9a-f]{64}$")
private val ALLOWED_SNIFFED_TYPES =
setOf(
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/bmp",
"audio/ogg",
"video/mp4",
)
} }
private class BlockedResourceException(
message: String,
) : java.io.IOException(message)
} }
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
internal fun NappletCapability.symbol(): MaterialSymbol = internal fun NappletCapability.symbol(): MaterialSymbol =
when (this) { when (this) {
NappletCapability.SHELL -> MaterialSymbols.Tune
NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle
NappletCapability.KEYS -> MaterialSymbols.Key NappletCapability.KEYS -> MaterialSymbols.Key
NappletCapability.RELAY -> MaterialSymbols.Public NappletCapability.RELAY -> MaterialSymbols.Public
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
@@ -73,11 +74,6 @@ class NappletProtocolJsonTest {
assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey","id":"1"}""")) assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey","id":"1"}"""))
} }
@Test
fun decodesShellSupports() {
assertEquals(NappletRequest.ShellSupports("relay"), NappletProtocolJson.decodeRequest("""{"type":"shell.supports","id":"1","domain":"relay"}"""))
}
@Test @Test
fun decodesPublishFromAnUnsignedTemplateInTheEventField() { fun decodesPublishFromAnUnsignedTemplateInTheEventField() {
// @napplet/shim carries the unsigned template in the `event` field. The shell signs it. // @napplet/shim carries the unsigned template in the `event` field. The shell signs it.
@@ -165,13 +161,22 @@ class NappletProtocolJsonTest {
assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k"}""")) assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k"}"""))
assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","key":"k","value":"v"}""")) assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","key":"k","value":"v"}"""))
assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","key":"k"}""")) assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","key":"k"}"""))
assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}""")) assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}"""))
assertEquals(
NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE),
NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k","scope":"instance"}"""),
)
} }
@Test @Test
fun decodesValueResourceUpload() { fun decodesValueResourceUpload() {
assertEquals(NappletRequest.PayInvoice("lnbc1"), NappletProtocolJson.decodeRequest("""{"type":"value.payInvoice","invoice":"lnbc1"}""")) assertEquals(NappletRequest.PayInvoice("lnbc1"), NappletProtocolJson.decodeRequest("""{"type":"value.payInvoice","invoice":"lnbc1"}"""))
assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info"}"""))
assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","url":"https://x"}""")) assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","url":"https://x"}"""))
assertEquals(
NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")),
NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","urls":["https://x","data:text/plain,hi"]}"""),
)
// "SGk=" is base64 for "Hi"; shell.html inlines the request Blob as request.dataBase64. // "SGk=" is base64 for "Hi"; shell.html inlines the request Blob as request.dataBase64.
val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk=","mimeType":"text/plain","filename":"a.txt"}}""") as NappletRequest.UploadBlob val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk=","mimeType":"text/plain","filename":"a.txt"}}""") as NappletRequest.UploadBlob
assertEquals("text/plain", up.contentType) assertEquals("text/plain", up.contentType)
@@ -184,6 +189,7 @@ class NappletProtocolJsonTest {
assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}"""))
// keys.signEvent is not a real domain method (keys = keyboard actions, not signing). // keys.signEvent is not a real domain method (keys = keyboard actions, not signing).
assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1"}""")) assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1"}"""))
assertNull(NappletProtocolJson.decodeRequest("""{"type":"identity.futureMethod","id":"1"}"""))
assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}""")) assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}"""))
} }
@@ -211,7 +217,9 @@ class NappletProtocolJsonTest {
assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) assertEquals("s1", ev["subId"]?.jsonPrimitive?.content)
assertEquals( assertEquals(
"a".repeat(64), "a".repeat(64),
ev["event"] ev["result"]
?.jsonObject
?.get("event")
?.jsonObject ?.jsonObject
?.get("id") ?.get("id")
?.jsonPrimitive ?.jsonPrimitive
@@ -233,13 +241,6 @@ class NappletProtocolJsonTest {
assertEquals("pk", o["pubkey"]?.jsonPrimitive?.content) assertEquals("pk", o["pubkey"]?.jsonPrimitive?.content)
} }
@Test
fun encodesSupported() {
val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("shell.supports", NappletResponse.Supported(true))).jsonObject
assertEquals("shell.supports.result", o["type"]?.jsonPrimitive?.content)
assertTrue(o["supported"]!!.jsonPrimitive.boolean)
}
@Test @Test
fun encodesPublishedEventAndEvents() { fun encodesPublishedEventAndEvents() {
// relay.publish resolves to the signed event (matching upstream NostrEvent return). // relay.publish resolves to the signed event (matching upstream NostrEvent return).
@@ -257,6 +258,18 @@ class NappletProtocolJsonTest {
val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject
assertEquals(1, events["events"]?.jsonArray?.size) assertEquals(1, events["events"]?.jsonArray?.size)
assertEquals(
"a".repeat(64),
events["events"]
?.jsonArray
?.first()
?.jsonObject
?.get("event")
?.jsonObject
?.get("id")
?.jsonPrimitive
?.content,
)
} }
@Test @Test
@@ -269,6 +282,71 @@ class NappletProtocolJsonTest {
assertEquals(2, keys["keys"]?.jsonArray?.size) assertEquals(2, keys["keys"]?.jsonArray?.size)
} }
@Test
fun encodesResourceInfoBulkItemsAndTypedErrors() {
val info =
json
.parseToJsonElement(
NappletProtocolJson.encodeResponse(
"resource.info",
NappletResponse.ResourceInfo(listOf("https"), 10L * 1024L * 1024L, 16),
),
).jsonObject
assertEquals(
"https",
info["info"]
?.jsonObject
?.get("schemes")
?.jsonArray
?.first()
?.jsonObject
?.get("scheme")
?.jsonPrimitive
?.content,
)
val items =
json
.parseToJsonElement(
NappletProtocolJson.encodeResponse(
"resource.bytesMany",
NappletResponse.ResourceItems(
listOf(
NappletResponse.ResourceItem("https://x", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")),
NappletResponse.ResourceItem("https://y", error = "not-found"),
),
),
),
).jsonObject["items"]
?.jsonArray
assertEquals(
"SGk=",
items
?.first()
?.jsonObject
?.get("bytes")
?.jsonPrimitive
?.content,
)
assertEquals(
"not-found",
items
?.get(1)
?.jsonObject
?.get("error")
?.jsonPrimitive
?.content,
)
val failure =
json
.parseToJsonElement(
NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.ResourceFailure("blocked-by-policy", "private target")),
).jsonObject
assertEquals("resource.bytes.error", failure["type"]?.jsonPrimitive?.content)
assertEquals("blocked-by-policy", failure["error"]?.jsonPrimitive?.content)
}
@Test @Test
fun encodesBytesAsBase64WithMime() { fun encodesBytesAsBase64WithMime() {
val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject
@@ -0,0 +1,108 @@
/*
* 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.quartz.nip01Core.core.Event
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class NappletRelayCleartextTest {
@Test
fun plaintextPassesThroughWithoutDecrypting() =
runTest {
val event = event(content = "hello")
val result =
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
error("plaintext must not be decrypted")
}
assertSame(event, result)
}
@Test
fun inboundNip04IsProjectedAsCleartext() =
runTest {
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER)))
val result =
NappletRelayCleartext.forDelivery(event, USER) { ciphertext, peer ->
assertEquals(NIP04, ciphertext)
assertEquals(AUTHOR, peer)
"secret"
}
assertEquals("secret", result?.content)
assertEquals(event.id, result?.id)
assertEquals(event.sig, result?.sig)
}
@Test
fun outboundEncryptedEventUsesItsRecipientAsPeer() =
runTest {
val event = event(pubKey = USER, content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT)))
val result =
NappletRelayCleartext.forDelivery(event, USER) { _, peer ->
assertEquals(RECIPIENT, peer)
"sent secret"
}
assertEquals("sent secret", result?.content)
}
@Test
fun encryptedEventForAnotherUserIsWithheld() =
runTest {
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT)))
val result =
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
error("unrelated ciphertext must not be offered to the signer")
}
assertNull(result)
}
@Test
fun decryptionFailureWithholdsCiphertext() =
runTest {
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER)))
val result =
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
error("signer refused")
}
assertNull(result)
}
private fun event(
pubKey: String = AUTHOR,
content: String,
tags: Array<Array<String>> = emptyArray(),
) = Event("id", pubKey, 1L, 4, tags, content, "sig")
companion object {
private const val USER = "user"
private const val AUTHOR = "author"
private const val RECIPIENT = "recipient"
private const val NIP04 = "ciphertext-that-is-long-enough?iv=123456789012345678901234"
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
@@ -114,17 +115,31 @@ class NappletSdkConformanceTest {
// RelayQueryResultMessage: { type:'relay.query.result', id, events, error? } // RelayQueryResultMessage: { type:'relay.query.result', id, events, error? }
val o = result("relay.query", NappletResponse.Events(listOf(sampleEvent()))) val o = result("relay.query", NappletResponse.Events(listOf(sampleEvent())))
assertEquals(1, o["events"]?.jsonArray?.size) assertEquals(1, o["events"]?.jsonArray?.size)
assertEquals(
"a".repeat(64),
o["events"]
?.jsonArray
?.first()
?.jsonObject
?.get("event")
?.jsonObject
?.get("id")
?.jsonPrimitive
?.content,
)
} }
@Test @Test
fun relayEventAndEosePushesMatchTheSdk() { fun relayEventAndEosePushesMatchTheSdk() {
// RelayEventMessage (PUSH): { type:'relay.event', subId, event } // RelayEventMessage (PUSH): { type:'relay.event', subId, result:{event, sidecar?} }
val ev = json.parseToJsonElement(NappletProtocolJson.encodeRelayEvent("s1", sampleEvent())).jsonObject val ev = json.parseToJsonElement(NappletProtocolJson.encodeRelayEvent("s1", sampleEvent())).jsonObject
assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content) assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content)
assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) assertEquals("s1", ev["subId"]?.jsonPrimitive?.content)
assertEquals( assertEquals(
"a".repeat(64), "a".repeat(64),
ev["event"] ev["result"]
?.jsonObject
?.get("event")
?.jsonObject ?.jsonObject
?.get("id") ?.get("id")
?.jsonPrimitive ?.jsonPrimitive
@@ -175,7 +190,11 @@ class NappletSdkConformanceTest {
assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k"}""")) assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k"}"""))
assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","id":"1","key":"k","value":"v"}""")) assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","id":"1","key":"k","value":"v"}"""))
assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","id":"1","key":"k"}""")) assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","id":"1","key":"k"}"""))
assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}""")) assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}"""))
assertEquals(
NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE),
NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k","scope":"instance"}"""),
)
// StorageGetResultMessage.value, StorageKeysResultMessage.keys // StorageGetResultMessage.value, StorageKeysResultMessage.keys
assertTrue(result("storage.get", NappletResponse.StorageValue("v")).containsKey("value")) assertTrue(result("storage.get", NappletResponse.StorageValue("v")).containsKey("value"))
@@ -186,7 +205,12 @@ class NappletSdkConformanceTest {
@Test @Test
fun resourceBytesRequestAndResultMatch() { fun resourceBytesRequestAndResultMatch() {
assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info","id":"0"}"""))
assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","id":"1","url":"https://x"}""")) assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","id":"1","url":"https://x"}"""))
assertEquals(
NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")),
NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","id":"2","urls":["https://x","data:text/plain,hi"]}"""),
)
// The host emits base64 bytes + mime; shell.html rebuilds the Blob the SDK expects. // The host emits base64 bytes + mime; shell.html rebuilds the Blob the SDK expects.
val o = result("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")) val o = result("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))
assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content) assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content)
@@ -203,29 +227,6 @@ class NappletSdkConformanceTest {
assertTrue(result("relay.query", NappletResponse.Failed("boom")).containsKey("error")) assertTrue(result("relay.query", NappletResponse.Failed("boom")).containsKey("error"))
} }
// ---------- shell handshake (ShellReadyMessage / ShellInitMessage) ----------
@Test
fun shellInitAdvertisesTheCapabilityEnvironment() {
// shell.ready is answered by the host (not the codec) with this shell.init env, which the
// SDK caches and answers shell.supports() from locally. ShellInitMessage:
// { type:'shell.init', capabilities:{ domains, protocols }, services }.
val o = json.parseToJsonElement(NappletProtocolJson.encodeShellInit(listOf("shell", "relay"), listOf("shell", "relay"))).jsonObject
assertEquals("shell.init", o["type"]?.jsonPrimitive?.content)
assertEquals(
2,
o["capabilities"]
?.jsonObject
?.get("domains")
?.jsonArray
?.size,
)
assertTrue(o["capabilities"]?.jsonObject?.containsKey("protocols") == true)
assertEquals(2, o["services"]?.jsonArray?.size)
// shell.ready stays a host-layer message — the codec doesn't treat it as a broker request.
assertNull(NappletProtocolJson.decodeRequest("""{"type":"shell.ready"}"""))
}
// ---------- keys (keyboard/command actions) ---------- // ---------- keys (keyboard/command actions) ----------
@Test @Test
@@ -0,0 +1,70 @@
/*
* 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.gateways
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.net.InetAddress
class NappletResourceFetcherPolicyTest {
@Test
fun blocksPrivateSpecialAndLocalAddresses() {
val blocked =
listOf(
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"192.0.0.1",
"192.0.2.1",
"192.168.1.1",
"198.18.0.1",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"::1",
"2001:db8::1",
"fc00::1",
"fe80::1",
)
blocked.forEach {
assertFalse(it, NappletResourceFetcher.isPublicAddress(InetAddress.getByName(it)))
}
}
@Test
fun permitsPublicAddresses() {
assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("1.1.1.1")))
assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("2606:4700:4700::1111")))
}
@Test
fun acceptsOnlyCredentialFreeHttpsUrls() {
assertTrue(NappletResourceFetcher.isSafeHttpsResourceUrl("https://example.com/a"))
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("http://example.com/a"))
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("https://user:secret@example.com/a"))
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("file:///etc/passwd"))
}
}
@@ -1,13 +1,10 @@
<!doctype html> <!doctype html>
<!-- <!--
Trusted napplet shell page (served on the shell origin https://napplet.local at /__shell__). Trusted napplet shell page (served on the shell origin https://napplet.local at /__shell__).
It hosts the untrusted applet in a sandboxed iframe pointed at the applet's OWN per-applet origin It hosts untrusted content and relays capability messages between the child (window.postMessage)
(a distinct napplet.local subdomain, injected as __APP_ORIGIN__) and relays capability messages and the origin-restricted native bridge (__nappletBridge). For NIP-5D the child receives verified,
between the applet (window.postMessage) and the native bridge (__nappletBridge), which is prelude-injected bytes through srcdoc and has only sandbox="allow-scripts" (opaque origin). NIP-5A
origin-restricted to this shell page only. The applet is cross-origin to the shell, so it can never websites retain Amethyst's separate-origin website posture and are navigated normally.
reach the bridge or read the shell DOM. The iframe carries allow-same-origin so the applet has real,
isolated storage on its own origin — which is safe precisely because that origin is never the
shell's: the applet is same-origin only with itself.
--> -->
<html> <html>
<head> <head>
@@ -19,7 +16,7 @@
</style> </style>
</head> </head>
<body> <body>
<iframe id="app" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe> <iframe id="app" sandbox="__APP_SANDBOX__" referrerpolicy="no-referrer"></iframe>
<script> <script>
(function () { (function () {
var iframe = document.getElementById('app'); var iframe = document.getElementById('app');
@@ -70,11 +67,22 @@
delete msg.bytes; delete msg.bytes;
} catch (_) {} } catch (_) {}
} }
if (msg && msg.type === 'resource.bytesMany.result' && Array.isArray(msg.items)) {
msg.items.forEach(function (item) {
if (!item || !item.ok || typeof item.bytes !== 'string') return;
try {
var bin = atob(item.bytes), u = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
item.blob = new Blob([u], { type: item.mime || '' });
delete item.bytes;
} catch (_) {}
});
}
iframe.contentWindow.postMessage(msg, '*'); iframe.contentWindow.postMessage(msg, '*');
}; };
} }
iframe.src = '__APP_ORIGIN__/'; __APP_BOOTSTRAP__
})(); })();
</script> </script>
</body> </body>
@@ -79,7 +79,7 @@
// Subscription pushes are keyed by subId, not a request id. // Subscription pushes are keyed by subId, not a request id.
if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') { if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') {
var sub = subs[msg.subId]; if (!sub) return; var sub = subs[msg.subId]; if (!sub) return;
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); } if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.result); }
else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); } else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); }
else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); } else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); }
return; return;
@@ -91,7 +91,7 @@
// identity.changed push: the active user's key changed (account switch / connect / disconnect). // identity.changed push: the active user's key changed (account switch / connect / disconnect).
if (msg.type === 'identity.changed') { identityHandlers.slice().forEach(function(h){ try { h(msg.pubkey); } catch (_) {} }); return; } if (msg.type === 'identity.changed') { identityHandlers.slice().forEach(function(h){ try { h(msg.pubkey); } catch (_) {} }); return; }
if (!msg.id) return; if (!msg.id) return;
var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; if (p.cleanup) p.cleanup();
if (msg.ok) p.resolve(msg); if (msg.ok) p.resolve(msg);
else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); } else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); }
} }
@@ -101,16 +101,31 @@
window.addEventListener('message', function(e){ if (e.source !== parent) return; onIncoming(e.data); }); window.addEventListener('message', function(e){ if (e.source !== parent) return; onIncoming(e.data); });
} }
function field(promise, name){ return promise.then(function(m){ return m[name]; }); } function field(promise, name){ return promise.then(function(m){ return m[name]; }); }
function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; } function resourceCall(type, fields, opts){
var signal = opts && opts.signal;
if (signal && signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
return new Promise(function(resolve, reject){
var env = { type: type }; for (var k in fields) env[k] = fields[k];
var id = send(env), onAbort;
var cleanup = function(){ if (signal && onAbort) signal.removeEventListener('abort', onAbort); };
pending[id] = { resolve: resolve, reject: reject, cleanup: cleanup };
if (signal) {
onAbort = function(){
if (!pending[id]) return;
delete pending[id]; cleanup();
post('resource.cancel', { id: id });
reject(new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
}
});
}
function normFilters(filters){ return { filters: Array.isArray(filters) ? filters : [filters || {}] }; }
function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); } function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
var napplet = { // Build the methods Amethyst actually implements, then project only the explicit domains the
shell: { // trusted host authorized for this launch. Domain-object presence is the NIP-5D availability
// supports() is synchronous in @napplet/shim; we expose a sync proxy backed by an async check. // signal; there is deliberately no legacy window.napplet.shell capability probe.
supports: function(domain, protocol){ return field(call('shell.supports', { domain: domain, protocol: protocol }), 'supported'); }, var available = {
ready: function(){ return Promise.resolve({}); },
onReady: function(cb){ if (typeof cb === 'function') cb({}); return { close: function(){} }; },
services: []
},
identity: { identity: {
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); }, getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
getProfile: function(){ return field(call('identity.getProfile'), 'profile'); }, getProfile: function(){ return field(call('identity.getProfile'), 'profile'); },
@@ -121,15 +136,12 @@
getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'entries'); }, getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'entries'); },
getZaps: function(){ return field(call('identity.getZaps'), 'zaps'); }, getZaps: function(){ return field(call('identity.getZaps'), 'zaps'); },
getBadges: function(){ return field(call('identity.getBadges'), 'badges'); }, getBadges: function(){ return field(call('identity.getBadges'), 'badges'); },
// onChanged: the shell pushes identity.changed when the active user's key changes. The first // The runtime owns identity.changed delivery; handlers are entirely local to the shim.
// handler opens the watch (identity.watch); closing the last one stops it (identity.unwatch).
onChanged: function(handler){ onChanged: function(handler){
if (typeof handler !== 'function') return { close: function(){} }; if (typeof handler !== 'function') return { close: function(){} };
identityHandlers.push(handler); identityHandlers.push(handler);
if (identityHandlers.length === 1) post('identity.watch');
return { close: function(){ return { close: function(){
var i = identityHandlers.indexOf(handler); if (i >= 0) identityHandlers.splice(i, 1); var i = identityHandlers.indexOf(handler); if (i >= 0) identityHandlers.splice(i, 1);
if (identityHandlers.length === 0) post('identity.unwatch');
} }; } };
} }
}, },
@@ -152,6 +164,7 @@
var subId = 's' + (seq++); var subId = 's' + (seq++);
subs[subId] = { onEvent: onEvent, onEose: onEose }; subs[subId] = { onEvent: onEvent, onEose: onEose };
var env = normFilters(filters); env.subId = subId; var env = normFilters(filters); env.subId = subId;
if (options && options.relay) env.relay = options.relay;
post('relay.subscribe', env); post('relay.subscribe', env);
return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } }; return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } };
} }
@@ -161,23 +174,49 @@
getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); }, getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); },
setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); }, setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); },
removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); }, removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); },
keys: function(){ return field(call('storage.keys'), 'keys'); } keys: function(){ return field(call('storage.keys'), 'keys'); },
instance: {
getItem: function(key){ return field(call('storage.get', { key: key, scope: 'instance' }), 'value'); },
setItem: function(key, value){ return call('storage.set', { key: key, value: value, scope: 'instance' }).then(function(){}); },
removeItem: function(key){ return call('storage.remove', { key: key, scope: 'instance' }).then(function(){}); },
keys: function(){ return field(call('storage.keys', { scope: 'instance' }), 'keys'); }
}
}, },
// value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim). // value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim).
value: { value: {
payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); } payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); }
}, },
resource: { resource: {
// The shell rebuilds the Blob from the host's base64 before this resolves. info: function(){ return field(call('resource.info'), 'info'); },
bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); }, // The shell rebuilds Blobs from the host's base64 before these resolve.
bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); } bytes: function(url, opts){ return field(resourceCall('resource.bytes', { url: url }, opts), 'blob'); },
bytesMany: function(urls, opts){ return field(resourceCall('resource.bytesMany', { urls: Array.from(urls || []) }, opts), 'items'); },
bytesAsObjectURL: function(url){
var objectUrl = '', revoked = false;
var handle = { url: '', revoke: function(){ if (revoked) return; revoked = true; if (objectUrl) URL.revokeObjectURL(objectUrl); } };
var ready = available.resource.bytes(url).then(function(blob){
if (revoked) return;
objectUrl = URL.createObjectURL(blob); handle.url = objectUrl; return objectUrl;
});
Object.defineProperty(handle, 'ready', { value: ready, enumerable: false });
return handle;
}
}, },
upload: { upload: {
// Sends the SDK's upload.upload; we inline the bytes as base64 (shell.html does the same for // Sends the SDK's upload.upload; we inline the bytes as base64 (shell.html does the same for
// a Blob from a stock napplet). Resolves to the uploaded URL. // a Blob from a stock napplet). Resolves to the uploaded URL.
blob: function(bytes, contentType){ return field(call('upload.upload', { request: { dataBase64: bytesToB64(bytes), mimeType: contentType } }), 'url'); } blob: function(bytes, contentType){ return field(call('upload.upload', { request: { dataBase64: bytesToB64(bytes), mimeType: contentType } }), 'url'); }
},
theme: {
get: function(){ return field(call('theme.get'), 'theme'); }
} }
}; };
var requested = [];
try { if (Array.isArray(window.__nappletDomains)) requested = window.__nappletDomains; } catch (_) {}
var napplet = {};
requested.forEach(function(domain){
if (typeof domain === 'string' && Object.prototype.hasOwnProperty.call(available, domain)) napplet[domain] = available[domain];
});
window.napplet = Object.freeze(napplet); window.napplet = Object.freeze(napplet);
// ---- IME agent (in-app browser only) ------------------------------------------------------- // ---- IME agent (in-app browser only) -------------------------------------------------------
@@ -0,0 +1,42 @@
/*
* 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.commons.napplet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
/** Pure NIP-5D artifact checks shared by every launch surface. */
object NappletArtifactPolicy {
/** Returns the runtime-computed artifact identity, or null when the manifest must not execute. */
fun verifiedAggregateHash(
paths: List<PathTag>,
declaredAggregateHash: HexKey?,
): HexKey? {
val entry = paths.singleOrNull() ?: return null
if (entry.path != "/index.html" || !SHA256.matches(entry.hash)) return null
val computed = SiteAggregateHash.compute(paths)
if (declaredAggregateHash != null && !declaredAggregateHash.equals(computed, ignoreCase = true)) return null
return computed
}
private val SHA256 = Regex("^[0-9a-fA-F]{64}$")
}
@@ -122,12 +122,6 @@ class NappletBroker(
): NappletResponse { ): NappletResponse {
val capability = request.capability val capability = request.capability
// shell.supports is capability negotiation: always answerable, no declaration/consent.
if (request is NappletRequest.ShellSupports) {
val cap = NappletCapability.fromNapDomain(request.domain)
return NappletResponse.Supported(cap != null && cap in declared)
}
if (capability !in declared) { if (capability !in declared) {
return NappletResponse.Denied(capability, "This napplet did not declare the '${capability.name.lowercase()}' capability.") return NappletResponse.Denied(capability, "This napplet did not declare the '${capability.name.lowercase()}' capability.")
} }
@@ -149,7 +143,7 @@ class NappletBroker(
// Keyboard/command action registration is a shell-mediated UI affordance, not key // Keyboard/command action registration is a shell-mediated UI affordance, not key
// access — declared is enough; it never prompts. // access — declared is enough; it never prompts.
request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true
// Cosmetic/negotiation capabilities (theme) never prompt. // Cosmetic capabilities (theme) never prompt.
!capability.requiresConsent -> true !capability.requiresConsent -> true
// A standing allow short-circuits, except for per-use capabilities (e.g. payments). // A standing allow short-circuits, except for per-use capabilities (e.g. payments).
ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true
@@ -219,9 +213,6 @@ class NappletBroker(
request: NappletRequest, request: NappletRequest,
): NappletResponse = ): NappletResponse =
when (request) { when (request) {
// Negotiation is resolved in handle(); execute() is never reached for it.
is NappletRequest.ShellSupports -> NappletResponse.Supported(true)
is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey) is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey)
is NappletRequest.ThemeGet -> { is NappletRequest.ThemeGet -> {
@@ -267,24 +258,24 @@ class NappletBroker(
is NappletRequest.StorageGet -> { is NappletRequest.StorageGet -> {
val store = storage ?: return NappletResponse.Unsupported("storage.getItem") val store = storage ?: return NappletResponse.Unsupported("storage.getItem")
NappletResponse.StorageValue(store.get(identity.coordinate, request.key)) NappletResponse.StorageValue(store.get(storageCoordinate(identity, request.scope), request.key))
} }
is NappletRequest.StorageSet -> { is NappletRequest.StorageSet -> {
val store = storage ?: return NappletResponse.Unsupported("storage.setItem") val store = storage ?: return NappletResponse.Unsupported("storage.setItem")
store.set(identity.coordinate, request.key, request.value) store.set(storageCoordinate(identity, request.scope), request.key, request.value)
NappletResponse.Done NappletResponse.Done
} }
is NappletRequest.StorageRemove -> { is NappletRequest.StorageRemove -> {
val store = storage ?: return NappletResponse.Unsupported("storage.removeItem") val store = storage ?: return NappletResponse.Unsupported("storage.removeItem")
store.remove(identity.coordinate, request.key) store.remove(storageCoordinate(identity, request.scope), request.key)
NappletResponse.Done NappletResponse.Done
} }
is NappletRequest.StorageKeys -> { is NappletRequest.StorageKeys -> {
val store = storage ?: return NappletResponse.Unsupported("storage.keys") val store = storage ?: return NappletResponse.Unsupported("storage.keys")
NappletResponse.Strings(store.keys(identity.coordinate)) NappletResponse.Strings(store.keys(storageCoordinate(identity, request.scope)))
} }
is NappletRequest.NotifyCreate -> { is NappletRequest.NotifyCreate -> {
@@ -316,8 +307,38 @@ class NappletBroker(
is NappletRequest.ResourceBytes -> { is NappletRequest.ResourceBytes -> {
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes") val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes")
val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.") when (val fetched = gateway.fetch(request.url, identity.coordinate)) {
NappletResponse.Bytes(fetched.bytes, fetched.contentType) is NappletResourceResult.Success -> NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType)
is NappletResourceResult.Failure -> NappletResponse.ResourceFailure(fetched.error, fetched.message)
}
}
is NappletRequest.ResourceInfo -> {
resource ?: return NappletResponse.Unsupported("resource.info")
NappletResponse.ResourceInfo(
schemes = listOf("data", "https", "blossom", "nostr"),
maxBytes = RESOURCE_MAX_BYTES,
maxUrls = RESOURCE_MAX_URLS,
)
}
is NappletRequest.ResourceBytesMany -> {
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytesMany")
if (request.urls.isEmpty()) return NappletResponse.ResourceFailure("invalid-request", "Resource URL list is empty.")
if (request.urls.size > RESOURCE_MAX_URLS) return NappletResponse.ResourceFailure("too-large", "Resource URL limit exceeded.")
NappletResponse.ResourceItems(
request.urls.map { url ->
when (val fetched = gateway.fetch(url, identity.coordinate)) {
is NappletResourceResult.Success ->
NappletResponse.ResourceItem(
url = url,
resource = NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType),
)
is NappletResourceResult.Failure ->
NappletResponse.ResourceItem(url = url, error = fetched.error, message = fetched.message)
}
},
)
} }
is NappletRequest.UploadBlob -> { is NappletRequest.UploadBlob -> {
@@ -353,6 +374,15 @@ class NappletBroker(
tags + arrayOf(arrayOf("p", recipient)) tags + arrayOf(arrayOf("p", recipient))
} }
private fun storageCoordinate(
identity: NappletIdentity,
scope: com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope,
): String =
when (scope) {
com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.SHARED -> identity.storageCoordinate
com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate
}
/** /**
* Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet. * Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet.
* On success, stores the chosen policy and bulk-grants all declared non-payment capabilities. * On success, stores the chosen policy and bulk-grants all declared non-payment capabilities.
@@ -505,16 +535,6 @@ class NappletBroker(
} }
} }
/**
* True when [capability] carries a standing denial for [identity]. Push-subscription edge ops
* (identity.watch and friends) short-circuit before [handle], so they have to apply the same
* "a standing denial always wins" rule themselves rather than trusting the declaration alone.
*/
suspend fun isDenied(
identity: NappletIdentity,
capability: NappletCapability,
): Boolean = ledger.decide(identity, capability) == PermissionDecision.DENY
companion object { companion object {
/** /**
* How long (ms) a Cancel on the first-connect dialog suppresses re-prompting for the same app. * How long (ms) a Cancel on the first-connect dialog suppresses re-prompting for the same app.
@@ -522,5 +542,7 @@ class NappletBroker(
* the dialog per request; short enough that a deliberate user retry seconds later prompts again. * the dialog per request; short enough that a deliberate user retry seconds later prompts again.
*/ */
private const val CANCEL_REPROMPT_COOLDOWN_MS = 3_000L private const val CANCEL_REPROMPT_COOLDOWN_MS = 3_000L
private const val RESOURCE_MAX_BYTES = 10L * 1024L * 1024L
private const val RESOURCE_MAX_URLS = 16
} }
} }
@@ -156,6 +156,17 @@ class NappletResource(
val contentType: String, val contentType: String,
) )
sealed interface NappletResourceResult {
data class Success(
val resource: NappletResource,
) : NappletResourceResult
data class Failure(
val error: String,
val message: String? = null,
) : NappletResourceResult
}
/** /**
* Bridges the broker to sandboxed resource fetching for [NappletCapability.RESOURCE] * Bridges the broker to sandboxed resource fetching for [NappletCapability.RESOURCE]
* (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf — * (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf —
@@ -164,13 +175,14 @@ class NappletResource(
* *
* [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can * [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can
* route the fetch the same way the applet's own page loads — through Tor or the open web — per that * route the fetch the same way the applet's own page loads — through Tor or the open web — per that
* applet's/site's network mode. * applet's/site's network mode. Failures carry the stable NAP-RESOURCE error code rather than
* collapsing policy rejections and network failures into one nullable result.
*/ */
fun interface NappletResourceGateway { fun interface NappletResourceGateway {
suspend fun fetch( suspend fun fetch(
url: String, url: String,
coordinate: String, coordinate: String,
): NappletResource? ): NappletResourceResult
} }
/** A completed upload: where the blob lives plus NIP-94-ish metadata. */ /** A completed upload: where the blob lives plus NIP-94-ish metadata. */
@@ -25,14 +25,11 @@ package com.vitorpamplona.amethyst.commons.napplet
* (`napplet/naps`, `@napplet/web`). A napplet declares the domains it needs via `requires` tags; * (`napplet/naps`, `@napplet/web`). A napplet declares the domains it needs via `requires` tags;
* [fromNapDomain] maps each bare domain string to the capability the broker enforces. * [fromNapDomain] maps each bare domain string to the capability the broker enforces.
* *
* The mapping is **default-deny**: an unrecognized NAP domain maps to `null` and the shell must * The mapping is **default-deny**: an unrecognized or only partially implemented NAP domain maps
* surface it as unknown rather than silently granting it. Domains we don't yet broker * to `null`. Keeping a broker implementation below does not advertise conformance; only domains
* (`intent`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore resolve to `null` for now. * whose current NAP contract is implemented are injected into `window.napplet`.
*/ */
enum class NappletCapability { enum class NappletCapability {
/** `shell` — capability negotiation (`shell.supports`). Always available; needs no consent. */
SHELL,
/** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */ /** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */
IDENTITY, IDENTITY,
@@ -70,13 +67,13 @@ enum class NappletCapability {
; ;
/** /**
* Whether using this capability requires user consent. Negotiation ([SHELL]) and the cosmetic, * Whether using this capability requires user consent. The cosmetic, read-only theme read
* read-only theme read ([THEME]) never prompt; everything else does (subject to the broker's * ([THEME]) never prompts; everything else does (subject to the broker's
* signer-self-gating and standing-grant rules). [INC] is authorized at the router edge on its * signer-self-gating and standing-grant rules). [INC] is authorized at the router edge on its
* declaration alone, so it never reaches the consent path regardless of this flag. * declaration alone, so it never reaches the consent path regardless of this flag.
*/ */
val requiresConsent: Boolean val requiresConsent: Boolean
get() = this != SHELL && this != THEME get() = this != THEME
/** /**
* Whether the user must confirm **every single use** — no standing auto-approval. True for * Whether the user must confirm **every single use** — no standing auto-approval. True for
@@ -96,25 +93,22 @@ enum class NappletCapability {
companion object { companion object {
/** /**
* Maps a bare NAP domain to the capability the broker enforces, case-insensitively. * Maps a bare, currently supported NAP domain to the capability the broker enforces.
* Returns `null` for any domain the shell does not recognize — callers MUST treat that as * Returns `null` for unknown and partial/legacy domains — callers MUST treat that as
* "unknown, do not grant". * "unavailable, do not inject or grant". NIP-5D domain names are exact lowercase strings.
*/ */
fun fromNapDomain(domain: String): NappletCapability? = fun fromNapDomain(domain: String): NappletCapability? =
when (domain.trim().lowercase()) { when (domain) {
"shell" -> SHELL
"identity" -> IDENTITY "identity" -> IDENTITY
"keys" -> KEYS "relay" -> RELAY
"relay", "relays" -> RELAY
"storage" -> STORAGE "storage" -> STORAGE
"value" -> VALUE
"resource" -> RESOURCE "resource" -> RESOURCE
"upload" -> UPLOAD
"theme" -> THEME "theme" -> THEME
"notify" -> NOTIFY
"inc" -> INC
else -> null else -> null
} }
/** Exact NIP-5D domain names Amethyst currently exposes through its injection prelude. */
val supportedNapDomains: Set<String> = setOf("identity", "relay", "storage", "resource", "theme")
} }
} }
@@ -41,7 +41,15 @@ data class NappletIdentity(
val authorPubKey: HexKey, val authorPubKey: HexKey,
val identifier: String, val identifier: String,
val aggregateHash: HexKey? = null, val aggregateHash: HexKey? = null,
/** Opaque host-assigned lifetime id used only for NAP-STORAGE's instance scope. */
val instanceId: String? = null,
) { ) {
/** The ledger key: coordinate only, never the [aggregateHash], so grants survive updates. */ /** The ledger key: coordinate only, never the [aggregateHash], so grants survive updates. */
val coordinate: String = "$authorPubKey:$identifier" val coordinate: String = "$authorPubKey:$identifier"
/** NAP-STORAGE shared namespace: exact publisher + dTag + verified artifact identity. */
val storageCoordinate: String = "$coordinate:${aggregateHash.orEmpty()}"
/** NAP-STORAGE instance namespace, stable for this host launch and isolated from sibling launches. */
val instanceStorageCoordinate: String = "$storageCoordinate:instance:${instanceId.orEmpty()}"
} }
@@ -41,33 +41,19 @@ object NappletWebContract {
const val SHELL_URL = "$ORIGIN/__shell__" const val SHELL_URL = "$ORIGIN/__shell__"
/** /**
* The applet runs on its **own per-applet origin** — a unique subdomain of [HOST] — served at the * Per-site origin retained for Amethyst's NIP-5A WEBSITE profile. NIP-5D napplets never navigate
* origin root, NOT on the shell [ORIGIN]. Two reasons, both load-bearing: * here: their verified, self-contained `/index.html` is assigned through `srcdoc` and therefore
* * executes with an opaque origin in an `allow-scripts`-only sandbox.
* 1. **A real (non-opaque) origin is what gives the applet working, persistent storage.** An
* `allow-scripts`-only opaque-origin iframe has no `localStorage`/`IndexedDB`/service worker
* (reads throw `SecurityError`), which crash-loops essentially every SPA. A real origin with
* `allow-same-origin` has them, scoped and isolated per applet (subdomains don't share
* storage), so applets can't read each other's data.
* 2. **Keeping it on a DISTINCT origin from the shell is what preserves the trust boundary.** The
* native bridge is origin-restricted to the shell [ORIGIN]; the applet, being cross-origin,
* still can't reach it (nor read the shell DOM) — it talks only via `postMessage`, which the
* shell relays. `allow-same-origin` is therefore safe here precisely because the applet is
* same-origin only with *itself*, never with the shell.
*
* The applet is served at its origin root because SPA bundlers (Vite, CRA, webpack, nsyte, …) emit
* **absolute** asset URLs (`/assets/app.js`, `/fonts/x.woff2`) that resolve against the origin root.
*
* [appId] must be a stable, unique, DNS-label-safe token per applet (the host derives it from the
* applet's author + identifier), so the same applet keeps its storage across launches.
*/ */
fun appOrigin(appId: String): String = "https://$appId.$HOST" fun appOrigin(appId: String): String = "https://$appId.$HOST"
/** True for the shell host and any per-applet subdomain — i.e. everything we serve internally. */ /** True for the shell host and any per-applet subdomain — i.e. everything we serve internally. */
fun isInternalHost(host: String?): Boolean = host == HOST || (host != null && host.endsWith(".$HOST")) fun isInternalHost(host: String?): Boolean = host == HOST || (host != null && host.endsWith(".$HOST"))
/** Placeholder in [SHELL_HTML_PATH] the host replaces with the per-applet [appOrigin] before serving. */ /** Placeholders in [SHELL_HTML_PATH] replaced by the host before serving the trusted shell. */
const val APP_ORIGIN_PLACEHOLDER = "__APP_ORIGIN__" const val APP_ORIGIN_PLACEHOLDER = "__APP_ORIGIN__"
const val APP_SANDBOX_PLACEHOLDER = "__APP_SANDBOX__"
const val APP_BOOTSTRAP_PLACEHOLDER = "__APP_BOOTSTRAP__"
/** Name of the origin-restricted native bridge the shell (and only the shell) can reach. */ /** Name of the origin-restricted native bridge the shell (and only the shell) can reach. */
const val BRIDGE_NAME = "__nappletBridge" const val BRIDGE_NAME = "__nappletBridge"
@@ -76,24 +62,72 @@ object NappletWebContract {
* CSP for the shell document: it may inline its own bridge script/style and frame **only this * CSP for the shell document: it may inline its own bridge script/style and frame **only this
* applet's** origin, but has no network and cannot navigate or submit anywhere. * applet's** origin, but has no network and cannot navigate or submit anywhere.
*/ */
fun shellCsp(appOrigin: String): String = fun shellCsp(frameSource: String): String =
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
"frame-src $appOrigin; base-uri 'none'; form-action 'none'" "frame-src $frameSource; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
/** /**
* CSP for the applet document. The applet has a real origin now, so `'self'` resolves to its own * Conservative NIP-5D CSP injected as the first element of the verified napplet's `head` before
* per-applet origin and the shell origin is deliberately NOT granted. The key lever is * the runtime prelude. A `srcdoc` napplet has an opaque origin, so self-hosted subresources are
* `connect-src 'none'`: the applet gets **no** direct network — every fetch goes through the * intentionally unavailable; a conforming napplet is one self-contained `/index.html`.
* brokered, consent-gated `resource.bytes`.
*/ */
const val APP_CSP: String = const val APP_CSP: String =
"default-src 'self'; " + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
"script-src 'self' 'unsafe-inline'; " + "img-src data: blob:; font-src data:; connect-src 'none'; worker-src 'none'; " +
"style-src 'self' 'unsafe-inline'; " + "child-src 'none'; frame-src 'none'; media-src 'none'; object-src 'none'; " +
"img-src 'self' data: blob:; " + "manifest-src 'none'; base-uri 'none'; form-action 'none'"
"font-src 'self' data:; " +
"media-src 'self' blob: data:; " + /**
"connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'" * Injects host-owned policy and the runtime prelude before any authored element in `head`.
* [locked] is the NIP-5D posture; WEBSITE callers deliberately retain Amethyst's NIP-07 and
* normal-origin behavior. Only syntactically valid, explicitly authorized NAP domains are
* projected onto `window.napplet` by the trusted [shimJs].
*/
fun injectPrelude(
html: ByteArray,
shimJs: String,
declaredDomains: List<String>,
locked: Boolean,
injectNip07: Boolean = false,
imeProxy: Boolean = false,
): ByteArray {
val text = html.decodeToString()
val policy =
if (locked) {
"<meta http-equiv=\"Content-Security-Policy\" content=\"$APP_CSP\">"
} else {
""
}
val style = "<style>html,body{overscroll-behavior:none !important}</style>"
val flags =
"<script>window.__nappletNip07=$injectNip07;" +
(if (imeProxy) "window.__nappletImeProxy=true;" else "") +
"</script>"
val safeDomains =
declaredDomains
.filter { it.matches(NAP_DOMAIN) && it in NappletCapability.supportedNapDomains }
.distinct()
val domainsJson = safeDomains.joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
val prelude = "$policy$style$flags<script>window.__nappletDomains=$domainsJson;$shimJs</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
headIdx >= 0 -> {
val close = text.indexOf('>', headIdx)
if (close >= 0) text.substring(0, close + 1) + prelude + text.substring(close + 1) else prelude + text
}
else -> {
val htmlIdx = text.indexOf("<html", ignoreCase = true)
val htmlClose = if (htmlIdx >= 0) text.indexOf('>', htmlIdx) else -1
if (htmlClose >= 0) {
text.substring(0, htmlClose + 1) + "<head>$prelude</head>" + text.substring(htmlClose + 1)
} else {
"<head>$prelude</head>$text"
}
}
}
return injected.encodeToByteArray()
}
const val SHELL_HTML_PATH = "files/napplet/shell.html" const val SHELL_HTML_PATH = "files/napplet/shell.html"
const val SHIM_JS_PATH = "files/napplet/shim.js" const val SHIM_JS_PATH = "files/napplet/shim.js"
@@ -114,4 +148,6 @@ object NappletWebContract {
/** The `window.napplet` client shim a host injects into the applet document. */ /** The `window.napplet` client shim a host injects into the applet document. */
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
suspend fun shimJs(): ByteArray = Res.readBytes(SHIM_JS_PATH) suspend fun shimJs(): ByteArray = Res.readBytes(SHIM_JS_PATH)
private val NAP_DOMAIN = Regex("^[a-z][a-z0-9-]*$")
} }
@@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
enum class NappletStorageScope {
SHARED,
INSTANCE,
}
/** /**
* A capability request from a napplet, after it has crossed the postMessage + IPC edge * A capability request from a napplet, after it has crossed the postMessage + IPC edge
* and been deserialized into a typed object. Each variant declares the [capability] the * and been deserialized into a typed object. Each variant declares the [capability] the
@@ -68,14 +73,6 @@ sealed interface NappletRequest {
override val capability get() = NappletCapability.THEME override val capability get() = NappletCapability.THEME
} }
/** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */
data class ShellSupports(
val domain: String,
val protocol: String? = null,
) : NappletRequest {
override val capability get() = NappletCapability.SHELL
}
/** /**
* Publish an event built from an **unsigned template**. The napplet supplies only `kind`, * Publish an event built from an **unsigned template**. The napplet supplies only `kind`,
* `tags`, and `content`; the shell sets `pubkey` from the real signer, stamps `created_at`, * `tags`, and `content`; the shell sets `pubkey` from the real signer, stamps `created_at`,
@@ -198,6 +195,7 @@ sealed interface NappletRequest {
/** Read a value from this napplet's sandboxed key-value store (`storage.getItem`). */ /** Read a value from this napplet's sandboxed key-value store (`storage.getItem`). */
data class StorageGet( data class StorageGet(
val key: String, val key: String,
val scope: NappletStorageScope = NappletStorageScope.SHARED,
) : NappletRequest { ) : NappletRequest {
override val capability get() = NappletCapability.STORAGE override val capability get() = NappletCapability.STORAGE
} }
@@ -206,6 +204,7 @@ sealed interface NappletRequest {
data class StorageSet( data class StorageSet(
val key: String, val key: String,
val value: String, val value: String,
val scope: NappletStorageScope = NappletStorageScope.SHARED,
) : NappletRequest { ) : NappletRequest {
override val capability get() = NappletCapability.STORAGE override val capability get() = NappletCapability.STORAGE
} }
@@ -213,12 +212,15 @@ sealed interface NappletRequest {
/** Remove a value from this napplet's sandboxed key-value store (`storage.removeItem`). */ /** Remove a value from this napplet's sandboxed key-value store (`storage.removeItem`). */
data class StorageRemove( data class StorageRemove(
val key: String, val key: String,
val scope: NappletStorageScope = NappletStorageScope.SHARED,
) : NappletRequest { ) : NappletRequest {
override val capability get() = NappletCapability.STORAGE override val capability get() = NappletCapability.STORAGE
} }
/** List the keys this napplet has stored (`storage.keys`). */ /** List the keys this napplet has stored (`storage.keys`). */
data object StorageKeys : NappletRequest { data class StorageKeys(
val scope: NappletStorageScope = NappletStorageScope.SHARED,
) : NappletRequest {
override val capability get() = NappletCapability.STORAGE override val capability get() = NappletCapability.STORAGE
} }
@@ -282,6 +284,18 @@ sealed interface NappletRequest {
override val capability get() = NappletCapability.RESOURCE override val capability get() = NappletCapability.RESOURCE
} }
/** Describe the bounded schemes and limits of this shell's existing resource broker. */
data object ResourceInfo : NappletRequest {
override val capability get() = NappletCapability.RESOURCE
}
/** Fetch several resources in input order, returning a per-URL success/error record. */
data class ResourceBytesMany(
val urls: List<String>,
) : NappletRequest {
override val capability get() = NappletCapability.RESOURCE
}
/** Upload a blob to the user's Blossom server (`upload.upload`). */ /** Upload a blob to the user's Blossom server (`upload.upload`). */
data class UploadBlob( data class UploadBlob(
val bytes: ByteArray, val bytes: ByteArray,
@@ -51,11 +51,6 @@ sealed interface NappletResponse {
val events: List<Event>, val events: List<Event>,
) : NappletResponse ) : NappletResponse
/** Result of `shell.supports(domain)`. */
data class Supported(
val supported: Boolean,
) : NappletResponse
/** Result of `keys.registerAction`: the shell-assigned [actionId] and the [binding] it honored (e.g. `"Ctrl+S"`). */ /** Result of `keys.registerAction`: the shell-assigned [actionId] and the [binding] it honored (e.g. `"Ctrl+S"`). */
data class ActionRegistered( data class ActionRegistered(
val actionId: String, val actionId: String,
@@ -97,6 +92,28 @@ sealed interface NappletResponse {
override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode() override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode()
} }
data class ResourceInfo(
val schemes: List<String>,
val maxBytes: Long,
val maxUrls: Int,
) : NappletResponse
data class ResourceItem(
val url: String,
val resource: Bytes? = null,
val error: String? = null,
val message: String? = null,
)
data class ResourceItems(
val items: List<ResourceItem>,
) : NappletResponse
data class ResourceFailure(
val error: String,
val message: String? = null,
) : NappletResponse
/** Result of an `upload.upload`; [url] is where the blob can be fetched, plus NIP-94-ish metadata. */ /** Result of an `upload.upload`; [url] is where the blob can be fetched, plus NIP-94-ish metadata. */
data class Uploaded( data class Uploaded(
val url: String, val url: String,
@@ -0,0 +1,54 @@
/*
* 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.commons.napplet
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class NappletArtifactPolicyTest {
private val htmlHash = "11".repeat(32)
private val index = PathTag("/index.html", htmlHash)
@Test
fun computesIdentityFromTheSignedSingleIndexPath() {
assertEquals(
SiteAggregateHash.compute(listOf(index)),
NappletArtifactPolicy.verifiedAggregateHash(listOf(index), null),
)
}
@Test
fun acceptsMatchingDeclaredIdentityCaseInsensitively() {
val computed = SiteAggregateHash.compute(listOf(index))
assertEquals(computed, NappletArtifactPolicy.verifiedAggregateHash(listOf(index), computed.uppercase()))
}
@Test
fun rejectsDriftedOrNonSelfContainedArtifacts() {
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index), "22".repeat(32)))
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("index.html", htmlHash)), null))
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index, PathTag("/app.js", "22".repeat(32))), null))
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("/index.html", "not-a-sha256")), null))
}
}
@@ -182,9 +182,18 @@ class NappletBrokerTest {
relay: NappletRelayGateway? = null, relay: NappletRelayGateway? = null,
storage: NappletStorage? = null, storage: NappletStorage? = null,
wallet: NappletWalletGateway? = null, wallet: NappletWalletGateway? = null,
resource: NappletResourceGateway? = null,
signer: NostrSigner = this.signer, signer: NostrSigner = this.signer,
ledger: NappletPermissionLedger = NappletPermissionLedger(InMemoryNappletPermissionStore()), ledger: NappletPermissionLedger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
) = NappletBroker(signer, ledger, prompt, relay, storage, wallet) ) = NappletBroker(
signer = signer,
ledger = ledger,
consentPrompt = prompt,
relay = relay,
storage = storage,
wallet = wallet,
resource = resource,
)
@Test @Test
fun getPublicKeyReturnsTheUsersKeyWhenAllowed() = fun getPublicKeyReturnsTheUsersKeyWhenAllowed() =
@@ -545,8 +554,8 @@ class NappletBrokerTest {
assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageSet("k", "v"), allDeclared)) assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageSet("k", "v"), allDeclared))
assertEquals(NappletResponse.StorageValue("v"), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared)) assertEquals(NappletResponse.StorageValue("v"), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared))
// Stored under the applet coordinate, never a shared namespace. // Shared storage is scoped to the verified artifact identity, not just the d-tag.
assertEquals("v", storage.data["${applet.coordinate}::k"]) assertEquals("v", storage.data["${applet.storageCoordinate}::k"])
assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageRemove("k"), allDeclared)) assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageRemove("k"), allDeclared))
assertEquals(NappletResponse.StorageValue(null), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared)) assertEquals(NappletResponse.StorageValue(null), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared))
@@ -563,7 +572,7 @@ class NappletBrokerTest {
broker.handle(applet, NappletRequest.StorageSet("b", "2"), allDeclared) broker.handle(applet, NappletRequest.StorageSet("b", "2"), allDeclared)
broker.handle(other, NappletRequest.StorageSet("c", "3"), allDeclared) broker.handle(other, NappletRequest.StorageSet("c", "3"), allDeclared)
val response = broker.handle(applet, NappletRequest.StorageKeys, allDeclared) val response = broker.handle(applet, NappletRequest.StorageKeys(), allDeclared)
assertIs<NappletResponse.Strings>(response) assertIs<NappletResponse.Strings>(response)
assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c" assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c"
} }
@@ -643,19 +652,6 @@ class NappletBrokerTest {
assertIs<NappletResponse.Denied>(response) assertIs<NappletResponse.Denied>(response)
} }
@Test
fun shellSupportsReflectsDeclaredCapabilitiesWithoutConsent() =
runTest {
// The DENY prompt would block anything that reached consent; supports must not.
val broker = broker(ScriptedPrompt(GrantState.DENY))
val declared = setOf(NappletCapability.RELAY)
assertEquals(NappletResponse.Supported(true), broker.handle(applet, NappletRequest.ShellSupports("relay"), declared))
assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("storage"), declared))
// Unknown/unbrokered domain.
assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("cvm"), declared))
}
@Test @Test
fun identityReadReturnsGatewayJsonOrUnsupported() = fun identityReadReturnsGatewayJsonOrUnsupported() =
runTest { runTest {
@@ -693,4 +689,60 @@ class NappletBrokerTest {
assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared)) assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared))
assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.UploadBlob(ByteArray(0), "image/png"), allDeclared)) assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.UploadBlob(ByteArray(0), "image/png"), allDeclared))
} }
@Test
fun resourceInfoAndTypedFailuresFollowTheCurrentNapContract() =
runTest {
val resource =
NappletResourceGateway { _, _ ->
NappletResourceResult.Failure("blocked-by-policy", "private target")
}
val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
val info = broker.handle(applet, NappletRequest.ResourceInfo, allDeclared)
assertIs<NappletResponse.ResourceInfo>(info)
assertEquals(listOf("data", "https", "blossom", "nostr"), info.schemes)
assertEquals(10L * 1024L * 1024L, info.maxBytes)
assertEquals(
NappletResponse.ResourceFailure("blocked-by-policy", "private target"),
broker.handle(applet, NappletRequest.ResourceBytes("https://internal.example"), allDeclared),
)
}
@Test
fun resourceBytesManyPreservesOrderAndSiblingResults() =
runTest {
val resource =
NappletResourceGateway { url, _ ->
if (url.endsWith("ok")) {
NappletResourceResult.Success(NappletResource("ok".encodeToByteArray(), "text/plain"))
} else {
NappletResourceResult.Failure("not-found")
}
}
val response =
broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
.handle(applet, NappletRequest.ResourceBytesMany(listOf("https://x/ok", "https://x/missing")), allDeclared)
assertIs<NappletResponse.ResourceItems>(response)
assertEquals(listOf("https://x/ok", "https://x/missing"), response.items.map { it.url })
assertIs<NappletResponse.Bytes>(response.items[0].resource)
assertEquals("not-found", response.items[1].error)
}
@Test
fun resourceBytesManyRejectsInvalidBulkSizesWithNapErrorCodes() =
runTest {
val resource = NappletResourceGateway { _, _ -> error("invalid bulk must not fetch") }
val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
val empty = broker.handle(applet, NappletRequest.ResourceBytesMany(emptyList()), allDeclared)
val tooLarge = broker.handle(applet, NappletRequest.ResourceBytesMany(List(17) { "data:,x" }), allDeclared)
assertIs<NappletResponse.ResourceFailure>(empty)
assertEquals("invalid-request", empty.error)
assertIs<NappletResponse.ResourceFailure>(tooLarge)
assertEquals("too-large", tooLarge.error)
}
} }
@@ -28,18 +28,12 @@ import kotlin.test.assertTrue
class NappletCapabilityTest { class NappletCapabilityTest {
@Test @Test
fun mapsKnownDomainsCaseInsensitively() { fun mapsOnlyConformingDomainsExactly() {
assertEquals(NappletCapability.SHELL, NappletCapability.fromNapDomain("shell"))
assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain("identity")) assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain("identity"))
assertEquals(NappletCapability.KEYS, NappletCapability.fromNapDomain("keys")) assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("relay"))
assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("Relay")) assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain("storage"))
assertEquals(NappletCapability.VALUE, NappletCapability.fromNapDomain("value"))
assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain(" STORAGE "))
assertEquals(NappletCapability.RESOURCE, NappletCapability.fromNapDomain("resource")) assertEquals(NappletCapability.RESOURCE, NappletCapability.fromNapDomain("resource"))
assertEquals(NappletCapability.UPLOAD, NappletCapability.fromNapDomain("upload"))
assertEquals(NappletCapability.THEME, NappletCapability.fromNapDomain("theme")) assertEquals(NappletCapability.THEME, NappletCapability.fromNapDomain("theme"))
assertEquals(NappletCapability.NOTIFY, NappletCapability.fromNapDomain("notify"))
assertEquals(NappletCapability.INC, NappletCapability.fromNapDomain("inc"))
} }
@Test @Test
@@ -48,6 +42,14 @@ class NappletCapabilityTest {
assertNull(NappletCapability.fromNapDomain("intent")) assertNull(NappletCapability.fromNapDomain("intent"))
assertNull(NappletCapability.fromNapDomain("cvm")) assertNull(NappletCapability.fromNapDomain("cvm"))
assertNull(NappletCapability.fromNapDomain("filesystem")) assertNull(NappletCapability.fromNapDomain("filesystem"))
assertNull(NappletCapability.fromNapDomain("shell"))
assertNull(NappletCapability.fromNapDomain("keys"))
assertNull(NappletCapability.fromNapDomain("value"))
assertNull(NappletCapability.fromNapDomain("upload"))
assertNull(NappletCapability.fromNapDomain("notify"))
assertNull(NappletCapability.fromNapDomain("inc"))
assertNull(NappletCapability.fromNapDomain("Relay"))
assertNull(NappletCapability.fromNapDomain(" storage "))
assertNull(NappletCapability.fromNapDomain("")) assertNull(NappletCapability.fromNapDomain(""))
} }
@@ -56,10 +58,10 @@ class NappletCapabilityTest {
val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "intent", "value")) val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "intent", "value"))
assertEquals( assertEquals(
setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.VALUE), setOf(NappletCapability.IDENTITY, NappletCapability.RELAY),
resolved.capabilities, resolved.capabilities,
) )
assertEquals(listOf(UnknownNapDomain("intent")), resolved.unknown) assertEquals(listOf(UnknownNapDomain("intent"), UnknownNapDomain("value")), resolved.unknown)
assertTrue(resolved.hasUnknown) assertTrue(resolved.hasUnknown)
} }
@@ -0,0 +1,79 @@
/*
* 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.commons.napplet
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NappletWebContractTest {
@Test
fun lockedPreludeIsTheFirstHeadContentAndRunsBeforeAuthoredCode() {
val authored = "<html><head><script id=\"authored\">run()</script></head><body></body></html>"
val injected =
NappletWebContract
.injectPrelude(
html = authored.encodeToByteArray(),
shimJs = "window.__shimRan=true;",
declaredDomains = listOf("identity", "relay", "shell", "Relay", "bad\"domain", "relay"),
locked = true,
).decodeToString()
val head = injected.indexOf("<head>") + "<head>".length
val csp = injected.indexOf("<meta http-equiv=\"Content-Security-Policy\"")
val domains = injected.indexOf("window.__nappletDomains=[\"identity\", \"relay\"]")
val shim = injected.indexOf("window.__shimRan=true")
val authoredScript = injected.indexOf("id=\"authored\"")
assertTrue(head == csp, "the CSP meta must be the first element in head")
assertTrue(csp < domains && domains < shim && shim < authoredScript)
assertContains(injected, "window.__nappletNip07=false")
assertFalse(injected.contains("\"shell\""))
assertFalse(injected.contains("bad\"domain"))
}
@Test
fun lockedCspMatchesTheConservativeOpaqueOriginPosture() {
val csp = NappletWebContract.APP_CSP
assertContains(csp, "default-src 'none'")
assertContains(csp, "connect-src 'none'")
assertContains(csp, "frame-src 'none'")
assertContains(csp, "object-src 'none'")
assertContains(csp, "base-uri 'none'")
assertContains(csp, "form-action 'none'")
assertFalse(csp.contains("'self'"))
}
@Test
fun shellTemplateKeepsTheOpaqueIframeAndSourceChecks() =
runTest {
val shell = NappletWebContract.shellHtml().decodeToString()
assertContains(shell, "sandbox=\"${NappletWebContract.APP_SANDBOX_PLACEHOLDER}\"")
assertContains(shell, NappletWebContract.APP_BOOTSTRAP_PLACEHOLDER)
assertContains(shell, "e.source !== iframe.contentWindow")
assertContains(shell, "iframe.contentWindow.postMessage(msg, '*')")
assertFalse(shell.contains("allow-same-origin"))
}
}
@@ -55,12 +55,6 @@ object NappletRequestRouter {
val subId: String, val subId: String,
) : Outcome ) : Outcome
/** Start streaming `identity.changed` pushes when the active user's key changes (fire-and-forget). */
data object WatchIdentity : Outcome
/** Stop the `identity.changed` stream (fire-and-forget; no reply). */
data object UnwatchIdentity : Outcome
/** Push these envelope(s) to the applet immediately, unkeyed (e.g. an EOSE closing a refused sub). */ /** Push these envelope(s) to the applet immediately, unkeyed (e.g. an EOSE closing a refused sub). */
data class Push( data class Push(
val payloads: List<String>, val payloads: List<String>,
@@ -89,7 +83,7 @@ object NappletRequestRouter {
declared: Set<NappletCapability>, declared: Set<NappletCapability>,
payload: String, payload: String,
): Outcome { ): Outcome {
val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: return Outcome.Ignore
// Fire-and-forget edge ops that never reach the broker. // Fire-and-forget edge ops that never reach the broker.
when (requestType) { when (requestType) {
@@ -97,25 +91,9 @@ object NappletRequestRouter {
val subId = runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() val subId = runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull()
return if (subId != null) Outcome.CloseSubscription(subId) else Outcome.Ignore return if (subId != null) Outcome.CloseSubscription(subId) else Outcome.Ignore
} }
"resource.cancel" -> "resource.cancel" -> return Outcome.Ignore
return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done))
// identity.watch/unwatch are a push subscription (like relay.subscribe), gated on the
// IDENTITY declaration directly — the actual pubkey stream is the host's job. The
// ledger still gets a say: this bypasses NappletBroker.handle, so without an explicit
// check a standing IDENTITY denial would not stop the pushes (and would keep leaking
// account-switch timing and every npub the user rotates between).
"identity.watch" ->
return if (NappletCapability.IDENTITY in declared &&
!broker.isDenied(identity, NappletCapability.IDENTITY)
) {
Outcome.WatchIdentity
} else {
Outcome.Ignore
}
"identity.unwatch" ->
return Outcome.UnwatchIdentity
// inc bus: a topic pub/sub between napplets/services, authorized on the INC declaration // inc bus: a topic pub/sub between napplets/services, authorized on the INC declaration
// alone (like identity.watch) — no per-call consent. The host owns the cross-session fan-out. // alone. The host owns the cross-session fan-out.
"inc.subscribe" -> { "inc.subscribe" -> {
val topic = runCatching { NappletProtocolJson.readTopic(payload) }.getOrNull() val topic = runCatching { NappletProtocolJson.readTopic(payload) }.getOrNull()
return if (topic != null && NappletCapability.INC in declared) Outcome.SubscribeInc(topic) else Outcome.Ignore return if (topic != null && NappletCapability.INC in declared) Outcome.SubscribeInc(topic) else Outcome.Ignore
@@ -136,7 +114,7 @@ object NappletRequestRouter {
val request = val request =
runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull() runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull()
?: return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Malformed or unsupported request."))) ?: return Outcome.Ignore
val response = broker.handle(identity, request, declared) val response = broker.handle(identity, request, declared)
@@ -26,6 +26,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int import kotlinx.serialization.json.int
@@ -34,6 +35,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long import kotlinx.serialization.json.long
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import java.util.Base64 import java.util.Base64
@@ -62,7 +64,7 @@ object NappletProtocolJson {
/** The `subId` of a subscription request, used to key the `relay.event`/`relay.eose` pushes back to it. */ /** The `subId` of a subscription request, used to key the `relay.event`/`relay.eose` pushes back to it. */
fun readSubId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("subId") fun readSubId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("subId")
/** A `relay.event` push: delivers one matching [event] to the subscription [subId] (no request id). */ /** A `relay.event` push carrying the NAP-RELAY `RelayEventResult` wrapper. */
fun encodeRelayEvent( fun encodeRelayEvent(
subId: String, subId: String,
event: Event, event: Event,
@@ -70,7 +72,9 @@ object NappletProtocolJson {
buildJsonObject { buildJsonObject {
put("type", "relay.event") put("type", "relay.event")
put("subId", subId) put("subId", subId)
putJsonObject("result") {
put("event", json.parseToJsonElement(event.toJson())) put("event", json.parseToJsonElement(event.toJson()))
}
}.toString() }.toString()
/** A `relay.eose` push: signals end-of-stored-events for the subscription [subId]. */ /** A `relay.eose` push: signals end-of-stored-events for the subscription [subId]. */
@@ -130,29 +134,10 @@ object NappletProtocolJson {
put("reason", reason) put("reason", reason)
}.toString() }.toString()
/**
* The `shell.init` handshake reply (`@napplet/core`): the capability environment the napplet
* caches and answers `shell.supports()` from. [domains] is the set of NAP domains this shell
* will broker for the applet; [services] mirrors them. We don't advertise numbered protocols.
*/
fun encodeShellInit(
domains: List<String>,
services: List<String>,
): String =
buildJsonObject {
put("type", "shell.init")
putJsonObject("capabilities") {
put("domains", buildJsonArray { domains.forEach { add(it) } })
putJsonObject("protocols") {}
}
put("services", buildJsonArray { services.forEach { add(it) } })
}.toString()
/** Parses a request envelope. Returns `null` for an unrecognized `type` so the broker can deny it. */ /** Parses a request envelope. Returns `null` for an unrecognized `type` so the broker can deny it. */
fun decodeRequest(envelopeJson: String): NappletRequest? { fun decodeRequest(envelopeJson: String): NappletRequest? {
val o = json.parseToJsonElement(envelopeJson).jsonObject val o = json.parseToJsonElement(envelopeJson).jsonObject
return when (o.str("type")) { return when (o.str("type")) {
"shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol"))
"theme.get" -> NappletRequest.ThemeGet "theme.get" -> NappletRequest.ThemeGet
"identity.getPublicKey" -> NappletRequest.GetPublicKey "identity.getPublicKey" -> NappletRequest.GetPublicKey
"relay.publish" -> { "relay.publish" -> {
@@ -181,10 +166,10 @@ object NappletProtocolJson {
createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000), createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000),
) )
} }
"storage.get" -> NappletRequest.StorageGet(o.req("key")) "storage.get" -> NappletRequest.StorageGet(o.req("key"), o.storageScope())
"storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value")) "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"), o.storageScope())
"storage.remove" -> NappletRequest.StorageRemove(o.req("key")) "storage.remove" -> NappletRequest.StorageRemove(o.req("key"), o.storageScope())
"storage.keys" -> NappletRequest.StorageKeys "storage.keys" -> NappletRequest.StorageKeys(o.storageScope())
"notify.create" -> NappletRequest.NotifyCreate(o.str("title") ?: "", o.str("body") ?: "") "notify.create" -> NappletRequest.NotifyCreate(o.str("title") ?: "", o.str("body") ?: "")
"notify.list" -> NappletRequest.NotifyList "notify.list" -> NappletRequest.NotifyList
"notify.dismiss" -> NappletRequest.NotifyDismiss(o.str("notificationId") ?: o.str("id") ?: "") "notify.dismiss" -> NappletRequest.NotifyDismiss(o.str("notificationId") ?: o.str("id") ?: "")
@@ -194,7 +179,9 @@ object NappletProtocolJson {
} }
"keys.unregisterAction" -> NappletRequest.UnregisterAction(o.req("actionId")) "keys.unregisterAction" -> NappletRequest.UnregisterAction(o.req("actionId"))
"value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice")) "value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice"))
"resource.info" -> NappletRequest.ResourceInfo
"resource.bytes" -> NappletRequest.ResourceBytes(o.req("url")) "resource.bytes" -> NappletRequest.ResourceBytes(o.req("url"))
"resource.bytesMany" -> NappletRequest.ResourceBytesMany(o.getValue("urls").jsonArray.map { it.jsonPrimitive.content })
"upload.upload" -> { "upload.upload" -> {
// UploadUploadMessage: { type, id, request: { data, mimeType?, filename?, ... } }. // UploadUploadMessage: { type, id, request: { data, mimeType?, filename?, ... } }.
// The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html. // The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html.
@@ -209,7 +196,7 @@ object NappletProtocolJson {
// Any other identity.* read (getProfile/getRelays/getFollows/getList/...) routes through // Any other identity.* read (getProfile/getRelays/getFollows/getList/...) routes through
// a generic IdentityRead; the broker/gateway decides which are implemented. // a generic IdentityRead; the broker/gateway decides which are implemented.
val type = o.str("type") val type = o.str("type")
if (type != null && type.startsWith("identity.")) { if (type != null && type in IDENTITY_READ_TYPES) {
NappletRequest.IdentityRead(type.removePrefix("identity."), o.str("listType") ?: o.str("argument")) NappletRequest.IdentityRead(type.removePrefix("identity."), o.str("listType") ?: o.str("argument"))
} else { } else {
null null
@@ -230,6 +217,7 @@ object NappletProtocolJson {
when (response) { when (response) {
is NappletResponse.NotifyCreated -> "notify.created" is NappletResponse.NotifyCreated -> "notify.created"
is NappletResponse.NotifyListed -> "notify.listed" is NappletResponse.NotifyListed -> "notify.listed"
is NappletResponse.ResourceFailure -> "$requestType.error"
else -> "$requestType.result" else -> "$requestType.result"
} }
put("type", responseType) put("type", responseType)
@@ -247,11 +235,11 @@ object NappletProtocolJson {
} }
is NappletResponse.Events -> { is NappletResponse.Events -> {
put("ok", true) put("ok", true)
put("events", buildJsonArray { response.events.forEach { add(json.parseToJsonElement(it.toJson())) } }) putJsonArray("events") {
response.events.forEach { event ->
addJsonObject { put("event", json.parseToJsonElement(event.toJson())) }
}
} }
is NappletResponse.Supported -> {
put("ok", true)
put("supported", response.supported)
} }
is NappletResponse.ActionRegistered -> { is NappletResponse.ActionRegistered -> {
put("ok", true) put("ok", true)
@@ -279,6 +267,42 @@ object NappletProtocolJson {
put("bytes", Base64.getEncoder().encodeToString(response.bytes)) put("bytes", Base64.getEncoder().encodeToString(response.bytes))
put("mime", response.contentType) put("mime", response.contentType)
} }
is NappletResponse.ResourceInfo -> {
put("ok", true)
putJsonObject("info") {
putJsonArray("schemes") {
response.schemes.forEach { scheme ->
addJsonObject {
put("scheme", scheme)
put("enabled", true)
}
}
}
put("maxBytes", response.maxBytes)
put("maxUrls", response.maxUrls)
}
}
is NappletResponse.ResourceItems -> {
put("ok", true)
putJsonArray("items") {
response.items.forEach { item ->
addJsonObject {
put("url", item.url)
put("ok", item.resource != null)
item.resource?.let {
put("bytes", Base64.getEncoder().encodeToString(it.bytes))
put("mime", it.contentType)
}
item.error?.let { put("error", it) }
item.message?.let { put("message", it) }
}
}
}
}
is NappletResponse.ResourceFailure -> {
put("error", response.error)
response.message?.let { put("message", it) }
}
is NappletResponse.Uploaded -> { is NappletResponse.Uploaded -> {
// UploadResult: { ok, uploadId, status, url?, sha256?, size?, mimeType?, ... }. // UploadResult: { ok, uploadId, status, url?, sha256?, size?, mimeType?, ... }.
put("ok", true) put("ok", true)
@@ -397,6 +421,8 @@ object NappletProtocolJson {
private fun JsonObject.kindOf(): Int = getValue("kind").jsonPrimitive.int private fun JsonObject.kindOf(): Int = getValue("kind").jsonPrimitive.int
private fun JsonObject.storageScope(): NappletStorageScope = if (str("scope") == "instance") NappletStorageScope.INSTANCE else NappletStorageScope.SHARED
/** The result field a given identity read returns, matching `@napplet/nap` identity message types. */ /** The result field a given identity read returns, matching `@napplet/nap` identity message types. */
private fun identityResultField(requestType: String): String = private fun identityResultField(requestType: String): String =
when (requestType) { when (requestType) {
@@ -408,4 +434,16 @@ object NappletProtocolJson {
"identity.getBadges" -> "badges" "identity.getBadges" -> "badges"
else -> "result" else -> "result"
} }
private val IDENTITY_READ_TYPES =
setOf(
"identity.getRelays",
"identity.getProfile",
"identity.getFollows",
"identity.getList",
"identity.getZaps",
"identity.getMutes",
"identity.getBlocked",
"identity.getBadges",
)
} }
@@ -85,11 +85,12 @@ class NappletRequestRouterTest {
} }
@Test @Test
fun resourceCancelRepliesDone() = fun resourceCancelIsSilentlyHandled() =
runTest { runTest {
val outcome = route("""{"type":"resource.cancel"}""") assertEquals(
assertIs<NappletRequestRouter.Outcome.Reply>(outcome) NappletRequestRouter.Outcome.Ignore,
assertTrue(outcome.payload.contains("resource.cancel.result")) route("""{"type":"resource.cancel"}"""),
)
} }
@Test @Test
@@ -117,11 +118,11 @@ class NappletRequestRouterTest {
} }
@Test @Test
fun malformedRequestRepliesFailed() = fun unknownAndMalformedRequestsAreSilentlyIgnored() =
runTest { runTest {
val outcome = route("""{"type":"totally.unknown"}""") assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"totally.unknown"}"""))
assertIs<NappletRequestRouter.Outcome.Reply>(outcome) assertEquals(NappletRequestRouter.Outcome.Ignore, route("not json"))
assertTrue(outcome.payload.contains("failed")) assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}"""))
} }
@Test @Test
@@ -153,29 +154,9 @@ class NappletRequestRouterTest {
} }
@Test @Test
fun identityWatchWhenDeclaredBecomesWatchIdentity() = fun removedIdentityWatchMessagesAreIgnored() =
runTest { runTest {
assertEquals( assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.watch"}"""))
NappletRequestRouter.Outcome.WatchIdentity, assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.unwatch"}"""))
NappletRequestRouter.route(broker(), applet, allDeclared, """{"type":"identity.watch"}"""),
)
}
@Test
fun identityWatchWithoutDeclarationIsIgnored() =
runTest {
assertEquals(
NappletRequestRouter.Outcome.Ignore,
NappletRequestRouter.route(broker(), applet, emptySet(), """{"type":"identity.watch"}"""),
)
}
@Test
fun identityUnwatchBecomesUnwatchIdentity() =
runTest {
assertEquals(
NappletRequestRouter.Outcome.UnwatchIdentity,
NappletRequestRouter.route(broker(), applet, emptySet(), """{"type":"identity.unwatch"}"""),
)
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.napplethost package com.vitorpamplona.amethyst.napplethost
import android.util.Base64
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
@@ -60,6 +61,9 @@ class NappletContentServer(
// The host posture: a WEBSITE nSite is a normal web app — NIP-07 window.nostr provider, normal // The host posture: a WEBSITE nSite is a normal web app — NIP-07 window.nostr provider, normal
// network (no app CSP; off-origin requests defer to the WebView), unlike a locked NAPPLET. // network (no app CSP; off-origin requests defer to the WebView), unlike a locked NAPPLET.
private val profile: HostProfile = HostProfile.NAPPLET, private val profile: HostProfile = HostProfile.NAPPLET,
// Exact NAP domains the trusted main process authorized for this launch. The injected prelude
// projects only these objects; absence is the NIP-5D capability-availability signal.
private val declaredDomains: List<String> = emptyList(),
// Embedded surfaces (a windowless Service) can't host the soft keyboard, so the shim installs the // Embedded surfaces (a windowless Service) can't host the soft keyboard, so the shim installs the
// IME proxy agent that relays the focused field to the host's keyboard. The full-screen Activity // IME proxy agent that relays the focused field to the host's keyboard. The full-screen Activity
// host has a native keyboard and leaves this false. // host has a native keyboard and leaves this false.
@@ -130,15 +134,44 @@ class NappletContentServer(
} }
private fun serveShell(): WebResourceResponse { private fun serveShell(): WebResourceResponse {
// The shell HTML carries an APP_ORIGIN_PLACEHOLDER for the iframe src; bind it to this applet's val sandbox: String
// origin so the shell frames exactly this applet (and the CSP frame-src is pinned to it too). val frameSource: String
val html = shellHtmlBytes.decodeToString().replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin).encodeToByteArray() val bootstrap: String
if (profile == HostProfile.NAPPLET) {
// NIP-5D identity is bound to the exact verified bytes that execute. Resolve the sole
// /index.html blob, inject host-owned policy/prelude outside its aggregate hash, then
// hand those bytes to the opaque-origin child via srcdoc (never a navigated src).
val resolution = resolveCacheFirst("/index.html")
if (resolution !is StaticSiteResolution.Resolved) return notFound()
val encoded = Base64.encodeToString(injectShim(resolution.bytes), Base64.NO_WRAP)
sandbox = "allow-scripts"
frameSource = "'self'"
bootstrap =
"var b=atob('$encoded'),u=new Uint8Array(b.length);" +
"for(var j=0;j<b.length;j++)u[j]=b.charCodeAt(j);" +
"iframe.srcdoc=new TextDecoder('utf-8').decode(u);"
} else {
// Amethyst's NIP-5A website posture is intentionally unchanged: a dedicated real origin,
// normal website storage/network, and NIP-07 behind the existing consent broker.
sandbox = "allow-scripts allow-same-origin"
frameSource = appOrigin
bootstrap = "iframe.src = '$appOrigin/';"
}
val html =
shellHtmlBytes
.decodeToString()
.replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin)
.replace(NappletWebContract.APP_SANDBOX_PLACEHOLDER, sandbox)
.replace(NappletWebContract.APP_BOOTSTRAP_PLACEHOLDER, bootstrap)
.encodeToByteArray()
return WebResourceResponse( return WebResourceResponse(
MIME_HTML, MIME_HTML,
"utf-8", "utf-8",
200, 200,
"OK", "OK",
mapOf("Content-Security-Policy" to NappletWebContract.shellCsp(appOrigin)), mapOf("Content-Security-Policy" to NappletWebContract.shellCsp(frameSource)),
ByteArrayInputStream(html), ByteArrayInputStream(html),
) )
} }
@@ -183,31 +216,16 @@ class NappletContentServer(
) )
} }
/** Inserts the `window.napplet` client shim into the applet's HTML document. */ /** Inserts host policy and the explicit-domain `window.napplet` prelude before authored code. */
private fun injectShim(html: ByteArray): ByteArray { private fun injectShim(html: ByteArray): ByteArray =
val text = html.decodeToString() NappletWebContract.injectPrelude(
// Disable the document's overscroll affordance (the bounce/stretch): the Android-level html = html,
// WebView.overScrollMode only governs the outer frame, so an applet whose own root background is shimJs = shimJs,
// transparent stretched on over-scroll and exposed the shell's background behind it (a stray declaredDomains = declaredDomains,
// white band at the bottom). `overscroll-behavior: none` removes the stretch at the source, locked = profile == HostProfile.NAPPLET,
// theme-independently. Allowed by the app CSP's `style-src 'unsafe-inline'`. injectNip07 = profile.injectsNip07,
val style = "<style>html,body{overscroll-behavior:none !important}</style>" imeProxy = imeProxy,
// In website mode, set the NIP-07 flag synchronously *before* the shim so window.nostr installs. )
val nip07Flag = if (profile.injectsNip07) "<script>window.__nappletNip07=true;</script>" else ""
// Embedded surface: turn on the IME proxy agent (set before the shim runs).
val imeFlag = if (imeProxy) "<script>window.__nappletImeProxy=true;</script>" else ""
val script = "$style$nip07Flag$imeFlag<script>$shimJs</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
headIdx >= 0 -> {
val close = text.indexOf('>', headIdx)
if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text
}
else -> script + text
}
return injected.encodeToByteArray()
}
private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0)))
@@ -67,7 +67,6 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.amethyst.napplethost.R import com.vitorpamplona.amethyst.napplethost.R
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
@@ -243,7 +242,7 @@ class NappletHostActivity : ComponentActivity() {
// "Open web" for a site makes everything direct — both its blob fetches (here) and its live // "Open web" for a site makes everything direct — both its blob fetches (here) and its live
// web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port. // web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port.
val effectiveProxy = if (useTor) proxyPort else -1 val effectiveProxy = if (useTor) proxyPort else -1
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile) contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile, declaredDomains)
// Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main // Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main
// thread concurrently with the index probe below (which runs on IO) — instead of serially after // thread concurrently with the index probe below (which runs on IO) — instead of serially after
@@ -473,10 +472,10 @@ class NappletHostActivity : ComponentActivity() {
webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE) webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE)
val requires = intent.getStringArrayListExtra(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() val requires = intent.getStringArrayListExtra(NappletHostContract.EXTRA_REQUIRES) ?: emptyList()
val resolved = resolveRequiredCapabilities(requires) val resolved = profile.declaredCapabilities(requires)
// shell is always available; the rest are the declared domains advertised to the applet in the // Domain-object presence is the NIP-5D availability signal. The broker still enforces the
// handshake. (The broker enforces the authoritative set from the launch token, not this list.) // authoritative set minted into the launch token in the main process.
declaredDomains = (listOf("shell") + resolved.capabilities.map { it.name.lowercase() }).distinct() declaredDomains = resolved.map { it.name.lowercase() }.distinct()
return author.isNotEmpty() && launchToken.isNotEmpty() return author.isNotEmpty() && launchToken.isNotEmpty()
} }
@@ -677,13 +676,6 @@ class NappletHostActivity : ComponentActivity() {
// correlate on its id. The broker reads `type` to decode and to build the .result reply. // correlate on its id. The broker reads `type` to decode and to build the .result reply.
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
// Shell handshake: the SDK posts `shell.ready` (no id) and answers shell.supports() locally
// from the `shell.init` environment we send back here.
if (envelope.optString("type") == "shell.ready") {
runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(declaredDomains, declaredDomains)) }
return
}
// Unbind a keyboard action as soon as the applet drops it (the broker's Done reply carries no // Unbind a keyboard action as soon as the applet drops it (the broker's Done reply carries no
// actionId, so the binding is removed here from the envelope itself). // actionId, so the binding is removed here from the envelope itself).
if (envelope.optString("type") == "keys.unregisterAction") { if (envelope.optString("type") == "keys.unregisterAction") {
@@ -54,8 +54,6 @@ import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract 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.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
@@ -201,7 +199,8 @@ class NappletHostService : Service() {
if (author.isEmpty() || launchToken.isEmpty()) return null if (author.isEmpty() || launchToken.isEmpty()) return null
val requires = data.getStringArrayList(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() val requires = data.getStringArrayList(NappletHostContract.EXTRA_REQUIRES) ?: emptyList()
val declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct() val profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE))
val declaredDomains = profile.declaredCapabilities(requires).map { it.name.lowercase() }.distinct()
val tab = val tab =
NappletTab( NappletTab(
@@ -212,7 +211,7 @@ class NappletHostService : Service() {
author = author, author = author,
identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(), identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(),
launchToken = launchToken, launchToken = launchToken,
profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)), profile = profile,
useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true), useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true),
proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1), proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1),
bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE), bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE),
@@ -300,7 +299,19 @@ class NappletHostService : Service() {
NappletWebViewProfile.apply(context, wv, tab.webViewProfile) NappletWebViewProfile.apply(context, wv, tab.webViewProfile)
val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier)) val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier))
val effectiveProxy = if (tab.useTor) tab.proxyPort else -1 val effectiveProxy = if (tab.useTor) tab.proxyPort else -1
tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.profile, imeProxy = true) tab.contentServer =
NappletContentServer(
tab.paths,
tab.servers,
effectiveProxy,
cacheDir,
shellHtml,
shimJs,
appOrigin,
tab.profile,
tab.declaredDomains,
imeProxy = true,
)
hardenWebView(wv, tab) hardenWebView(wv, tab)
// Theme the pre-load background so the shell/app loading shows Amethyst's background, not white. // Theme the pre-load background so the shell/app loading shows Amethyst's background, not white.
@@ -468,11 +479,6 @@ class NappletHostService : Service() {
val raw = message.data ?: return val raw = message.data ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
if (envelope.optString("type") == "shell.ready") {
runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(tab.declaredDomains, tab.declaredDomains)) }
return
}
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
if (envelope.optString("type").startsWith("ime.")) { if (envelope.optString("type").startsWith("ime.")) {
val reply = val reply =