diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index 287d9c29a..483d8b794 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -26,13 +26,13 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.delay @@ -258,8 +258,7 @@ object DmCommands { val out = mutableListOf() for ((relay, event) in raw) { if (event !is GiftWrapEvent) continue - val sealed = event.unwrapOrNull(ctx.signer) as? SealedRumorEvent ?: continue - val inner = sealed.unsealOrNull(ctx.signer) as? ChatMessageEvent ?: continue + val inner = event.unwrapAndUnsealOrNull(ctx.signer) as? ChatMessageEvent ?: continue if (!seen.add(inner.id)) continue val members = inner.groupMembers() if (peerHex != null && peerHex !in members) continue diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt index 870c75b52..7f5517e5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor import com.vitorpamplona.quartz.marmot.WelcomeResult @@ -27,7 +28,6 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent /** @@ -96,19 +96,12 @@ suspend fun MarmotManager.ingest(event: Event): MarmotIngestResult = private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult = try { - // NIP-59 wraps contain TWO encryption layers: - // kind:1059 gift wrap → kind:13 sealed rumor → the rumor itself. - // `GiftWrapEvent.unwrapOrNull` only peels the outer layer; when the - // result is a [SealedRumorEvent] we must unseal it to reach the - // kind:444 Welcome rumor. The old code checked `isWelcomeEvent` on - // the seal (kind:13) and always took the Ignored branch, which is - // why every inbound Welcome was silently dropped by the CLI and by - // any non-Amethyst consumer. - val rumor = - when (val inner = wrap.unwrapOrNull(signer) ?: return MarmotIngestResult.Ignored) { - is SealedRumorEvent -> inner.unsealOrNull(signer) ?: return MarmotIngestResult.Ignored - else -> inner - } + // NIP-59 wraps carry two encryption layers (kind:1059 → kind:13 → rumor). + // [unwrapAndUnsealOrNull] peels both so we land directly on the inner + // kind:444 Welcome rumor. Checking `isWelcomeEvent` on the seal itself + // (the old bug) always took the Ignored branch and silently dropped + // every inbound Welcome. + val rumor = wrap.unwrapAndUnsealOrNull(signer) ?: return MarmotIngestResult.Ignored if (!MarmotInboundProcessor.isWelcomeEvent(rumor) || rumor !is WelcomeEvent) { return MarmotIngestResult.Ignored } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/GiftWrapDecryptor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/GiftWrapDecryptor.kt new file mode 100644 index 000000000..cf19417ca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/GiftWrapDecryptor.kt @@ -0,0 +1,46 @@ +/* + * 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.nip17Dm + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * Fully decrypt a NIP-59 gift wrap down to the inner rumor. + * + * A gift wrap carries two encryption layers: + * kind:1059 [GiftWrapEvent] → kind:13 [SealedRumorEvent] → the rumor. + * + * [GiftWrapEvent.unwrapOrNull] peels only the outer layer. Every non-Android + * consumer that looks at the rumor directly (the CLI's `dm list`, the desktop + * chat receive path, the Marmot welcome ingest in commons) needs both peels. + * + * If the inner content isn't a [SealedRumorEvent] (malformed, or a future + * NIP-59 variant that wraps a rumor directly), the outer unwrap result is + * returned as-is so callers can still route on kind. Either unwrap returning + * null propagates as null. + */ +suspend fun GiftWrapEvent.unwrapAndUnsealOrNull(signer: NostrSigner): Event? { + val inner = unwrapOrNull(signer) ?: return null + return if (inner is SealedRumorEvent) inner.unsealOrNull(signer) else inner +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fa46a6d2b..e34b57f39 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -1022,13 +1023,9 @@ fun MainContent( } is com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -> { - // NIP-17: unwrap gift wrap → seal → inner event + // NIP-17: peel both the gift-wrap and sealed-rumor layers. scope.launch { - val seal = - event.unwrapOrNull(iAccount.signer) - as? com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent - ?: return@launch - val innerEvent = seal.unsealOrNull(iAccount.signer) ?: return@launch + val innerEvent = event.unwrapAndUnsealOrNull(iAccount.signer) ?: return@launch when (innerEvent) { is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -> { val innerNote = localCache.getOrCreateNote(innerEvent.id)