mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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)
|
||||
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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('/'),
|
||||
)
|
||||
|
||||
+31
@@ -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>")
|
||||
|
||||
Reference in New Issue
Block a user