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)