diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt index ff47f1d38d..a61d888450 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.desktop.auth import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.sha256.sha256 import java.util.prefs.Preferences /** @@ -46,6 +48,16 @@ import java.util.prefs.Preferences * `ONCE` scope is never persisted — that's the in-memory contract enforced * by the [AuthApprovalStore] interface. This implementation only writes * `ALWAYS` and `BLOCKED`. + * + * **Key length:** `java.util.prefs.Preferences` caps keys at + * [Preferences.MAX_KEY_LENGTH] (80 chars) and throws `IllegalArgumentException` + * from [Preferences.put] for anything longer. Relay URLs routinely exceed that + * — e.g. an outbox-proxy URL that embeds an npub and a query string + * (`wss://filter.nostr.wine/npub1…?broadcast=true`, 100+ chars). Storing such a + * URL raw made [setScope] throw; the caller (`RelayAuthenticator`) swallows the + * exception, so the `ALWAYS` / `BLOCKED` grant was silently never persisted and + * the AUTH banner re-appeared on every challenge. [keyFor] folds any over-long + * URL into a bounded 64-char SHA-256 hex key to stay under the cap. */ class PreferencesAuthApprovalStore( private val accountPubKeyHex: String, @@ -55,8 +67,27 @@ class PreferencesAuthApprovalStore( "/com/vitorpamplona/amethyst/desktop/auth/$accountPubKeyHex", ) + /** + * The Preferences key for a relay URL, guaranteed to fit within + * [Preferences.MAX_KEY_LENGTH]. + * + * Short URLs are stored verbatim (readable, and backward-compatible with + * grants written before this fix). URLs at or over the limit are hashed to + * a `sha256:`-prefixed 64-char hex digest (71 chars total, under the 80 + * cap). The prefix keeps the hashed keyspace disjoint from raw relay URLs, + * which always start with `ws://` / `wss://`, so the two can never collide. + */ + private fun keyFor(relayUrl: NormalizedRelayUrl): String { + val url = relayUrl.url + return if (url.length <= Preferences.MAX_KEY_LENGTH) { + url + } else { + "sha256:" + sha256(url.encodeToByteArray()).toHexKey() + } + } + override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? { - val raw = node.get(relayUrl.url, null) ?: return null + val raw = node.get(keyFor(relayUrl), null) ?: return null return runCatching { AuthApprovalScope.valueOf(raw) }.getOrNull() } @@ -70,7 +101,7 @@ class PreferencesAuthApprovalStore( // upgrade to "until next clear()". return } - node.put(relayUrl.url, scope.name) + node.put(keyFor(relayUrl), scope.name) node.flush() } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt new file mode 100644 index 0000000000..6fa6506c48 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStoreTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.auth + +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PreferencesAuthApprovalStoreTest { + // A distinct per-run account so the test never collides with a real user's + // stored grants under Preferences.userRoot(). + private val accountPubKeyHex = "test${System.nanoTime()}".padEnd(64, '0').take(64) + private val store = PreferencesAuthApprovalStore(accountPubKeyHex) + + @AfterTest + fun cleanup() = + runTest { + store.clear() + } + + @Test + fun shortRelayUrlRoundTrips() = + runTest { + val relay = NormalizedRelayUrl("wss://relay.example/") + store.setScope(relay, AuthApprovalScope.ALWAYS) + assertEquals(AuthApprovalScope.ALWAYS, store.getScope(relay)) + } + + @Test + fun overLongRelayUrlPersistsInsteadOfThrowing() = + runTest { + // 102 chars — over java.util.prefs.Preferences.MAX_KEY_LENGTH (80). + // Storing this raw threw "Key too long", the exception was swallowed + // upstream, and the grant silently never persisted -> the AUTH banner + // re-appeared on every challenge. Regression guard for that bug. + val relay = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") + assertEquals(102, relay.url.length) + + store.setScope(relay, AuthApprovalScope.ALWAYS) + assertEquals(AuthApprovalScope.ALWAYS, store.getScope(relay)) + } + + @Test + fun overLongRelayUrlBlockedPersists() = + runTest { + val relay = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") + store.setScope(relay, AuthApprovalScope.BLOCKED) + assertEquals(AuthApprovalScope.BLOCKED, store.getScope(relay)) + } + + @Test + fun distinctOverLongRelayUrlsDoNotCollide() = + runTest { + val a = NormalizedRelayUrl("wss://filter.nostr.wine/npub1max2lm5977tkj4zc28djq25g2muzmjgh2jqf83mq7vy539hfs7eqgec4et?broadcast=true") + val b = NormalizedRelayUrl("wss://filter.nostr.wine/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq?broadcast=true") + store.setScope(a, AuthApprovalScope.ALWAYS) + store.setScope(b, AuthApprovalScope.BLOCKED) + assertEquals(AuthApprovalScope.ALWAYS, store.getScope(a)) + assertEquals(AuthApprovalScope.BLOCKED, store.getScope(b)) + } + + @Test + fun onceIsNeverPersisted() = + runTest { + val relay = NormalizedRelayUrl("wss://relay.example/") + store.setScope(relay, AuthApprovalScope.ONCE) + assertNull(store.getScope(relay)) + } +}