diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomReadAuthFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomReadAuthFetcher.kt index 92a7566f8b..faca958fcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomReadAuthFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomReadAuthFetcher.kt @@ -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() } 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 b4c6d19bc0..dab6674db2 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 @@ -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 } 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 index 61f9d30826..af62b37767 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProvider.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProvider.kt @@ -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 /` 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? { + private fun signOnce(host: String): CompletableDeferred? { 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), 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 a83278fd94..602918a979 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 @@ -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>() + val warmed = mutableListOf() 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) 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 index 12f0dbe265..b209522a56 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlossomReadAuthTokenProviderTest.kt @@ -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 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 da2a44dfbf..929d24366a 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 @@ -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 /`. 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 /`. + * + * [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 = emptyList(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index 9cabfaa20e..590e82b634 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -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 `. Mirrors NIP-98's - * [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.toAuthToken], - * which Blossom auth reuses. + * The full `Authorization` header value for a Blossom request: + * `Nostr ` (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 /`, 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 = emptyList(), diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt index 6d82a3913a..b9733297ef 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt @@ -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 /`, 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]) + } }