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:
Kgothatso Ngako
2026-09-09 00:38:49 +02:00
parent ccaef5d36a
commit 1930d6aaef
8 changed files with 236 additions and 85 deletions

View File

@@ -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.
*

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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)