From c2f554f019db7e22aeed2a5f9ce4a5675cdafd0e Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 15 Sep 2026 16:33:12 +0200 Subject: [PATCH 1/4] fix(blossom): send the auth token as standard padded Base64 again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.15.0 switched the Blossom Authorization token to BUD-11's base64url without padding. Deployed servers decode with a strict standard decoder (khatru: Go base64.StdEncoding), which rejects missing padding and the url-safe alphabet as "invalid base64 token". Padding is needed whenever the event JSON length is not a multiple of three, so two uploads in three failed, on every Blossom server the reporter used, since 1.15.0. Back to the encoding every deployed decoder accepts — the same one NIP-98 already uses. The KDoc records the conflict with the draft so this is not "fixed" back. Tests now assert a strict standard decode across lengths covering all three `mod 3` cases. Reported on nostr 14 Sep 2026 (nevent1qqsqqqqd3626guc7w2s92c93mnrx5a5ddews8pzx7mzm6lztj0445tspypmhxue69uhhgmmjw3jkcmrfdehjucnpwdehq6tnw3hkctn0wfnj7q3q8ug6hvhzxhdz6nw6t4k7ktcjx9e5w6n5tg7226y458g0vv4y9aqqxpqqqqqqzlzcuwq). --- .../nipB7Blossom/BlossomAuthorizationEvent.kt | 32 +++++++++------- .../BlossomAuthorizationEventTest.kt | 38 ++++++++++--------- 2 files changed, 39 insertions(+), 31 deletions(-) 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 590e82b634..15fbf39510 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -37,22 +37,25 @@ 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. BUD-11 §"HTTP Authorization Header" reads + * "the authorization token MUST be encoded as Base64 URL-safe without padding + * (Base64url, as used by JWTs)", and 1.15.0 shipped exactly that. Deployed + * servers decode with a strict standard decoder (Go's base64.StdEncoding in + * khatru-based relays): missing padding and the `-`/`_` alphabet are both + * "invalid base64 token". Padding is needed whenever the JSON length is not a + * multiple of three, so two uploads in three failed in the field (reported + * 14 Sep 2026). Until the spec and the reference servers agree, the encoding + * every deployed decoder accepts wins. Do not switch this back to + * `Base64.UrlSafe` without re-probing a khatru server. */ - fun rawToken() = BASE64URL.encode(toJson().encodeToByteArray()) + fun rawToken() = TOKEN_BASE64.encode(toJson().encodeToByteArray()) /** * The full `Authorization` header value for a Blossom request: - * `Nostr ` (BUD-11, HTTP Authorization Header). + * `Nostr ` (BUD-11, HTTP Authorization Header). */ fun toAuthorizationHeader() = "$AUTH_HEADER_SCHEME${rawToken()}" @@ -62,8 +65,11 @@ 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) + /** + * Token encoding: standard alphabet, `=` padding. See [rawToken] for why + * this deliberately ignores BUD-11's base64url MUST. + */ + val TOKEN_BASE64: Base64 = Base64.Default /** * BUD-11 `t=get` read authorization. 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 b9733297ef..b28cf4524c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt @@ -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,36 @@ 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. + * BUD-11 (draft) says base64url without padding, and 1.15.0 shipped exactly that — + * and two uploads in three failed in the field. Deployed Blossom servers decode + * with a STRICT standard decoder: padding is required and the url-safe alphabet + * is rejected (probed 15 Sep 2026: unpadded and url-safe tokens both answer + * "invalid base64 token"; padded standard gets past the decoder). Interop wins + * over the draft. Several alt lengths so all three `length mod 3` cases are hit; + * only some of them need padding, which is exactly why the field failure was + * intermittent. */ @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`") + assertTrue(token.none { it == '-' || it == '_' }, "url-safe alphabet must not appear for `$alt`, got: $token") + if (token.endsWith("=")) sawPadding = true } + assertTrue(sawPadding, "at least one of these lengths needs padding; if none did, the encoder is still dropping it") } /** From b2961e372b42582c87c3ced48c0dfe209873f5e9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 15 Sep 2026 16:36:10 +0200 Subject: [PATCH 2/4] test(blossom): read-auth token test decodes with the strict standard decoder --- .../service/okhttp/BlossomReadAuthTokenProviderTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 5c1655434b..773af27f96 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 @@ -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) From 9ed7acd426b260135deb0602304cf546a28a7373 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 15 Sep 2026 16:41:01 +0200 Subject: [PATCH 3/4] refactor(blossom): drop the Base64.Default alias and trim duplicated token rationale --- .../nipB7Blossom/BlossomAuthorizationEvent.kt | 23 ++++++------------- .../BlossomAuthorizationEventTest.kt | 12 +++------- 2 files changed, 10 insertions(+), 25 deletions(-) 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 15fbf39510..e48f60a840 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -40,18 +40,15 @@ class BlossomAuthorizationEvent( * This event's JSON as standard Base64 WITH padding — the same encoder as * NIP-98's [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.rawToken]. * - * NOT what BUD-11 (draft) says. BUD-11 §"HTTP Authorization Header" reads - * "the authorization token MUST be encoded as Base64 URL-safe without padding - * (Base64url, as used by JWTs)", and 1.15.0 shipped exactly that. Deployed + * 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 relays): missing padding and the `-`/`_` alphabet are both - * "invalid base64 token". Padding is needed whenever the JSON length is not a - * multiple of three, so two uploads in three failed in the field (reported - * 14 Sep 2026). Until the spec and the reference servers agree, the encoding - * every deployed decoder accepts wins. Do not switch this back to - * `Base64.UrlSafe` without re-probing a khatru server. + * 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() = TOKEN_BASE64.encode(toJson().encodeToByteArray()) + fun rawToken() = Base64.encode(toJson().encodeToByteArray()) /** * The full `Authorization` header value for a Blossom request: @@ -65,12 +62,6 @@ class BlossomAuthorizationEvent( /** Scheme prefix for the `Authorization` header value (BUD-11). */ const val AUTH_HEADER_SCHEME = "Nostr " - /** - * Token encoding: standard alphabet, `=` padding. See [rawToken] for why - * this deliberately ignores BUD-11's base64url MUST. - */ - val TOKEN_BASE64: Base64 = Base64.Default - /** * BUD-11 `t=get` read authorization. * 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 b28cf4524c..71c9707f53 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt @@ -102,14 +102,9 @@ class BlossomAuthorizationEventTest { } /** - * BUD-11 (draft) says base64url without padding, and 1.15.0 shipped exactly that — - * and two uploads in three failed in the field. Deployed Blossom servers decode - * with a STRICT standard decoder: padding is required and the url-safe alphabet - * is rejected (probed 15 Sep 2026: unpadded and url-safe tokens both answer - * "invalid base64 token"; padded standard gets past the decoder). Interop wins - * over the draft. Several alt lengths so all three `length mod 3` cases are hit; - * only some of them need padding, which is exactly why the field failure was - * intermittent. + * 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 authorizationTokenIsStandardPaddedBase64BecauseServersDecodeStrictly() = @@ -123,7 +118,6 @@ class BlossomAuthorizationEventTest { // 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`") - assertTrue(token.none { it == '-' || it == '_' }, "url-safe alphabet must not appear for `$alt`, got: $token") if (token.endsWith("=")) sawPadding = true } assertTrue(sawPadding, "at least one of these lengths needs padding; if none did, the encoder is still dropping it") From 23f621f45bba6d191a33d83b372b1bbd924ca5f8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 15 Sep 2026 17:15:24 +0200 Subject: [PATCH 4/4] fix(blossom): point the Sovbit default server at files.sovbit.host cdn.sovbit.host is NXDOMAIN at sovbit's own nameservers, so picking the recommended Sovbit server failed every upload with UnknownHostException. files.sovbit.host is the live Blossom endpoint (already used by the amy live tests) and accepts uploads. --- .../java/com/vitorpamplona/amethyst/ImageUploadTesting.kt | 2 +- .../amethyst/ui/actions/mediaServers/ServerName.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index eb31a759d4..8e8e4fcd67 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt index 4fc05e1c02..5f777ba65e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt @@ -43,7 +43,7 @@ val DEFAULT_MEDIA_SERVERS: List = 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),