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), 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) 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..e48f60a840 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,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 ` (BUD-11, HTTP Authorization Header). + * `Nostr ` (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. * 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..71c9707f53 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,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") } /**