Merge pull request #3789 from vitorpamplona/claude/buzz-media-upload-decode-gc5hxm

Add BUD-01 read-auth retry for gated Blossom blob downloads
This commit is contained in:
Vitor Pamplona
2026-07-28 20:40:00 -04:00
committed by GitHub
8 changed files with 629 additions and 1 deletions
@@ -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
@@ -0,0 +1,132 @@
/*
* 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 com.vitorpamplona.quartz.utils.Hex
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
* 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 <base64-event>`.
*
* 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.
*
* 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<String> = ConcurrentHashMap.newKeySet()
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 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
// 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()
return chain.proceed(request.withAuth(header))
}
private fun Request.withAuth(header: String) =
newBuilder()
.header("Authorization", header)
.build()
companion object {
/**
* Extracts the sha256 blob id from a Blossom URL path. The blob is the
* last path segment, up to its first `.` — so both `<hash>.png` and the
* derived `<hash>.thumb.jpg` resolve to `<hash>`. Returns `null` when the
* 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 base = encodedPath.substringAfterLast('/').substringBefore('.').lowercase()
return if (base.length == 64 && Hex.isHex64(base)) base else null
}
}
}
@@ -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<String, CachedToken>()
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
}
}
@@ -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<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
@@ -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
@@ -0,0 +1,273 @@
/*
* 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 <hash>.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"))
// 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("/"))
}
// --- 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()
}
@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?,
) {
val calls = mutableListOf<Pair<String, HexKey>>()
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<Int>,
) {
val requests = mutableListOf<Request>()
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<Int>,
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)
}
}
@@ -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)
}
}
@@ -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 /<sha256>`. 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<String> = emptyList(),
): String = BlossomAuthorizationEvent.createGetAuth(hash, alt, signer, servers).toAuthorizationHeader()
suspend fun createUploadAuth(
hash: HexKey,
size: Long,