Address NIP-5D review comments

This commit is contained in:
sandwich
2026-08-09 15:19:15 +01:00
parent 4f6d6acefd
commit 94b679fe7c
9 changed files with 149 additions and 22 deletions
@@ -320,7 +320,7 @@ class NappletBrokerService : Service() {
// 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) {
if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) {
identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) }
}
}
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It
@@ -41,21 +42,24 @@ class NappletIdentityWatch(
private val scope: CoroutineScope,
private val pubKey: (boundPubKey: String) -> Flow<String>,
) {
private val jobs = mutableMapOf<String, Job>()
private val jobs = ConcurrentHashMap<String, Job>()
fun start(
watchId: String,
boundPubKey: String,
push: (String) -> Unit,
) {
if (jobs.containsKey(watchId)) return
jobs[watchId] =
scope.launch {
pubKey(boundPubKey)
.distinctUntilChanged()
.drop(1)
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
}
jobs.computeIfAbsent(watchId) { id ->
scope
.launch {
pubKey(boundPubKey)
.distinctUntilChanged()
.drop(1)
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
}.also { job ->
job.invokeOnCompletion { jobs.remove(id, job) }
}
}
}
fun stopAll() {
@@ -24,7 +24,6 @@ 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
@@ -39,6 +38,7 @@ 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
import com.vitorpamplona.quartz.utils.Log
/**
* Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only
@@ -56,7 +56,7 @@ object NappletLauncher {
) {
val event = manifest as? Event
if (event?.verify() != true || event.pubKey != authorPubKey) {
Log.w(TAG, "Refusing NIP-5D manifest that failed signature/author verification")
Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" }
return
}
buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) }
@@ -80,7 +80,7 @@ object NappletLauncher {
profile: HostProfile,
) {
if (profile != HostProfile.WEBSITE) {
Log.w(TAG, "Refusing raw NIP-5D launch without a verified manifest")
Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" }
return
}
val params =
@@ -219,7 +219,7 @@ object NappletLauncher {
): 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")
Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" }
return null
}
return runCatching {
@@ -39,16 +39,21 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Call
import okhttp3.Callback
import okhttp3.CookieJar
import okhttp3.Dns
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InterruptedIOException
import java.net.InetAddress
import java.net.URLDecoder
@@ -127,7 +132,7 @@ class NappletResourceFetcher(
)
}.build()
private fun fetchHttps(
private suspend fun fetchHttps(
url: String,
client: OkHttpClient,
): NappletResourceResult {
@@ -141,7 +146,7 @@ class NappletResourceFetcher(
.url(current)
.get()
.build(),
).execute()
).await()
.use { response ->
if (response.isRedirect) {
if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
@@ -227,7 +232,7 @@ class NappletResourceFetcher(
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
* serves it.
*/
private fun fetchBlossom(
private suspend fun fetchBlossom(
url: String,
client: OkHttpClient,
): NappletResourceResult {
@@ -257,6 +262,32 @@ class NappletResourceFetcher(
return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.")
}
private suspend fun Call.await(): Response =
suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation { cancel() }
enqueue(
object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
if (continuation.isActive) continuation.resumeWith(Result.failure(e))
}
override fun onResponse(
call: Call,
response: Response,
) {
if (continuation.isActive) {
continuation.resumeWith(Result.success(response))
} else {
response.close()
}
}
},
)
}
/** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */
private fun decodeDataUrl(url: String): NappletResourceResult {
val comma = url.indexOf(',')
@@ -0,0 +1,68 @@
/*
* 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 android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertNull
import org.junit.Test
class NappletLauncherTest {
private val context = mockk<Context>(relaxed = true)
private val manifest = mockk<NappletManifest>()
private val author = "aa".repeat(32)
@Test
fun manifestLaunchRejectsNonEventManifest() {
NappletLauncher.launch(context, manifest, author, "demo")
verify(exactly = 0) { context.startActivity(any<Intent>()) }
}
@Test
fun embeddedBuildLaunchParamsRejectsNonEventManifest() {
assertNull(NappletLauncher.buildLaunchParams(context, manifest, author, "demo"))
}
@Test
fun rawLaunchRejectsNappletProfile() {
every { context.startActivity(any<Intent>()) } returns Unit
NappletLauncher.launch(
context = context,
paths = emptyList(),
servers = emptyList(),
authorPubKey = author,
identifier = "demo",
aggregateHash = null,
title = "Demo",
requires = emptyList(),
profile = HostProfile.NAPPLET,
)
verify(exactly = 0) { context.startActivity(any<Intent>()) }
}
}
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision
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.amethyst.commons.napplet.protocol.toSignerOp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -376,11 +377,11 @@ class NappletBroker(
private fun storageCoordinate(
identity: NappletIdentity,
scope: com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope,
scope: NappletStorageScope,
): String =
when (scope) {
com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.SHARED -> identity.storageCoordinate
com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate
NappletStorageScope.SHARED -> identity.storageCoordinate
NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate
}
/**
@@ -42,6 +42,7 @@ object NappletRequestRouter {
/** Send this `.result` payload back, correlated to the request's id. */
data class Reply(
val payload: String,
val response: NappletResponse? = null,
) : Outcome
/** Open a live relay subscription; the host streams `relay.event`/`relay.eose`/`relay.closed` by [subId]. */
@@ -114,7 +115,12 @@ object NappletRequestRouter {
val request =
runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull()
?: return Outcome.Ignore
?: return if (runCatching { NappletProtocolJson.readId(payload) }.getOrNull() != null) {
val failure = NappletResponse.Failed("Malformed or unsupported request.")
Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, failure), failure)
} else {
Outcome.Ignore
}
val response = broker.handle(identity, request, declared)
@@ -131,6 +137,6 @@ object NappletRequestRouter {
}
}
return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response))
return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response), response)
}
}
@@ -61,6 +61,9 @@ object NappletProtocolJson {
/** The `type` discriminant of a request envelope, used to build the matching `.result` type. */
fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type")
/** The request `id`, when the envelope expects a correlated reply. */
fun readId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("id")
/** 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")
@@ -125,6 +125,20 @@ class NappletRequestRouterTest {
assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}"""))
}
@Test
fun keyedUnknownAndMalformedRequestsReplyWithFailure() =
runTest {
val unknown = route("""{"type":"totally.unknown","id":"r1"}""")
assertIs<NappletRequestRouter.Outcome.Reply>(unknown)
assertTrue(unknown.payload.contains("totally.unknown.result"))
assertTrue(unknown.payload.contains("Malformed or unsupported request."))
val malformed = route("""{"type":"relay.publish","id":"r2"}""")
assertIs<NappletRequestRouter.Outcome.Reply>(malformed)
assertTrue(malformed.payload.contains("relay.publish.result"))
assertTrue(malformed.payload.contains("Malformed or unsupported request."))
}
@Test
fun queryRepliesWithItsResult() =
runTest {