test: pin who can open a gift wrap, and what happens to everyone else's

The Invalid Mac crash had no test standing between it and a repeat, so this
adds one that reproduces it.

GiftWrapMessageTest builds real NIP-59 wraps with real secp256k1 rather than
recorded fixtures. The property under test is the key agreement itself —
whether ECDH(ourPriv, ephemeralPub) can stand in for the conversation key the
wrap was sealed under — and a fixture would only prove that the fixture still
parses. Three cases carry the regression:

  - someone else's mail comes back null rather than throwing
  - not even the sender can reopen what they sent
  - isAddressedTo answers exactly what unsealing would

Checked against the reverted fix, those three fail with the production
exception verbatim (java.lang.IllegalStateException: Invalid Mac: Calculated
bf2e6480…), while the two describing behaviour that never broke — the happy
path, and isAddressedTo's reading of the p tag — stay green. A test that
cannot fail against the bug it names is not worth the run time, so the split
matters.

The last of the three is the one guarding the fix's structure rather than its
outcome. NostrDao decides whether to index on isAddressedTo, then throws
GiftWrapUnsealException if decryptGiftWrapSeal returns null anyway; those two
answers have to agree for either path to be correct. If they drift, the DAO
either skips mail we can open or resumes rolling back transactions, and
neither shows up as a failure anywhere near the change that caused it.

commonTest gains kotlinx-coroutines-test for runTest. decryptGiftWrapSeal is
suspending, runBlocking does not exist in common code, and every layer worth
testing below the ViewModels — DAOs, repositories, the model's crypto — is
suspending too, so the dependency pays for more than this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 23:21:52 +02:00
parent f38a5f12f3
commit e1d35bbd6c
3 changed files with 145 additions and 0 deletions

View File

@@ -121,6 +121,9 @@ kotlin {
}
commonTest.dependencies {
implementation(libs.kotlin.test)
// runTest: the DAO and model layers are suspending, so anything worth
// asserting about them needs a coroutine to assert it in.
implementation(libs.kotlinx.coroutinesTest)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)

View File

@@ -0,0 +1,141 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Who can open a gift wrap, and what becomes of everyone else's.
*
* NIP-59 encrypts a wrap under ECDH(ephemeralPriv, recipientPub), and
* [GiftWrapEvent.create] discards that ephemeral key before it returns. The
* recipient named in the `p` tag is therefore the only party who can ever unseal
* one -- the sender included. Reading a wrap that is not ours is not a decryption
* that might fail, it is one that cannot be attempted, and the code that tried
* anyway threw `IllegalStateException: Invalid Mac` out of Nip44 and took the
* enclosing Room transaction down with it, so the event was rolled back and
* re-fetched on every later sync.
*
* These run real secp256k1 rather than recorded fixtures on purpose: the property
* under test is about the key agreement itself, and a fixture would only prove the
* fixture still parses.
*/
class GiftWrapMessageTest {
private val us = KeyPair()
private val peer = KeyPair()
private val stranger = KeyPair()
/**
* A real wrap, sealed the way DatabaseChatRepository seals one and then mapped
* into the entity the way NostrEvent.toGiftWrapMessageWithReceiverPTag maps it.
*/
private fun wrap(
sender: KeyPair,
recipient: KeyPair,
): GiftWrapMessage {
val signer = NostrSignerSync(sender)
val seal = signer.signNormal<SealedRumorEvent>(
createdAt = SEALED_AT,
kind = SealedRumorEvent.KIND,
tags = emptyArray(),
content = signer.nip44Encrypt(
plaintext = """{"kind":14,"content":"dumela"}""",
toPublicKey = recipient.pubKey.toHexKey(),
),
)
val giftWrap = GiftWrapEvent.create(
event = seal,
recipientPubKey = recipient.pubKey.toHexKey(),
createdAt = WRAPPED_AT,
)
return GiftWrapMessage(
id = giftWrap.id,
publicKey = giftWrap.pubKey,
receiverPublicKey = recipient.pubKey.toHexKey(),
receiverRelayHit = null,
content = giftWrap.content,
signature = giftWrap.sig,
nostrEventId = giftWrap.id,
createdAt = Instant.fromEpochSeconds(giftWrap.createdAt),
)
}
@Test
fun `a wrap addressed to us gives up its seal`() = runTest {
val seal = wrap(sender = peer, recipient = us).decryptGiftWrapSeal(us)
assertNotNull(seal)
// Only the wrap was anonymous. The seal inside carries the real sender, which
// is what lets the impersonation check downstream compare it to the payload.
assertEquals(peer.pubKey.toHexKey(), seal.publicKey)
}
@Test
fun `someone else's mail comes back null rather than throwing`() = runTest {
// The event from the crash report: a wrap between two other people, pulled in
// by a filter that named a peer where it should have named us.
val theirs = wrap(sender = stranger, recipient = peer)
assertNull(theirs.decryptGiftWrapSeal(us))
}
@Test
fun `not even the sender can reopen what they sent`() = runTest {
// What the old branch called "a message we may have sent" and tried to decrypt
// regardless. The key that encrypted it no longer exists anywhere; holding the
// sending identity buys nothing back.
val ours = wrap(sender = us, recipient = peer)
assertNull(ours.decryptGiftWrapSeal(us))
}
@Test
fun `isAddressedTo reads the p tag whatever case it arrived in`() {
val message = wrap(sender = peer, recipient = us)
assertTrue(message.isAddressedTo(us))
assertFalse(message.isAddressedTo(peer))
assertTrue(
message
.copy(receiverPublicKey = message.receiverPublicKey.uppercase())
.isAddressedTo(us),
)
}
@Test
fun `isAddressedTo answers exactly what unsealing would`() = runTest {
// NostrDao skips indexing on isAddressedTo and throws GiftWrapUnsealException on
// a null seal. Should those two ever disagree, one path or the other is wrong:
// either mail we can open is skipped, or the transaction rolls back again.
listOf(
wrap(sender = peer, recipient = us),
wrap(sender = stranger, recipient = peer),
wrap(sender = us, recipient = peer),
).forEach { message ->
assertEquals(
message.isAddressedTo(us),
message.decryptGiftWrapSeal(us) != null,
"isAddressedTo and decryptGiftWrapSeal disagree on ${message.id}",
)
}
}
private companion object {
const val SEALED_AT = 1_700_000_000L
const val WRAPPED_AT = 1_700_000_100L
}
}

View File

@@ -63,6 +63,7 @@ kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" }