diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt index c33f421c56..4ed732db3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt @@ -55,9 +55,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRoleChips @@ -72,13 +74,14 @@ import com.vitorpamplona.amethyst.commons.resources.relay_members_loading import com.vitorpamplona.amethyst.commons.resources.relay_members_request_join import com.vitorpamplona.amethyst.commons.resources.relay_members_request_leave import com.vitorpamplona.amethyst.commons.resources.relay_members_title +import com.vitorpamplona.amethyst.commons.resources.relay_members_unverifiable import com.vitorpamplona.amethyst.commons.resources.relay_members_you_are_member import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -107,20 +110,37 @@ fun RelayMembersScreen( var members by remember { mutableStateOf>(emptyList()) } var roles by remember { mutableStateOf>(emptyMap()) } var isLoading by remember { mutableStateOf(true) } + // The relay publishes no NIP-11 `self`, so nothing it serves can be verified as relay-signed. + var isUnverifiable by remember { mutableStateOf(false) } var isMember by remember { mutableStateOf(false) } var joinRequestSent by remember { mutableStateOf(false) } var leaveRequestSent by remember { mutableStateOf(false) } var inviteCode by remember { mutableStateOf("") } val scope = rememberCoroutineScope() - // NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. - // Filter by it once the doc resolves; until then, take whatever the relay serves. - val relayInfo by loadRelayInfo(normalizedRelayUrl) - val relaySelf = relayInfo.self - - LaunchedEffect(normalizedRelayUrl, relaySelf) { + // NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. Resolve it + // first (loading state meanwhile) and fetch once, by that author only. A relay that publishes no + // `self` gets an explanatory state instead of lists anyone could have signed. + LaunchedEffect(normalizedRelayUrl) { launch(Dispatchers.IO) { - val authors = relaySelf?.let { listOf(it) } + var relaySelf: HexKey? = null + Amethyst.instance.nip11Cache.loadRelayInfo( + relay = normalizedRelayUrl, + onInfo = { relaySelf = it.self }, + onError = { _, _, _ -> }, + ) + + val self = relaySelf + if (self == null) { + members = emptyList() + roles = emptyMap() + isMember = false + isUnverifiable = true + isLoading = false + return@launch + } + + val authors = listOf(self) val filters = listOf( Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = authors, limit = 1), @@ -135,14 +155,13 @@ fun RelayMembersScreen( val membershipEvent = events ?.mapNotNull { it as? RelayMembershipListEvent } + ?.filter { it.pubKey == self } ?.maxByOrNull { it.createdAt } - // Only trust role definitions from whoever signed the member list. - val roleSigner = relaySelf ?: membershipEvent?.pubKey roles = events ?.mapNotNull { it as? RelayRoleEvent } - ?.filter { it.pubKey == roleSigner } + ?.filter { it.pubKey == self } ?.groupBy { it.roleId() } ?.mapValues { (_, versions) -> versions.maxBy { it.createdAt }.role() } ?: emptyMap() @@ -150,6 +169,7 @@ fun RelayMembersScreen( val memberList = membershipEvent?.membersWithRoles() ?: emptyList() members = memberList isMember = memberList.any { it.pubKey == accountViewModel.account.signer.pubKey } + isUnverifiable = false isLoading = false } } @@ -218,7 +238,7 @@ fun RelayMembersScreen( } } else if (members.isEmpty()) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -230,8 +250,9 @@ fun RelayMembersScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringRes(Res.string.relay_members_empty), + text = stringRes(if (isUnverifiable) Res.string.relay_members_unverifiable else Res.string.relay_members_empty), color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) } } else { diff --git a/cli/README.md b/cli/README.md index d226121f21..a6eb26f49e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -599,7 +599,7 @@ screen speaks. | `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). | | `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). | | `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). | -| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list is kept. | +| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. | ### Buzz workspaces (block/buzz — NIP-29 dialect) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt index 91cdbbbad1..569120d012 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -24,7 +24,10 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent @@ -144,6 +147,10 @@ object RelayGroupModerationCommands { * list, so this reads the group's current kind-39005 and re-submits it with REF added (at the * end) or removed — every other pin, `e` or `a`, kept verbatim. REF is an event (`note1`, * `nevent1`, 64-hex → `e`) or an addressable event (`naddr1`, `kind:pubkey:d` → `a`). + * + * The 39005 is only trusted when signed by the relay's NIP-11 `self` key, and a read that did not + * reach EOSE aborts instead of being taken as "no pins" — publishing a full replacement from a + * failed read would wipe every existing pin. */ suspend fun pin( dataDir: DataDir, @@ -162,14 +169,23 @@ object RelayGroupModerationCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val filter = Filter(kinds = listOf(GroupPinnedEvent.KIND), tags = mapOf("d" to listOf(groupId)), limit = 1) + // NIP-29: the 39005 is "signed by the relay keypair … as stated by the NIP-11 `self`". + // Without that key we cannot tell the real list from a forged one, so do not rewrite it. + val relayKey = + ctx.relayInfo(relay)?.self + ?: return Output.error("no_relay_key", "could not read the NIP-11 self pubkey of ${relay.url}; refusing to rewrite the pin list") + val filter = + Filter( + kinds = listOf(GroupPinnedEvent.KIND), + authors = listOf(relayKey), + tags = mapOf("d" to listOf(groupId)), + limit = 1, + ) val current = - ctx - .drain(mapOf(relay to listOf(filter)), 6_000) - .map { it.second } - .filterIsInstance() - .maxByOrNull { it.createdAt } - ?.pins() ?: emptyList() + when (val read = readPinList(ctx.drainResult(mapOf(relay to listOf(filter)), 6_000), relay, relayKey)) { + is PinListRead.Found -> read.pins + is PinListRead.Failed -> return Output.error(read.code, read.detail) + } val updated = if (pin) { @@ -195,6 +211,42 @@ object RelayGroupModerationCommands { } } + /** The outcome of reading a group's current kind-39005 before a read-merge-write. */ + internal sealed interface PinListRead { + class Found( + val pins: List, + ) : PinListRead + + class Failed( + val code: String, + val detail: String, + ) : PinListRead + } + + /** + * The latest relay-signed 39005 in [result]; an empty list only when the relay answered (EOSE) + * and had none. A timeout maps to `timeout` (exit 124), any other unanswered read to + * `fetch_failed` (exit 1). + */ + internal fun readPinList( + result: FetchAllResult, + relay: NormalizedRelayUrl, + relayKey: HexKey, + ): PinListRead { + val latest = + result.events + .map { it.second } + .filterIsInstance() + .filter { it.pubKey == relayKey } + .maxByOrNull { it.createdAt } + return when { + latest != null -> PinListRead.Found(latest.pins()) + result.anyRelayServed -> PinListRead.Found(emptyList()) + relay in result.stalled -> PinListRead.Failed("timeout", "${relay.url} did not answer the pin-list read; refusing to overwrite pins") + else -> PinListRead.Failed("fetch_failed", "could not read the pin list from ${relay.url} (${result.doneReasons[relay] ?: "no answer"}); refusing to overwrite pins") + } + } + /** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */ suspend fun invite( dataDir: DataDir, diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt new file mode 100644 index 0000000000..9262d824b9 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt @@ -0,0 +1,85 @@ +/* + * 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.cli + +import com.vitorpamplona.amethyst.cli.commands.RelayGroupModerationCommands +import com.vitorpamplona.amethyst.cli.commands.RelayGroupModerationCommands.PinListRead +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DONE_REASON_EOSE +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * `relaygroup pin|unpin` re-submits the whole 39005 list, so a failed read must abort instead of + * being read as "no pins" (which would publish an empty replacement and wipe them). + */ +class RelayGroupPinReadTest { + private val relay = RelayUrlNormalizer.normalize("wss://groups.example.com") + private val relayKey = "aa".repeat(32) + private val stranger = "bb".repeat(32) + private val pinnedId = "cc".repeat(32) + + private fun pinned( + author: String, + createdAt: Long, + ) = GroupPinnedEvent( + id = "dd".repeat(32), + pubKey = author, + createdAt = createdAt, + tags = arrayOf(arrayOf("d", "gid"), arrayOf("e", pinnedId)), + content = "", + sig = "ee".repeat(64), + ) + + @Test + fun eoseWithoutAListMeansNoPins() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to DONE_REASON_EOSE), emptySet()), relay, relayKey) + assertEquals(emptyList(), assertIs(read).pins) + } + + @Test + fun aTimedOutReadAbortsWithTimeout() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), emptyMap(), setOf(relay)), relay, relayKey) + assertEquals("timeout", assertIs(read).code) + } + + @Test + fun aClosedOrUnreachableReadAborts() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to "cannot:refused"), emptySet()), relay, relayKey) + assertEquals("fetch_failed", assertIs(read).code) + } + + @Test + fun onlyTheRelaySignedListCounts() { + val events = listOf(relay to pinned(stranger, 200), relay to pinned(relayKey, 100)) + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(events, mapOf(relay to DONE_REASON_EOSE), emptySet()), relay, relayKey) + assertEquals(listOf(pinnedId), assertIs(read).pins.map { it.ref }) + } + + @Test + fun aForgedListAloneIsNotAnAnswerWithoutEose() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(listOf(relay to pinned(stranger, 200)), emptyMap(), setOf(relay)), relay, relayKey) + assertEquals("timeout", assertIs(read).code) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt index 523d87f95b..2638f54f53 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt @@ -115,6 +115,14 @@ object RelayGroupMigrationDetector { * [com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore]. */ object RelayGroupAdminCache { + /** + * Most groups kept. The cache is device-global (shared by every account on the device) and is + * filled for any group whose migration bar is shown, so it is bounded by recency of change instead + * of by one account's kind-10009: past the cap, the entry changed least recently is dropped. + */ + const val MAX_GROUPS = 256 + + // Insertion order = recency of change (oldest first), so the cap trims from the front. private val admins = MutableStateFlow>>(emptyMap()) val flow: StateFlow>> = admins @@ -131,13 +139,40 @@ object RelayGroupAdminCache { while (true) { val current = admins.value if (current[key] == pubkeys) return - if (admins.compareAndSet(current, current + (key to pubkeys))) return + val next = LinkedHashMap(current) + next.remove(key) + next[key] = pubkeys + if (admins.compareAndSet(current, next.trimToCap())) return } } - /** Replaces the whole map — used to restore from disk at startup. */ - fun restore(map: Map>) { - admins.value = map + /** + * Merges the map read from disk at startup UNDER the in-memory one: the disk read is async, so a + * [remember] that landed before it completed is newer and wins. Returns the merged map. + */ + fun restore(fromDisk: Map>): Map> { + while (true) { + val current = admins.value + val merged = LinkedHashMap>(fromDisk.size + current.size) + merged.putAll(fromDisk) + for ((key, value) in current) { + merged.remove(key) + merged[key] = value + } + val next = merged.trimToCap() + if (admins.compareAndSet(current, next)) return next + } + } + + private fun LinkedHashMap>.trimToCap(): LinkedHashMap> { + if (size > MAX_GROUPS) { + val oldestFirst = entries.iterator() + repeat(size - MAX_GROUPS) { + oldestFirst.next() + oldestFirst.remove() + } + } + return this } /** Test-only: clears the cache so unit tests don't leak state into each other. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt index c422e772a2..c1d3189c25 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlin.concurrent.Volatile /** * The account's own kind:30382 contact cards — one card per target user, signed @@ -65,14 +66,19 @@ class UserAssertionsState( // The account's own kind-3 follow list, whose `p` tags may carry NIP-02 petnames. private val followListNote: AddressableNote by lazy { cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)) } - // pubkey -> petname for the latest follow list, rebuilt only when that list changes. + // pubkey -> petname for the latest follow list, rebuilt only when that list changes. An immutable + // snapshot published through a volatile field: the flows rebuild it on an IO thread while + // composition reads it through [cachedFollowListPetname], and a racing rebuild only costs a + // redundant (identical) index, never a torn one. private class PetnameIndex( val event: ContactListEvent, val petnames: Map, ) + @Volatile private var petnameIndex: PetnameIndex? = null + /** Looks [target] up in [followList]'s index, rebuilding it when stale. Parses tags: keep off the main thread. */ private fun followListPetname( followList: ContactListEvent?, target: HexKey, @@ -150,9 +156,14 @@ class UserAssertionsState( .stateFlow .map { followListPetname(it.note.event as? ContactListEvent, target.pubkeyHex) } .distinctUntilChanged() + .flowOn(Dispatchers.IO) - /** Synchronous counterpart of [followListPetnameFlow]. */ - fun cachedFollowListPetname(target: User): String? = followListPetname(followListNote.event as? ContactListEvent, target.pubkeyHex) + /** + * Synchronous counterpart of [followListPetnameFlow], for initial values in composition: it only + * reads the last index the flows built (possibly for the previous version of the follow list — + * the flow corrects it right after) and never parses the follow list on the calling thread. + */ + fun cachedFollowListPetname(target: User): String? = petnameIndex?.petnames?.get(target.pubkeyHex) /** * The name the account knows [target] by, for rendering: the NIP-85 nickname when it has a @@ -178,9 +189,10 @@ class UserAssertionsState( } /** - * The name to render for [target], per the NIP-81 policy: the nickname the - * account gave them wins over the profile's own display name, then the - * account's NIP-02 follow-list petname, falling back to the short npub. + * The name to render for [target]: the NIP-85 nickname the account gave them (NIP-81 policy), + * then the account's NIP-02 follow-list petname — the user's own local name for the contact, so + * it too wins over the profile's self-chosen name — then the profile's display name, falling + * back to the short npub. */ fun displayNameFlow(target: User): Flow = combine( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt index 2fcbf9eaf7..d9fda8fb27 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt @@ -29,7 +29,6 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupAdmin import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException @@ -47,19 +46,30 @@ class RelayGroupAdminCacheStore( ) { init { scope.launch { - restoreFromDisk() - RelayGroupAdminCache.flow.drop(1).collect { persist(it) } + // What the disk holds; only a map that differs from it is written back. Comparing instead + // of dropping the flow's first value also persists a remember() that raced the restore. + var onDisk = restoreFromDisk() + RelayGroupAdminCache.flow.collect { + if (it != onDisk) { + persist(it) + onDisk = it + } + } } } - private suspend fun restoreFromDisk() { - try { - val raw = store.data.first()[KEY] ?: return - if (raw.isNotEmpty()) RelayGroupAdminCache.restore(decode(raw)) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("RelayGroupAdminCache") { "Error reading cached group admins: ${e.message}" } - } + /** Merges the saved map into the cache (in-memory entries win) and returns what the disk held. */ + private suspend fun restoreFromDisk(): Map> { + val fromDisk = + try { + store.data.first()[KEY]?.let(::decode) ?: emptyMap() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupAdminCache") { "Error reading cached group admins: ${e.message}" } + emptyMap() + } + RelayGroupAdminCache.restore(fromDisk) + return fromDisk } private suspend fun persist(map: Map>) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt index 92c1253805..92f702038b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt @@ -66,23 +66,26 @@ fun filterRelayGroupState( relays.map { RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, ids = pinnedIds)) } } - // Same back-fill for `a` pins (addressable events, NIP-29 #2416): one kind+author+#d filter per - // pinned address. No `since` either — the pin stays valid however old the latest version is. + // Same back-fill for `a` pins (addressable events, NIP-29 #2416): one kind+author filter per + // (kind, author) pair carrying every pinned `d` of that pair — the union of the per-address + // filters, in as few filters as the pins allow. No `since` either — the pin stays valid however + // old the latest version is. val pinnedAddresses = channel.pinnedAddresses val addressPins = if (pinnedAddresses.isEmpty()) { emptyList() } else { + val byKindAndAuthor = pinnedAddresses.groupBy({ it.kind to it.pubKeyHex }, { it.dTag }) relays.flatMap { relay -> - pinnedAddresses.map { address -> + byKindAndAuthor.map { (kindAndAuthor, dTags) -> RelayBasedFilter( relay = relay, filter = ExplainedFilter( purpose = SubPurpose.RELAY_GROUPS, - kinds = listOf(address.kind), - authors = listOf(address.pubKeyHex), - tags = mapOf("d" to listOf(address.dTag)), + kinds = listOf(kindAndAuthor.first), + authors = listOf(kindAndAuthor.second), + tags = mapOf("d" to dTags.distinct()), ), ) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt index edf8158329..58c80731d8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt @@ -136,4 +136,34 @@ class RelayGroupMigrationDetectorTest { assertEquals(mapOf(current.toKey() to setOf(admin, friend)), restored) assertEquals(setOf(admin, friend), RelayGroupAdminCache.adminsOf(current)) } + + @Test + fun restoringFromDiskKeepsRememberCallsThatLandedFirst() { + val other = GroupId("other", newRelay) + // The async disk read completes after the UI already recorded a fresher roster. + RelayGroupAdminCache.remember(current, setOf(friend)) + + val merged = RelayGroupAdminCache.restore(mapOf(current.toKey() to setOf(admin), other.toKey() to setOf(stranger))) + + assertEquals(setOf(friend), RelayGroupAdminCache.adminsOf(current), "the in-memory entry is newer and wins") + assertEquals(setOf(stranger), RelayGroupAdminCache.adminsOf(other), "disk-only entries are restored") + assertEquals(RelayGroupAdminCache.flow.value, merged) + } + + @Test + fun adminCacheIsCappedDroppingTheLeastRecentlyChanged() { + val first = GroupId("g0", newRelay) + RelayGroupAdminCache.remember(first, setOf(admin)) + repeat(RelayGroupAdminCache.MAX_GROUPS) { i -> RelayGroupAdminCache.remember(GroupId("g${i + 1}", newRelay), setOf(admin)) } + + assertEquals(RelayGroupAdminCache.MAX_GROUPS, RelayGroupAdminCache.flow.value.size) + assertTrue(RelayGroupAdminCache.adminsOf(first).isEmpty(), "the oldest entry is evicted") + assertEquals(setOf(admin), RelayGroupAdminCache.adminsOf(GroupId("g${RelayGroupAdminCache.MAX_GROUPS}", newRelay))) + + // A restore past the cap trims too, keeping the in-memory (newer) entries. + val fromDisk = (0 until RelayGroupAdminCache.MAX_GROUPS).associate { GroupId("disk$it", newRelay).toKey() to setOf(friend) } + RelayGroupAdminCache.restore(fromDisk) + assertEquals(RelayGroupAdminCache.MAX_GROUPS, RelayGroupAdminCache.flow.value.size) + assertEquals(setOf(admin), RelayGroupAdminCache.adminsOf(GroupId("g${RelayGroupAdminCache.MAX_GROUPS}", newRelay))) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt new file mode 100644 index 0000000000..138fa1a50a --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt @@ -0,0 +1,128 @@ +/* + * 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.model.nip85TrustedAssertions + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The display-name order: NIP-85 nickname, then the account's NIP-02 follow-list petname (the + * user's own local name for the contact, so it beats the profile's self-chosen name), then the + * profile's name, then the short npub. + */ +class UserAssertionsDisplayNameTest { + private val signer = NostrSignerInternal(KeyPair()) + private val target = "aa".repeat(32) + + private class MapCache : ICacheProvider { + private val context = UserContext { addr -> AddressableNote(addr) } + val users = HashMap() + val addressables = HashMap() + + override val relayHints = HintIndexer() + + override fun getAnyChannel(note: Note): Channel? = null + + override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = null + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null + + override fun getOrCreateAddressableNote(address: Address): AddressableNote = addressables.getOrPut(address) { AddressableNote(address) } + + override fun getEventStream(): ICacheEventStream = error("unused") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User = users.getOrPut(pubkey) { User(pubkey, context) } + + override fun consumeEmbedded(event: Event) = Unit + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } + + private fun setProfileName( + user: User, + name: String, + ) { + user.metadata().flow.value = UserInfo(UserMetadata().apply { this.name = name }, EmptyTagList, emptyList(), 1) + } + + private fun loadFollowList( + cache: MapCache, + vararg tags: Array, + ) { + val event = ContactListEvent("11".repeat(32), signer.pubKey, 10, arrayOf(*tags), "", "22".repeat(64)) + cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)).loadEvent(event, cache.getOrCreateUser(signer.pubKey), emptyList()) + } + + @Test + fun followListPetnameBeatsTheProfileName() = + runTest { + val cache = MapCache() + val state = UserAssertionsState(signer, cache, UserAssertionDecryptionCache(signer), EmojiPackState(signer, cache, backgroundScope)) + val user = cache.getOrCreateUser(target) + setProfileName(user, "Profile Name") + + assertEquals("Profile Name", state.displayNameFlow(user).first()) + + loadFollowList(cache, arrayOf("p", target, "", "bestie")) + assertEquals("bestie", state.displayNameFlow(user).first()) + // The synchronous initial value reads the index the flow just built. + assertEquals("bestie", state.cachedDisplayName(user)) + } + + @Test + fun withoutAPetnameTheProfileNameShows() = + runTest { + val cache = MapCache() + val state = UserAssertionsState(signer, cache, UserAssertionDecryptionCache(signer), EmojiPackState(signer, cache, backgroundScope)) + val user = cache.getOrCreateUser(target) + setProfileName(user, "Profile Name") + loadFollowList(cache, arrayOf("p", target)) + + assertEquals("Profile Name", state.displayNameFlow(user).first()) + assertEquals("Profile Name", state.cachedDisplayName(user)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt index 813b8d82ae..f45d56aa1d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt @@ -152,4 +152,36 @@ class FilterRelayGroupStateTest { assertEquals(listOf("article"), addressFilter.filter.tags!!["d"]) assertNull(addressFilter.filter.since) } + + @Test + fun `address pins sharing a kind and author collapse into one filter`() { + val channel = RelayGroupChannel(groupId) + val author = "b".repeat(64) + val other = "e".repeat(64) + channel.updatePinned( + GroupPinnedEvent( + id = "d".repeat(64), + pubKey = relaySignKey, + createdAt = 100L, + tags = + arrayOf( + arrayOf("d", "g1"), + arrayOf("a", "30023:$author:one"), + arrayOf("a", "30023:$author:two"), + arrayOf("a", "30023:$other:three"), + arrayOf("a", "30311:$author:live"), + ), + content = "", + sig = sig, + ), + ) + + val addressFilters = filterRelayGroupState(channel, since = null).filter { it.filter.authors != null } + assertEquals(3, addressFilters.size, "one filter per (kind, author), not per address") + + val byKey = addressFilters.associateBy { it.filter.kinds!!.single() to it.filter.authors!!.single() } + assertEquals(listOf("one", "two"), byKey[30023 to author]!!.filter.tags!!["d"]) + assertEquals(listOf("three"), byKey[30023 to other]!!.filter.tags!!["d"]) + assertEquals(listOf("live"), byKey[30311 to author]!!.filter.tags!!["d"]) + } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 84674960c8..9843e6cbf1 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2856,6 +2856,7 @@ You are a member Invite code This relay admits members with an invite code from its operator + This relay does not publish its identity key (NIP-11 self), so its member list cannot be verified Relay membership list Member added to relay %1$d members added to relay diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index b5faa9ba59..72c0cfb90e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -360,8 +360,11 @@ suspend fun INostrClient.fetchAllPages( // Ids delivered on this page, kept only while an EOSE `"auth"` hint could still make // the relay re-serve the page after AUTH (at most once per walk), so the re-served - // copies of events already handed to [onEvent] are dropped. Reader-thread only. - val pageIds: HashSet? = if (pendingOnAuthRequired && !authRetried) HashSet() else null + // copies of events already handed to [onEvent] are dropped. The hint is rare, so the + // page only appends to a plain list (no hashing, no per-entry node); the lookup set + // is built from it once, on the first re-served event. Both are reader-thread only. + val pageIds: ArrayList? = if (pendingOnAuthRequired && !authRetried) ArrayList() else null + var reServedIds: HashSet? = null var reServing = false try { @@ -393,7 +396,10 @@ suspend fun INostrClient.fetchAllPages( if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return // The relay re-serving this page after an EOSE "auth" hint: skip what // this page already delivered. - if (reServing && pageIds != null && event.id in pageIds) return + if (reServing && pageIds != null) { + val seenOnPage = reServedIds ?: HashSet(pageIds).also { reServedIds = it } + if (event.id in seenOnPage) return + } // Count this event against every active filter it satisfies // (one event can match more than one). Only a non-search filter @@ -421,7 +427,10 @@ suspend fun INostrClient.fetchAllPages( if (atLeastOne) { onEvent(event) delivered++ - pageIds?.add(event.id) + if (pageIds != null) { + val seenOnPage = reServedIds + if (seenOnPage != null) seenOnPage.add(event.id) else pageIds.add(event.id) + } // Track the oldest advancing second and the ids delivered // in it — that becomes the next boundary and its dedup set. if (advancesCursor) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index 5c3efeceac..fb0794a279 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.equalsConstantTime import kotlinx.coroutines.CancellationException import kotlin.io.encoding.Base64 +import kotlin.math.floor +import kotlin.math.log2 /** * NIP-44 v2 encryption. @@ -160,6 +162,21 @@ class Nip44v2( return chunk * ((len - 1) / chunk + 1) } + /** + * The padded length earlier Amethyst builds produced: the spec formula evaluated in Float/Int + * math, which rounds `len - 1` to the nearest Float above 2^24 and so sometimes jumps a bucket. + * Only [unpad] uses it, to keep decrypting payloads those builds encrypted. Replicates the old + * code exactly, Int overflow included; lengths that never fit an Int could not have been padded. + */ + private fun legacyCalcPaddedLen(len: Long): Long { + if (len <= 0 || len > Int.MAX_VALUE) return -1 + val intLen = len.toInt() + if (intLen <= 32) return 32 + val nextPower = 1 shl (floor(log2(intLen - 1f)) + 1).toInt() + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return (chunk * (floor((intLen - 1f) / chunk).toInt() + 1)).toLong() + } + fun pad(plaintext: String): ByteArray { val unpadded = plaintext.encodeToByteArray() val unpaddedLen = unpadded.size @@ -204,7 +221,10 @@ class Nip44v2( "Invalid size $unpaddedLenExt not between $extMinPlaintextSize and $extMaxPlaintextSize" } - check(padded.size.toLong() == 6 + calcPaddedLen(unpaddedLenExt)) { + // Encryption is spec-exact, but earlier Amethyst builds padded with float math that picks a + // bigger bucket for some lengths above 2^24; accept those so old payloads still decrypt. + val paddedLen = padded.size.toLong() - 6 + check(paddedLen == calcPaddedLen(unpaddedLenExt) || paddedLen == legacyCalcPaddedLen(unpaddedLenExt)) { "Invalid padding ${calcPaddedLen(unpaddedLenExt)} != $unpaddedLenExt" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index eaf76ae30c..ee47c16950 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -256,6 +256,9 @@ class NostrConnectSignerService( RateLimiter.Decision.DENY_AND_NOTIFY -> { Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…; replying with an error" } + // The client is told this request failed, so a relay replaying it after a + // restart must not get it serviced: persist its id like a serviced one. + onHandledId?.invoke(event.id) handleGate.acquire() launch { try { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt index d7041b22f7..4a0fcadc40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt @@ -116,8 +116,14 @@ class SimpleGroupListEvent( /** * Swaps [from] for [to] in one signed version — e.g. a NIP-29 group that migrated to another - * relay keeps its id but gets a new relay hint. [from] is dropped from both the public tags and - * the private items; [to] is added as a public tag. + * relay keeps its id but gets a new relay hint. + * + * Entries are matched by group id + *normalized* relay url on both sides, so a stored + * `wss://relay.example` (another client's spelling) is still found when [from] carries the + * normalized `wss://relay.example/`. Every copy of [from] and [to] is dropped from both the + * public tags and the private items, then [to] is added back where [from] lived: as a private + * item when [from] was only in the encrypted items (so a private membership stays private), + * otherwise as a public tag. */ suspend fun replace( earlierVersion: SimpleGroupListEvent, @@ -127,18 +133,31 @@ class SimpleGroupListEvent( createdAt: Long = TimeUtils.now(), ): SimpleGroupListEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() - return resign( - privateTags = privateTags.remove(from.toTagIdOnly()), - tags = - earlierVersion.tags - .remove(from.toTagIdOnly()) - .remove(to.toTagIdOnly()) - .plus(to.toTagArray()), - signer = signer, - createdAt = createdAt, - ) + + val fromKey = groupKey(from.groupId, from.relayUrl) + val toKey = groupKey(to.groupId, to.relayUrl) + val isFrom = { tag: Array -> tagGroupKey(tag) == fromKey } + val isFromOrTo = { tag: Array -> tagGroupKey(tag).let { it == fromKey || it == toKey } } + + val wasPrivateOnly = privateTags.any(isFrom) && earlierVersion.tags.none(isFrom) + + val newPublic = earlierVersion.tags.remove(isFromOrTo) + val newPrivate = privateTags.remove(isFromOrTo) + + return if (wasPrivateOnly) { + resign(tags = newPublic, privateTags = newPrivate.plus(to.toTagArray()), signer = signer, createdAt = createdAt) + } else { + resign(tags = newPublic.plus(to.toTagArray()), privateTags = newPrivate, signer = signer, createdAt = createdAt) + } } + private fun groupKey( + groupId: String, + relayUrl: String, + ) = groupId + "@" + (RelayUrlNormalizer.normalizeOrNull(relayUrl)?.url ?: relayUrl) + + private fun tagGroupKey(tag: Array): String? = GroupTag.parse(tag)?.let { groupKey(it.groupId, it.relayUrl) } + suspend fun resign( tags: TagArray, privateTags: TagArray, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt index 92f92d52a8..ef3d3f2d77 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29SpecUpdatesTest.kt @@ -190,4 +190,38 @@ class Nip29SpecUpdatesTest { ) assertEquals(2, moved.publicGroups().size) } + + @Test + fun replaceMatchesAnUnnormalizedStoredRelayUrl() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + // Stored by another client without the trailing slash our normalizer adds. + val stored = GroupTag(gid, "wss://old.example.com", "Pizza") + val list = SimpleGroupListEvent.create(publicGroups = listOf(stored), signer = signer) + + val moved = + SimpleGroupListEvent.replace( + list, + GroupTag(gid, "wss://old.example.com/", "Pizza"), + GroupTag(gid, "wss://new.example.com/", "Pizza"), + signer, + ) + + assertEquals(listOf(gid to "wss://new.example.com/"), moved.publicGroups().map { it.groupId to it.relayUrl }) + assertEquals(emptyList(), moved.privateGroups(signer)?.map { it.groupId to it.relayUrl }) + } + + @Test + fun replaceKeepsAPrivateEntryPrivate() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val publicOther = GroupTag("other", "wss://old.example.com/", null) + val secret = GroupTag(gid, "wss://old.example.com/", "Pizza") + val list = SimpleGroupListEvent.create(publicGroups = listOf(publicOther), privateGroups = listOf(secret), signer = signer) + + val moved = SimpleGroupListEvent.replace(list, secret, GroupTag(gid, "wss://new.example.com/", "Pizza"), signer) + + assertEquals(listOf("other" to "wss://old.example.com/"), moved.publicGroups().map { it.groupId to it.relayUrl }) + assertEquals(listOf(gid to "wss://new.example.com/"), moved.privateGroups(signer)?.map { it.groupId to it.relayUrl }) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt index 9ff794032c..aa722c0157 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip44Encryption import com.vitorpamplona.quartz.utils.RandomInstance import kotlin.io.encoding.Base64 +import kotlin.math.floor +import kotlin.math.log2 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -87,6 +89,48 @@ class Nip44v2PaddingTest { assertFailsWith { nip44v2.unpad(padded2) } } + @Test + fun unpadAcceptsLegacyFloatPaddingAbove2e24() { + // Earlier builds padded 20,971,520 bytes to 25,165,824 (float bucket) instead of 20,971,520. + val len = 20_971_520 + val legacy = extendedPadded(len, paddedLen = 25_165_824) + assertEquals(len, nip44v2.unpad(legacy).length) + + // The spec-correct padding for the same length still decodes. + assertEquals(len, nip44v2.unpad(extendedPadded(len, paddedLen = 20_971_520)).length) + + // Neither bucket: still rejected. + assertFailsWith { nip44v2.unpad(extendedPadded(len, paddedLen = 20_971_520 + 32)) } + } + + @Test + fun unpadAcceptsLegacyFloatPaddingAt2e25() { + // 2^25 - 1 rounds up to 2^25 as a Float, so the old math jumped to the 2^26 power bucket. + val len = 1 shl 25 + assertEquals(41_943_040, extendedPaddedLenLegacy(len)) + assertEquals(len, nip44v2.unpad(extendedPadded(len, paddedLen = 41_943_040)).length) + } + + // The old Float formula, verbatim, as an independent oracle for the crafted arrays above. + private fun extendedPaddedLenLegacy(len: Int): Int { + val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt() + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return chunk * (floor((len - 1f) / chunk).toInt() + 1) + } + + private fun extendedPadded( + len: Int, + paddedLen: Int, + ): ByteArray { + val padded = ByteArray(6 + paddedLen) + padded[2] = (len shr 24).toByte() + padded[3] = (len shr 16).toByte() + padded[4] = (len shr 8).toByte() + padded[5] = (len and 0xFF).toByte() + padded.fill('a'.code.toByte(), 6, 6 + len) + return padded + } + @Test fun unpadRejectsZeroLength() { assertFailsWith { nip44v2.unpad(ByteArray(2 + 32)) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index b653dbaf39..f3295c43da 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -343,6 +343,36 @@ class NostrConnectSignerServiceTest { assertEquals(BunkerRequestProcessor.ERROR_RATE_LIMITED, replies[2].error) } + @Test + fun aRateLimitedRequestThatWasAnsweredIsRecordedAsHandled() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val handled = mutableListOf() + val service = + NostrConnectSignerService( + client, + signer, + processor, + setOf(relay), + maxRequestsPerWindow = 1, + rateWindowSeconds = 3600, + onHandledId = { handled.add(it) }, + ) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + val serviced = request(BunkerRequestConnect(id = "ok", remoteKey = serverKey, secret = "s")) + val limited = request(BunkerRequestConnect(id = "limited", remoteKey = serverKey, secret = "s")) + client.deliver(serviced) + client.deliver(limited) + + // The client was told `limited` failed; a relay replaying it after a restart must not get it + // serviced, so its id is persisted just like a serviced one. + assertEquals(listOf(serviced.id, limited.id), handled) + } + @Test fun signEventWithoutParamsGetsAnErrorReply() = runTest {