Align napplet host with current NIP-5D and NAPs

This commit is contained in:
sandwich
2026-08-04 19:59:22 +01:00
parent d3bd7a45b9
commit 4f6d6acefd
37 changed files with 1537 additions and 473 deletions
@@ -165,27 +165,17 @@ object FavoriteAppLauncher {
return when (event) {
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(
@@ -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
}
}
@@ -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<String, Job>()
// 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
}
@@ -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
@@ -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 -> ""
}
}
@@ -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<String>,
) {
private var job: Job? = null
private val jobs = mutableMapOf<String, Job>()
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()
}
}
@@ -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
}
@@ -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<String>,
// 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<String>,
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<PathTag>,
servers: List<String>,
authorPubKey: HexKey,
identifier: String,
aggregateHash: HexKey?,
title: String,
requires: List<String>,
profile: HostProfile,
): Bundle {
val effectiveAggregateHash =
if (profile == HostProfile.NAPPLET) {
requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) {
"NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate."
}
} else {
aggregateHash
}
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
// 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"
}
@@ -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<String, LiveSub>()
private val liveSeq = AtomicInteger(0)
@@ -53,6 +59,20 @@ class NappletLiveSubscriptions {
val client: INostrClient,
) {
val eoseSent = AtomicBoolean(false)
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
var deliveryJob: Job? = null
}
private sealed interface Delivery {
data class RelayEvent(
val event: Event,
) : Delivery
data object Eose : Delivery
data class Closed(
val reason: String,
) : Delivery
}
/**
@@ -77,6 +97,20 @@ class NappletLiveSubscriptions {
// can't collide with the subscription it's replacing.
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<Filter>?,
) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event))
) {
sub.deliveries.trySend(Delivery.RelayEvent(event))
}
// A subscription fans out to several relays; collapse their EOSEs into the single
// relay.eose the SDK expects (fired when the first relay finishes its stored events).
@@ -93,14 +129,16 @@ class NappletLiveSubscriptions {
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId))
if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) = 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()
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */
internal object NappletRelayCleartext {
suspend fun forDelivery(
event: Event,
signer: NostrSigner,
): Event? = forDelivery(event, signer.pubKey, signer::decrypt)
internal suspend fun forDelivery(
event: Event,
userPubKey: HexKey,
decrypt: suspend (String, HexKey) -> String,
): Event? {
if (!isEncrypted(event)) return event
val peer =
when {
event.pubKey == userPubKey -> event.recipientPubKey()
event.isAddressedTo(userPubKey) -> event.pubKey
else -> null
} ?: return null
val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null
// NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and
// signature fields so callers can still correlate it, while making clear that this object
// must never be republished as a signed event after its content projection has changed.
return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig)
}
internal fun isEncrypted(event: Event): Boolean =
event is PrivateDmEvent ||
EncryptedInfo.isNIP04(event.content) ||
isNip44V2(event.content)
private fun isNip44V2(content: String): Boolean =
content.length >= MIN_NIP44_V2_LENGTH &&
runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess
private fun Event.recipientPubKey(): HexKey? =
tags.firstNotNullOfOrNull { tag ->
tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" }
}
private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey }
private const val MIN_NIP44_V2_LENGTH = 132
}
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator
import com.vitorpamplona.amethyst.napplet.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) }
}
/**
@@ -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:[<mediatype>][;base64],<data>` 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("<svg")
}
private fun isJson(bytes: ByteArray): Boolean = runCatching { Json.parseToJsonElement(bytes.decodeToString()) }.isSuccess
private fun isPlainText(bytes: ByteArray): Boolean =
runCatching {
Charsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
}.isSuccess && bytes.none { it == 0.toByte() }
private fun success(resource: NappletResource): NappletResourceResult = NappletResourceResult.Success(resource)
private fun failure(
error: String,
message: String? = null,
): NappletResourceResult = NappletResourceResult.Failure(error, message)
private fun readBounded(input: java.io.InputStream): ByteArray? {
input.use { source ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(8 * 1024)
var total = 0
while (true) {
val read = source.read(buffer)
if (read < 0) break
total += read
if (total > MAX_RESOURCE_BYTES) return null
output.write(buffer, 0, read)
}
return output.toByteArray()
}
}
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)
}
@@ -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
@@ -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
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class NappletRelayCleartextTest {
@Test
fun plaintextPassesThroughWithoutDecrypting() =
runTest {
val event = event(content = "hello")
val result =
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
error("plaintext must not be decrypted")
}
assertSame(event, result)
}
@Test
fun inboundNip04IsProjectedAsCleartext() =
runTest {
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER)))
val result =
NappletRelayCleartext.forDelivery(event, USER) { ciphertext, peer ->
assertEquals(NIP04, ciphertext)
assertEquals(AUTHOR, peer)
"secret"
}
assertEquals("secret", result?.content)
assertEquals(event.id, result?.id)
assertEquals(event.sig, result?.sig)
}
@Test
fun outboundEncryptedEventUsesItsRecipientAsPeer() =
runTest {
val event = event(pubKey = USER, content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT)))
val result =
NappletRelayCleartext.forDelivery(event, USER) { _, peer ->
assertEquals(RECIPIENT, peer)
"sent secret"
}
assertEquals("sent secret", result?.content)
}
@Test
fun encryptedEventForAnotherUserIsWithheld() =
runTest {
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT)))
val result =
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
error("unrelated ciphertext must not be offered to the signer")
}
assertNull(result)
}
@Test
fun decryptionFailureWithholdsCiphertext() =
runTest {
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER)))
val result =
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
error("signer refused")
}
assertNull(result)
}
private fun event(
pubKey: String = AUTHOR,
content: String,
tags: Array<Array<String>> = emptyArray(),
) = Event("id", pubKey, 1L, 4, tags, content, "sig")
companion object {
private const val USER = "user"
private const val AUTHOR = "author"
private const val RECIPIENT = "recipient"
private const val NIP04 = "ciphertext-that-is-long-enough?iv=123456789012345678901234"
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.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
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet.gateways
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.net.InetAddress
class NappletResourceFetcherPolicyTest {
@Test
fun blocksPrivateSpecialAndLocalAddresses() {
val blocked =
listOf(
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"192.0.0.1",
"192.0.2.1",
"192.168.1.1",
"198.18.0.1",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"::1",
"2001:db8::1",
"fc00::1",
"fe80::1",
)
blocked.forEach {
assertFalse(it, NappletResourceFetcher.isPublicAddress(InetAddress.getByName(it)))
}
}
@Test
fun permitsPublicAddresses() {
assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("1.1.1.1")))
assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("2606:4700:4700::1111")))
}
@Test
fun acceptsOnlyCredentialFreeHttpsUrls() {
assertTrue(NappletResourceFetcher.isSafeHttpsResourceUrl("https://example.com/a"))
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("http://example.com/a"))
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("https://user:secret@example.com/a"))
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("file:///etc/passwd"))
}
}
@@ -1,13 +1,10 @@
<!doctype html>
<!--
Trusted napplet shell page (served on the shell origin https://napplet.local at /__shell__).
It hosts the untrusted applet in a sandboxed iframe pointed at the applet's OWN per-applet origin
(a distinct napplet.local subdomain, injected as __APP_ORIGIN__) and relays capability messages
between the applet (window.postMessage) and the native bridge (__nappletBridge), which is
origin-restricted to this shell page only. The applet is cross-origin to the shell, so it can never
reach the bridge or read the shell DOM. The iframe carries allow-same-origin so the applet has real,
isolated storage on its own origin — which is safe precisely because that origin is never the
shell's: the applet is same-origin only with itself.
It hosts untrusted content and relays capability messages between the child (window.postMessage)
and the origin-restricted native bridge (__nappletBridge). For NIP-5D the child receives verified,
prelude-injected bytes through srcdoc and has only sandbox="allow-scripts" (opaque origin). NIP-5A
websites retain Amethyst's separate-origin website posture and are navigated normally.
-->
<html>
<head>
@@ -19,7 +16,7 @@
</style>
</head>
<body>
<iframe id="app" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>
<iframe id="app" sandbox="__APP_SANDBOX__" referrerpolicy="no-referrer"></iframe>
<script>
(function () {
var iframe = document.getElementById('app');
@@ -70,11 +67,22 @@
delete msg.bytes;
} catch (_) {}
}
if (msg && msg.type === 'resource.bytesMany.result' && Array.isArray(msg.items)) {
msg.items.forEach(function (item) {
if (!item || !item.ok || typeof item.bytes !== 'string') return;
try {
var bin = atob(item.bytes), u = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
item.blob = new Blob([u], { type: item.mime || '' });
delete item.bytes;
} catch (_) {}
});
}
iframe.contentWindow.postMessage(msg, '*');
};
}
iframe.src = '__APP_ORIGIN__/';
__APP_BOOTSTRAP__
})();
</script>
</body>
@@ -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<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
var napplet = {
shell: {
// supports() is synchronous in @napplet/shim; we expose a sync proxy backed by an async check.
supports: function(domain, protocol){ return field(call('shell.supports', { domain: domain, protocol: protocol }), 'supported'); },
ready: function(){ return Promise.resolve({}); },
onReady: function(cb){ if (typeof cb === 'function') cb({}); return { close: function(){} }; },
services: []
},
// Build the methods Amethyst actually implements, then project only the explicit domains the
// trusted host authorized for this launch. Domain-object presence is the NIP-5D availability
// signal; there is deliberately no legacy window.napplet.shell capability probe.
var available = {
identity: {
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
getProfile: function(){ return field(call('identity.getProfile'), 'profile'); },
@@ -121,15 +136,12 @@
getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'entries'); },
getZaps: function(){ return field(call('identity.getZaps'), 'zaps'); },
getBadges: function(){ return field(call('identity.getBadges'), 'badges'); },
// onChanged: the shell pushes identity.changed when the active user's key changes. The first
// handler opens the watch (identity.watch); closing the last one stops it (identity.unwatch).
// The runtime owns identity.changed delivery; handlers are entirely local to the shim.
onChanged: function(handler){
if (typeof handler !== 'function') return { close: function(){} };
identityHandlers.push(handler);
if (identityHandlers.length === 1) post('identity.watch');
return { close: function(){
var i = identityHandlers.indexOf(handler); if (i >= 0) identityHandlers.splice(i, 1);
if (identityHandlers.length === 0) post('identity.unwatch');
} };
}
},
@@ -152,6 +164,7 @@
var subId = 's' + (seq++);
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) -------------------------------------------------------
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
/** Pure NIP-5D artifact checks shared by every launch surface. */
object NappletArtifactPolicy {
/** Returns the runtime-computed artifact identity, or null when the manifest must not execute. */
fun verifiedAggregateHash(
paths: List<PathTag>,
declaredAggregateHash: HexKey?,
): HexKey? {
val entry = paths.singleOrNull() ?: return null
if (entry.path != "/index.html" || !SHA256.matches(entry.hash)) return null
val computed = SiteAggregateHash.compute(paths)
if (declaredAggregateHash != null && !declaredAggregateHash.equals(computed, ignoreCase = true)) return null
return computed
}
private val SHA256 = Regex("^[0-9a-fA-F]{64}$")
}
@@ -122,12 +122,6 @@ class NappletBroker(
): NappletResponse {
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
}
}
@@ -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. */
@@ -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<String> = setOf("identity", "relay", "storage", "resource", "theme")
}
}
@@ -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()}"
}
@@ -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<String>,
locked: Boolean,
injectNip07: Boolean = false,
imeProxy: Boolean = false,
): ByteArray {
val text = html.decodeToString()
val policy =
if (locked) {
"<meta http-equiv=\"Content-Security-Policy\" content=\"$APP_CSP\">"
} else {
""
}
val style = "<style>html,body{overscroll-behavior:none !important}</style>"
val flags =
"<script>window.__nappletNip07=$injectNip07;" +
(if (imeProxy) "window.__nappletImeProxy=true;" else "") +
"</script>"
val safeDomains =
declaredDomains
.filter { it.matches(NAP_DOMAIN) && it in NappletCapability.supportedNapDomains }
.distinct()
val domainsJson = safeDomains.joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
val prelude = "$policy$style$flags<script>window.__nappletDomains=$domainsJson;$shimJs</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
headIdx >= 0 -> {
val close = text.indexOf('>', headIdx)
if (close >= 0) text.substring(0, close + 1) + prelude + text.substring(close + 1) else prelude + text
}
else -> {
val htmlIdx = text.indexOf("<html", ignoreCase = true)
val htmlClose = if (htmlIdx >= 0) text.indexOf('>', htmlIdx) else -1
if (htmlClose >= 0) {
text.substring(0, htmlClose + 1) + "<head>$prelude</head>" + text.substring(htmlClose + 1)
} else {
"<head>$prelude</head>$text"
}
}
}
return injected.encodeToByteArray()
}
const val SHELL_HTML_PATH = "files/napplet/shell.html"
const val 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-]*$")
}
@@ -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<String>,
) : NappletRequest {
override val capability get() = NappletCapability.RESOURCE
}
/** Upload a blob to the user's Blossom server (`upload.upload`). */
data class UploadBlob(
val bytes: ByteArray,
@@ -51,11 +51,6 @@ sealed interface NappletResponse {
val events: List<Event>,
) : 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<String>,
val maxBytes: Long,
val maxUrls: Int,
) : NappletResponse
data class ResourceItem(
val url: String,
val resource: Bytes? = null,
val error: String? = null,
val message: String? = null,
)
data class ResourceItems(
val items: List<ResourceItem>,
) : NappletResponse
data class ResourceFailure(
val error: String,
val message: String? = null,
) : NappletResponse
/** Result of an `upload.upload`; [url] is where the blob can be fetched, plus NIP-94-ish metadata. */
data class Uploaded(
val url: String,
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class NappletArtifactPolicyTest {
private val htmlHash = "11".repeat(32)
private val index = PathTag("/index.html", htmlHash)
@Test
fun computesIdentityFromTheSignedSingleIndexPath() {
assertEquals(
SiteAggregateHash.compute(listOf(index)),
NappletArtifactPolicy.verifiedAggregateHash(listOf(index), null),
)
}
@Test
fun acceptsMatchingDeclaredIdentityCaseInsensitively() {
val computed = SiteAggregateHash.compute(listOf(index))
assertEquals(computed, NappletArtifactPolicy.verifiedAggregateHash(listOf(index), computed.uppercase()))
}
@Test
fun rejectsDriftedOrNonSelfContainedArtifacts() {
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index), "22".repeat(32)))
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("index.html", htmlHash)), null))
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index, PathTag("/app.js", "22".repeat(32))), null))
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("/index.html", "not-a-sha256")), null))
}
}
@@ -182,9 +182,18 @@ class NappletBrokerTest {
relay: NappletRelayGateway? = null,
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<NappletResponse.Strings>(response)
assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c"
}
@@ -643,19 +652,6 @@ class NappletBrokerTest {
assertIs<NappletResponse.Denied>(response)
}
@Test
fun shellSupportsReflectsDeclaredCapabilitiesWithoutConsent() =
runTest {
// The DENY prompt would block anything that reached consent; supports must not.
val broker = broker(ScriptedPrompt(GrantState.DENY))
val declared = setOf(NappletCapability.RELAY)
assertEquals(NappletResponse.Supported(true), broker.handle(applet, NappletRequest.ShellSupports("relay"), declared))
assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("storage"), declared))
// Unknown/unbrokered domain.
assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("cvm"), declared))
}
@Test
fun identityReadReturnsGatewayJsonOrUnsupported() =
runTest {
@@ -693,4 +689,60 @@ class NappletBrokerTest {
assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared))
assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.UploadBlob(ByteArray(0), "image/png"), allDeclared))
}
@Test
fun resourceInfoAndTypedFailuresFollowTheCurrentNapContract() =
runTest {
val resource =
NappletResourceGateway { _, _ ->
NappletResourceResult.Failure("blocked-by-policy", "private target")
}
val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
val info = broker.handle(applet, NappletRequest.ResourceInfo, allDeclared)
assertIs<NappletResponse.ResourceInfo>(info)
assertEquals(listOf("data", "https", "blossom", "nostr"), info.schemes)
assertEquals(10L * 1024L * 1024L, info.maxBytes)
assertEquals(
NappletResponse.ResourceFailure("blocked-by-policy", "private target"),
broker.handle(applet, NappletRequest.ResourceBytes("https://internal.example"), allDeclared),
)
}
@Test
fun resourceBytesManyPreservesOrderAndSiblingResults() =
runTest {
val resource =
NappletResourceGateway { url, _ ->
if (url.endsWith("ok")) {
NappletResourceResult.Success(NappletResource("ok".encodeToByteArray(), "text/plain"))
} else {
NappletResourceResult.Failure("not-found")
}
}
val response =
broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
.handle(applet, NappletRequest.ResourceBytesMany(listOf("https://x/ok", "https://x/missing")), allDeclared)
assertIs<NappletResponse.ResourceItems>(response)
assertEquals(listOf("https://x/ok", "https://x/missing"), response.items.map { it.url })
assertIs<NappletResponse.Bytes>(response.items[0].resource)
assertEquals("not-found", response.items[1].error)
}
@Test
fun resourceBytesManyRejectsInvalidBulkSizesWithNapErrorCodes() =
runTest {
val resource = NappletResourceGateway { _, _ -> error("invalid bulk must not fetch") }
val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
val empty = broker.handle(applet, NappletRequest.ResourceBytesMany(emptyList()), allDeclared)
val tooLarge = broker.handle(applet, NappletRequest.ResourceBytesMany(List(17) { "data:,x" }), allDeclared)
assertIs<NappletResponse.ResourceFailure>(empty)
assertEquals("invalid-request", empty.error)
assertIs<NappletResponse.ResourceFailure>(tooLarge)
assertEquals("too-large", tooLarge.error)
}
}
@@ -28,18 +28,12 @@ import kotlin.test.assertTrue
class NappletCapabilityTest {
@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)
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NappletWebContractTest {
@Test
fun lockedPreludeIsTheFirstHeadContentAndRunsBeforeAuthoredCode() {
val authored = "<html><head><script id=\"authored\">run()</script></head><body></body></html>"
val injected =
NappletWebContract
.injectPrelude(
html = authored.encodeToByteArray(),
shimJs = "window.__shimRan=true;",
declaredDomains = listOf("identity", "relay", "shell", "Relay", "bad\"domain", "relay"),
locked = true,
).decodeToString()
val head = injected.indexOf("<head>") + "<head>".length
val csp = injected.indexOf("<meta http-equiv=\"Content-Security-Policy\"")
val domains = injected.indexOf("window.__nappletDomains=[\"identity\", \"relay\"]")
val shim = injected.indexOf("window.__shimRan=true")
val authoredScript = injected.indexOf("id=\"authored\"")
assertTrue(head == csp, "the CSP meta must be the first element in head")
assertTrue(csp < domains && domains < shim && shim < authoredScript)
assertContains(injected, "window.__nappletNip07=false")
assertFalse(injected.contains("\"shell\""))
assertFalse(injected.contains("bad\"domain"))
}
@Test
fun lockedCspMatchesTheConservativeOpaqueOriginPosture() {
val csp = NappletWebContract.APP_CSP
assertContains(csp, "default-src 'none'")
assertContains(csp, "connect-src 'none'")
assertContains(csp, "frame-src 'none'")
assertContains(csp, "object-src 'none'")
assertContains(csp, "base-uri 'none'")
assertContains(csp, "form-action 'none'")
assertFalse(csp.contains("'self'"))
}
@Test
fun shellTemplateKeepsTheOpaqueIframeAndSourceChecks() =
runTest {
val shell = NappletWebContract.shellHtml().decodeToString()
assertContains(shell, "sandbox=\"${NappletWebContract.APP_SANDBOX_PLACEHOLDER}\"")
assertContains(shell, NappletWebContract.APP_BOOTSTRAP_PLACEHOLDER)
assertContains(shell, "e.source !== iframe.contentWindow")
assertContains(shell, "iframe.contentWindow.postMessage(msg, '*')")
assertFalse(shell.contains("allow-same-origin"))
}
}
@@ -55,12 +55,6 @@ object NappletRequestRouter {
val subId: String,
) : Outcome
/** Start streaming `identity.changed` pushes when the active user's key changes (fire-and-forget). */
data object WatchIdentity : Outcome
/** Stop the `identity.changed` stream (fire-and-forget; no reply). */
data object UnwatchIdentity : Outcome
/** Push these envelope(s) to the applet immediately, unkeyed (e.g. an EOSE closing a refused sub). */
data class Push(
val payloads: List<String>,
@@ -89,7 +83,7 @@ object NappletRequestRouter {
declared: Set<NappletCapability>,
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)
@@ -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<String>,
services: List<String>,
): String =
buildJsonObject {
put("type", "shell.init")
putJsonObject("capabilities") {
put("domains", buildJsonArray { domains.forEach { add(it) } })
putJsonObject("protocols") {}
}
put("services", buildJsonArray { services.forEach { add(it) } })
}.toString()
/** Parses a request envelope. Returns `null` for an unrecognized `type` so the broker can deny it. */
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",
)
}
@@ -85,11 +85,12 @@ class NappletRequestRouterTest {
}
@Test
fun resourceCancelRepliesDone() =
fun resourceCancelIsSilentlyHandled() =
runTest {
val outcome = route("""{"type":"resource.cancel"}""")
assertIs<NappletRequestRouter.Outcome.Reply>(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<NappletRequestRouter.Outcome.Reply>(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"}"""))
}
}
@@ -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<String> = 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;j<b.length;j++)u[j]=b.charCodeAt(j);" +
"iframe.srcdoc=new TextDecoder('utf-8').decode(u);"
} else {
// Amethyst's NIP-5A website posture is intentionally unchanged: a dedicated real origin,
// normal website storage/network, and NIP-07 behind the existing consent broker.
sandbox = "allow-scripts allow-same-origin"
frameSource = appOrigin
bootstrap = "iframe.src = '$appOrigin/';"
}
val html =
shellHtmlBytes
.decodeToString()
.replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin)
.replace(NappletWebContract.APP_SANDBOX_PLACEHOLDER, sandbox)
.replace(NappletWebContract.APP_BOOTSTRAP_PLACEHOLDER, bootstrap)
.encodeToByteArray()
return WebResourceResponse(
MIME_HTML,
"utf-8",
200,
"OK",
mapOf("Content-Security-Policy" to NappletWebContract.shellCsp(appOrigin)),
mapOf("Content-Security-Policy" to NappletWebContract.shellCsp(frameSource)),
ByteArrayInputStream(html),
)
}
@@ -183,31 +216,16 @@ class NappletContentServer(
)
}
/** Inserts the `window.napplet` client shim into the applet's HTML document. */
private fun injectShim(html: ByteArray): ByteArray {
val text = html.decodeToString()
// Disable the document's overscroll affordance (the bounce/stretch): the Android-level
// WebView.overScrollMode only governs the outer frame, so an applet whose own root background is
// transparent stretched on over-scroll and exposed the shell's background behind it (a stray
// white band at the bottom). `overscroll-behavior: none` removes the stretch at the source,
// theme-independently. Allowed by the app CSP's `style-src 'unsafe-inline'`.
val style = "<style>html,body{overscroll-behavior:none !important}</style>"
// In website mode, set the NIP-07 flag synchronously *before* the shim so window.nostr installs.
val nip07Flag = if (profile.injectsNip07) "<script>window.__nappletNip07=true;</script>" else ""
// Embedded surface: turn on the IME proxy agent (set before the shim runs).
val imeFlag = if (imeProxy) "<script>window.__nappletImeProxy=true;</script>" else ""
val script = "$style$nip07Flag$imeFlag<script>$shimJs</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
headIdx >= 0 -> {
val close = text.indexOf('>', headIdx)
if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text
}
else -> script + text
}
return injected.encodeToByteArray()
}
/** 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)))
@@ -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") {
@@ -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 =