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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSwjzewTFcwzqAKVJ1WJ9B
This commit is contained in:
Claude
2026-10-02 19:40:50 +00:00
parent 2e3e818945
commit 5bfe049db3
7 changed files with 245 additions and 57 deletions
@@ -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()
}
@@ -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<RelayAuthTarget> =
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)
}
}
}
}
@@ -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 })
}
}
@@ -1135,7 +1135,8 @@
<string name="relay_auth_session_row_desc">Logged in until you restart Amethyst</string>
<string name="relay_auth_forget_session">Forget this login</string>
<string name="relay_auth_event_title">Relay login</string>
<string name="relay_auth_event_explainer">Proves to the relay that you control this account. Nothing is posted.</string>
<string name="relay_auth_event_explainer">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.</string>
<string name="relay_auth_event_unusual_relay">This is not a plain relay address. Check it before signing.</string>
<string name="relay_auth_event_no_relay">No relay named in this login</string>
<string name="relay_auth_event_challenge">Challenge: %1$s</string>
<string name="relay_auth_session_forgotten_undo">%1$s will ask again the next time it needs you.</string>
@@ -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 <relay>" 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<NormalizedRelayUrl>,
relays: List<RelayAuthTarget>,
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(
@@ -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
@@ -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)