fix(blossom): bring read-auth tokens into line with BUD-11

Two deviations from BUD-11, both predating the read-auth rework and both
carried forward by it.

The `x` tag defeated the per-host token cache. BUD-11 lists `x` as
optional for `GET /<sha256>`, but its Tag scoping rule is strict about
what including one means: "When `x` tags are present, the token is only
valid for operations on the specified blob hashes." Tokens are cached per
host and replayed for every blob on it, so from the second image onward we
were sending a token scoped to some other blob's hash. The old comment had
the reasoning backwards — it kept `x` "for servers that check it", which is
precisely the case that rejects a reused token. createGetAuth now takes a
nullable hash, and the read-auth path passes null: the `server` tag alone
scopes the token, which is what makes reuse legitimate. That widens the
grant from one blob to any blob on the host for the token's hour, which is
the inherent price of caching and is the shape BUD-11 sanctions.

The token encoding was standard Base64. BUD-11: "MUST be encoded as Base64
URL-safe without padding (Base64url, as used by JWTs)". In practice the
alphabets coincide — a token's JSON is printable ASCII and a sextet only
reaches 62/63 when the third byte of its group is `>`, `~`, `?` or DEL, so
`+` and `/` never appeared across 600 sampled tokens — but padding did, on
52% of them. NIP-98's encoder is deliberately left alone; it specifies no
variant.

Nothing in the tree decodes a Blossom auth header, so the encoding change
is client-side only.

Tests pin both rules at the event level and end-to-end on the token this
path actually mints, with several content lengths for the padding case
since whether padding appears depends on the JSON length mod 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYrDf5Z8TE4uivADuFwPFz
This commit is contained in:
Claude
2026-08-26 03:32:13 +00:00
parent 2f1e1d546c
commit f1c461dcfa
8 changed files with 179 additions and 63 deletions
@@ -59,8 +59,10 @@ class BlossomReadAuthFetcher(
if (e.response.code != HTTP_UNAUTHORIZED) throw e
val httpUrl = url.toHttpUrlOrNull() ?: throw e
val sha256 = BlossomReadAuthInterceptor.blossomHashOrNull(httpUrl.encodedPath) ?: throw e
val header = auth.header(httpUrl.host, sha256) ?: throw e
// Gate only: read-auth applies to Blossom blob URLs, but the token is
// scoped to the host (BUD-11 `server` tag) and carries no `x` tag.
BlossomReadAuthInterceptor.blossomHashOrNull(httpUrl.encodedPath) ?: throw e
val header = auth.header(httpUrl.host) ?: throw e
return build(header).fetch()
}
@@ -69,7 +69,7 @@ class BlossomReadAuthInterceptor(
/** Pure cache read — must not sign, must not block. */
private val cachedHeaderProvider: (host: String) -> String?,
/** Fire-and-forget: starts a signature for a host we just learned is gated. */
private val onAuthRequired: (host: String, sha256: HexKey) -> Unit,
private val onAuthRequired: (host: String) -> Unit,
) : 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
@@ -86,7 +86,9 @@ class BlossomReadAuthInterceptor(
return chain.proceed(request)
}
val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
// The hash is a gate, not an input: read-auth applies only to Blossom
// blob URLs. The token itself is host-scoped and carries no `x` tag.
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.
@@ -107,7 +109,7 @@ class BlossomReadAuthInterceptor(
// Start the signature but do not wait for it: this thread holds a
// per-host dispatcher slot. BlossomReadAuthFetcher performs the signed
// retry for this very request from a coroutine.
onAuthRequired(host, sha256)
onAuthRequired(host)
return response
}
@@ -21,7 +21,6 @@
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.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -48,11 +47,19 @@ import kotlin.coroutines.cancellation.CancellationException
* [inFlight] is what collapses them; the leader signs and every follower awaits
* the same [CompletableDeferred].
*
* 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.
* Tokens are cached per host, not per blob, and are therefore minted with a
* BUD-11 `server` tag and **no** `x` tag. BUD-11 lists `x` as optional for
* `GET /<sha256>` but is strict about what including one means: "When `x` tags
* are present, the token is only valid for operations on the specified blob
* hashes." A token carrying the hash of whichever blob happened to trigger
* signing would therefore be invalid for every other blob it was reused for.
* Server-scoped and hash-free, one signed event legitimately covers a whole
* feed's worth of images from the host for the life of the token.
*
* The tradeoff that buys: the token authorizes reading any blob on that host
* until it expires, rather than one. It is only ever sent to that host, over
* TLS, and BUD-11 sanctions the shape — but it is a wider grant than a
* per-blob token, which is the price of caching at all.
*/
class BlossomReadAuthTokenProvider(
private val signerProvider: () -> NostrSigner?,
@@ -78,12 +85,9 @@ class BlossomReadAuthTokenProvider(
* blocking, so the caller must already be in a coroutine — on the image path
* that is Coil's `Fetcher.fetch()`.
*/
suspend fun header(
host: String,
sha256: HexKey,
): String? {
suspend fun header(host: String): String? {
cachedHeader(host)?.let { return it }
return signOnce(host, sha256)?.await()
return signOnce(host)?.await()
}
/**
@@ -91,12 +95,9 @@ class BlossomReadAuthTokenProvider(
* cannot suspend (the interceptor) and only need the token to exist by the
* time some later request needs it.
*/
fun warm(
host: String,
sha256: HexKey,
) {
fun warm(host: String) {
if (cachedHeader(host) != null) return
signOnce(host, sha256)
signOnce(host)
}
/**
@@ -108,10 +109,7 @@ class BlossomReadAuthTokenProvider(
* that finishes immediately would run that removal *inside* the mapping
* function, which `ConcurrentHashMap` forbids.
*/
private fun signOnce(
host: String,
sha256: HexKey,
): CompletableDeferred<String?>? {
private fun signOnce(host: String): CompletableDeferred<String?>? {
inFlight[host]?.let { return it }
val signer = signerProvider() ?: return null
@@ -125,7 +123,9 @@ class BlossomReadAuthTokenProvider(
try {
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
BlossomAuth.createGetAuth(
hash = sha256,
// No `x` tag: this token is reused for every blob
// on the host. See the class kdoc.
hash = null,
alt = "Downloading media from $host",
signer = signer,
servers = listOf(host),
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -91,7 +90,7 @@ class BlossomReadAuthInterceptorTest {
assertEquals("the 401 is surfaced for the fetcher to retry", 401, response.code)
assertEquals("the interceptor must not retry itself", 1, chain.requests.size)
assertNull("the only attempt is anonymous", chain.requests[0].header("Authorization"))
assertEquals(host to sha, provider.warmed.single())
assertEquals(host, provider.warmed.single())
response.close()
}
@@ -102,7 +101,7 @@ class BlossomReadAuthInterceptorTest {
provider.interceptor().intercept(chain.asChain()).close()
assertEquals(sha, provider.warmed.single().second)
assertEquals(host, provider.warmed.single())
}
@Test
@@ -222,7 +221,7 @@ class BlossomReadAuthInterceptorTest {
val blockingScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val blockingProbe = BlossomReadAuthTokenProvider({ DelayingTestSigner(delayMs = SIGN_MS) }, blockingScope)
val beforeAt = System.nanoTime()
runBlocking { blockingProbe.header(host, sha) }
runBlocking { blockingProbe.header(host) }
val beforeMs = (System.nanoTime() - beforeAt) / 1_000_000
blockingScope.cancel()
@@ -265,15 +264,12 @@ class BlossomReadAuthInterceptorTest {
private class RecordingProvider(
var cached: String?,
) {
val warmed = mutableListOf<Pair<String, HexKey>>()
val warmed = mutableListOf<String>()
fun cachedHeader(host: String): String? = cached
fun warm(
host: String,
sha256: HexKey,
) {
warmed.add(host to sha256)
fun warm(host: String) {
warmed.add(host)
}
fun interceptor() = BlossomReadAuthInterceptor(::cachedHeader, ::warm)
@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -53,7 +55,7 @@ class BlossomReadAuthTokenProviderTest {
fun signsAndFormatsHeader() =
runBlocking {
val provider = BlossomReadAuthTokenProvider({ signer }, scope)
val header = provider.header(host, sha)
val header = provider.header(host)
assertTrue("expected a Nostr auth header, got $header", header!!.startsWith("Nostr "))
}
@@ -61,7 +63,7 @@ class BlossomReadAuthTokenProviderTest {
fun returnsNullWhenNoSigner() =
runBlocking {
val provider = BlossomReadAuthTokenProvider({ null }, scope)
assertNull(provider.header(host, sha))
assertNull(provider.header(host))
}
@Test
@@ -78,7 +80,7 @@ class BlossomReadAuthTokenProviderTest {
assertNull(provider.cachedHeader(host))
assertEquals(0, lookups)
val minted = provider.header(host, sha)
val minted = provider.header(host)
assertEquals(minted, provider.cachedHeader(host))
}
@@ -92,8 +94,8 @@ class BlossomReadAuthTokenProviderTest {
signer
}, scope, clock = { 0L })
val first = provider.header(host, sha)
val second = provider.header(host, sha)
val first = provider.header(host)
val second = provider.header(host)
assertEquals("second call must be served from cache", first, second)
assertEquals("signer must be resolved only once for the same host", 1, lookups)
@@ -103,8 +105,8 @@ class BlossomReadAuthTokenProviderTest {
fun differentHostSignsSeparately() =
runBlocking {
val provider = BlossomReadAuthTokenProvider({ signer }, scope, clock = { 0L })
val a = provider.header(host, sha)
val b = provider.header("other.example.com", sha)
val a = provider.header(host)
val b = provider.header("other.example.com")
assertNotEquals(a, b)
}
@@ -114,10 +116,10 @@ class BlossomReadAuthTokenProviderTest {
var now = 0L
val provider = BlossomReadAuthTokenProvider({ signer }, scope, clock = { now })
val first = provider.header(host, sha)
val first = provider.header(host)
now += 56L * 60L * 1000L
assertNull("token must be gone from the pure read once expired", provider.cachedHeader(host))
val second = provider.header(host, sha)
val second = provider.header(host)
assertNotEquals("an expired token must be re-signed", first, second)
}
@@ -127,7 +129,7 @@ class BlossomReadAuthTokenProviderTest {
runBlocking {
val provider = BlossomReadAuthTokenProvider({ signer }, scope)
provider.warm(host, sha)
provider.warm(host)
// warm() returns immediately; the token lands shortly after.
withTimeout(5_000) {
@@ -138,6 +140,35 @@ class BlossomReadAuthTokenProviderTest {
assertTrue(provider.cachedHeader(host)!!.startsWith("Nostr "))
}
/**
* End-to-end BUD-11 check on the token this path actually mints: reused
* across every blob on the host, so it must be `server`-scoped and carry no
* `x` tag ("When `x` tags are present, the token is only valid for
* operations on the specified blob hashes"), and be Base64url without
* padding.
*/
@Test
fun mintedTokenIsAReusableBud11GetToken() =
runBlocking {
val provider = BlossomReadAuthTokenProvider({ signer }, scope)
val token =
provider.header(host)!!.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)
assertTrue("token must be base64url without padding, got: $token", token.none { it == '=' || it == '+' || it == '/' })
val event = BlossomAuthorizationEvent.BASE64URL.decode(token).decodeToString()
val parsed = JacksonMapper.fromJson(event) as BlossomAuthorizationEvent
assertEquals(BlossomAuthorizationEvent.KIND, parsed.kind)
assertEquals("get", parsed.tags.first { it[0] == "t" }[1])
assertEquals(host, parsed.tags.first { it[0] == "server" }[1])
assertTrue("a host-cached token must not be blob-scoped", parsed.tags.none { it[0] == "x" })
assertTrue(
"BUD-11 requires an expiration in the future",
parsed.tags.first { it[0] == "expiration" }[1].toLong() > parsed.createdAt,
)
}
/**
* The single-flight guarantee. Before it existed the token cache was only
* populated *after* a signature returned, so a cold burst of N images from a
@@ -153,7 +184,7 @@ class BlossomReadAuthTokenProviderTest {
val startedAt = System.nanoTime()
val results =
(1..CONCURRENT_CALLERS)
.map { async(Dispatchers.Default) { provider.header(host, sha) } }
.map { async(Dispatchers.Default) { provider.header(host) } }
.awaitAll()
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
@@ -26,14 +26,18 @@ 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]).
* BUD-11 read auth (`t=get`). Servers that gate downloads (e.g. Buzz's
* private media relay) require this on `GET /<sha256>`.
*
* [servers] adds BUD-11 `server` tags, scoping the token to those hosts.
* [hash] adds an `x` tag, scoping it to that one blob — pass null to leave
* it off, which is what makes a token reusable for every blob on the host
* (derived blobs like `.thumb.jpg` included). BUD-11 allows either for
* `GET`, but a token that carries `x` is valid *only* for that hash. See
* [BlossomAuthorizationEvent.createGetAuth].
*/
suspend fun createGetAuth(
hash: HexKey,
hash: HexKey?,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
@@ -36,25 +36,48 @@ class BlossomAuthorizationEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/** Base64 of this event's JSON, as carried in the `Authorization` header value. */
fun rawToken() = Base64.encode(toJson().encodeToByteArray())
/**
* This event's JSON as Base64url without padding, per BUD-11: "the
* authorization token MUST be encoded as Base64 URL-safe without padding
* (Base64url, as used by JWTs)".
*
* Deliberately NOT the same encoder as NIP-98's
* [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.rawToken],
* which stays on standard Base64 because NIP-98 does not specify a variant.
* In practice the alphabets coincide here — a token's JSON is printable
* ASCII, and a sextet can only reach 62/63 when the third byte of its group
* is `>`, `~`, `?` or DEL — so the observable change is the dropped `=`.
*/
fun rawToken() = BASE64URL.encode(toJson().encodeToByteArray())
/**
* The full `Authorization` header value for a BUD-01/BUD-02 request:
* `Nostr <base64-event>`. Mirrors NIP-98's
* [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.toAuthToken],
* which Blossom auth reuses.
* The full `Authorization` header value for a Blossom request:
* `Nostr <base64url-event>` (BUD-11, HTTP Authorization Header).
*/
fun toAuthorizationHeader() = "$AUTH_HEADER_SCHEME${rawToken()}"
companion object {
const val KIND = 24242
/** Scheme prefix for the `Authorization` header value (BUD-01). */
/** Scheme prefix for the `Authorization` header value (BUD-11). */
const val AUTH_HEADER_SCHEME = "Nostr "
/** BUD-11's required token encoding: URL-safe alphabet, no `=` padding. */
val BASE64URL = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
/**
* BUD-11 `t=get` read authorization.
*
* [hash] is optional because BUD-11 lists the `x` tag as *optional* for
* `GET /<sha256>`, and its Tag scoping rule is strict about what adding
* one means: "When `x` tags are present, the token is only valid for
* operations on the specified blob hashes." So pass a hash only for a
* token used to fetch that one blob; pass null for a token that will be
* reused across blobs on a host, and let the `server` tag scope it.
* A hash-scoped token replayed for a different blob is invalid.
*/
suspend fun createGetAuth(
hash: HexKey,
hash: HexKey?,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.io.encoding.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -96,7 +95,66 @@ class BlossomAuthorizationEventTest {
val header = event.toAuthorizationHeader()
assertTrue(header.startsWith(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME))
val decoded = Base64.decode(header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)).decodeToString()
val token = header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)
val decoded = BlossomAuthorizationEvent.BASE64URL.decode(token).decodeToString()
assertEquals(event.toJson(), decoded)
}
/**
* BUD-11: "the authorization token MUST be encoded as Base64 URL-safe
* without padding (Base64url, as used by JWTs)". Padded standard Base64 was
* what this produced before, so both halves are worth pinning. Several
* lengths, because whether padding appears at all depends on the JSON
* length mod 3 — a single sample passes by luck about half the time.
*/
@Test
fun authorizationTokenIsBase64UrlWithoutPadding() =
runTest {
listOf("a", "List", "List blobs", "List all of the blobs", "List blobs \u00e1\u00e9\u00ed")
.forEach { alt ->
val header = BlossomAuthorizationEvent.createListAuth(signer, alt).toAuthorizationHeader()
val token = header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)
assertTrue(token.none { it == '=' }, "padding must be absent for `$alt`, got: $token")
assertTrue(
token.none { it == '+' || it == '/' },
"standard-alphabet chars must not appear for `$alt`, got: $token",
)
assertTrue(
token.all { it.isLetterOrDigit() || it == '-' || it == '_' },
"token must be base64url for `$alt`, got: $token",
)
}
}
/**
* BUD-11 lists `x` as optional for `GET /<sha256>`, and its Tag scoping rule
* makes the omission load-bearing: "When `x` tags are present, the token is
* only valid for operations on the specified blob hashes." A token cached
* per host and reused across blobs must therefore carry no `x`.
*/
@Test
fun getAuthOmitsTheBlobScopeWhenNoHashIsGiven() =
runTest {
val event =
BlossomAuthorizationEvent.createGetAuth(
hash = null,
alt = "Downloading media from cdn.example.com",
signer = signer,
servers = listOf("https://cdn.example.com"),
)
assertEquals("get", event.tags.first { it[0] == "t" }[1])
assertTrue(event.tags.none { it[0] == "x" }, "a reusable get token must not be blob-scoped")
assertEquals("cdn.example.com", event.tags.first { it[0] == "server" }[1])
}
@Test
fun getAuthKeepsTheBlobScopeWhenAHashIsGiven() =
runTest {
val event = BlossomAuthorizationEvent.createGetAuth(hash, "Downloading one blob", signer)
assertEquals("get", event.tags.first { it[0] == "t" }[1])
assertEquals(hash, event.tags.first { it[0] == "x" }[1])
}
}