diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index bfdc4466ed..f46e2e7f24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -165,27 +165,17 @@ object FavoriteAppLauncher { return when (event) { is RootNappletEvent -> NappletLauncher.buildLaunchParams( - context, - event.paths(), - event.servers(), - event.pubKey, - "", - event.declaredAggregateHash() ?: event.computeAggregateHash(), - event.title() ?: "Napplet", - event.requires(), - HostProfile.NAPPLET, + context = context, + manifest = event, + authorPubKey = event.pubKey, + identifier = "", ) is NamedNappletEvent -> NappletLauncher.buildLaunchParams( - context, - event.paths(), - event.servers(), - event.pubKey, - event.identifier(), - event.declaredAggregateHash() ?: event.computeAggregateHash(), - event.title() ?: event.identifier(), - event.requires(), - HostProfile.NAPPLET, + context = context, + manifest = event, + authorPubKey = event.pubKey, + identifier = event.identifier(), ) is RootSiteEvent -> NappletLauncher.buildLaunchParams( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 893f02ec87..14d3499371 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt @@ -57,7 +57,27 @@ class DataStoreNappletStorage( key: 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( @@ -87,4 +107,9 @@ class DataStoreNappletStorage( coordinate: String, key: String, ) = stringPreferencesKey(prefixOf(coordinate) + key) + + companion object { + /** NAP-STORAGE's recommended per-napplet UTF-8 quota. */ + const val MAX_STORAGE_BYTES = 512 * 1024 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 37a124c67e..58453f479b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * 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 // 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() // 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) } @@ -117,7 +123,7 @@ class NappletBrokerService : Service() { override fun onDestroy() { liveSubscriptions.closeAll() - identityWatch.stop() + identityWatch.stopAll() // 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 // their in-memory session grants have to be dropped explicitly here — that keeps the lifetime @@ -280,38 +286,57 @@ class NappletBrokerService : Service() { // Resolve the launch token to the trusted identity + declared set. The sandbox never states // its own coordinate, so a compromised :napplet process can only ever act as the napplet it // was launched as (it holds only its own token). An unknown token = no session; refuse. - val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) + val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) + val session = NappletLaunchRegistry.resolve(launchToken) if (session == null) { reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session."))) return true } val identity = session.identity 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 { - // 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 - // the broker, the Messenger transport, and the live relay subscription each Outcome implies. - // The launch token decides whose key signs — not the active account. A surface opened by - // one account can never be handed another's signer, even while it stays open across a switch. - val broker = brokerFor(session.accountPubKey) - if (broker == null) { - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) - return@launch - } - when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { - is NappletRequestRouter.Outcome.Ignore -> {} - is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) - is NappletRequestRouter.Outcome.OpenSubscription -> - liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } - 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.SubscribeInc -> incBus.subscribe(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) + 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 + // 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 launch token decides whose key signs — not the active account. A surface opened by + // one account can never be handed another's signer, even while it stays open across a switch. + val broker = brokerFor(session.accountPubKey) + if (broker == null) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) + return@launch + } + when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { + is NappletRequestRouter.Outcome.Ignore -> {} + 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 -> + liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } + is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) + is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } + is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(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) + } } + if (tracksResourceRequest) { + resourceRequests.put(resourceRequestKey, requestJob)?.cancel() + requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) } + requestJob.start() } return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index adff3b46f6..15ab1abd71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability @StringRes fun NappletCapability.labelRes(): Int = when (this) { - NappletCapability.SHELL -> R.string.napplet_cap_shell NappletCapability.IDENTITY -> R.string.napplet_cap_identity NappletCapability.KEYS -> R.string.napplet_cap_keys NappletCapability.RELAY -> R.string.napplet_cap_relay @@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int = @StringRes fun NappletCapability.descriptionRes(): Int = when (this) { - NappletCapability.SHELL -> R.string.napplet_cap_shell_desc NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc NappletCapability.KEYS -> R.string.napplet_cap_keys_desc NappletCapability.RELAY -> R.string.napplet_cap_relay_desc diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 42f4ec93f5..583cda40b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -294,9 +294,10 @@ class NappletConsentSummary( 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) // 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 -> "" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt index f94f7bc2cf..bbb6a3aaa2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -34,21 +34,22 @@ import kotlinx.coroutines.launch * 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. * - * One watch at a time per host binding; [start] replaces any prior one. Reached only after the - * router confirmed the applet declared the IDENTITY capability. + * Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's + * streams. A watch starts only after that surface successfully obtains its public-key snapshot. */ class NappletIdentityWatch( private val scope: CoroutineScope, private val pubKey: (boundPubKey: String) -> Flow, ) { - private var job: Job? = null + private val jobs = mutableMapOf() fun start( + watchId: String, boundPubKey: String, push: (String) -> Unit, ) { - stop() - job = + if (jobs.containsKey(watchId)) return + jobs[watchId] = scope.launch { pubKey(boundPubKey) .distinctUntilChanged() @@ -57,8 +58,8 @@ class NappletIdentityWatch( } } - fun stop() { - job?.cancel() - job = null + fun stopAll() { + jobs.values.forEach { it.cancel() } + jobs.clear() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt index b91aabad36..8f6741e564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -76,7 +76,7 @@ object NappletLaunchRegistry { accountPubKey: HexKey, ): String { 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 2c98ffb8fe..29c0a2f091 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -24,14 +24,18 @@ import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.util.Log import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletHostActivity 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.crypto.verify import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -49,20 +53,18 @@ object NappletLauncher { manifest: NappletManifest, authorPubKey: HexKey, identifier: String, - ) = launch( - 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(), - ) + ) { + val event = manifest as? Event + if (event?.verify() != true || event.pubKey != authorPubKey) { + Log.w(TAG, "Refusing NIP-5D manifest that failed signature/author verification") + return + } + buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) } + } /** - * Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite — - * the broker then refuses every capability, so the site renders as inert static content. + * Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified + * manifest overload so raw callers cannot bypass signature/author validation. */ fun launch( context: Context, @@ -73,12 +75,26 @@ object NappletLauncher { aggregateHash: HexKey?, title: String, requires: List, - // nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The - // broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless - // of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET]. - profile: HostProfile = HostProfile.NAPPLET, + // Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must + // use the signature-checking manifest overload above. + profile: HostProfile, + ) { + 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 = Intent(context, NappletHostActivity::class.java).apply { putExtras(params) @@ -105,6 +121,29 @@ object NappletLauncher { requires: List, profile: HostProfile, ): 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, + servers: List, + authorPubKey: HexKey, + identifier: String, + aggregateHash: HexKey?, + title: String, + requires: List, + 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 // 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 // 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) // 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). @@ -156,7 +195,7 @@ object NappletLauncher { putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers)) putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey) putString(NappletHostContract.EXTRA_IDENTIFIER, identifier) - putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash) + putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash) putString(NappletHostContract.EXTRA_TITLE, title) putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires)) putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels)) @@ -170,4 +209,34 @@ object NappletLauncher { 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" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt index 8429a0b721..12d3500cd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -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.filters.Filter 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.atomic.AtomicBoolean 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 * subscription (RELAY consent). */ -class NappletLiveSubscriptions { +class NappletLiveSubscriptions( + private val scope: CoroutineScope, +) { private val liveSubs = ConcurrentHashMap() private val liveSeq = AtomicInteger(0) @@ -53,6 +59,20 @@ class NappletLiveSubscriptions { val client: INostrClient, ) { val eoseSent = AtomicBoolean(false) + val deliveries = Channel(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. val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client) 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 = object : SubscriptionListener { @@ -85,7 +119,9 @@ class NappletLiveSubscriptions { isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, - ) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event)) + ) { + sub.deliveries.trySend(Delivery.RelayEvent(event)) + } // 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). @@ -93,14 +129,16 @@ class NappletLiveSubscriptions { relay: NormalizedRelayUrl, forFilters: List?, ) { - if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose) } override fun onClosed( message: String, relay: NormalizedRelayUrl, forFilters: List?, - ) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message)) + ) { + sub.deliveries.trySend(Delivery.Closed(message)) + } } 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. */ fun close(nappletSubId: String) { val sub = liveSubs.remove(nappletSubId) ?: return + sub.deliveries.close() + sub.deliveryJob?.cancel() runCatching { sub.client.unsubscribe(sub.clientSubId) } } /** Tears down every open subscription (service teardown). */ 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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt new file mode 100644 index 0000000000..3b55dad5cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 139a935d5f..3222e810c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore +import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext import com.vitorpamplona.amethyst.napplet.buildConnectInfo import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -265,7 +266,8 @@ class AccountNappletGateways( .distinctBy { it.id } .sortedByDescending { it.createdAt } 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) } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index c64fb12eca..9acdf7b690 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways import android.util.Base64 import com.vitorpamplona.amethyst.commons.napplet.NappletResource +import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry 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 kotlinx.coroutines.Dispatchers 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.Request +import java.io.ByteArrayOutputStream +import java.io.InterruptedIOException +import java.net.InetAddress 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 @@ -63,41 +76,100 @@ class NappletResourceFetcher( private val account: Account, 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( url: String, coordinate: String, - ): NappletResource? = + ): NappletResourceResult = 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 { url.startsWith("data:") -> decodeDataUrl(url) - url.startsWith("https://") -> { - runCatching { - client - .newCall( - Request - .Builder() - .url(url) - .get() - .build(), - ).execute() - .use { r -> - if (!r.isSuccessful) return@withContext null - val body = r.body.bytes() - val type = r.header("Content-Type") ?: "application/octet-stream" - NappletResource(body, type) - } - }.getOrNull() + url.startsWith("nostr:") -> + 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) } - url.startsWith("blossom:") -> fetchBlossom(url, client) - url.startsWith("nostr:") -> resolveNostr(url) - else -> null + 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 + .newCall( + Request + .Builder() + .url(current) + .get() + .build(), + ).execute() + .use { response -> + if (response.isRedirect) { + if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.") + val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.") + current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.") + return@repeat + } + 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) + } + } catch (e: BlockedResourceException) { + return failure(ERROR_BLOCKED, e.message) + } 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` * carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to @@ -158,62 +230,176 @@ class NappletResourceFetcher( private fun fetchBlossom( url: String, client: OkHttpClient, - ): NappletResource? { - val hash = - url - .removePrefix("blossom://") - .removePrefix("blossom:") - .substringBefore('/') - .substringBefore('?') - .trim() - .lowercase() - if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null + ): NappletResourceResult { + if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.") + val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase() + if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.") val servers = account.blossomServers .getBlossomServersList() ?.servers() .orEmpty() + var sawHashMismatch = false for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { - val bytes = - runCatching { - client - .newCall( - Request - .Builder() - .url(candidate) - .get() - .build(), - ).execute() - .use { r -> - if (r.isSuccessful) r.body.bytes() else null - } - }.getOrNull() ?: continue - if (StaticSiteResolver.verify(bytes, hash)) { - return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream") + when (val fetched = fetchHttps(candidate, client)) { + is NappletResourceResult.Success -> { + if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) { + sawHashMismatch = true + continue + } + return fetched + } + 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:[][;base64],` URL into bytes + content type. */ - private fun decodeDataUrl(url: String): NappletResource? { + private fun decodeDataUrl(url: String): NappletResourceResult { 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 data = url.substring(comma + 1) + if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE) 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 = 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 { - URLDecoder.decode(data, "UTF-8").encodeToByteArray() + runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull() + ?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.") } - return NappletResource(bytes, contentType) + 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(" + 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() + } } 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 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt index 66bce77ba8..201c818aa3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability internal fun NappletCapability.symbol(): MaterialSymbol = when (this) { - NappletCapability.SHELL -> MaterialSymbols.Tune NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle NappletCapability.KEYS -> MaterialSymbols.Key NappletCapability.RELAY -> MaterialSymbols.Public diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 9fc041b421..211ea1a7be 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -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.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull @@ -73,11 +74,6 @@ class NappletProtocolJsonTest { 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 fun decodesPublishFromAnUnsignedTemplateInTheEventField() { // @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.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.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 fun decodesValueResourceUpload() { 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.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. val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk=","mimeType":"text/plain","filename":"a.txt"}}""") as NappletRequest.UploadBlob assertEquals("text/plain", up.contentType) @@ -184,6 +189,7 @@ class NappletProtocolJsonTest { assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) // 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":"identity.futureMethod","id":"1"}""")) assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}""")) } @@ -211,7 +217,9 @@ class NappletProtocolJsonTest { assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) assertEquals( "a".repeat(64), - ev["event"] + ev["result"] + ?.jsonObject + ?.get("event") ?.jsonObject ?.get("id") ?.jsonPrimitive @@ -233,13 +241,6 @@ class NappletProtocolJsonTest { 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 fun encodesPublishedEventAndEvents() { // 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 assertEquals(1, events["events"]?.jsonArray?.size) + assertEquals( + "a".repeat(64), + events["events"] + ?.jsonArray + ?.first() + ?.jsonObject + ?.get("event") + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content, + ) } @Test @@ -269,6 +282,71 @@ class NappletProtocolJsonTest { 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 fun encodesBytesAsBase64WithMime() { val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt new file mode 100644 index 0000000000..d2de5606d6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt @@ -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> = 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" + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt index fe65a51e6c..82f99a1a98 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt @@ -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.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray @@ -114,17 +115,31 @@ class NappletSdkConformanceTest { // RelayQueryResultMessage: { type:'relay.query.result', id, events, error? } val o = result("relay.query", NappletResponse.Events(listOf(sampleEvent()))) assertEquals(1, o["events"]?.jsonArray?.size) + assertEquals( + "a".repeat(64), + o["events"] + ?.jsonArray + ?.first() + ?.jsonObject + ?.get("event") + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content, + ) } @Test 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 assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content) assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) assertEquals( "a".repeat(64), - ev["event"] + ev["result"] + ?.jsonObject + ?.get("event") ?.jsonObject ?.get("id") ?.jsonPrimitive @@ -175,7 +190,11 @@ class NappletSdkConformanceTest { 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.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 assertTrue(result("storage.get", NappletResponse.StorageValue("v")).containsKey("value")) @@ -186,7 +205,12 @@ class NappletSdkConformanceTest { @Test 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.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. val o = result("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")) assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content) @@ -203,29 +227,6 @@ class NappletSdkConformanceTest { 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) ---------- @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt new file mode 100644 index 0000000000..5f8643317d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt @@ -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")) + } +} diff --git a/commons/src/commonMain/composeResources/files/napplet/shell.html b/commons/src/commonMain/composeResources/files/napplet/shell.html index 78e42c56c3..e7658bad3e 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shell.html +++ b/commons/src/commonMain/composeResources/files/napplet/shell.html @@ -1,13 +1,10 @@ @@ -19,7 +16,7 @@ - + diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 89e256a2f4..6ccd099cac 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -79,7 +79,7 @@ // Subscription pushes are keyed by subId, not a request id. if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') { 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 { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); } return; @@ -91,7 +91,7 @@ // 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.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); 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); }); } 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= 0) identityHandlers.splice(i, 1); - if (identityHandlers.length === 0) post('identity.unwatch'); } }; } }, @@ -152,6 +164,7 @@ var subId = 's' + (seq++); subs[subId] = { onEvent: onEvent, onEose: onEose }; var env = normFilters(filters); env.subId = subId; + if (options && options.relay) env.relay = options.relay; post('relay.subscribe', env); 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'); }, 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(){}); }, - 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: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); } }, resource: { - // The shell rebuilds the Blob from the host's base64 before this resolves. - bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); }, - bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); } + info: function(){ return field(call('resource.info'), 'info'); }, + // The shell rebuilds Blobs from the host's base64 before these resolve. + 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: { // 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. 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); // ---- IME agent (in-app browser only) ------------------------------------------------------- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt new file mode 100644 index 0000000000..8cc23cdada --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt @@ -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, + 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}$") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 48800d865e..0a9a923848 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -122,12 +122,6 @@ class NappletBroker( ): NappletResponse { 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) { 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 // access — declared is enough; it never prompts. request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true - // Cosmetic/negotiation capabilities (theme) never prompt. + // Cosmetic capabilities (theme) never prompt. !capability.requiresConsent -> true // A standing allow short-circuits, except for per-use capabilities (e.g. payments). ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true @@ -219,9 +213,6 @@ class NappletBroker( request: NappletRequest, ): NappletResponse = 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.ThemeGet -> { @@ -267,24 +258,24 @@ class NappletBroker( is NappletRequest.StorageGet -> { 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 -> { 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 } is NappletRequest.StorageRemove -> { val store = storage ?: return NappletResponse.Unsupported("storage.removeItem") - store.remove(identity.coordinate, request.key) + store.remove(storageCoordinate(identity, request.scope), request.key) NappletResponse.Done } is NappletRequest.StorageKeys -> { 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 -> { @@ -316,8 +307,38 @@ class NappletBroker( is NappletRequest.ResourceBytes -> { val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes") - val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.") - NappletResponse.Bytes(fetched.bytes, fetched.contentType) + when (val fetched = gateway.fetch(request.url, identity.coordinate)) { + 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 -> { @@ -353,6 +374,15 @@ class NappletBroker( 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. * 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 { /** * 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. */ 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 } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index e229884068..27b84f70de 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -156,6 +156,17 @@ class NappletResource( 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] * (`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 * 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 { suspend fun fetch( url: String, coordinate: String, - ): NappletResource? + ): NappletResourceResult } /** A completed upload: where the blob lives plus NIP-94-ish metadata. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt index a781dc7bc1..33ee94cf7c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt @@ -25,14 +25,11 @@ package com.vitorpamplona.amethyst.commons.napplet * (`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. * - * The mapping is **default-deny**: an unrecognized NAP domain maps to `null` and the shell must - * surface it as unknown rather than silently granting it. Domains we don't yet broker - * (`intent`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore resolve to `null` for now. + * The mapping is **default-deny**: an unrecognized or only partially implemented NAP domain maps + * to `null`. Keeping a broker implementation below does not advertise conformance; only domains + * whose current NAP contract is implemented are injected into `window.napplet`. */ enum class NappletCapability { - /** `shell` — capability negotiation (`shell.supports`). Always available; needs no consent. */ - SHELL, - /** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */ IDENTITY, @@ -70,13 +67,13 @@ enum class NappletCapability { ; /** - * Whether using this capability requires user consent. Negotiation ([SHELL]) and the cosmetic, - * read-only theme read ([THEME]) never prompt; everything else does (subject to the broker's + * Whether using this capability requires user consent. The cosmetic, read-only theme read + * ([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 * declaration alone, so it never reaches the consent path regardless of this flag. */ 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 @@ -96,25 +93,22 @@ enum class NappletCapability { companion object { /** - * Maps a bare NAP domain to the capability the broker enforces, case-insensitively. - * Returns `null` for any domain the shell does not recognize — callers MUST treat that as - * "unknown, do not grant". + * Maps a bare, currently supported NAP domain to the capability the broker enforces. + * Returns `null` for unknown and partial/legacy domains — callers MUST treat that as + * "unavailable, do not inject or grant". NIP-5D domain names are exact lowercase strings. */ fun fromNapDomain(domain: String): NappletCapability? = - when (domain.trim().lowercase()) { - "shell" -> SHELL + when (domain) { "identity" -> IDENTITY - "keys" -> KEYS - "relay", "relays" -> RELAY + "relay" -> RELAY "storage" -> STORAGE - "value" -> VALUE "resource" -> RESOURCE - "upload" -> UPLOAD "theme" -> THEME - "notify" -> NOTIFY - "inc" -> INC else -> null } + + /** Exact NIP-5D domain names Amethyst currently exposes through its injection prelude. */ + val supportedNapDomains: Set = setOf("identity", "relay", "storage", "resource", "theme") } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt index a543bf505f..9d2a759ab6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt @@ -41,7 +41,15 @@ data class NappletIdentity( val authorPubKey: HexKey, val identifier: String, 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. */ 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()}" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt index 74f960c331..43d780edf3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt @@ -41,33 +41,19 @@ object NappletWebContract { const val SHELL_URL = "$ORIGIN/__shell__" /** - * The applet runs on its **own per-applet origin** — a unique subdomain of [HOST] — served at the - * origin root, NOT on the shell [ORIGIN]. Two reasons, both load-bearing: - * - * 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. + * Per-site origin retained for Amethyst's NIP-5A WEBSITE profile. NIP-5D napplets never navigate + * here: their verified, self-contained `/index.html` is assigned through `srcdoc` and therefore + * executes with an opaque origin in an `allow-scripts`-only sandbox. */ fun appOrigin(appId: String): String = "https://$appId.$HOST" /** 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")) - /** 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_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. */ 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 * 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'; " + - "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 - * per-applet origin and the shell origin is deliberately NOT granted. The key lever is - * `connect-src 'none'`: the applet gets **no** direct network — every fetch goes through the - * brokered, consent-gated `resource.bytes`. + * Conservative NIP-5D CSP injected as the first element of the verified napplet's `head` before + * the runtime prelude. A `srcdoc` napplet has an opaque origin, so self-hosted subresources are + * intentionally unavailable; a conforming napplet is one self-contained `/index.html`. */ const val APP_CSP: String = - "default-src 'self'; " + - "script-src 'self' 'unsafe-inline'; " + - "style-src 'self' 'unsafe-inline'; " + - "img-src 'self' data: blob:; " + - "font-src 'self' data:; " + - "media-src 'self' blob: data:; " + - "connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'" + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + + "img-src data: blob:; font-src data:; connect-src 'none'; worker-src 'none'; " + + "child-src 'none'; frame-src 'none'; media-src 'none'; object-src 'none'; " + + "manifest-src 'none'; base-uri 'none'; 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, + locked: Boolean, + injectNip07: Boolean = false, + imeProxy: Boolean = false, + ): ByteArray { + val text = html.decodeToString() + val policy = + if (locked) { + "" + } else { + "" + } + val style = "" + val flags = + "" + 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" + val headIdx = text.indexOf("= 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("= 0) text.indexOf('>', htmlIdx) else -1 + if (htmlClose >= 0) { + text.substring(0, htmlClose + 1) + "$prelude" + text.substring(htmlClose + 1) + } else { + "$prelude$text" + } + } + } + return injected.encodeToByteArray() + } const val SHELL_HTML_PATH = "files/napplet/shell.html" 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. */ @OptIn(ExperimentalResourceApi::class) suspend fun shimJs(): ByteArray = Res.readBytes(SHIM_JS_PATH) + + private val NAP_DOMAIN = Regex("^[a-z][a-z0-9-]*$") } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt index d85925650f..25a58bca43 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt @@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.quartz.nip01Core.core.HexKey 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 * 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 } - /** `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`, * `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`). */ data class StorageGet( val key: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } @@ -206,6 +204,7 @@ sealed interface NappletRequest { data class StorageSet( val key: String, val value: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { 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`). */ data class StorageRemove( val key: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } /** 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 } @@ -282,6 +284,18 @@ sealed interface NappletRequest { 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, + ) : NappletRequest { + override val capability get() = NappletCapability.RESOURCE + } + /** Upload a blob to the user's Blossom server (`upload.upload`). */ data class UploadBlob( val bytes: ByteArray, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt index 63ad63afff..07f0394b0c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt @@ -51,11 +51,6 @@ sealed interface NappletResponse { val events: List, ) : 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"`). */ data class ActionRegistered( val actionId: String, @@ -97,6 +92,28 @@ sealed interface NappletResponse { override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode() } + data class ResourceInfo( + val schemes: List, + 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, + ) : 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. */ data class Uploaded( val url: String, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt new file mode 100644 index 0000000000..a079eeab6d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt @@ -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)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 0b5bcca09f..fc59bb7f87 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -182,9 +182,18 @@ class NappletBrokerTest { relay: NappletRelayGateway? = null, storage: NappletStorage? = null, wallet: NappletWalletGateway? = null, + resource: NappletResourceGateway? = null, signer: NostrSigner = this.signer, 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 fun getPublicKeyReturnsTheUsersKeyWhenAllowed() = @@ -545,8 +554,8 @@ class NappletBrokerTest { assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageSet("k", "v"), allDeclared)) assertEquals(NappletResponse.StorageValue("v"), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared)) - // Stored under the applet coordinate, never a shared namespace. - assertEquals("v", storage.data["${applet.coordinate}::k"]) + // Shared storage is scoped to the verified artifact identity, not just the d-tag. + assertEquals("v", storage.data["${applet.storageCoordinate}::k"]) assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageRemove("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(other, NappletRequest.StorageSet("c", "3"), allDeclared) - val response = broker.handle(applet, NappletRequest.StorageKeys, allDeclared) + val response = broker.handle(applet, NappletRequest.StorageKeys(), allDeclared) assertIs(response) assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c" } @@ -643,19 +652,6 @@ class NappletBrokerTest { assertIs(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 fun identityReadReturnsGatewayJsonOrUnsupported() = runTest { @@ -693,4 +689,60 @@ class NappletBrokerTest { assertIs(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared)) assertIs(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(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(response) + assertEquals(listOf("https://x/ok", "https://x/missing"), response.items.map { it.url }) + assertIs(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(empty) + assertEquals("invalid-request", empty.error) + assertIs(tooLarge) + assertEquals("too-large", tooLarge.error) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt index 4894ab6951..52cf819efd 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt @@ -28,18 +28,12 @@ import kotlin.test.assertTrue class NappletCapabilityTest { @Test - fun mapsKnownDomainsCaseInsensitively() { - assertEquals(NappletCapability.SHELL, NappletCapability.fromNapDomain("shell")) + fun mapsOnlyConformingDomainsExactly() { assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain("identity")) - assertEquals(NappletCapability.KEYS, NappletCapability.fromNapDomain("keys")) - assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("Relay")) - assertEquals(NappletCapability.VALUE, NappletCapability.fromNapDomain("value")) - assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain(" STORAGE ")) + assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("relay")) + assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain("storage")) assertEquals(NappletCapability.RESOURCE, NappletCapability.fromNapDomain("resource")) - assertEquals(NappletCapability.UPLOAD, NappletCapability.fromNapDomain("upload")) assertEquals(NappletCapability.THEME, NappletCapability.fromNapDomain("theme")) - assertEquals(NappletCapability.NOTIFY, NappletCapability.fromNapDomain("notify")) - assertEquals(NappletCapability.INC, NappletCapability.fromNapDomain("inc")) } @Test @@ -48,6 +42,14 @@ class NappletCapabilityTest { assertNull(NappletCapability.fromNapDomain("intent")) assertNull(NappletCapability.fromNapDomain("cvm")) 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("")) } @@ -56,10 +58,10 @@ class NappletCapabilityTest { val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "intent", "value")) assertEquals( - setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.VALUE), + setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), resolved.capabilities, ) - assertEquals(listOf(UnknownNapDomain("intent")), resolved.unknown) + assertEquals(listOf(UnknownNapDomain("intent"), UnknownNapDomain("value")), resolved.unknown) assertTrue(resolved.hasUnknown) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt new file mode 100644 index 0000000000..656ebcc1a6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt @@ -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 = "" + 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("") + "".length + val csp = injected.indexOf(", @@ -89,7 +83,7 @@ object NappletRequestRouter { declared: Set, payload: String, ): 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. when (requestType) { @@ -97,25 +91,9 @@ object NappletRequestRouter { val subId = runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() return if (subId != null) Outcome.CloseSubscription(subId) else Outcome.Ignore } - "resource.cancel" -> - 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 + "resource.cancel" -> return Outcome.Ignore // 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" -> { val topic = runCatching { NappletProtocolJson.readTopic(payload) }.getOrNull() return if (topic != null && NappletCapability.INC in declared) Outcome.SubscribeInc(topic) else Outcome.Ignore @@ -136,7 +114,7 @@ object NappletRequestRouter { val request = 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) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 4a1c52243b..1df933a9e7 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int @@ -34,6 +35,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject 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. */ 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( subId: String, event: Event, @@ -70,7 +72,9 @@ object NappletProtocolJson { buildJsonObject { put("type", "relay.event") put("subId", subId) - put("event", json.parseToJsonElement(event.toJson())) + putJsonObject("result") { + put("event", json.parseToJsonElement(event.toJson())) + } }.toString() /** A `relay.eose` push: signals end-of-stored-events for the subscription [subId]. */ @@ -130,29 +134,10 @@ object NappletProtocolJson { put("reason", reason) }.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, - services: List, - ): 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. */ fun decodeRequest(envelopeJson: String): NappletRequest? { val o = json.parseToJsonElement(envelopeJson).jsonObject return when (o.str("type")) { - "shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol")) "theme.get" -> NappletRequest.ThemeGet "identity.getPublicKey" -> NappletRequest.GetPublicKey "relay.publish" -> { @@ -181,10 +166,10 @@ object NappletProtocolJson { createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000), ) } - "storage.get" -> NappletRequest.StorageGet(o.req("key")) - "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value")) - "storage.remove" -> NappletRequest.StorageRemove(o.req("key")) - "storage.keys" -> NappletRequest.StorageKeys + "storage.get" -> NappletRequest.StorageGet(o.req("key"), o.storageScope()) + "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"), o.storageScope()) + "storage.remove" -> NappletRequest.StorageRemove(o.req("key"), o.storageScope()) + "storage.keys" -> NappletRequest.StorageKeys(o.storageScope()) "notify.create" -> NappletRequest.NotifyCreate(o.str("title") ?: "", o.str("body") ?: "") "notify.list" -> NappletRequest.NotifyList "notify.dismiss" -> NappletRequest.NotifyDismiss(o.str("notificationId") ?: o.str("id") ?: "") @@ -194,7 +179,9 @@ object NappletProtocolJson { } "keys.unregisterAction" -> NappletRequest.UnregisterAction(o.req("actionId")) "value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice")) + "resource.info" -> NappletRequest.ResourceInfo "resource.bytes" -> NappletRequest.ResourceBytes(o.req("url")) + "resource.bytesMany" -> NappletRequest.ResourceBytesMany(o.getValue("urls").jsonArray.map { it.jsonPrimitive.content }) "upload.upload" -> { // UploadUploadMessage: { type, id, request: { data, mimeType?, filename?, ... } }. // 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 // a generic IdentityRead; the broker/gateway decides which are implemented. 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")) } else { null @@ -230,6 +217,7 @@ object NappletProtocolJson { when (response) { is NappletResponse.NotifyCreated -> "notify.created" is NappletResponse.NotifyListed -> "notify.listed" + is NappletResponse.ResourceFailure -> "$requestType.error" else -> "$requestType.result" } put("type", responseType) @@ -247,11 +235,11 @@ object NappletProtocolJson { } is NappletResponse.Events -> { put("ok", true) - put("events", buildJsonArray { response.events.forEach { add(json.parseToJsonElement(it.toJson())) } }) - } - is NappletResponse.Supported -> { - put("ok", true) - put("supported", response.supported) + putJsonArray("events") { + response.events.forEach { event -> + addJsonObject { put("event", json.parseToJsonElement(event.toJson())) } + } + } } is NappletResponse.ActionRegistered -> { put("ok", true) @@ -279,6 +267,42 @@ object NappletProtocolJson { put("bytes", Base64.getEncoder().encodeToString(response.bytes)) 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 -> { // UploadResult: { ok, uploadId, status, url?, sha256?, size?, mimeType?, ... }. put("ok", true) @@ -397,6 +421,8 @@ object NappletProtocolJson { 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. */ private fun identityResultField(requestType: String): String = when (requestType) { @@ -408,4 +434,16 @@ object NappletProtocolJson { "identity.getBadges" -> "badges" else -> "result" } + + private val IDENTITY_READ_TYPES = + setOf( + "identity.getRelays", + "identity.getProfile", + "identity.getFollows", + "identity.getList", + "identity.getZaps", + "identity.getMutes", + "identity.getBlocked", + "identity.getBadges", + ) } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt index 94e3e4c763..eca9000437 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt @@ -85,11 +85,12 @@ class NappletRequestRouterTest { } @Test - fun resourceCancelRepliesDone() = + fun resourceCancelIsSilentlyHandled() = runTest { - val outcome = route("""{"type":"resource.cancel"}""") - assertIs(outcome) - assertTrue(outcome.payload.contains("resource.cancel.result")) + assertEquals( + NappletRequestRouter.Outcome.Ignore, + route("""{"type":"resource.cancel"}"""), + ) } @Test @@ -117,11 +118,11 @@ class NappletRequestRouterTest { } @Test - fun malformedRequestRepliesFailed() = + fun unknownAndMalformedRequestsAreSilentlyIgnored() = runTest { - val outcome = route("""{"type":"totally.unknown"}""") - assertIs(outcome) - assertTrue(outcome.payload.contains("failed")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"totally.unknown"}""")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("not json")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}""")) } @Test @@ -153,29 +154,9 @@ class NappletRequestRouterTest { } @Test - fun identityWatchWhenDeclaredBecomesWatchIdentity() = + fun removedIdentityWatchMessagesAreIgnored() = runTest { - assertEquals( - NappletRequestRouter.Outcome.WatchIdentity, - 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"}"""), - ) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.watch"}""")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.unwatch"}""")) } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt index 7f32a39474..b7186df770 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.napplethost +import android.util.Base64 import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse 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 // network (no app CSP; off-origin requests defer to the WebView), unlike a locked 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 = emptyList(), // 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 // host has a native keyboard and leaves this false. @@ -130,15 +134,44 @@ class NappletContentServer( } private fun serveShell(): WebResourceResponse { - // The shell HTML carries an APP_ORIGIN_PLACEHOLDER for the iframe src; bind it to this applet's - // origin so the shell frames exactly this applet (and the CSP frame-src is pinned to it too). - val html = shellHtmlBytes.decodeToString().replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin).encodeToByteArray() + val sandbox: String + val frameSource: String + 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;jwindow.__nappletNip07=true;" else "" - // Embedded surface: turn on the IME proxy agent (set before the shim runs). - val imeFlag = if (imeProxy) "" else "" - val script = "$style$nip07Flag$imeFlag" - val headIdx = text.indexOf("= 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() - } + /** Inserts host policy and the explicit-domain `window.napplet` prelude before authored code. */ + private fun injectShim(html: ByteArray): ByteArray = + NappletWebContract.injectPrelude( + html = html, + shimJs = shimJs, + declaredDomains = declaredDomains, + locked = profile == HostProfile.NAPPLET, + injectNip07 = profile.injectsNip07, + imeProxy = imeProxy, + ) private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index d061653f72..74c3c6aaac 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -67,7 +67,6 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson -import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities import com.vitorpamplona.amethyst.napplethost.R import com.vitorpamplona.quartz.nip01Core.core.toHexKey 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 // web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port. 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 // 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) val requires = intent.getStringArrayListExtra(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() - val resolved = resolveRequiredCapabilities(requires) - // shell is always available; the rest are the declared domains advertised to the applet in the - // handshake. (The broker enforces the authoritative set from the launch token, not this list.) - declaredDomains = (listOf("shell") + resolved.capabilities.map { it.name.lowercase() }).distinct() + val resolved = profile.declaredCapabilities(requires) + // Domain-object presence is the NIP-5D availability signal. The broker still enforces the + // authoritative set minted into the launch token in the main process. + declaredDomains = resolved.map { it.name.lowercase() }.distinct() 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. 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 // actionId, so the binding is removed here from the envelope itself). if (envelope.optString("type") == "keys.unregisterAction") { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index ceaf53b6e2..37dd512352 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -54,8 +54,6 @@ import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature 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.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -201,7 +199,8 @@ class NappletHostService : Service() { if (author.isEmpty() || launchToken.isEmpty()) return null 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 = NappletTab( @@ -212,7 +211,7 @@ class NappletHostService : Service() { author = author, identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(), launchToken = launchToken, - profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)), + profile = profile, useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true), proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1), bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE), @@ -300,7 +299,19 @@ class NappletHostService : Service() { NappletWebViewProfile.apply(context, wv, tab.webViewProfile) val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier)) 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) // 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 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. if (envelope.optString("type").startsWith("ime.")) { val reply =