feat(nsite): runtime hardening — blossom: fetch, sniffing, server fallback, blob cache

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:<sha256> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-21 20:11:15 +00:00
parent 76d9951d4f
commit 0d2ac83ebb
7 changed files with 203 additions and 19 deletions
@@ -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:<sha256>` 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
@@ -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:<sha256>` (or `blossom://<sha256>`) 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:[<mediatype>][;base64],<data>` URL into bytes + content type. */
private fun decodeDataUrl(url: String): NappletResource? {
val comma = url.indexOf(',')
@@ -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 (`<server>/<sha256>`) 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__"
@@ -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<String>,
) {
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)
@@ -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
}
}
@@ -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('/'),
)
@@ -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("<html>hi</html>".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<StaticSiteResolution.Resolved>(resolution)
assertEquals("image/png", resolution.contentType)
}
@Test
fun verifyAcceptsMatchingAndRejectsTamperedBytes() {
val good = bytes("<html>napplet</html>")