From 0d2ac83ebb30f131266026e1068b6a5edf7e62bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:11:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(nsite):=20runtime=20hardening=20=E2=80=94?= =?UTF-8?q?=20blossom:=20fetch,=20sniffing,=20server=20fallback,=20blob=20?= =?UTF-8?q?cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining nsite/napplet runtime gaps, all keeping content integrity (every blob is sha256-verified) and the sandbox intact: - resource.bytes blossom: scheme — blossom: fetches from the user's kind:10063 Blossom servers and verifies the hash before returning. nostr: stays deferred (bytes semantics unspecified). - Content-type byte-sniffing in the resolver: when a manifest path has no/unknown extension, sniff magic bytes (png/jpeg/webp/gif/pdf/wasm/...). Text/markup is never sniffed so HTML detection stays extension-driven. Unit-tested in quartz. - kind:10063 server fallback: the launcher augments the manifest's servers with the author's published Blossom list (best-effort, when cached). - Blob caching: the host OkHttp client caches blobs on disk with a forced immutable policy (content-addressed). The resolver re-verifies every served blob's sha256, so a stale/poisoned cache entry can't be served. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../2026-06-20-napplet-ecosystem-audit.md | 24 +++++++ .../amethyst/napplet/NappletBrokerService.kt | 71 ++++++++++++++++--- .../amethyst/napplet/NappletHostActivity.kt | 37 +++++++--- .../amethyst/napplet/NappletLauncher.kt | 14 +++- .../resolver/StaticSitePathLookup.kt | 40 +++++++++++ .../resolver/StaticSiteResolver.kt | 5 +- .../resolver/StaticSiteResolverTest.kt | 31 ++++++++ 7 files changed, 203 insertions(+), 19 deletions(-) diff --git a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md index 9002c4ef7e..f8f1eda762 100644 --- a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md +++ b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md @@ -172,6 +172,30 @@ Still open: a **live subscription tail** (push as events arrive, not just the sn `upload` gateway; and **on-device verification** — the shell/shim changes are JS and not exercised by the JVM unit tests. +## Update (2026-06-21, even later): feed surfacing + nsite runtime hardening + +- **Feed surfacing (sandbox-preserving).** Napplets gained an inline feed card (nsites already had + one), both render via `NoteCompose`, are indexed in `LocalCache`, and a profile **"Apps & Sites"** + tab lists a user's manifests. The cards are inert (`Text`+`Button`, no WebView); execution begins + only on explicit tap, in the `:napplet` process. +- **SPA route fallback** — a document navigation (Accept: text/html) to a route not in the manifest + serves the verified `index.html`; missing sub-resources still 404. +- **External-link handoff** — a user-tapped off-origin http(s) link opens in the system browser + (gesture-gated so a hostile site can't auto-redirect); the sandbox WebView never navigates away. +- **`resource.bytes` `blossom:` scheme** — `blossom:` fetches from the user's kind:10063 + Blossom servers and verifies the hash before returning. `nostr:` stays deferred (unspecified). +- **Content-type byte-sniffing** — when a manifest path has no/unknown extension, the resolver + sniffs magic bytes; text/markup is never sniffed, so HTML detection stays extension-driven. + Unit-tested in quartz. +- **kind:10063 fallback** — the launcher augments the manifest's `servers` with the author's + published Blossom list (best-effort); every blob is still sha256-verified. +- **Blob caching** — the host OkHttp client caches blobs on disk (forced-immutable, since they're + content-addressed); the resolver re-verifies every served blob, so a stale entry can't be served. + +Remaining: live subscription tail + `identity.onChanged`/`inc.on`, the Blossom `upload` gateway, +`getList`/`getZaps`/`getBadges`, the `nostr:` resource scheme, multi-`filters` queries — and +**on-device verification** of all the WebView-host behavior. + ## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected Pulled the authoritative SDK (`@napplet/shim` v0.16.0, npm/unpkg) and corrected the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index ca71b52bc7..41e8f37f67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -57,6 +57,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -275,15 +277,8 @@ class NappletBrokerService : Service() { when { url.startsWith("data:") -> decodeDataUrl(url) url.startsWith("https://") -> { - val port = account.let { Amethyst.instance.torManager.activePortOrNull.value } ?: -1 - val client = - if (port > 0) { - OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build() - } else { - OkHttpClient() - } runCatching { - client + blobHttpClient() .newCall( Request .Builder() @@ -299,11 +294,69 @@ class NappletBrokerService : Service() { } }.getOrNull() } - // blossom: / nostr: schemes are a follow-up. + url.startsWith("blossom:") -> fetchBlossom(account, url) + // nostr: resolution (event → bytes) is unspecified for resource.bytes; left as a follow-up. else -> null } } + /** Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network). */ + private fun blobHttpClient(): OkHttpClient { + val port = Amethyst.instance.torManager.activePortOrNull.value ?: -1 + return if (port > 0) { + OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build() + } else { + OkHttpClient() + } + } + + /** + * Fetches a `blossom:` (or `blossom://`) blob from the user's Blossom servers + * (kind:10063), verifying the sha256 before returning — content-addressed, so a wrong server + * can never substitute the blob. Returns null for a malformed hash or if no server serves it. + */ + private fun fetchBlossom( + account: Account, + url: String, + ): NappletResource? { + val hash = + url + .removePrefix("blossom://") + .removePrefix("blossom:") + .substringBefore('/') + .substringBefore('?') + .trim() + .lowercase() + if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null + + val servers = + account.blossomServers + .getBlossomServersList() + ?.servers() + .orEmpty() + val client = blobHttpClient() + 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") + } + } + return null + } + /** Parses a `data:[][;base64],` URL into bytes + content type. */ private fun decodeDataUrl(url: String): NappletResource? { val comma = url.indexOf(',') diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index 54446a7607..705b101417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -49,10 +49,12 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolutio import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import kotlinx.coroutines.runBlocking +import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject import java.io.ByteArrayInputStream +import java.io.File import java.net.InetSocketAddress import java.net.Proxy @@ -405,16 +407,34 @@ class NappletHostActivity : ComponentActivity() { return true } - /** Routes blob fetches through the user's Tor SOCKS proxy when one is active (port > 0). */ - private fun buildHttpClient(port: Int): OkHttpClient = + /** + * Routes blob fetches through the user's Tor SOCKS proxy when one is active (port > 0), and + * caches them on disk. Blobs are content-addressed (`/`) and therefore + * immutable, so a long-lived forced cache is safe — and the resolver re-verifies every blob's + * sha256 on the way out regardless, so a stale/poisoned cache entry can never be served. + */ + private fun buildHttpClient(port: Int): OkHttpClient { + val builder = OkHttpClient.Builder() if (port > 0) { - OkHttpClient - .Builder() - .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) - .build() - } else { - OkHttpClient() + builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) } + runCatching { + builder.cache(Cache(File(cacheDir, "napplet-blobs"), BLOB_CACHE_BYTES)) + builder.addNetworkInterceptor { chain -> + val response = chain.proceed(chain.request()) + if (response.isSuccessful) { + response + .newBuilder() + .header("Cache-Control", "public, max-age=31536000, immutable") + .removeHeader("Pragma") + .build() + } else { + response + } + } + } + return builder.build() + } private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) @@ -428,6 +448,7 @@ class NappletHostActivity : ComponentActivity() { companion object { private const val TAG = "NappletHostActivity" + private const val BLOB_CACHE_BYTES = 50L * 1024 * 1024 private const val HOST = "napplet.local" private const val ORIGIN = "https://napplet.local" private const val SHELL_URL = "$ORIGIN/__shell__" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index e6d8896b99..7a8265db4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -23,9 +23,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import android.content.Intent import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent /** * Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only @@ -80,11 +82,21 @@ object NappletLauncher { requires: List, ) { val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 + + // Augment the manifest's servers with the author's published Blossom list (kind:10063), if + // we already hold it, so a blob the manifest's servers dropped can still be fetched. The + // host re-verifies every blob's sha256, so a wrong/extra server can never inject content. + val authorBlossomServers = + runCatching { + (LocalCache.getAddressableNoteIfExists(BlossomServersEvent.createAddressTag(authorPubKey))?.event as? BlossomServersEvent)?.servers() + }.getOrNull().orEmpty() + val allServers = (servers + authorBlossomServers).distinct() + val intent = Intent(context, NappletHostActivity::class.java).apply { putExtra(EXTRA_PATHS, ArrayList(paths.map { it.path })) putExtra(EXTRA_HASHES, ArrayList(paths.map { it.hash })) - putExtra(EXTRA_SERVERS, ArrayList(servers)) + putExtra(EXTRA_SERVERS, ArrayList(allServers)) putExtra(EXTRA_AUTHOR, authorPubKey) putExtra(EXTRA_IDENTIFIER, identifier) putExtra(EXTRA_AGGREGATE_HASH, aggregateHash) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt index 2d4f2f2362..c82f8ca789 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt @@ -101,3 +101,43 @@ fun guessStaticContentType(path: String): String { else -> "application/octet-stream" } } + +/** The fallback type [guessStaticContentType] returns when the extension is unknown. */ +const val GENERIC_CONTENT_TYPE = "application/octet-stream" + +/** + * Best-effort content sniff from the leading magic bytes, used **only** when the path extension + * yields no type ([GENERIC_CONTENT_TYPE]). Deliberately conservative: it recognises well-known + * binary signatures and never returns a text/markup type, so HTML detection (and the shell's shim + * injection) keeps keying off the extension. Returns `null` when nothing matches. + */ +fun sniffContentType(bytes: ByteArray): String? { + fun match(vararg sig: Int): Boolean { + if (bytes.size < sig.size) return false + for (i in sig.indices) if ((bytes[i].toInt() and 0xFF) != sig[i]) return false + return true + } + + fun at( + offset: Int, + vararg sig: Int, + ): Boolean { + if (bytes.size < offset + sig.size) return false + for (i in sig.indices) if ((bytes[offset + i].toInt() and 0xFF) != sig[i]) return false + return true + } + + return when { + match(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) -> "image/png" + match(0xFF, 0xD8, 0xFF) -> "image/jpeg" + match(0x47, 0x49, 0x46, 0x38) -> "image/gif" // GIF8 + match(0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50) -> "image/webp" // RIFF….WEBP + match(0x25, 0x50, 0x44, 0x46) -> "application/pdf" // %PDF + match(0x00, 0x61, 0x73, 0x6D) -> "application/wasm" // \0asm + match(0x1F, 0x8B) -> "application/gzip" + match(0x42, 0x4D) -> "image/bmp" + match(0x4F, 0x67, 0x67, 0x53) -> "audio/ogg" // OggS + at(4, 0x66, 0x74, 0x79, 0x70) -> "video/mp4" // ….ftyp + else -> null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt index 47d8ba8b2b..d0eafff239 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt @@ -136,10 +136,13 @@ object StaticSiteResolver { } ?: continue if (verify(bytes, match.hash)) { + // Prefer the extension; only sniff magic bytes when the extension gives no type. + val byExtension = guessStaticContentType(match.path) + val contentType = if (byExtension == GENERIC_CONTENT_TYPE) sniffContentType(bytes) ?: byExtension else byExtension return StaticSiteResolution.Resolved( path = match.path, hash = match.hash, - contentType = guessStaticContentType(match.path), + contentType = contentType, bytes = bytes, server = url.substringBeforeLast('/'), ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt index 782115b8b9..feb142465a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt @@ -67,6 +67,37 @@ class StaticSiteResolverTest { assertEquals("application/octet-stream", guessStaticContentType("blob.unknownext")) } + @Test + fun sniffsBinaryMagicBytesAndIgnoresText() { + val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0) + assertEquals("image/png", sniffContentType(png)) + + val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0) + assertEquals("image/jpeg", sniffContentType(jpeg)) + + val webp = "RIFF".encodeToByteArray() + byteArrayOf(0, 0, 0, 0) + "WEBP".encodeToByteArray() + assertEquals("image/webp", sniffContentType(webp)) + + val wasm = byteArrayOf(0x00, 0x61, 0x73, 0x6D, 1, 0, 0, 0) + assertEquals("application/wasm", sniffContentType(wasm)) + + // Text / markup is never sniffed — HTML detection must stay extension-driven. + assertEquals(null, sniffContentType("hi".encodeToByteArray())) + assertEquals(null, sniffContentType(byteArrayOf())) + } + + @Test + fun resolveSniffsContentTypeWhenTheExtensionIsUnknown() = + runTest { + val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + val hash = sha256(png).toHexKey() + val paths = listOf(PathTag("/icon", hash)) // no extension → extension guess is generic + + val resolution = StaticSiteResolver.resolve("/icon", paths, listOf("https://s")) { png } + assertIs(resolution) + assertEquals("image/png", resolution.contentType) + } + @Test fun verifyAcceptsMatchingAndRejectsTamperedBytes() { val good = bytes("napplet")