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
@@ -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
}
@@ -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
}