test: exercise MarmotOutboundDao past the MLS guard

02e70d9 claimed the paths past the membership guard "need a real peer key
package to exercise, which means an MLS fixture this test file deliberately
does not build", and left them uncovered on that basis. That was wrong, and this
corrects it.

Nothing about a key package needs a relay. DatabaseMarmotRepository.generateKeyPackage
already builds this device's own entirely locally: two X25519 key generations,
one Ed25519, a leaf node signed under "LeafNodeTBS" and a key package signed
under "KeyPackageTBS". Everything it touches is quartz public API, so
MarmotKeyPackageFixture replicates it in about forty lines. The capabilities it
advertises are not decoration -- the group's RequiredCapabilities rejects a leaf
that does not carry LastResort and NostrGroupData, so a fixture omitting them is
refused at addMember rather than at decode, and the comment says so.

With that, three properties past the guard are asserted rather than described.

The invitee is persisted. sealGiftWrapPayload walks the room's participants to
decide who to wrap a Welcome for, so without the row the Welcome produces no
gift wraps at all and sits unsealed forever.

The advanced epoch reaches the database. addMember moves the in-memory group
forward, and the comment on that write explains what happens when it is not
saved back: the creator keeps encrypting under the old epoch, which the new
member cannot decrypt, and the next invite re-derives from stale state and
produces a conflicting commit. The test asserts the stored state changed, that
it still restores, and that the restored group has two members -- so it is
checking a real advance rather than any write at all.

The epoch being left behind is retained, at epoch 0 for a freshly created group.
That is the call that actually writes a retained secret, so it belongs here as
well as in the retention-window tests that only read them.

Verified by mutation: deleting the write that persists the advanced state fails
`inviting a member persists the advanced group state` and nothing else. The
mutation was reverted; no production source is touched by this commit.

The peer needs a Profile row here where the guard tests did not, because
Participant.participantPublicKey is a foreign key onto Profile and only a
successful invite reaches that write -- the same constraint that shapes the
nip17 tests in 5fa0d08. Found the same way, by three of these failing with
SQLite 787 first.

Still not covered: the Welcome itself, the deferred-welcome path for a group
that already has members, and the batching in addMembersToChatRoom. Those need
more than a key package -- a second device's view of the group -- and are a
separate piece of work.

3 tests added, 7 in the class. composeApp jvmTest is 312 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 03:35:27 +02:00
parent 5fa0d08dfd
commit 44127cf514
2 changed files with 193 additions and 6 deletions

View File

@@ -0,0 +1,73 @@
package press.mantra.compose.database.dao
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.marmot.mls.tree.Extension
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource
import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.utils.TimeUtils
import press.mantra.compose.database.model.MarmotKeyPackage
/**
* A real MLS key package for [publicKey], built the same way
* `DatabaseMarmotRepository.generateKeyPackage` builds this device's own. Entirely local:
* three key generations, a signed leaf node and a signed key package. No relay, no network,
* nothing to stub.
*/
internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage {
val initKp = X25519.generateKeyPair()
val encKp = X25519.generateKeyPair()
val sigKp = Ed25519.generateKeyPair()
val now = TimeUtils.now()
val unsignedLeaf = LeafNode(
encryptionKey = encKp.publicKey,
signatureKey = sigKp.publicKey,
credential = Credential.Basic(publicKey.hexToByteArray()),
capabilities = Capabilities(
// LastResort, then NostrGroupData -- the group's RequiredCapabilities rejects a
// leaf that does not advertise both, so a fixture without them is refused at
// addMember rather than at decode.
extensions = listOf(0x000A, 0xF2EE),
proposals = listOf(0x000A),
),
leafNodeSource = LeafNodeSource.KEY_PACKAGE,
lifetime = Lifetime(notBefore = now, notAfter = now + 60L * 60L * 24L * 90L),
extensions = emptyList(),
signature = ByteArray(0),
)
val leafNode = unsignedLeaf.copy(
signature = MlsCryptoProvider.signWithLabel(
sigKp.privateKey,
"LeafNodeTBS",
unsignedLeaf.encodeTbs(groupId = null, leafIndex = null),
),
)
val unsigned = MlsKeyPackage(
initKey = initKp.publicKey,
leafNode = leafNode,
extensions = listOf(Extension(extensionType = 0x000A, extensionData = ByteArray(0))),
signature = ByteArray(0),
)
val keyPackage = unsigned.copy(
signature = MlsCryptoProvider.signWithLabel(
sigKp.privateKey,
"KeyPackageTBS",
unsigned.encodeTbs(),
),
)
return MarmotKeyPackage(
id = publicKey,
publicKey = publicKey,
tlsEncodedMarmotKeyPackage = keyPackage.toTlsBytes(),
)
}

View File

@@ -11,11 +11,17 @@ import press.mantra.compose.database.model.MarmotKeyPackage
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.Relays
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import press.mantra.compose.exceptions.MarmotMissingChatGroupException
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
@@ -29,10 +35,10 @@ import kotlin.test.assertTrue
* compile, still look like it worked, and leave a room whose members believe someone was
* invited.
*
* These cover the paths that need no MLS material. Everything past the guard -- the commit, the
* Welcome, the epoch advance and its persistence -- needs a real peer key package to exercise,
* which means an MLS fixture this test file deliberately does not build. Those paths are worth
* covering and are not covered here.
* Past the guard, the tests build a real MLS group and a real peer key package with
* [marmotKeyPackageFor], so the commit, the epoch advance and its persistence are exercised
* rather than described. Nothing there needs a relay: a key package is three local key
* generations and two signatures.
*/
class MarmotOutboundDaoJvmTest {
@@ -76,8 +82,8 @@ class MarmotOutboundDaoJvmTest {
}
/**
* Never decoded: the guard throws before any of these tests reach the MLS layer, so the
* bytes only have to exist. A test that got past the guard would need a real key package.
* Never decoded: the guard throws before the tests using it reach the MLS layer, so the
* bytes only have to exist. The tests past the guard use [marmotKeyPackageFor] instead.
*/
private fun keyPackage() = MarmotKeyPackage(
id = "d".repeat(64),
@@ -85,6 +91,44 @@ class MarmotOutboundDaoJvmTest {
tlsEncodedMarmotKeyPackage = ByteArray(0),
)
/**
* A room holding real MLS state, as a room this device created would.
*
* The peer gets a Profile row because Participant.participantPublicKey is a foreign key
* onto it, and a successful invite writes a Participant. The guard tests above never reach
* that write, which is why only they can get away without one.
*/
private suspend fun seedMlsRoom(): LocalChatRoom {
val stateless = seedStatelessRoom()
val peerEventId = "e".repeat(64)
db.nostrEventDao().upsert(
NostrEvent(
id = peerEventId,
pubKey = peer,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(Profile(publicKey = peer, userName = "peer", nostrEventId = peerEventId))
val mlsGroup = MlsGroup.create(
identity = user.hexToByteArray(),
initialExtensions = listOf(
MarmotGroupData.bootstrap(
nostrGroupId = roomId,
creatorPubKey = user,
outboxRelays = Relays.DefaultDMRelayList.map { it.url },
).toExtension()
),
)
val chatRoom = stateless.chatRoom.copy(
mlsGroupState = mlsGroup.saveState().encodeTls().toHex()
)
db.chatRoomDao().upsert(chatRoom)
return LocalChatRoom(chatRoom = chatRoom)
}
@Test
fun `inviting into a room with no mls state is refused rather than ignored`() = runBlocking<Unit> {
val localChatRoom = seedStatelessRoom()
@@ -151,4 +195,74 @@ class MarmotOutboundDaoJvmTest {
assertEquals(emptyList(), failed)
}
/**
* Past the guard. The invitee is persisted before the Welcome is sealed, because
* sealGiftWrapPayload walks the room's participants to decide who to wrap for -- without
* the row the Welcome produced no gift wraps at all and sat unsealed forever.
*/
@Test
fun `inviting a member into a real group persists the invitee`() = runBlocking {
val localChatRoom = seedMlsRoom()
db.marmotOutboundDao().inviteMemberToChatRoom(
localChatRoom = localChatRoom,
peerPublicKey = peer,
peerKeyPackage = marmotKeyPackageFor(peer),
)
assertTrue(
db.participantDao().findParticipantsByChatRoomId(roomId)
.any { it.participantPublicKey == peer },
"the invitee was not persisted",
)
}
/**
* The epoch advance has to reach the database. `addMember` moves the in-memory group to
* the next epoch; without saving it back the creator keeps encrypting under the old one --
* which the new member cannot decrypt -- and the next invite re-derives from stale state
* and produces a conflicting commit.
*/
@Test
fun `inviting a member persists the advanced group state`() = runBlocking {
val localChatRoom = seedMlsRoom()
val stateBefore = localChatRoom.chatRoom.mlsGroupState
db.marmotOutboundDao().inviteMemberToChatRoom(
localChatRoom = localChatRoom,
peerPublicKey = peer,
peerKeyPackage = marmotKeyPackageFor(peer),
)
val stateAfter = assertNotNull(db.chatRoomDao().findChatRoomById(roomId)).chatRoom.mlsGroupState
assertNotNull(stateAfter)
assertTrue(stateAfter != stateBefore, "the advanced epoch was never written back")
val group = assertNotNull(
db.chatRoomDao().findChatRoomById(roomId)!!.chatRoom.toMlsGroup(),
"the persisted state no longer restores",
)
assertEquals(2, group.members().size.toInt(), "the invitee is not in the restored group")
}
/**
* The epoch the group is leaving is retained on the way past, so messages already sent
* under it stay readable. Asserted here rather than only in the retention-window tests,
* because this is the call that actually writes one.
*/
@Test
fun `inviting a member retains the epoch being left behind`() = runBlocking {
val localChatRoom = seedMlsRoom()
db.marmotOutboundDao().inviteMemberToChatRoom(
localChatRoom = localChatRoom,
peerPublicKey = peer,
peerKeyPackage = marmotKeyPackageFor(peer),
)
val retained = db.marmotRetainedEpochSecretDao()
.getMarmotRetainedEpochSecretForChatRoomId(roomId)
assertEquals(1, retained.size, "the pre-commit epoch was not retained")
assertEquals(0L, retained.single().epoch, "a freshly created group is at epoch 0")
}
}