From 5bfe049db3aa21e4bb2357499566fecf7071ab0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:40:50 +0000 Subject: [PATCH] fix: show the relay a 22242 login names verbatim, and warn when it's odd Audit of the relay-login card. The requesting app writes the relay tag of a 22242 verbatim, and a fresh signature lets it AUTH to that relay as the user (giftwrap inbox, paid-relay quota), so the relay is the decision. - The card and the consent preview showed the normalizer's *repaired* URL (it adds schemes, trims %20, splits run-on URLs) and silently dropped values it rejected, reporting "No relay named" for an event that does name one. They now show the tag value as signed. - Bidi overrides and zero-width characters in that value are escaped as \uXXXX instead of rendered, so an RLO can't make one host read as another. Any such value, or one that needed rewriting or won't normalize, is shown in the error color with a "check before signing" note. ws:// is no longer stripped from the display. - The explainer said "Nothing is posted", which undersold a login grant at the moment of consent; it now says what holding it allows. - The relay address is never ellipsized; the challenge line uses Text(fontFamily) instead of copying a TextStyle per recomposition. - The helper lives in commons (RelayAuthTarget); the quartz TagArray helpers from the previous commit had no callers left and are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GSwjzewTFcwzqAKVJ1WJ9B --- .../consent/Nip46ConsentInfoBuilder.kt | 5 +- .../relayClient/auth/RelayAuthTargets.kt | 98 ++++++++++++++++++ .../relayClient/auth/RelayAuthTargetsTest.kt | 99 +++++++++++++++++++ .../composeResources/values/strings.xml | 3 +- .../commons/ui/note/types/RelayAuth.kt | 62 ++++++++---- .../quartz/nip42RelayAuth/RelayAuthEvent.kt | 4 +- .../quartz/nip42RelayAuth/TagArrayExt.kt | 31 ------ 7 files changed, 245 insertions(+), 57 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargets.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargetsTest.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt index fdcc046cd8..7d84156e75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt @@ -23,13 +23,12 @@ package com.vitorpamplona.amethyst.connectedApps.consent import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTargets import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip42RelayAuth.authRelays import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.utils.Log @@ -144,7 +143,7 @@ object Nip46ConsentInfoBuilder { content: String, ): String = if (kind == RelayAuthEvent.KIND) { - tags.authRelays().joinToString(", ") { it.displayUrl() } + tags.relayAuthTargets().joinToString(", ") { it.display } } else { content.take(PREVIEW_MAX_CHARS).trim() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargets.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargets.kt new file mode 100644 index 0000000000..2242192e13 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargets.kt @@ -0,0 +1,98 @@ +/* + * 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.commons.relayClient.auth + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag + +/** + * One `relay` tag of a NIP-42 auth event (kind 22242), as a person deciding whether to sign it + * must see it. + * + * The requesting app writes that tag verbatim, and a fresh signature over it lets the app log in + * to that relay as the user (see `NostrSignerPermissionLedger` on why 22242 is never auto-allowed). + * So the address shown is the one being signed: never the normalizer's repaired version (it adds + * schemes, trims `%20`, splits run-on URLs), and never a value whose hidden characters could make + * it read as a different relay. + */ +@Immutable +data class RelayAuthTarget( + /** The tag value, with invisible and direction-changing characters escaped as `\uXXXX`. */ + val display: String, + /** For the relay's icon and NIP-11 info only. Null when the value is not a relay we could reach. */ + val url: NormalizedRelayUrl?, + /** The value is not a plain relay address: it does not normalize, needed rewriting, or hid characters. */ + val unusual: Boolean, +) + +/** Every `relay` tag of a NIP-42 auth event or template, in tag order. Usually just one. */ +fun TagArray.relayAuthTargets(): List = + mapNotNull { tag -> + if (tag.size > 1 && tag[0] == RelayTag.TAG_NAME && tag[1].isNotEmpty()) relayAuthTarget(tag[1]) else null + } + +fun relayAuthTarget(raw: String): RelayAuthTarget { + val escaped = escapeHiddenChars(raw) + val url = RelayUrlNormalizer.normalizeOrNull(raw) + val plain = url != null && escaped == raw && sameAddress(raw, url) + return RelayAuthTarget( + // Only the default `wss://` is dropped: an insecure `ws://` stays visible. + display = if (plain) url.url.removePrefix("wss://").removeSuffix("/") else escaped, + url = url, + unusual = !plain, + ) +} + +/** Only scheme/host case and a trailing slash may differ; anything else means the normalizer rewrote it. */ +private fun sameAddress( + raw: String, + url: NormalizedRelayUrl, +) = raw.trimEnd('/').equals(url.url.trimEnd('/'), ignoreCase = true) + +private fun isHidden(c: Char) = + c.isISOControl() || + c in '\u200B'..'\u200F' || + c in '\u202A'..'\u202E' || + c in '\u2060'..'\u2069' || + c == '\u061C' || + c == '\uFEFF' + +private fun escapeHiddenChars(raw: String): String { + if (raw.none(::isHidden)) return raw + return buildString(raw.length + 16) { + raw.forEach { c -> + if (isHidden(c)) { + append("\\u") + append( + c.code + .toString(16) + .uppercase() + .padStart(4, '0'), + ) + } else { + append(c) + } + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargetsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargetsTest.kt new file mode 100644 index 0000000000..81bb15e4a9 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargetsTest.kt @@ -0,0 +1,99 @@ +/* + * 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.commons.relayClient.auth + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RelayAuthTargetsTest { + @Test + fun aPlainRelayShowsAsItsHost() { + val target = relayAuthTarget("wss://relay.damus.io") + assertEquals("relay.damus.io", target.display) + assertNotNull(target.url) + assertFalse(target.unusual) + } + + @Test + fun trailingSlashAndHostCaseAreNotUnusual() { + assertFalse(relayAuthTarget("wss://relay.damus.io/").unusual) + assertFalse(relayAuthTarget("wss://Relay.Damus.io").unusual) + } + + @Test + fun anInsecureSchemeStaysVisible() { + val target = relayAuthTarget("ws://relay.example.com") + assertEquals("ws://relay.example.com", target.display) + assertFalse(target.unusual) + } + + @Test + fun aValueTheNormalizerWouldRepairIsShownVerbatim() { + // The normalizer adds a scheme / trims %20; the user must see what is actually signed. + val noScheme = relayAuthTarget("relay.damus.io") + assertEquals("relay.damus.io", noScheme.display) + assertTrue(noScheme.unusual) + + val padded = relayAuthTarget("wss://relay.damus.io%20") + assertEquals("wss://relay.damus.io%20", padded.display) + assertTrue(padded.unusual) + } + + @Test + fun hiddenDirectionCharactersAreEscapedNotRendered() { + // RLO would make "wss://\u202Eoi.live.xyz" read as a different host. + val target = relayAuthTarget("wss://\u202Eoi.live.xyz") + assertEquals("wss://\\u202Eoi.live.xyz", target.display) + assertTrue(target.unusual) + } + + @Test + fun zeroWidthCharactersAreEscaped() { + val target = relayAuthTarget("wss://relay.da\u200Bmus.io") + assertEquals("wss://relay.da\\u200Bmus.io", target.display) + assertTrue(target.unusual) + } + + @Test + fun garbageIsStillShownAndFlagged() { + val target = relayAuthTarget("not a relay") + assertEquals("not a relay", target.display) + assertNull(target.url) + assertTrue(target.unusual) + } + + @Test + fun readsEveryRelayTagInOrderAndSkipsTheRest() { + val tags = + arrayOf( + arrayOf("relay", "wss://a.example.com"), + arrayOf("challenge", "abc"), + arrayOf("relay", ""), + arrayOf("relay"), + arrayOf("relay", "wss://b.example.com"), + ) + assertEquals(listOf("a.example.com", "b.example.com"), tags.relayAuthTargets().map { it.display }) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 82e11bc2a9..4dd494e2ba 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1135,7 +1135,8 @@ Logged in until you restart Amethyst Forget this login Relay login - Proves to the relay that you control this account. Nothing is posted. + Not a post. It logs you in to this relay: whoever holds it can read what the relay only shows to you, like your private messages. + This is not a plain relay address. Check it before signing. No relay named in this login Challenge: %1$s %1$s will ask again the next time it needs you. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt index 4771cdbf46..547e9a3aaa 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -40,11 +41,15 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthTarget +import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTarget +import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTargets import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_challenge import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_explainer import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_no_relay import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_title +import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_unusual_relay import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.RenderRelay import com.vitorpamplona.amethyst.commons.ui.note.RenderRelayIcon @@ -52,13 +57,13 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent /** * NIP-42 kind 22242: a login to a relay. It is not a post — its content is empty and nobody but * the relay ever sees it — so it renders as "log in to " instead of falling through to the - * text-note layout. Mostly met in signer consent prompts, where an app asks to sign one. + * text-note layout. Mostly met in signer consent prompts, where an app asks to sign one, so the + * relay is shown exactly as the event names it (see [RelayAuthTarget]). */ @Composable fun RenderRelayAuth( @@ -67,7 +72,7 @@ fun RenderRelayAuth( nav: INav, ) { val noteEvent = baseNote.event as? RelayAuthEvent ?: return - val relays = remember(noteEvent) { noteEvent.relays() } + val relays = remember(noteEvent) { noteEvent.tags.relayAuthTargets() } val challenge = remember(noteEvent) { noteEvent.challenge() } RelayAuthCard(relays, challenge) { relay -> @@ -77,7 +82,7 @@ fun RenderRelayAuth( @Composable fun RelayAuthCard( - relays: List, + relays: List, challenge: String?, relayIcon: @Composable (NormalizedRelayUrl) -> Unit, ) { @@ -122,26 +127,45 @@ fun RelayAuthCard( } relays.forEach { relay -> - Row( - modifier = Modifier.padding(start = 32.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - relayIcon(relay) - Text( - text = relay.displayUrl(), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column(modifier = Modifier.padding(start = 32.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val url = relay.url + if (url != null) { + relayIcon(url) + } else { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + modifier = Modifier.size(17.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + // Never ellipsized: the whole address is what the user is agreeing to. + Text( + text = relay.display, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + color = if (relay.unusual) MaterialTheme.colorScheme.error else Color.Unspecified, + ) + } + if (relay.unusual) { + Text( + text = stringRes(Res.string.relay_auth_event_unusual_relay), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } challenge?.let { Text( text = stringRes(Res.string.relay_auth_event_challenge, it), - style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -156,7 +180,7 @@ fun RelayAuthCard( private fun RelayAuthCardPreview() { ThemeComparisonColumn { RelayAuthCard( - relays = listOf(NormalizedRelayUrl("wss://relay.damus.io/")), + relays = listOf(relayAuthTarget("wss://relay.damus.io"), relayAuthTarget("not a relay")), challenge = "4f2c9a1e-7b3d-4c8e-a6f0-2d9b1e3c5a7f", ) { relay -> RenderRelayIcon( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt index 8505a1caa0..e030bafc20 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt @@ -42,9 +42,7 @@ class RelayAuthEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) - fun relays() = tags.authRelays() - - fun challenge() = tags.authChallenge() + fun challenge() = tags.firstNotNullOfOrNull(ChallengeTag::parse) companion object { const val KIND = 22242 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt deleted file mode 100644 index a60c94ef3f..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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.quartz.nip42RelayAuth - -import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull -import com.vitorpamplona.quartz.nip42RelayAuth.tags.ChallengeTag -import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag - -/** Every relay a NIP-42 auth event (or its unsigned template) logs into. Usually just one. */ -fun TagArray.authRelays() = mapNotNull(RelayTag::parse) - -fun TagArray.authChallenge() = fastFirstNotNullOfOrNull(ChallengeTag::parse)