fix: stop discarding gift wraps addressed to someone else

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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 23:06:49 +02:00
parent 3e4166f13d
commit f57644aa1f
2 changed files with 27 additions and 15 deletions

View File

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

View File

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