Merge pull request #4127 from davotoula/fix/blossom-auth-standard-base64

fix(blossom): send the auth token as standard padded Base64 again
This commit is contained in:
Vitor Pamplona
2026-09-16 08:32:52 -04:00
committed by GitHub
5 changed files with 32 additions and 38 deletions
@@ -241,7 +241,7 @@ class ImageUploadTesting {
@Ignore("Returns invalid hash")
fun testSovbit() =
runBlocking {
testBase(ServerName("sovbit", "https://cdn.sovbit.host", ServerType.Blossom))
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.Blossom))
}
@Test()
@@ -43,7 +43,7 @@ val DEFAULT_MEDIA_SERVERS: List<ServerName> =
ServerName("Azzamo", "https://blossom.azzamo.media", ServerType.Blossom),
ServerName("YakiHonne", "https://blossom.yakihonne.com/", ServerType.Blossom),
ServerName("Primal", "https://blossom.primal.net/", ServerType.Blossom),
ServerName("Sovbit", "https://cdn.sovbit.host", ServerType.Blossom),
ServerName("Sovbit", "https://files.sovbit.host", ServerType.Blossom),
ServerName("Nostr.Download", "https://nostr.download", ServerType.Blossom),
ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom),
ServerName("NostrMedia (Paid)", "https://nostrmedia.com", ServerType.Blossom),
@@ -40,6 +40,7 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.io.encoding.Base64
class BlossomReadAuthTokenProviderTest {
private val sha = "2c5287a55cc550c9d6bc4206a4663900e083315f4a544ea3bc189e43dc330af6"
@@ -160,8 +161,8 @@ class BlossomReadAuthTokenProviderTest {
* 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.
* operations on the specified blob hashes"), and be standard padded Base64
* that a strict decoder accepts.
*/
@Test
fun mintedTokenIsAReusableBud11GetToken() =
@@ -170,9 +171,9 @@ class BlossomReadAuthTokenProviderTest {
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()
// Strict standard decode, as deployed servers do — see BlossomAuthorizationEvent.rawToken.
val event = Base64.decode(token).decodeToString()
val parsed = JacksonMapper.fromJson(event) as BlossomAuthorizationEvent
assertEquals(BlossomAuthorizationEvent.KIND, parsed.kind)
@@ -37,22 +37,22 @@ class BlossomAuthorizationEvent(
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/**
* 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)".
* This event's JSON as standard Base64 WITH padding — the same encoder as
* NIP-98's [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.rawToken].
*
* 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 `=`.
* NOT what BUD-11 (draft) says: "the authorization token MUST be encoded as
* Base64 URL-safe without padding (Base64url, as used by JWTs)". Deployed
* servers decode with a strict standard decoder (Go's base64.StdEncoding in
* khatru-based servers), which rejects both missing padding and the `-`/`_`
* alphabet as "invalid base64 token" — and padding is needed whenever the
* JSON length is not a multiple of three. Interop wins over the draft. Do not
* switch this back to `Base64.UrlSafe` without re-probing a khatru server.
*/
fun rawToken() = BASE64URL.encode(toJson().encodeToByteArray())
fun rawToken() = Base64.encode(toJson().encodeToByteArray())
/**
* The full `Authorization` header value for a Blossom request:
* `Nostr <base64url-event>` (BUD-11, HTTP Authorization Header).
* `Nostr <base64-event>` (BUD-11, HTTP Authorization Header).
*/
fun toAuthorizationHeader() = "$AUTH_HEADER_SCHEME${rawToken()}"
@@ -62,9 +62,6 @@ class BlossomAuthorizationEvent(
/** 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.
*
@@ -23,6 +23,7 @@ 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,35 +97,30 @@ class BlossomAuthorizationEventTest {
assertTrue(header.startsWith(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME))
val token = header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)
val decoded = BlossomAuthorizationEvent.BASE64URL.decode(token).decodeToString()
val decoded = Base64.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.
* Must survive a strict standard decode, as deployed servers do — see
* [BlossomAuthorizationEvent.rawToken]. The alt lengths cover every
* `length mod 3` case, since only some of them need padding.
*/
@Test
fun authorizationTokenIsBase64UrlWithoutPadding() =
fun authorizationTokenIsStandardPaddedBase64BecauseServersDecodeStrictly() =
runTest {
var sawPadding = false
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)
val event = BlossomAuthorizationEvent.createListAuth(signer, alt)
val token = event.toAuthorizationHeader().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",
)
// kotlin.io.encoding.Base64 (default) is the strict standard decoder:
// it throws on missing padding and on `-`/`_`, like Go's StdEncoding.
assertEquals(event.toJson(), Base64.decode(token).decodeToString(), "strict standard decode for `$alt`")
if (token.endsWith("=")) sawPadding = true
}
assertTrue(sawPadding, "at least one of these lengths needs padding; if none did, the encoder is still dropping it")
}
/**