From 493aed860931e18b8b6a83364ded8834ea3f519e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 23:25:57 +0000 Subject: [PATCH 1/3] feat: sign Blossom read-auth to view media on auth-gated hosts Buzz's private media relay (*.communities.buzz.xyz) gates blob downloads behind BUD-01 read auth, returning `401 {"error":"authentication failed"}` to anonymous GETs. Amethyst loaded every media URL anonymously through Coil/OkHttp, so those images (and their thumbnails) never decoded. Quartz already had BlossomAuthorizationEvent.createGetAuth but nothing in the app ever called it. Wire it into the media HTTP client: - BlossomReadAuthInterceptor: on a 401 for a GET whose URL last segment is a Blossom sha256 filename (covers `.png` and `.thumb.jpg`), retry once with a signed `Authorization: Nostr ` header. Narrowly gated so unrelated 401s never trigger a second request or any signing. - BlossomReadAuthTokenProvider: bridges the suspend signer synchronously (runBlocking + timeout so a slow remote/external signer can't pin the OkHttp thread) and caches one server-scoped token per host, which also covers derived blobs like thumbnails. - BlossomAuth.createGetAuth exposes the read-auth builder to the app layer. Public Blossom/NIP-96 hosts stay zero-overhead: they answer 200, so no token is ever signed for them. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp --- .../com/vitorpamplona/amethyst/AppModules.kt | 12 + .../okhttp/BlossomReadAuthInterceptor.kt | 101 ++++++++ .../okhttp/BlossomReadAuthTokenProvider.kt | 90 +++++++ .../service/okhttp/DualHttpClientManager.kt | 5 +- .../service/okhttp/OkHttpClientFactory.kt | 13 + .../okhttp/BlossomReadAuthInterceptorTest.kt | 231 ++++++++++++++++++ .../BlossomReadAuthTokenProviderTest.kt | 91 +++++++ .../commons/service/upload/BlossomAuth.kt | 14 ++ 8 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProvider.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6d902c7577..f903682bfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -73,6 +73,8 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver +import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor +import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache @@ -424,6 +426,16 @@ class AppModules( }, onionCache = onionLocationCache, usageInterceptor = httpUsageInterceptor, + // Retries auth-gated Blossom blob downloads (e.g. Buzz's private + // media relay) with a BUD-01 read-auth token signed by the current + // account on a 401. Reads the signer at call time so it always + // tracks the logged-in account. + blossomReadAuth = + BlossomReadAuthInterceptor( + BlossomReadAuthTokenProvider( + signerProvider = { sessionManager.loggedInAccount()?.signer }, + )::authHeader, + ), ) // Offers easy methods to know when connections are happening through Tor or not diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt new file mode 100644 index 0000000000..8e14bf15d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt @@ -0,0 +1,101 @@ +/* + * 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.service.okhttp + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Retries a Blossom blob download with a BUD-01 `t=get` authorization when the + * server answers `401`. + * + * Most Blossom / NIP-96 hosts serve blobs anonymously, so images load without + * any signing overhead. A few gate reads behind auth — Buzz's private media + * relay (`*.communities.buzz.xyz`) returns + * `401 {"error":"authentication failed"}` to an anonymous `GET`. For those we + * sign a kind-24242 read-auth event (via [authHeaderProvider]) and replay the + * request once with `Authorization: Nostr `. + * + * Gating is deliberately narrow so we never turn an unrelated `401` into a + * second request storm: + * - only `GET` requests, + * - only when the request doesn't already carry an `Authorization` header, + * - only when the URL's last path segment is a Blossom sha256 filename + * (`<64-hex>` optionally followed by an extension such as `.png` or + * `.thumb.jpg`), + * - only after the anonymous attempt actually returned `401`, + * - and at most one retry (an application interceptor's second `chain.proceed` + * runs the downstream chain again, it does not re-enter this interceptor). + * + * [authHeaderProvider] is `(host, sha256) -> header?`. It is synchronous by + * contract (the caller bridges the suspend signer), returns `null` when no + * signer is available or signing times out, and is only consulted on a real + * `401`, so an unauthenticated user simply keeps seeing the broken image + * rather than paying any signing cost. + */ +class BlossomReadAuthInterceptor( + private val authHeaderProvider: (host: String, sha256: HexKey) -> String?, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (!request.method.equals("GET", ignoreCase = true) || + request.header("Authorization") != null + ) { + return chain.proceed(request) + } + + val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request) + + val response = chain.proceed(request) + if (response.code != 401) return response + + val header = authHeaderProvider(request.url.host, sha256) ?: return response + + // Close the 401 body before replaying so the connection can be reused. + response.close() + + val authed = + request + .newBuilder() + .header("Authorization", header) + .build() + + return chain.proceed(authed) + } + + companion object { + private val SHA256_HEX = Regex("^[0-9a-f]{64}$") + + /** + * Extracts the sha256 blob id from a Blossom URL path. The blob is the + * last path segment, up to its first `.` — so both `.png` and the + * derived `.thumb.jpg` resolve to ``. Returns `null` when the + * segment isn't a lowercase 64-char hex string. + */ + fun blossomHashOrNull(encodedPath: String): HexKey? { + val lastSegment = encodedPath.substringAfterLast('/') + val base = lastSegment.substringBefore('.').lowercase() + return if (SHA256_HEX.matches(base)) base else null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProvider.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProvider.kt new file mode 100644 index 0000000000..7033dfb756 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProvider.kt @@ -0,0 +1,90 @@ +/* + * 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.service.okhttp + +import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap + +/** + * Signs and caches BUD-01 read-auth headers for [BlossomReadAuthInterceptor]. + * + * The interceptor is synchronous (it runs on an OkHttp dispatcher thread) but + * signing is `suspend`, so [authHeader] bridges with [runBlocking] guarded by a + * timeout: an internal key signs instantly, while a remote (NIP-46) or external + * (NIP-55) signer that hangs or needs user interaction simply yields `null` and + * the download stays unauthenticated instead of pinning the thread. + * + * Tokens are cached per host, not per blob. A BUD-11 `server`-scoped token + * grants reads for every blob on the host (thumbnails included), so one signed + * event covers a whole feed's worth of images from an auth-gated host for the + * life of the token. The blob hash of the request that first triggered signing + * is still included as the `x` tag for BUD-01 servers that check it. + */ +class BlossomReadAuthTokenProvider( + private val signerProvider: () -> NostrSigner?, + private val clock: () -> Long = { System.currentTimeMillis() }, +) { + private class CachedToken( + val header: String, + val expiresAtMs: Long, + ) + + private val cache = ConcurrentHashMap() + + fun authHeader( + host: String, + sha256: HexKey, + ): String? { + val now = clock() + + cache[host]?.let { if (it.expiresAtMs > now) return it.header } + + val signer = signerProvider() ?: return null + + val header = + runBlocking { + withTimeoutOrNull(SIGN_TIMEOUT_MS) { + BlossomAuth.createGetAuth( + hash = sha256, + alt = "Downloading media from $host", + signer = signer, + servers = listOf(host), + ) + } + } ?: return null + + cache[host] = CachedToken(header, now + CACHE_TTL_MS) + return header + } + + companion object { + // The signed event expires one hour out (BlossomAuthorizationEvent), so + // refresh a little early to avoid handing over a token that dies mid-flight. + private const val CACHE_TTL_MS = 55L * 60L * 1000L + + // Bounds how long an image download may block waiting on a slow signer. + private const val SIGN_TIMEOUT_MS = 8_000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index dec043dd04..8ef8b0983b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -50,8 +50,11 @@ class DualHttpClientManager( // Resource-usage ledger counter, installed on the shared base client so // every derived client is accounted. See [OkHttpClientFactory]. usageInterceptor: Interceptor? = null, + // Signs BUD-01 read-auth to retry auth-gated Blossom downloads on 401. + // See [BlossomReadAuthInterceptor]. + blossomReadAuth: Interceptor? = null, ) : IHttpClientManager { - val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor) + val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 7a3b1f2e2e..01d1f72284 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -69,6 +69,13 @@ class OkHttpClientFactory( * tests / pre-configuration call sites. */ private val usageInterceptor: Interceptor? = null, + /** + * Retries auth-gated Blossom blob downloads with a signed BUD-01 `t=get` + * token when the host answers `401` (e.g. Buzz's private media relay). Null + * in tests / pre-configuration call sites, in which case such downloads stay + * anonymous. See [BlossomReadAuthInterceptor]. + */ + private val blossomReadAuth: Interceptor? = null, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) @@ -116,6 +123,12 @@ class OkHttpClientFactory( .apply { blossomCacheRedirect?.let { addInterceptor(it) } } + // Sits outside the network interceptors so its retry re-runs the + // full stack (content-type, blossom cache, key decryptor) for the + // authenticated response. Only signs on an actual 401. + .apply { + blossomReadAuth?.let { addInterceptor(it) } + } // .addNetworkInterceptor(logging) .addNetworkInterceptor(keyDecryptor) // Passively populates [onionCache] from any HTTP/WebSocket response diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt new file mode 100644 index 0000000000..2899418497 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt @@ -0,0 +1,231 @@ +/* + * 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.service.okhttp + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Proxy + +class BlossomReadAuthInterceptorTest { + private val sha = "2c5287a55cc550c9d6bc4206a4663900e083315f4a544ea3bc189e43dc330af6" + private val host = "nosfabrica.communities.buzz.xyz" + + // --- blossomHashOrNull ------------------------------------------------ + + @Test + fun hashParsedFromPlainBlob() { + assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/$sha.png")) + } + + @Test + fun hashParsedFromThumbnailVariant() { + // Buzz thumbnails use the dot form .thumb.jpg — the base is still the hash. + assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/$sha.thumb.jpg")) + } + + @Test + fun hashParsedWithoutExtension() { + assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/$sha")) + } + + @Test + fun hashLowercasedFromUppercaseSegment() { + assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha.uppercase()}.png")) + } + + @Test + fun nonBlobPathsReturnNull() { + assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/avatar.png")) + assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/nostr.build_$sha.jpg")) + assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha}_thumb.jpg")) + assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/")) + } + + // --- intercept behavior ---------------------------------------------- + + @Test + fun retriesWithAuthOn401() { + val provider = RecordingProvider(header = "Nostr token") + val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401, 200)) + + val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain()) + + assertEquals(200, response.code) + assertEquals(2, chain.requests.size) + assertNull("first attempt is anonymous", chain.requests[0].header("Authorization")) + assertEquals("Nostr token", chain.requests[1].header("Authorization")) + assertEquals(host to sha, provider.calls.single()) + response.close() + } + + @Test + fun thumbnailUrlAlsoRetries() { + val provider = RecordingProvider(header = "Nostr token") + val chain = fakeChain("https://$host/media/$sha.thumb.jpg", codes = listOf(401, 200)) + + val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain()) + + assertEquals(200, response.code) + assertEquals(sha, provider.calls.single().second) + response.close() + } + + @Test + fun successfulRequestNeverSigns() { + val provider = RecordingProvider(header = "Nostr token") + val chain = fakeChain("https://blossom.example.com/$sha.png", codes = listOf(200)) + + val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain()) + + assertEquals(200, response.code) + assertEquals(1, chain.requests.size) + assertTrue("public host must not be signed", provider.calls.isEmpty()) + response.close() + } + + @Test + fun keepsThe401WhenNoSignerAvailable() { + val provider = RecordingProvider(header = null) + val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401)) + + val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain()) + + assertEquals(401, response.code) + assertEquals(1, chain.requests.size) + assertEquals(host to sha, provider.calls.single()) + response.close() + } + + @Test + fun nonBlobUrlNeverSignsEvenOn401() { + val provider = RecordingProvider(header = "Nostr token") + val chain = fakeChain("https://example.com/media/avatar.png", codes = listOf(401)) + + val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain()) + + assertEquals(401, response.code) + assertEquals(1, chain.requests.size) + assertTrue(provider.calls.isEmpty()) + response.close() + } + + @Test + fun requestWithExistingAuthPassesThrough() { + val provider = RecordingProvider(header = "Nostr token") + val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401), preAuthHeader = "Nostr existing") + + val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain()) + + assertEquals(401, response.code) + assertEquals(1, chain.requests.size) + assertTrue(provider.calls.isEmpty()) + response.close() + } + + @Test + fun nonGetRequestPassesThrough() { + val provider = RecordingProvider(header = "Nostr token") + val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401), method = "PUT") + + val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain()) + + assertEquals(401, response.code) + assertEquals(1, chain.requests.size) + assertTrue(provider.calls.isEmpty()) + response.close() + } + + private class RecordingProvider( + private val header: String?, + ) { + val calls = mutableListOf>() + + fun header( + host: String, + sha256: HexKey, + ): String? { + calls.add(host to sha256) + return header + } + } + + /** + * Records every [Request] it is asked to proceed and answers each with the + * next code from [codes], so `[401, 200]` models "anonymous fails, + * authenticated succeeds". + */ + private class FakeChain( + private val request: Request, + private val codes: List, + ) { + val requests = mutableListOf() + + fun asChain(): Interceptor.Chain = + Proxy.newProxyInstance( + Interceptor.Chain::class.java.classLoader, + arrayOf(Interceptor.Chain::class.java), + ) { _, method, args -> + when (method.name) { + "request" -> request + "proceed" -> { + val proceeded = args[0] as Request + requests.add(proceeded) + Response + .Builder() + .request(proceeded) + .protocol(Protocol.HTTP_1_1) + .code(codes[requests.size - 1]) + .message("msg") + .body("".toResponseBody(null)) + .build() + } + else -> throw UnsupportedOperationException(method.name) + } + } as Interceptor.Chain + } + + private fun fakeChain( + url: String, + codes: List, + method: String = "GET", + preAuthHeader: String? = null, + ): FakeChain { + val request = + Request + .Builder() + .url(url.toHttpUrl()) + .apply { + if (method == "GET") get() else method(method, "".toRequestBody()) + preAuthHeader?.let { header("Authorization", it) } + }.build() + return FakeChain(request, codes) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt new file mode 100644 index 0000000000..7edf2dd05a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt @@ -0,0 +1,91 @@ +/* + * 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.service.okhttp + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BlossomReadAuthTokenProviderTest { + private val sha = "2c5287a55cc550c9d6bc4206a4663900e083315f4a544ea3bc189e43dc330af6" + private val host = "nosfabrica.communities.buzz.xyz" + private val signer = NostrSignerInternal(KeyPair()) + + @Test + fun signsAndFormatsHeader() { + val provider = BlossomReadAuthTokenProvider(signerProvider = { signer }) + val header = provider.authHeader(host, sha) + assertTrue("expected a Nostr auth header, got $header", header!!.startsWith("Nostr ")) + } + + @Test + fun returnsNullWhenNoSigner() { + val provider = BlossomReadAuthTokenProvider(signerProvider = { null }) + assertNull(provider.authHeader(host, sha)) + } + + @Test + fun cachesPerHostWithinTtl() { + // signerProvider is consulted only on a cache miss, so its invocation + // count is the number of times a fresh token was signed. + var lookups = 0 + val provider = + BlossomReadAuthTokenProvider( + signerProvider = { + lookups++ + signer + }, + clock = { 0L }, + ) + + val first = provider.authHeader(host, sha) + val second = provider.authHeader(host, sha) + + assertEquals("second call must be served from cache", first, second) + assertEquals("signer must run only once for the same host", 1, lookups) + } + + @Test + fun differentHostSignsSeparately() { + val provider = BlossomReadAuthTokenProvider(signerProvider = { signer }, clock = { 0L }) + + val a = provider.authHeader(host, sha) + val b = provider.authHeader("other.example.com", sha) + + assertNotEquals(a, b) + } + + @Test + fun refreshesAfterExpiry() { + var now = 0L + val provider = BlossomReadAuthTokenProvider(signerProvider = { signer }, clock = { now }) + + val first = provider.authHeader(host, sha) + now += 60L * 60L * 1000L // one hour later — past the 55-min TTL + val second = provider.authHeader(host, sha) + + assertNotEquals("expired token must be re-signed", first, second) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt index 7c1597f992..da2a44dfbf 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt @@ -25,6 +25,20 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent object BlossomAuth { + /** + * BUD-01 read auth (`t=get`). Servers that gate downloads (e.g. Buzz's + * private media relay) require this on `GET /`. The [servers] list + * adds BUD-11 `server` tags so a single token can be scoped to a whole host + * (which also covers derived blobs like `.thumb.jpg` whose hash differs + * from [hash]). + */ + suspend fun createGetAuth( + hash: HexKey, + alt: String, + signer: NostrSigner, + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createGetAuth(hash, alt, signer, servers).toAuthorizationHeader() + suspend fun createUploadAuth( hash: HexKey, size: Long, From 7d1c45607bf247ded6d1ac8b5c749f2a517d6183 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 23:54:31 +0000 Subject: [PATCH 2/3] perf: preemptively sign Blossom reads for known auth-gated hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry-on-401 interceptor re-probed anonymously on every blob, so each image from an auth-gated host (e.g. a Buzz community feed, where nearly all media is on one host) paid a wasted 401 round trip before the signed retry. Remember hosts that answered 401 and attach the cached token up front on their subsequent blobs — one round trip instead of two in steady state. The first blob per host still costs the probe; the learned set falls back to an anonymous request when no signer is available so a logged-out user never loops. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp --- .../okhttp/BlossomReadAuthInterceptor.kt | 44 +++++++++++++++---- .../okhttp/BlossomReadAuthInterceptorTest.kt | 40 +++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt index 8e14bf15d2..78aa21c415 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.service.okhttp import com.vitorpamplona.quartz.nip01Core.core.HexKey import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response +import java.util.concurrent.ConcurrentHashMap /** * Retries a Blossom blob download with a BUD-01 `t=get` authorization when the @@ -51,10 +53,24 @@ import okhttp3.Response * signer is available or signing times out, and is only consulted on a real * `401`, so an unauthenticated user simply keeps seeing the broken image * rather than paying any signing cost. + * + * The first blob from an auth-gated host costs an extra round trip (anonymous + * `GET` → `401` → signed retry), but that host is then remembered in + * [knownAuthHosts] so every later blob from it is signed **up front** — one + * round trip, not two. This matters on a Buzz community feed where nearly every + * image comes from the same gated host: without it each image would keep paying + * the wasted 401 probe. The learned host also short-circuits to anonymous when + * no signer is available, so a logged-out user never re-probes needlessly. */ class BlossomReadAuthInterceptor( private val authHeaderProvider: (host: String, sha256: HexKey) -> String?, ) : Interceptor { + // Hosts observed to answer 401 to an anonymous Blossom GET. Small (a user + // follows a handful of auth-gated servers at most) and shared across all + // clients derived from the same factory. newKeySet() is thread-safe for the + // concurrent reads/writes of parallel feed downloads. + private val knownAuthHosts: MutableSet = ConcurrentHashMap.newKeySet() + override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() @@ -65,24 +81,36 @@ class BlossomReadAuthInterceptor( } val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request) + val host = request.url.host + + // Known-gated host: skip the anonymous probe and sign the first attempt. + // Falls through to anonymous only when we can't produce a token (no + // signer / timeout) — the server would 401 either way. + if (host in knownAuthHosts) { + authHeaderProvider(host, sha256)?.let { header -> + return chain.proceed(request.withAuth(header)) + } + } val response = chain.proceed(request) if (response.code != 401) return response - val header = authHeaderProvider(request.url.host, sha256) ?: return response + // Learn the host so its next blob is signed up front. + knownAuthHosts.add(host) + + val header = authHeaderProvider(host, sha256) ?: return response // Close the 401 body before replaying so the connection can be reused. response.close() - val authed = - request - .newBuilder() - .header("Authorization", header) - .build() - - return chain.proceed(authed) + return chain.proceed(request.withAuth(header)) } + private fun Request.withAuth(header: String) = + newBuilder() + .header("Authorization", header) + .build() + companion object { private val SHA256_HEX = Regex("^[0-9a-f]{64}$") diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt index 2899418497..e3ea88bf47 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt @@ -163,6 +163,46 @@ class BlossomReadAuthInterceptorTest { response.close() } + @Test + fun learnsHostThenSignsSubsequentBlobsUpFront() { + val provider = RecordingProvider(header = "Nostr token") + val interceptor = BlossomReadAuthInterceptor(provider::header) + val otherSha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + + // First blob learns the host via the 401 probe + signed retry. + val first = fakeChain("https://$host/media/$sha.png", codes = listOf(401, 200)) + interceptor.intercept(first.asChain()).close() + assertEquals(2, first.requests.size) + + // Second, different blob on the same host is signed on the first attempt — + // no anonymous probe, so a single request. + val second = fakeChain("https://$host/media/$otherSha.png", codes = listOf(200)) + val response = interceptor.intercept(second.asChain()) + + assertEquals(200, response.code) + assertEquals("no anonymous probe on a known-gated host", 1, second.requests.size) + assertEquals("Nostr token", second.requests.single().header("Authorization")) + response.close() + } + + @Test + fun learnedHostWithoutSignerDoesNotLoop() { + val provider = RecordingProvider(header = null) + val interceptor = BlossomReadAuthInterceptor(provider::header) + + val first = fakeChain("https://$host/media/$sha.png", codes = listOf(401)) + interceptor.intercept(first.asChain()).close() + + // Host is now known, but with no signer the preemptive path must fall + // back to a single anonymous request rather than retrying endlessly. + val second = fakeChain("https://$host/media/$sha.png", codes = listOf(401)) + val response = interceptor.intercept(second.asChain()) + + assertEquals(401, response.code) + assertEquals(1, second.requests.size) + response.close() + } + private class RecordingProvider( private val header: String?, ) { From defb89898785587a43a1acd03fc53d705e209973 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 00:35:53 +0000 Subject: [PATCH 3/3] refactor: use Hex.isHex64 instead of a regex for the blob-id check blossomHashOrNull runs on every media URL the feed loads. Quartz's unrolled Hex.isHex64 is the fast path for validating a 32-byte hex id (~30% faster than isHex, far faster than a regex match). It only checks the first 64 chars and doesn't verify length, so keep an explicit length == 64 guard to reject longer segments. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp --- .../service/okhttp/BlossomReadAuthInterceptor.kt | 15 +++++++++------ .../okhttp/BlossomReadAuthInterceptorTest.kt | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt index 78aa21c415..8356ca7e55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptor.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.okhttp import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Hex import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response @@ -112,18 +113,20 @@ class BlossomReadAuthInterceptor( .build() companion object { - private val SHA256_HEX = Regex("^[0-9a-f]{64}$") - /** * Extracts the sha256 blob id from a Blossom URL path. The blob is the * last path segment, up to its first `.` — so both `.png` and the * derived `.thumb.jpg` resolve to ``. Returns `null` when the - * segment isn't a lowercase 64-char hex string. + * segment isn't a 64-char hex string. + * + * Uses Quartz's unrolled [Hex.isHex64] rather than a regex — this runs on + * every media URL the feed loads. [Hex.isHex64] only checks the first 64 + * chars and doesn't verify total length, so the `length == 64` guard is + * what rejects longer segments. */ fun blossomHashOrNull(encodedPath: String): HexKey? { - val lastSegment = encodedPath.substringAfterLast('/') - val base = lastSegment.substringBefore('.').lowercase() - return if (SHA256_HEX.matches(base)) base else null + val base = encodedPath.substringAfterLast('/').substringBefore('.').lowercase() + return if (base.length == 64 && Hex.isHex64(base)) base else null } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt index e3ea88bf47..233df8338b 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthInterceptorTest.kt @@ -66,6 +66,8 @@ class BlossomReadAuthInterceptorTest { assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/avatar.png")) assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/nostr.build_$sha.jpg")) assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha}_thumb.jpg")) + // 65 hex chars: isHex64 checks only the first 64, so the length guard must reject it. + assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha}a.png")) assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/")) }