From f57644aa1fc8d0957488ec177bd590c4a52b87ec Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:06:49 +0200 Subject: [PATCH] fix: stop discarding gift wraps addressed to someone else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inbound kind:1059 whose `p` tag is not our pubkey took down the entire save transaction: java.lang.IllegalStateException: Invalid Mac: Calculated f1db537e…, decoded: 45c8c86a… at com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf.fastExpand at com.vitorpamplona.quartz.nip44Encryption.Nip44v2.checkMessageKeys … at press.mantra.compose.database.model.GiftWrapMessage.decryptGiftWrapSeal at press.mantra.compose.database.dao.NostrDao.indexNostrEvent at press.mantra.compose.database.dao.NostrDao.storeNostrEvent Two separate things were wrong. The first is that decryptGiftWrapSeal attempted the decryption at all. When the recipient did not match our key it logged "We are unwrapping a message we may have sent" and called Nip44.decrypt(content, privateKey = ourPrivKey, pubKey = giftWrapEvent.pubKey) giftWrapEvent.pubKey is the wrap's ephemeral author. NIP-59 encrypts the wrap under ECDH(ephemeralPriv, recipientPub), and GiftWrapEvent.create mints that ephemeral key with NostrSignerSync(KeyPair()) and discards it on return. ECDH(ourPriv, ephemeralPub) is a third, unrelated key, so the MAC check could never pass. A sender genuinely cannot unwrap their own gift wrap; that is the point of the construction, not a gap in it. The call threw its result away anyway (keyPair.privKey?.let { …; null }) and fell through to the trailing `return null`, so it was a probe whose only possible outcome was an exception. The second is that a null seal was treated as a failure. indexNostrEvent throws GiftWrapUnsealException on null, which unwinds out of the Room transaction in storeNostrEvent and rolls back everything written for the event: the NostrEvent row, its NostrEventRelay row, and the GiftWrapMessage upserted moments earlier. The only catch sits in DatabaseNostrRepository, which logs and continues — and that catch also swallows the `status = "processed"` upsert on the SynchronizeNostrEventRequest, so the event was re-fetched and re-failed on every later sync pass. isAddressedTo now answers the question with no crypto at all, and the indexer returns early for wraps that are not ours: the event and the wrap row survive, the remainder of indexNostrEvent still runs, the transaction commits, and the sync request is marked processed. GiftWrapUnsealException goes back to meaning what it says — addressed to us, but unsealing failed. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 9 +++++ .../compose/database/model/GiftWrapMessage.kt | 33 ++++++++++--------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 18685171..9533c89e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -523,6 +523,15 @@ abstract class NostrDao( giftWrapMessage ) + if (!giftWrapMessage.isAddressedTo(activeKeyPair)) { + // Undecryptable by design rather than by failure, so keep the event and + // the wrap we just stored and stop here. Throwing would roll the whole + // transaction back and lose both. + logger.d("GiftWrap ${nostrEvent.id} is addressed to ${giftWrapMessage.receiverPublicKey}, nothing to index") + + return@let + } + giftWrapMessage.decryptGiftWrapSeal( activeKeyPair ).let { giftWrapSeal -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt index c0b471ea..a7b92840 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt @@ -7,13 +7,11 @@ import androidx.room3.PrimaryKey import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlin.time.Clock import kotlin.time.Instant @@ -83,6 +81,18 @@ data class GiftWrapMessage( @Ignore private val logger = Logger.withTag("GiftWrapMessage") + /** + * Whether this gift wrap is addressed to [keyPair], i.e. whether we hold the + * private key that can unwrap it. + * + * NIP-59 encrypts the wrap to its recipient using an ephemeral key that + * [GiftWrapEvent.create] throws away, so a wrap addressed to anyone else can + * never be decrypted by us, not even one we sent ourselves. + */ + fun isAddressedTo( + keyPair: KeyPair + ): Boolean = receiverPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true) + suspend fun decryptGiftWrapSeal( keyPair: KeyPair ): GiftWrapSeal? { @@ -105,19 +115,12 @@ data class GiftWrapMessage( giftWrapEvent.recipientPubKey()?.let { recipientPublicKey -> logger.d("Recipient PublicKey: $recipientPublicKey") - if (keyPair.pubKey.toHexKey() != recipientPublicKey) { - logger.e("We are unwrapping a message we may have sent from ${keyPair.pubKey.toHexKey()}") - - keyPair.privKey?.let { privateKey -> - val sealJSON = Nip44.decrypt( - giftWrapEvent.content, - privateKey = privateKey, - pubKey = giftWrapEvent.pubKey.hexToByteArray() - ) - logger.d("SealJSON: $sealJSON") - - null - } + if (!recipientPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true)) { + // Not ours to open, and no key we hold ever will be: the wrap is + // encrypted to the recipient with a one-off key that + // GiftWrapEvent.create() discards, so not even the sender can + // unwrap their own gift wrap. + logger.d("GiftWrap $id is addressed to $recipientPublicKey, not to us") } else { val nostrSigner = NostrSignerInternal( keyPair = KeyPair(privKey = keyPair.privKey)