fix(subgroups): a subgroup may be the whole group
"A subgroup cannot be the whole group. Leave at least one member out." That rule shipped in Phase 8 and it was wrong twice. **It refused something legitimate.** A subgroup is a logical division -- a group deciding that some of its work belongs to a differently-keyed room -- not a group carving out a smaller membership. Every member being in it is an ordinary case, and no guard here had any business deciding otherwise. **And it was a proxy, not a check.** The thing it stood in for is real: a ceremony room is derived from its admins, so a subgroup over everybody lands in the room the group's *own* ceremony was held in, and `proposeRitual` handing back that ceremony would quietly make the child the parent. But set size does not detect that. A parent whose membership has changed since its own ceremony derives a different room -- so the sizes can match with no collision, and differ with one. **Sharing the room was never the problem; sharing a ceremony was.** `DkgSession.parentChatRoomId` already told two ceremonies apart, so the fix is to scope the lookup by it rather than to forbid the selection. `DkgSessionDao.getLatestSessionFor(room, parent)` replaces `...ForChatRoom` at the three places that decide whether a ceremony already exists: `proposeRitual`'s one-at-a-time guard, `refuseCeremonyRoom`, and the two repository observers the ritual screen follows. A room may now hold the group's own ceremony and a subgroup's at once. Nothing below that lookup had to learn about the second one. A ceremony's messages, approvals and transcript are already keyed by session id; only the question "what is this room's current ceremony" was ever room-scoped, and that question was always really "for what purpose". One collision survives and it is degenerate: the same parent, over the same admins, twice. Those two have nothing left to distinguish them -- which is another way of saying they are one subgroup asked for twice, and that is what the message now says. `a subgroup that is the whole group is refused` becomes `a subgroup may be the whole group`, and two new cases pin the scoping: the group's own ceremony sitting in the derived room does not block a subgroup there, and the same parent asking twice over the same admins still does while a different admin set is untouched. `docs/subgroups.md` keeps the withdrawn rule struck through in the refusals table with a section saying why, rather than quietly deleting it -- the reasoning that led to it is the reasoning somebody would repeat. 397 common tests, 713 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,42 @@ interface DkgSessionDao {
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* The room's newest ceremony *for one purpose*: the group's own when
|
||||
* [parentChatRoomId] is null, or the subgroup of that parent when it is not.
|
||||
*
|
||||
* A room can hold more than one. The ceremony room for a set of admins is
|
||||
* derived from those admins, so a subgroup whose admins are everybody lands
|
||||
* in the room the group's own ceremony was held in -- and that is a legitimate
|
||||
* subgroup rather than a mistake, because a subgroup is a logical division and
|
||||
* not a smaller membership.
|
||||
*
|
||||
* `DkgSession.parentChatRoomId` is what tells them apart, and it is enough
|
||||
* because it is the only thing that differs: two ceremonies in one room are
|
||||
* either one group's and one subgroup's, or two subgroups' of different
|
||||
* parents. Two subgroups of the *same* parent over the *same* admins would
|
||||
* still collide, and that pair is one subgroup asked for twice.
|
||||
*
|
||||
* Everything else about a ceremony is already keyed by session id -- the
|
||||
* messages, the approvals, the transcript -- so nothing below this needed to
|
||||
* learn about the second one.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " +
|
||||
"(parentChatRoomId = :parentChatRoomId OR " +
|
||||
"(:parentChatRoomId IS NULL AND parentChatRoomId IS NULL)) " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): DkgSession?
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " +
|
||||
"(parentChatRoomId = :parentChatRoomId OR " +
|
||||
"(:parentChatRoomId IS NULL AND parentChatRoomId IS NULL)) " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
fun observeLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): Flow<DkgSession?>
|
||||
|
||||
/**
|
||||
* Every ceremony this device came out of holding a share, newest first.
|
||||
*
|
||||
|
||||
@@ -28,8 +28,11 @@ class DatabaseDkgRepository(
|
||||
): DkgRepository {
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> =
|
||||
database.dkgSessionDao().observeLatestSessionForChatRoom(chatRoomId)
|
||||
override fun observeLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
): Flow<DkgSession?> =
|
||||
database.dkgSessionDao().observeLatestSessionFor(chatRoomId, parentChatRoomId)
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>> =
|
||||
database.dkgSessionDao().observeMessages(sessionId)
|
||||
@@ -86,7 +89,10 @@ class DatabaseDkgRepository(
|
||||
): Flow<List<LocalFrostSigningSession>> =
|
||||
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
|
||||
|
||||
override fun observeSignedGroupKeyState(chatRoomId: String): Flow<GroupKeyState?> =
|
||||
override fun observeSignedGroupKeyState(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
): Flow<GroupKeyState?> =
|
||||
database.groupSignedEventDao()
|
||||
.observeByKind(GroupKeyStateEvent.KIND)
|
||||
.map { signedEvents ->
|
||||
@@ -94,7 +100,7 @@ class DatabaseDkgRepository(
|
||||
// rather than once: the ceremony has no key until it finishes,
|
||||
// and this flow is running before it does.
|
||||
val adminRoomId = database.dkgSessionDao()
|
||||
.getLatestSessionForChatRoom(chatRoomId)
|
||||
.getLatestSessionFor(chatRoomId, parentChatRoomId)
|
||||
?.thresholdPublicKey
|
||||
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
|
||||
|
||||
@@ -149,7 +155,7 @@ class DatabaseDkgRepository(
|
||||
// rather than once: the ceremony has no key until it finishes,
|
||||
// and this flow is running before it does.
|
||||
val subgroupChatRoomId = database.dkgSessionDao()
|
||||
.getLatestSessionForChatRoom(ceremonyChatRoomId)
|
||||
.getLatestSessionFor(ceremonyChatRoomId, parentChatRoomId)
|
||||
?.thresholdPublicKey
|
||||
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
|
||||
?: return@map null
|
||||
|
||||
@@ -125,17 +125,24 @@ object ChillDkgRitualManager {
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey? = null
|
||||
): DkgSession {
|
||||
// A room runs one ceremony at a time, and this is reachable twice now that
|
||||
// a robust group opens one as it is created: the room id is derived from
|
||||
// its members, so making the same group again lands back in the same room
|
||||
// and asks again. A second proposal is a second participant set for every
|
||||
// member to reconcile, and the key the first one produced would be left
|
||||
// with nothing pointing at it. A failed ritual is not running, and is
|
||||
// there to be replaced.
|
||||
// A room runs one ceremony at a time *for one purpose*, and this is
|
||||
// reachable twice now that a robust group opens one as it is created: the
|
||||
// room id is derived from its members, so making the same group again
|
||||
// lands back in the same room and asks again. A second proposal for the
|
||||
// same purpose is a second participant set for every member to reconcile,
|
||||
// and the key the first one produced would be left with nothing pointing
|
||||
// at it. A failed ritual is not running, and is there to be replaced.
|
||||
//
|
||||
// Scoped by [parentChatRoomId] rather than by room alone. A subgroup's
|
||||
// ceremony room is derived from its admins, so a subgroup whose admins are
|
||||
// the whole group lands in the room the group's own ceremony was held in
|
||||
// -- and that is a legitimate subgroup, since a subgroup is a logical
|
||||
// division rather than a smaller membership. Refusing it on the room would
|
||||
// hand back the *group's* key and quietly make the child the parent.
|
||||
//
|
||||
// Checked before the arguments are, because a running ritual makes the
|
||||
// requested threshold moot -- it settled that question when it opened.
|
||||
database.dkgSessionDao().getLatestSessionForChatRoom(localChatRoom.chatRoom.id)
|
||||
database.dkgSessionDao().getLatestSessionFor(localChatRoom.chatRoom.id, parentChatRoomId)
|
||||
?.takeIf { it.stage != DkgRitualStage.FAILED }
|
||||
?.let { running ->
|
||||
logger.i(
|
||||
|
||||
@@ -157,21 +157,26 @@ object SubgroupManager {
|
||||
return "Everyone in a subgroup has to be in this group first."
|
||||
}
|
||||
|
||||
// A proper subset, and this is not fussiness. The ceremony room is derived
|
||||
// from its members, so selecting the whole group derives the room this
|
||||
// group's own ceremony was held in -- and `proposeRitual` would hand back
|
||||
// that ceremony, making the "child" this very group.
|
||||
if (admins.size == members.size) {
|
||||
return "A subgroup cannot be the whole group. Leave at least one member out."
|
||||
}
|
||||
|
||||
// Any completed or running ceremony in the room these admins derive is the
|
||||
// same trap one step removed: two subgroups with exactly the same admins
|
||||
// would share a key and therefore a room id.
|
||||
// A subgroup *may* be the whole group. It is a logical division -- a group
|
||||
// deciding that some of its work belongs to a differently-keyed room --
|
||||
// rather than a smaller membership, so "everybody" is a normal answer and
|
||||
// was never this function's business to refuse.
|
||||
//
|
||||
// What the old rule was standing in for is real and is checked below,
|
||||
// precisely: the ceremony room is derived from its admins, so a subgroup
|
||||
// over everybody lands in the room the group's own ceremony was held in.
|
||||
// Sharing the room is fine; sharing a *ceremony* is not, and
|
||||
// `DkgSession.parentChatRoomId` is what keeps them apart.
|
||||
//
|
||||
// Checking on set size was wrong twice over. It refused a legitimate
|
||||
// subgroup, and it refused it on a proxy: a parent whose membership has
|
||||
// changed since its own ceremony derives a different room, so the sizes
|
||||
// could match with no collision at all, and could differ with one.
|
||||
val ceremonyRoomId = ceremonyRoomIdFor(adminPublicKeys, coordinatorPublicKey)
|
||||
val running = database.dkgSessionDao().getLatestSessionForChatRoom(ceremonyRoomId)
|
||||
val running = database.dkgSessionDao()
|
||||
.getLatestSessionFor(ceremonyRoomId, parentChatRoomId)
|
||||
if (running != null && running.stage != DkgRitualStage.FAILED) {
|
||||
return "These members already hold a shared key together. Change who is in the subgroup."
|
||||
return "This group already has a subgroup run by exactly these members."
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -20,8 +20,19 @@ import kotlinx.coroutines.flow.flowOf
|
||||
* not publish on their behalf until they say so.
|
||||
*/
|
||||
interface DkgRepository {
|
||||
/** The room's current ritual — abandoned attempts are superseded by the newest. */
|
||||
fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?>
|
||||
/**
|
||||
* The room's current ritual for one purpose — the group's own when
|
||||
* [parentChatRoomId] is null, or that parent's subgroup when it is not.
|
||||
* Abandoned attempts are superseded by the newest.
|
||||
*
|
||||
* A room can hold two: a subgroup whose admins are the whole group derives
|
||||
* the room the group's own ceremony ran in. See
|
||||
* `DkgSessionDao.getLatestSessionFor`.
|
||||
*/
|
||||
fun observeLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String? = null,
|
||||
): Flow<DkgSession?>
|
||||
|
||||
fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>>
|
||||
|
||||
@@ -92,7 +103,10 @@ interface DkgRepository {
|
||||
* changes is the moment there is a room to make -- and the state it is
|
||||
* about does not exist as a row until somebody makes one.
|
||||
*/
|
||||
fun observeSignedGroupKeyState(chatRoomId: String): Flow<GroupKeyState?>
|
||||
fun observeSignedGroupKeyState(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String? = null,
|
||||
): Flow<GroupKeyState?>
|
||||
|
||||
/** Files the state the group signed for a room that now exists. */
|
||||
suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState?
|
||||
@@ -140,7 +154,10 @@ interface DkgRepository {
|
||||
|
||||
companion object {
|
||||
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
|
||||
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> = flowOf(null)
|
||||
override fun observeLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
): Flow<DkgSession?> = flowOf(null)
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>> = flowOf(emptyList())
|
||||
|
||||
@@ -173,7 +190,10 @@ interface DkgRepository {
|
||||
chatRoomId: String
|
||||
): Flow<List<LocalFrostSigningSession>> = flowOf(emptyList())
|
||||
|
||||
override fun observeSignedGroupKeyState(chatRoomId: String): Flow<GroupKeyState?> =
|
||||
override fun observeSignedGroupKeyState(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
): Flow<GroupKeyState?> =
|
||||
flowOf(null)
|
||||
|
||||
override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null
|
||||
|
||||
@@ -134,7 +134,8 @@ class DkgRitualViewModel(
|
||||
|
||||
observeKeyState()
|
||||
|
||||
dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session ->
|
||||
dkgRepository.observeLatestSessionForChatRoom(chatRoomId, parentChatRoomId)
|
||||
.collect { session ->
|
||||
val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
|
||||
?: DkgRitualUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
@@ -212,7 +213,8 @@ class DkgRitualViewModel(
|
||||
}
|
||||
|
||||
launch {
|
||||
dkgRepository.observeSignedGroupKeyState(chatRoomId).collect { state ->
|
||||
dkgRepository.observeSignedGroupKeyState(chatRoomId, parentChatRoomId)
|
||||
.collect { state ->
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
|
||||
|
||||
dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null)
|
||||
|
||||
@@ -330,56 +330,60 @@ class SubgroupManagerJvmTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subgroup that is the whole group is refused`(): Unit = runBlocking {
|
||||
// The trap this exists for: the ceremony room is derived from its members,
|
||||
// so selecting everybody derives the room this group's own ceremony was
|
||||
// held in -- and `proposeRitual` would hand that ceremony back, making the
|
||||
// "child" this very group.
|
||||
fun `a subgroup may be the whole group`(): Unit = runBlocking {
|
||||
// A subgroup is a logical division -- a group deciding that some of its
|
||||
// work belongs to a differently-keyed room -- not a smaller membership.
|
||||
// "Everybody" is a normal answer.
|
||||
//
|
||||
// This used to be refused on set size, which was wrong twice: it turned
|
||||
// down a legitimate subgroup, and it did so on a proxy for a collision
|
||||
// that a parent whose membership has changed would not even have.
|
||||
val parent = parentWith(listOf(alice, bob))
|
||||
|
||||
assertNotNull(refusal(parent, setOf(alice, bob)))
|
||||
assertNull(refusal(parent, setOf(alice, bob)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an admin set that already holds a ceremony is refused`(): Unit = runBlocking {
|
||||
fun `the group's own ceremony does not block a subgroup in the same room`(): Unit =
|
||||
runBlocking {
|
||||
// The collision the size rule was standing in for, made concrete. A
|
||||
// subgroup over everybody derives the room the group's own ceremony was
|
||||
// held in -- so the room is shared, and only `parentChatRoomId` keeps
|
||||
// the two ceremonies apart. Without that scoping `proposeRitual` would
|
||||
// hand back the *group's* key and quietly make the child the parent.
|
||||
val parent = parentWith(listOf(alice, bob))
|
||||
val ceremonyRoomId = SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user)
|
||||
|
||||
seedCeremonyRoom(ceremonyRoomId, listOf(alice, bob, user))
|
||||
db.dkgSessionDao().upsert(ownCeremony(ceremonyRoomId))
|
||||
|
||||
assertNull(
|
||||
refusal(parent, setOf(alice, bob)),
|
||||
"the group's own ceremony is not this subgroup's",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same parent cannot have two subgroups over the same admins`(): Unit = runBlocking {
|
||||
val parent = parentWith(listOf(alice, bob, carol))
|
||||
|
||||
assertNull(refusal(parent, setOf(alice, bob)))
|
||||
|
||||
// One admin set, one ceremony room, forever -- the id is a pure function
|
||||
// of the members. A second subgroup with the same admins would come back
|
||||
// with the first one's key and therefore the first one's room id.
|
||||
//
|
||||
// Room first, the way the picker stands one up: the ceremony room is a
|
||||
// real NIP-17 room and `DkgSession.chatRoomId` is a foreign key onto it.
|
||||
val ceremonyRoom = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(alice, bob),
|
||||
subject = "Translation team",
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user),
|
||||
ceremonyRoom.chatRoom.id,
|
||||
"the derived ceremony room is the one createNip17ChatRoom lands on",
|
||||
)
|
||||
|
||||
// The one collision that survives, and it is degenerate: same parent,
|
||||
// same admins. The ceremony room is derived from the admins and the
|
||||
// ceremonies in it are told apart by their parent, so this pair has
|
||||
// nothing left to distinguish them -- which is another way of saying it
|
||||
// is one subgroup asked for twice.
|
||||
val ceremonyRoomId = SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user)
|
||||
seedCeremonyRoom(ceremonyRoomId, listOf(alice, bob, user))
|
||||
db.dkgSessionDao().upsert(
|
||||
DkgSession(
|
||||
id = "s1",
|
||||
chatRoomId = ceremonyRoom.chatRoom.id,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
hostPublicKey = user,
|
||||
round1Random = "aa".repeat(32),
|
||||
round2AuxRandom = "bb".repeat(32),
|
||||
)
|
||||
ownCeremony(ceremonyRoomId).copy(id = "s1", parentChatRoomId = parentRoomId)
|
||||
)
|
||||
|
||||
assertNotNull(refusal(parent, setOf(alice, bob)))
|
||||
|
||||
// And a different admin set is a different room, so it is untouched.
|
||||
assertNull(refusal(parent, setOf(alice, carol)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -445,6 +449,41 @@ class SubgroupManagerJvmTest {
|
||||
|
||||
// ---- fixtures ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The NIP-17 room a ceremony runs in, stood up the way the picker does it --
|
||||
* room first, because `DkgSession.chatRoomId` is a foreign key onto it.
|
||||
*/
|
||||
private suspend fun seedCeremonyRoom(expectedId: String, members: List<String>) {
|
||||
members.forEach { seedProfile(it) }
|
||||
|
||||
val room = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = members.filterNot { it == user },
|
||||
subject = "Translation team",
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
expectedId,
|
||||
room.chatRoom.id,
|
||||
"the derived ceremony room is the one createNip17ChatRoom lands on",
|
||||
)
|
||||
}
|
||||
|
||||
/** A ceremony held in [chatRoomId] for no subgroup: the group's own. */
|
||||
private fun ownCeremony(chatRoomId: String) = DkgSession(
|
||||
id = "own-ceremony",
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
hostPublicKey = user,
|
||||
round1Random = "aa".repeat(32),
|
||||
round2AuxRandom = "bb".repeat(32),
|
||||
)
|
||||
|
||||
/** A finished ceremony for the child, as far as these guards can tell. */
|
||||
private fun completedChildCeremony() = DkgSession(
|
||||
id = "child-ceremony",
|
||||
|
||||
Reference in New Issue
Block a user