test(subgroups): the ceremony in somebody else's room, and the four ways it lies
Six cases, and the ones worth writing are the ones where reading the room instead of the ceremony produces a plausible wrong answer rather than a crash. **The transport, and the p-tags on it.** The proposal is a `MarmotInnerEvent` and there are no gift wraps at all, and it p-tags the picked admins and nobody else. Both halves: the transport is the change, and the p-tags are what keeps the change from being a disaster, because dropping them the way `FrostSigningManager` correctly does in a Marmot room would enrol the whole parent in the child's permanent signing quorum. **The name.** On the proposal, on the session, once -- and the room keeps its own. There is nowhere else for a child's name to live now, and the parent's is the one name it must not take. **A parent member who was not picked drops the proposal**, opens no session, and publishes nothing. This is the property that makes the parent's room a safe place to hold a ceremony a subset of it is in: everyone can read the message, only the p-tagged are in the ceremony, and "can read" and "is in" have to stay different questions when the second one is permanent. **A picked admin joins, and not before approving.** The approval gate is unchanged by the move and has to stay that way -- a relay delivering a group event to a phone in a pocket must not enrol its owner in anything -- so this asserts nothing goes out until `approve`, then that what goes out is an inner event carrying the roster minus its own sender. **A host key that beat the proposal is replayed**, out of the inner-event store. The resume machinery is what makes the ritual safe to run anywhere, and it reads the backlog out of whichever store the room's transport writes to; looking in the wrong one is a stall with nothing to blame it on. **`completedKey` refuses to hand the parent its child's key.** The regression test for `ba8aa1e2`: a parent member with no key state and no share -- the position every member welcomed after the group's own ceremony is in, and the one that reaches the last fallback -- with a completed subgroup ceremony sitting in the room. Before the fix that fallback returned the child's key and the parent would have authored events as its own subgroup. It also asserts the child's *own* room still resolves it, by rederiving the id from the key, which is the check that actually binds a room to a key. Two databases, and the MLS group in each is real but not joint: messages are handed to the manager rather than encrypted between two trees. That is the right seam here -- what is under test is which store a message is queued in and which set it names, not whether quartz can encrypt it -- and `SignedGroupKeyStateTest` is where a session runs end to end. 1136 tests pass, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
|
||||
/**
|
||||
* A subgroup's key ceremony, held in the room of the group making it.
|
||||
*
|
||||
* It used to be held in a sibling NIP-17 room whose id was an aggregate of the
|
||||
* child's admins. That bought a transport tolerant of reordering, and it cost a
|
||||
* room per subgroup and a hard limit -- one admin set could hold one subgroup
|
||||
* ever, because asking again derived the same room and handed back the same key.
|
||||
* Now the ceremony runs where the parent already is, and everything the sibling
|
||||
* room used to answer has to be answered by the session instead.
|
||||
*
|
||||
* Three of those answers are what this covers, because each one silently reads
|
||||
* the wrong thing if it comes off the room:
|
||||
*
|
||||
* - **who the ceremony is with** is a subset of the parent's members, so the
|
||||
* room's roster names every person the subgroup is not;
|
||||
* - **what the room it makes is called** is not the parent's name, which is the
|
||||
* one name a child must not take;
|
||||
* - **what key the parent signs with** is not the child's, which is now a
|
||||
* completed ceremony sitting in the parent's own room.
|
||||
*
|
||||
* The MLS group here is real but not joint: messages are handed to the manager
|
||||
* directly rather than encrypted and decrypted between two trees. That is the
|
||||
* right seam for these -- what is under test is which store a message is queued
|
||||
* in and which set it names, not whether quartz can encrypt it -- and
|
||||
* `SignedGroupKeyStateTest` is where a session is run end to end.
|
||||
*/
|
||||
class SubgroupCeremonyInParentRoomTest {
|
||||
|
||||
private val parent: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
/** A second device's database, so a proposal can be received rather than made. */
|
||||
private val receiver: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() {
|
||||
parent.close()
|
||||
receiver.close()
|
||||
}
|
||||
|
||||
/** The parent's own key, which its room's id is derived from. */
|
||||
private val parentMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = 4,
|
||||
threshold = 3
|
||||
)
|
||||
|
||||
private val parentRoomId =
|
||||
SharedKeyDerivation.marmotGroupId(parentMaterial.thresholdPublicKey.value.toHex())
|
||||
|
||||
// Real keys throughout: a host key is derived from a nostr secret, and the
|
||||
// participant set is sorted by hex, so filler exercises neither.
|
||||
private val coordinator = KeyPair()
|
||||
private val user = coordinator.pubKey.toHexKey()
|
||||
|
||||
private val aliceKeys = KeyPair()
|
||||
private val alice = aliceKeys.pubKey.toHexKey()
|
||||
private val bob = KeyPair().pubKey.toHexKey()
|
||||
|
||||
/** A parent member who is not picked for the subgroup. */
|
||||
private val mallory = KeyPair()
|
||||
private val malloryPublicKey = mallory.pubKey.toHexKey()
|
||||
|
||||
private val members = listOf(alice, bob, malloryPublicKey)
|
||||
|
||||
/** The subgroup's admins: three of the parent's four members. */
|
||||
private val admins = setOf(alice, bob)
|
||||
|
||||
private val quorum = 2
|
||||
|
||||
private suspend fun openCeremony(room: LocalChatRoom) = ChillDkgRitualManager.proposeRitual(
|
||||
database = parent,
|
||||
localChatRoom = room,
|
||||
userPublicKey = user,
|
||||
nostrPrivateKey = coordinator.privKey!!,
|
||||
threshold = quorum,
|
||||
parentChatRoomId = parentRoomId,
|
||||
participantPublicKeys = admins,
|
||||
subject = "Translation"
|
||||
)
|
||||
|
||||
private suspend fun innerEvents(database: MantraDatabase) = database.marmotInnerEventDao()
|
||||
.getByChatRoomAndKinds(parentRoomId, DkgRitualEvents.ALL.toList())
|
||||
|
||||
/** The rumor as the manager queued it, which is what the other side receives. */
|
||||
private fun MarmotInnerEvent.asEvent() = Event(
|
||||
id = id,
|
||||
pubKey = publicKey,
|
||||
createdAt = createdAt.epochSeconds,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* The ceremony goes out as group events, and names only the admins.
|
||||
*
|
||||
* Both halves matter. The transport is the change; the p-tags are what stops
|
||||
* the change being a disaster, because they are the participant set every
|
||||
* device derives `n` from -- so leaving them off in a Marmot room, the way
|
||||
* `FrostSigningManager` correctly does, would enrol the whole parent in the
|
||||
* child's signing quorum.
|
||||
*/
|
||||
@Test
|
||||
fun `a subgroup's ceremony rides the parent's transport and names its own admins`(): Unit =
|
||||
runBlocking {
|
||||
val room = parentRoom()
|
||||
|
||||
assertTrue(innerEvents(parent).isEmpty(), "nothing has gone out yet")
|
||||
|
||||
val session = openCeremony(room)
|
||||
|
||||
assertTrue(
|
||||
parent.giftWrapPayloadDao()
|
||||
.getByChatRoomAndKinds(parentRoomId, DkgRitualEvents.ALL.toList())
|
||||
.isEmpty(),
|
||||
"a ceremony in a Marmot room queues no gift wraps",
|
||||
)
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL },
|
||||
"the proposal is an inner event for the parent's room",
|
||||
)
|
||||
assertEquals(user, proposal.publicKey)
|
||||
assertEquals(parentRoomId, proposal.chatRoomId)
|
||||
|
||||
// The p-tags are the roster, not an address list -- the sender is in
|
||||
// the set without tagging themselves.
|
||||
assertEquals(
|
||||
admins,
|
||||
proposal.tags.taggedUsers().map { it.pubKey }.toSet(),
|
||||
"the proposal names the picked admins and nobody else",
|
||||
)
|
||||
assertEquals(
|
||||
admins + user,
|
||||
session.participantPublicKeySet(),
|
||||
"and the session records the same set, the coordinator included",
|
||||
)
|
||||
assertEquals(3, session.participantCount)
|
||||
assertEquals(parentRoomId, session.parentChatRoomId)
|
||||
|
||||
// The coordinator's host key goes out on the same transport, since
|
||||
// opening a ceremony is the act of agreeing to be in it.
|
||||
assertEquals(
|
||||
1,
|
||||
innerEvents(parent).count { it.kind == DkgRitualEvents.HOST_KEY },
|
||||
"the coordinator joins the ceremony it opened",
|
||||
)
|
||||
assertNotNull(session.hostKeyApprovedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* The child's name has nowhere else to live.
|
||||
*
|
||||
* It used to be the ceremony room's subject: a NIP-17 room called `Translation`
|
||||
* whose proposal carried the name to everyone else, and which every later step
|
||||
* read back off the room. The parent's room is called something else, and the
|
||||
* one name a child must not take is its parent's.
|
||||
*/
|
||||
@Test
|
||||
fun `the subgroup's name travels on the proposal and lands on the session`(): Unit = runBlocking {
|
||||
val room = parentRoom()
|
||||
val session = openCeremony(room)
|
||||
|
||||
assertEquals("Translation", session.subject)
|
||||
assertEquals("Ekklesia", room.chatRoom.subject, "the room keeps its own name")
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
)
|
||||
assertEquals("Translation", DkgRitualEvents.parseSubject(proposal.tags))
|
||||
|
||||
// Only on the proposal. Every later message belongs to a session the
|
||||
// receiver already has this on, and a later say in what a ceremony makes
|
||||
// would be a second answer to a settled question.
|
||||
innerEvents(parent).filterNot { it.kind == DkgRitualEvents.PROPOSAL }.forEach {
|
||||
assertNull(DkgRitualEvents.parseSubject(it.tags), "kind ${it.kind} repeats the name")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everybody in the parent's room can read the proposal. Only the admins are in
|
||||
* it.
|
||||
*
|
||||
* This is the price of the parent's transport and the thing that makes it
|
||||
* safe: an MLS message reaches the whole tree, so the ceremony has to say who
|
||||
* it is with, and a member who is not named drops it rather than joining. A
|
||||
* member who published a host key would be in the group's permanent signing
|
||||
* quorum, so "reads the message" and "is in the ceremony" must not be the same
|
||||
* question.
|
||||
*/
|
||||
@Test
|
||||
fun `a parent member who was not picked drops the proposal`(): Unit = runBlocking {
|
||||
val room = parentRoom()
|
||||
val session = openCeremony(room)
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
)
|
||||
|
||||
receiverRoom()
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = receiver,
|
||||
localChatRoom = assertNotNull(receiver.chatRoomDao().findChatRoomById(parentRoomId)),
|
||||
innerEvent = proposal.asEvent(),
|
||||
userPublicKey = malloryPublicKey,
|
||||
nostrPrivateKey = mallory.privKey!!
|
||||
)
|
||||
|
||||
assertNull(
|
||||
receiver.dkgSessionDao().getSessionById(session.id),
|
||||
"a member outside the p-tags opens no session",
|
||||
)
|
||||
assertTrue(
|
||||
innerEvents(receiver).isEmpty(),
|
||||
"and publishes nothing, so their host key never joins the quorum",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A picked admin joins, on the same transport, and only when they say so.
|
||||
*
|
||||
* The approval gate is unchanged by the move and has to stay that way: a
|
||||
* relay delivering a group event to a phone in a pocket must not enrol its
|
||||
* owner in a permanent signing quorum.
|
||||
*/
|
||||
@Test
|
||||
fun `a picked admin joins the ceremony, and not before approving`(): Unit = runBlocking {
|
||||
val room = parentRoom()
|
||||
openCeremony(room)
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
)
|
||||
|
||||
val theirRoom = receiverRoom()
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = receiver,
|
||||
localChatRoom = theirRoom,
|
||||
innerEvent = proposal.asEvent(),
|
||||
userPublicKey = alice,
|
||||
nostrPrivateKey = aliceKeys.privKey!!
|
||||
)
|
||||
|
||||
val joined = assertNotNull(
|
||||
receiver.dkgSessionDao().getLatestSessionForChatRoom(parentRoomId),
|
||||
"a picked admin records the ceremony",
|
||||
)
|
||||
assertEquals(3, joined.participantCount)
|
||||
assertEquals(quorum, joined.threshold)
|
||||
assertEquals(parentRoomId, joined.parentChatRoomId)
|
||||
assertEquals("Translation", joined.subject)
|
||||
assertEquals(admins + user, joined.participantPublicKeySet())
|
||||
|
||||
assertTrue(innerEvents(receiver).isEmpty(), "nothing goes out before they agree")
|
||||
assertEquals(
|
||||
DkgApprovalStep.HOST_KEY,
|
||||
ChillDkgRitualManager.pendingApproval(receiver, joined),
|
||||
)
|
||||
|
||||
ChillDkgRitualManager.approve(
|
||||
database = receiver,
|
||||
localChatRoom = theirRoom,
|
||||
sessionId = joined.id,
|
||||
step = DkgApprovalStep.HOST_KEY,
|
||||
nostrPrivateKey = aliceKeys.privKey!!
|
||||
)
|
||||
|
||||
val published = innerEvents(receiver)
|
||||
assertEquals(1, published.size, "one message, and it is an inner event")
|
||||
assertEquals(DkgRitualEvents.HOST_KEY, published.first().kind)
|
||||
assertEquals(
|
||||
admins + user - alice,
|
||||
published.first().tags.taggedUsers().map { it.pubKey }.toSet(),
|
||||
"a later message carries the roster too, minus its own sender",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A message that beat the proposal is replayed out of the store its transport
|
||||
* writes to.
|
||||
*
|
||||
* The ritual's own resume machinery is what makes it safe to run anywhere:
|
||||
* every payload is stored as it decrypts and read back once there is a session
|
||||
* to file it under. That lookup has to make the same reading of the room that
|
||||
* `broadcast` does -- a ceremony in a Marmot room has its backlog in the inner
|
||||
* events and none at all in the gift wraps, and looking in the wrong one is a
|
||||
* stall with nothing to blame it on.
|
||||
*/
|
||||
@Test
|
||||
fun `a host key that arrived before the proposal is replayed`(): Unit = runBlocking {
|
||||
val room = parentRoom()
|
||||
val session = openCeremony(room)
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
)
|
||||
|
||||
val theirRoom = receiverRoom()
|
||||
|
||||
// Bob's host key, stored by the inbound path and dropped at the time for
|
||||
// want of a session to file it under.
|
||||
val early = MarmotInnerEvent(
|
||||
id = "ee".repeat(32),
|
||||
publicKey = bob,
|
||||
kind = DkgRitualEvents.HOST_KEY,
|
||||
createdAt = Instant.fromEpochSeconds(1),
|
||||
tags = DkgRitualEvents.assembleTags(sessionId = session.id),
|
||||
content = "02".repeat(33),
|
||||
chatRoomId = parentRoomId
|
||||
)
|
||||
receiver.marmotInnerEventDao().upsert(early)
|
||||
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = receiver,
|
||||
localChatRoom = theirRoom,
|
||||
innerEvent = proposal.asEvent(),
|
||||
userPublicKey = alice,
|
||||
nostrPrivateKey = aliceKeys.privKey!!
|
||||
)
|
||||
|
||||
assertNotNull(
|
||||
receiver.dkgSessionDao().getMessage(session.id, DkgRitualEvents.HOST_KEY, bob),
|
||||
"the message that lost the race is not lost",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent does not start signing as its own child.
|
||||
*
|
||||
* `completedKey`'s last fallback is a ceremony held in this very room, and it
|
||||
* is load-bearing: it is what every member welcomed after the group's own
|
||||
* ceremony falls back to, since they hold no key state and no share. The
|
||||
* parent's room now holds a completed ceremony whose key belongs to the child,
|
||||
* and handing that back would have the parent author events as its subgroup,
|
||||
* silently.
|
||||
*/
|
||||
@Test
|
||||
fun `a subgroup's completed ceremony is not the parent's key`(): Unit = runBlocking {
|
||||
// A member with no key state and no share of the parent's key: exactly the
|
||||
// position that reaches the fallback.
|
||||
parentRoom(withOwnKey = false)
|
||||
|
||||
val childMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("2bada550000000000000000000000000000000000000000000000000000000b2")
|
||||
),
|
||||
nParticipants = 3,
|
||||
threshold = 2
|
||||
)
|
||||
|
||||
parent.dkgSessionDao().upsert(
|
||||
DkgSession(
|
||||
id = "child-ceremony",
|
||||
chatRoomId = parentRoomId,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
threshold = quorum,
|
||||
participantCount = 3,
|
||||
participantPublicKeys = DkgSession.formatParticipants(admins + user),
|
||||
subject = "Translation",
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
hostPublicKey = user,
|
||||
round1Random = "aa".repeat(32),
|
||||
round2AuxRandom = "bb".repeat(32),
|
||||
thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(),
|
||||
secretShare = "cc".repeat(32),
|
||||
parentChatRoomId = parentRoomId
|
||||
)
|
||||
)
|
||||
|
||||
assertNull(
|
||||
FrostSigningManager.completedKey(parent, parentRoomId),
|
||||
"a ceremony run to make a subgroup is never the room's own key",
|
||||
)
|
||||
|
||||
// And the child's own room still resolves it, by rederiving the id from
|
||||
// the key -- which is the check that actually binds a room to a key.
|
||||
assertEquals(
|
||||
"child-ceremony",
|
||||
FrostSigningManager.completedKey(
|
||||
parent,
|
||||
SharedKeyDerivation.marmotGroupId(
|
||||
childMaterial.thresholdPublicKey.value.toHex()
|
||||
)
|
||||
)?.id,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent's admin room, as the device that made it holds it.
|
||||
*
|
||||
* [withOwnKey] seeds the group's own completed ceremony, which is how a real
|
||||
* admin room resolves the key it signs with. Turned off for the member
|
||||
* welcomed later, who holds neither a share nor a key state.
|
||||
*/
|
||||
private suspend fun parentRoom(withOwnKey: Boolean = true): LocalChatRoom {
|
||||
(members + user).forEach { seedProfile(parent, it) }
|
||||
|
||||
val metadata = MarmotGroupData(
|
||||
nostrGroupId = parentRoomId,
|
||||
name = "Ekklesia",
|
||||
description = SharedKeyDerivation.describe("Admins of Ekklesia."),
|
||||
adminPubkeys = members + user,
|
||||
relays = Relays.DefaultDMRelayList.map { it.url }
|
||||
)
|
||||
val mlsGroup = MlsGroup.create(
|
||||
KeyPair().pubKey,
|
||||
Ed25519.generateKeyPair().privateKey,
|
||||
listOf(metadata.toExtension())
|
||||
)
|
||||
|
||||
parent.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = parentRoomId,
|
||||
userPublicKey = user,
|
||||
subject = "Ekklesia",
|
||||
description = metadata.description,
|
||||
mlsGroupState = mlsGroup.saveState().encodeTls().toHex(),
|
||||
)
|
||||
)
|
||||
parent.participantDao().upsert(
|
||||
(members + user).map {
|
||||
Participant(participantPublicKey = it, chatRoomId = parentRoomId, relayHint = null)
|
||||
}
|
||||
)
|
||||
|
||||
if (withOwnKey) {
|
||||
parent.dkgSessionDao().upsert(
|
||||
DkgSession(
|
||||
id = "parent-ceremony",
|
||||
// Filed under the parent's own room. In life it ran in the
|
||||
// NIP-17 room the group's members shared, and either way it
|
||||
// claims no parent -- which is the only thing that has to be
|
||||
// true for it to be found as the room's own key.
|
||||
chatRoomId = parentRoomId,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
threshold = 3,
|
||||
participantCount = 4,
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
hostPublicKey = user,
|
||||
round1Random = "aa".repeat(32),
|
||||
round2AuxRandom = "bb".repeat(32),
|
||||
thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(),
|
||||
secretShare = "cc".repeat(32),
|
||||
)
|
||||
)
|
||||
(members + user).forEachIndexed { index, member ->
|
||||
parent.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = "parent-ceremony",
|
||||
participantPublicKey = member,
|
||||
kind = DkgRitualEvents.HOST_KEY,
|
||||
payload = "0${index + 1}".repeat(33)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return assertNotNull(parent.chatRoomDao().findChatRoomById(parentRoomId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The same room on somebody else's device.
|
||||
*
|
||||
* Its own MLS group rather than a copy of the coordinator's: nothing here is
|
||||
* encrypted between the two, and a shared state object would make the two
|
||||
* databases one device wearing two hats.
|
||||
*/
|
||||
private suspend fun receiverRoom(): LocalChatRoom {
|
||||
(members + user).forEach { seedProfile(receiver, it) }
|
||||
|
||||
val metadata = MarmotGroupData(
|
||||
nostrGroupId = parentRoomId,
|
||||
name = "Ekklesia",
|
||||
description = SharedKeyDerivation.describe("Admins of Ekklesia."),
|
||||
adminPubkeys = members + user,
|
||||
relays = Relays.DefaultDMRelayList.map { it.url }
|
||||
)
|
||||
val mlsGroup = MlsGroup.create(
|
||||
KeyPair().pubKey,
|
||||
Ed25519.generateKeyPair().privateKey,
|
||||
listOf(metadata.toExtension())
|
||||
)
|
||||
|
||||
receiver.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = parentRoomId,
|
||||
userPublicKey = alice,
|
||||
subject = "Ekklesia",
|
||||
description = metadata.description,
|
||||
mlsGroupState = mlsGroup.saveState().encodeTls().toHex(),
|
||||
)
|
||||
)
|
||||
receiver.participantDao().upsert(
|
||||
(members + user).map {
|
||||
Participant(participantPublicKey = it, chatRoomId = parentRoomId, relayHint = null)
|
||||
}
|
||||
)
|
||||
|
||||
return assertNotNull(receiver.chatRoomDao().findChatRoomById(parentRoomId))
|
||||
}
|
||||
|
||||
private suspend fun seedProfile(database: MantraDatabase, publicKey: String) {
|
||||
if (database.profileDao().getProfileByPublicKey(publicKey) != null) return
|
||||
|
||||
val nostrEventId = publicKey.take(63) + "f"
|
||||
database.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
database.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user