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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user