refactor(commons): share NIP-59 gift-wrap+seal unwrap helper

Three call sites did the same two-layer peel —
  kind:1059 → (nip44 decrypt) → kind:13 sealed rumor → (nip44 decrypt) → rumor

Extracted as `GiftWrapEvent.unwrapAndUnsealOrNull(signer)` in
commons/relayClient/nip17Dm/, and collapsed:

- commons/marmot/MarmotIngest.kt — was an inline `when` switch; now a
  one-liner. Behaviour unchanged (still passes through if the inner
  isn't a seal, matching the previous defensive `else -> inner` branch).
- desktopApp/Main.kt — dropped the nested unwrap/unseal block in the
  kind:1059 case of the LocalCache dispatcher.
- cli/DmCommands.kt — replaced the manual chain in `decryptChatMessages`.

Android's DecryptAndIndexProcessor keeps its two separate handlers
(processNewGiftWrap / processNewSealedRumor) because the LocalCache
pipeline re-enters on each peel — that's a different shape and not
touched.

No behaviour change on any platform.
This commit is contained in:
Claude
2026-04-23 18:40:35 +00:00
parent 15205c24b5
commit 1e5b5d6276
4 changed files with 58 additions and 23 deletions
@@ -26,13 +26,13 @@ import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey 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.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -258,8 +258,7 @@ object DmCommands {
val out = mutableListOf<DecryptedDm>() val out = mutableListOf<DecryptedDm>()
for ((relay, event) in raw) { for ((relay, event) in raw) {
if (event !is GiftWrapEvent) continue if (event !is GiftWrapEvent) continue
val sealed = event.unwrapOrNull(ctx.signer) as? SealedRumorEvent ?: continue val inner = event.unwrapAndUnsealOrNull(ctx.signer) as? ChatMessageEvent ?: continue
val inner = sealed.unsealOrNull(ctx.signer) as? ChatMessageEvent ?: continue
if (!seen.add(inner.id)) continue if (!seen.add(inner.id)) continue
val members = inner.groupMembers() val members = inner.groupMembers()
if (peerHex != null && peerHex !in members) continue if (peerHex != null && peerHex !in members) continue
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.commons.marmot 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.GroupEventResult
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
import com.vitorpamplona.quartz.marmot.WelcomeResult 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.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent 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 = private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult =
try { try {
// NIP-59 wraps contain TWO encryption layers: // NIP-59 wraps carry two encryption layers (kind:1059 → kind:13 → rumor).
// kind:1059 gift wrap → kind:13 sealed rumor → the rumor itself. // [unwrapAndUnsealOrNull] peels both so we land directly on the inner
// `GiftWrapEvent.unwrapOrNull` only peels the outer layer; when the // kind:444 Welcome rumor. Checking `isWelcomeEvent` on the seal itself
// result is a [SealedRumorEvent] we must unseal it to reach the // (the old bug) always took the Ignored branch and silently dropped
// kind:444 Welcome rumor. The old code checked `isWelcomeEvent` on // every inbound Welcome.
// the seal (kind:13) and always took the Ignored branch, which is val rumor = wrap.unwrapAndUnsealOrNull(signer) ?: return MarmotIngestResult.Ignored
// 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
}
if (!MarmotInboundProcessor.isWelcomeEvent(rumor) || rumor !is WelcomeEvent) { if (!MarmotInboundProcessor.isWelcomeEvent(rumor) || rumor !is WelcomeEvent) {
return MarmotIngestResult.Ignored return MarmotIngestResult.Ignored
} }
@@ -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
}
@@ -71,6 +71,7 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState 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.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -1022,13 +1023,9 @@ fun MainContent(
} }
is com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -> { 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 { scope.launch {
val seal = val innerEvent = event.unwrapAndUnsealOrNull(iAccount.signer) ?: return@launch
event.unwrapOrNull(iAccount.signer)
as? com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
?: return@launch
val innerEvent = seal.unsealOrNull(iAccount.signer) ?: return@launch
when (innerEvent) { when (innerEvent) {
is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -> { is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -> {
val innerNote = localCache.getOrCreateNote(innerEvent.id) val innerNote = localCache.getOrCreateNote(innerEvent.id)