mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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
|
||||
|
||||
+132
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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 ->
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+273
@@ -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)
|
||||
}
|
||||
}
|
||||
+91
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user