From b54ff61eb6ea2831e1a91b58122b92fa8823af90 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:05:15 +0200 Subject: [PATCH] feat(subgroups): adopt an existing group, accept a parent, and stop claiming one Phase 4 of docs/subgroups-multiple-parents.md, the operations. Two signings and no ceremony, which is the whole of why this is cheap: the key exists, so there is no ChillDKG, and the room exists, so there is nothing to create. Still unreachable -- no screen offers any of it yet. ``` 1. birth certificate, in the adopting group -> its quorum signs the child's id 2. key state, in the child itself -> the child's quorum re-announces, carrying every certificate it accepts ``` **The order is forced and it decides who is deciding.** The key state carries the certificate, so the certificate has to exist first -- which means the parent commits first and the child answers second. Step 1 is an offer; step 2 is the acceptance, and it is the only half that settles anything. Picking a parent is not choosing on the child's behalf, and the wording of every message here says so. **`proposeAdoption` is the founding certificate with the child already there.** Same kind, same six checks on the other side, same room it has always been proposed in. What differs is that the child's id and threshold key are read off its key state rather than derived from a ceremony this device may not hold. The roster and the name are the child's *current* ones -- on a founding certificate they are the founding roster because there is nothing else they could be, and here the adopting group's admins are being asked about a group that exists, so what they can weigh is who runs it now. Still frozen the moment they are signed, and still never read as a live roster. **`proposeAcceptance` carries the resulting set, not the addition.** The footgun is real: a key state is replaceable and the newest wins *wholesale*, so a state naming only the new parent would silently drop every existing one. The current lineage is read off the row here rather than taken from the caller, and `SubgroupLineage.plus` is what makes an addition an addition. It signs in the child's **own** room, which it can because the room exists -- `propose`'s first branch holds and `signingPath` resolves off the child's own key state, neither of which founding can do. **`proposeDisavowal` is the same act with the set the other way**, and it revokes nothing. A certificate is a signature and exists forever; what this removes is the child's half of the link, which is the half that decides, since a lineage is what the child's quorum most recently declared. The parent's own list does not follow -- `subgroupsOf` reads that group's certificates -- and the comment says so where somebody will wonder. **`refuseAdoption` is seven refusals and the picker is the same function.** `adoptingCandidatesFor` filters the active profile's rooms through the guard itself rather than through a copy of its rules, so screen and manager cannot drift. The list is the profile's own rooms and cannot be anything else: a certificate is proposed inside the adopting group, so a candidate has to be a room the key in the chair can act in, and `getChatRoomListByUserPublicKey` is keyed by identity. An empty answer is a fact about the member rather than a failure -- the remedy is a person, not a button. Two refusals are new in kind. "This group has not agreed what it signs with" is the overlap requirement surfacing: the certificate carries the child's threshold key, which only the child's members hold, so a would-be parent with no sight of it cannot certify anything. And a cycle check, because a lineage is a DAG now rather than a tree -- with one parent nothing was ever tempted to recurse. `isAncestorOf` is depth-bounded and visited-set guarded; it is advisory, since a device sees only the lineages it holds states for, and the real protection is that an adoption needs the child's own quorum. Fifteen tests. Every refusal once, including the cycle and the picker's filter, and then the three operations by reading the proposed event back out of the signing session -- which is what catches the wholesale-replacement footgun: accepting a second parent has to carry the first, and disavowing the only one has to leave a state that claims none rather than one that claims nothing readable. `parentWith` is now one case of a `marmotRoom` helper taking the id and the key, since adoption needs two real group rooms at once rather than one; it keeps its own doc comment and the warning it carries about `nostrGroupId` lengths, which is still the trap it was. `certificate` gains a parent id so a sibling group can sign one. 454 common tests, 962 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../compose/managers/SubgroupManager.kt | 384 ++++++++++++++++++ .../managers/SubgroupManagerJvmTest.kt | 375 ++++++++++++++++- 2 files changed, 746 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 26993788..0d68a0f7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -72,6 +72,18 @@ object SubgroupManager { */ const val MINIMUM_ADMINS: Int = 3 + /** + * How far an ancestor walk goes before it gives up. + * + * A lineage is a DAG rather than a tree once a room can have two parents, so + * "is this group already beneath that one" is a graph walk. Bounded because a + * cycle is constructible: a child's quorum can certify its own parent -- the + * parent's threshold key is public -- and the parent's quorum can re-announce + * a state accepting it. Both have to cooperate in something pointless, which + * is no reason to let a reader loop forever. + */ + const val MAXIMUM_LINEAGE_DEPTH: Int = 16 + /** * Why these admins cannot hold a ceremony together, or null when they can. * @@ -330,6 +342,378 @@ object SubgroupManager { ) } + // ---- Adoption: a group that already exists gaining a parent ------------- + // + // Two signings and no ceremony, which is the whole of why this is cheap: the + // key exists, so there is no ChillDKG, and the room exists, so there is + // nothing to create. + // + // ``` + // 1. birth certificate, in the adopting group -> its quorum signs the child's id + // 2. key state, in the child itself -> the child's quorum re-announces, + // carrying every certificate it accepts + // ``` + // + // The order is forced, not chosen: the key state carries the certificate, so + // the certificate has to exist first. Which means **the parent commits first + // and the child decides second** -- step 1 is an offer, and step 2 is the + // acceptance. Picking a parent is not choosing on the child's behalf. + // + // Both steps need somebody in both groups, and there is no way around it: + // step 1 needs the child's threshold key, which only the child's members + // hold, and step 2 needs the certificate, which `recordSignedEvents` files + // only for the adopting group's members. They need not be the same person and + // their capabilities differ. See docs/subgroups-multiple-parents.md. + + /** + * Why [adoptingRoom] cannot offer to adopt [childChatRoomId], or null when it + * can. + * + * Read by the picker so nothing is offered that could only fail, and again + * before anything is published, because a screen not drawing something is not + * a guard. Each message says what to do rather than what is wrong. + * + * "This device" throughout means **the profile in the chair**, and + * `FrostSigningManager.canSign` does not currently tell the two apart -- see + * the note under docs/subgroups-multiple-parents.md. These refusals are + * honest about capability and silent about persona. + */ + suspend fun refuseAdoption( + database: MantraDatabase, + adoptingRoom: LocalChatRoom, + childChatRoomId: String, + userPublicKey: HexKey + ): String? { + val adoptingChatRoomId = adoptingRoom.chatRoom.id + + // The certificate is signed by the adopting group's key, so a device + // holding no share of it cannot open the session at all -- + // `proposeSigningBatch` throws outright, and throwing at a button is not + // a UI. First, because it is the one refusal the user cannot fix by + // picking differently. + if (!FrostSigningManager.canSign(database, adoptingChatRoomId)) { + return "Only a member who holds a share of that group's key can adopt a group into it." + } + + // Read off the room's own `MarmotGroupData` rather than off + // `Participant.adminAt`, for the reason `refuseSubgroup` gives: that + // column is written only by `processGroupMembershipChanges` on an + // arriving commit, so on a room this device created it is null for + // everybody, including the creator. + val adoptingAdmins = runCatching { + adoptingRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys + }.getOrNull() + ?: return "A group can only adopt another if it has admins." + + if (!adoptingAdmins.contains(userPublicKey)) { + return "Only an admin of that group can adopt a group into it." + } + + if (adoptingChatRoomId.equals(childChatRoomId, ignoreCase = true)) { + return "A group cannot be its own parent." + } + + // The certificate carries the child's threshold key, and without it + // `certifies` fails its fourth question on every device including this + // one. That key lives on the child's own key state, which only the + // child's members hold -- so this is the overlap requirement showing up + // as a refusal. + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: return "This group has not agreed what it signs with, so there is nothing to certify." + + if (child.lineage()?.names(adoptingChatRoomId) == true) { + return "That group has already adopted this one." + } + + // One certificate per child per parent. A second is a `d`-tag replacement + // of the first rather than a second adoption, and spending a quorum's + // attention to restate something they have already signed is worse than + // doing nothing. + if (certificateFor(database, childChatRoomId, adoptingChatRoomId) != null) { + return "That group has already offered to adopt this one." + } + + // A cycle, as far as this device can see -- and only as far as that. The + // walk is bounded and visited-set guarded because a lineage is a DAG now + // rather than a tree: with one parent nothing was ever tempted to recurse. + if (isAncestorOf(database, childChatRoomId, adoptingChatRoomId)) { + return "That group is already a subgroup of this one." + } + + return null + } + + /** + * Whether [candidate] is [chatRoomId] or anything beneath it, as far as this + * device's own verified lineages reach. + * + * Bounded and visited-set guarded. A cycle is constructible -- two quorums + * cooperating in something pointless -- and more to the point a lineage is a + * DAG now, so the fan-out makes an unbounded walk worth refusing to write. + * + * Advisory. A device sees only the lineages it holds states for, so a false + * answer here is "I cannot see one", never "there is none". The real + * protection is that an adoption needs the child's own quorum. + */ + private suspend fun isAncestorOf( + database: MantraDatabase, + chatRoomId: String, + candidate: String, + depth: Int = MAXIMUM_LINEAGE_DEPTH + ): Boolean { + val seen = mutableSetOf(chatRoomId.lowercase()) + var frontier = listOf(chatRoomId) + + repeat(depth) { + if (frontier.isEmpty()) return false + + val children = frontier.flatMap { parent -> + database.groupKeyStateDao().getByParentChatRoomId(parent) + .map { it.keyState.chatRoomId } + } + + if (children.any { it.equals(candidate, ignoreCase = true) }) return true + + frontier = children.filter { seen.add(it.lowercase()) } + } + + return false + } + + /** + * The groups this profile could offer [childChatRoomId] to, in room order. + * + * **The active profile's own rooms, and it cannot be anything else.** A + * certificate is proposed *inside* the adopting group, so a candidate has to + * be a room the key in the chair can already act in. Seeded from + * `getChatRoomListByUserPublicKey`, which is keyed by identity: since several + * profiles landed a device can hold rooms it must not offer here, because the + * key that would have to sign is not the one acting. + * + * A group cannot be offered as a parent by somebody who is not in it. That is + * the overlap requirement showing up as a list length, and an empty answer is + * a fact about this member rather than a failure -- the remedy is a person, + * not a button. + * + * Filtered through [refuseAdoption] itself rather than through a copy of its + * rules, so the picker and the manager cannot drift. The cost is one MLS + * decode per room, to reach `adminPubkeys`, plus two indexed reads; call it + * off the main thread. + */ + suspend fun adoptingCandidatesFor( + database: MantraDatabase, + childChatRoomId: String, + userPublicKey: HexKey + ): List = + database.chatRoomDao().getChatRoomListByUserPublicKey(userPublicKey) + .filter { candidate -> + refuseAdoption( + database = database, + adoptingRoom = candidate, + childChatRoomId = childChatRoomId, + userPublicKey = userPublicKey + ) == null + } + + /** + * Step 1: asks [adoptingRoom]'s quorum to certify an existing group as its + * child. + * + * The same event kind, the same six checks on the other side, and the same + * room it has always been proposed in -- what differs from founding is only + * that the child is already there, so its id and key are read off its key + * state rather than derived from a ceremony this device may not hold. + * + * **The roster and the name are the child's current ones**, and that is the + * honest value rather than a compromise. On a founding certificate they are + * the founding roster, frozen, because there is nothing else they could be. + * Here the adopting group's admins are being asked about a group that exists, + * and what they can weigh is who runs it now and what it is called now. They + * are still frozen the moment they are signed, and still never a live roster: + * `certifies` does not check either, for the reason it never did. + */ + suspend fun proposeAdoption( + database: MantraDatabase, + adoptingRoom: LocalChatRoom, + childRoom: LocalChatRoom, + userPublicKey: HexKey + ): FrostSigningSession { + val adoptingChatRoomId = adoptingRoom.chatRoom.id + val childChatRoomId = childRoom.chatRoom.id + + refuseAdoption(database, adoptingRoom, childChatRoomId, userPublicKey)?.let { + throw IllegalStateException(it) + } + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException("$childChatRoomId has agreed no key state to certify") + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + val childAdmins = runCatching { + childRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys + }.getOrNull().orEmpty() + + val childName = MarmotGroupName.of(childRoom.chatRoom.subject.orEmpty().ifBlank { "a group" }) + + logger.i("Asking $adoptingChatRoomId to adopt $childChatRoomId as its subgroup") + + return FrostSigningManager.proposeSigning( + database = database, + localChatRoom = adoptingRoom, + userPublicKey = userPublicKey, + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = childChatRoomId, + parentChatRoomId = adoptingChatRoomId, + thresholdPublicKey = child.keyState.thresholdPublicKey, + adminPublicKeys = childAdmins, + name = childName, + path = path + ), + content = childChatRoomId + ) + } + + /** + * Step 2: asks the child's own quorum to accept [parentChatRoomId], by + * re-announcing its key state with that parentage added. + * + * The acceptance, and the only half of an adoption that decides anything. The + * certificate is an offer until this is signed, which is why + * `ProposedEvent`'s 30326 arm leads with the parents -- this proposal is + * where a member of the child answers it. + * + * **Carries the resulting set, not the addition.** A key state is replaceable + * and the newest wins wholesale, so a state naming only the new parent would + * silently drop every existing one. The current lineage is read off the row + * here rather than taken from the caller, and `SubgroupLineage.plus` is what + * makes adding an addition. + * + * Signed in the child's **own** room, which it can be because the room + * exists: `GroupKeyStateManager.propose`'s first branch holds, and + * `FrostSigningManager.signingPath` resolves off the child's own key state. + * Founding could not do either, which is why its key state is signed where + * its ceremony ran. + */ + suspend fun proposeAcceptance( + database: MantraDatabase, + childRoom: LocalChatRoom, + userPublicKey: HexKey, + parentChatRoomId: HexKey + ): FrostSigningSession { + val childChatRoomId = childRoom.chatRoom.id + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException( + "$childChatRoomId has agreed no key state, so it has none to re-announce" + ) + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + // The certificate this device holds for the pair. Only a member of the + // adopting group has one -- `recordSignedEvents` files it against that + // group's room -- so this is the overlap requirement again, from the + // other side. + val certificate = certificateFor(database, childChatRoomId, parentChatRoomId) + ?: throw IllegalStateException( + "$parentChatRoomId has not certified $childChatRoomId, so there is nothing to accept" + ) + + val parentage = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = certificate.toEvent() + ) + + val lineage = child.lineage()?.plus(parentage) ?: SubgroupLineage.of(parentage) + checkNotNull(lineage) { "$childChatRoomId already names $parentChatRoomId as a parent" } + + val key = FrostSigningManager.completedKey(database, childChatRoomId) + ?: throw IllegalStateException( + "This device holds no share of $childChatRoomId's key to propose with" + ) + + logger.i( + "Asking $childChatRoomId to accept $parentChatRoomId, making " + + "${lineage.size} parents in all" + ) + + return GroupKeyStateManager.propose( + database = database, + localChatRoom = childRoom, + userPublicKey = userPublicKey, + key = key, + path = path, + lineage = lineage + ) + } + + /** + * Asks the child's own quorum to stop claiming [parentChatRoomId], by + * re-announcing its key state without that parentage. + * + * The same act as [proposeAcceptance] with the set the other way, and the + * reason `SubgroupLineage.minus` exists. It **revokes nothing**: a certificate + * is a signature and exists forever, and what this removes is the child's half + * of the link -- which is the half that decides, since a lineage is what the + * child's quorum most recently declared. + * + * The parent's own list does not follow. `subgroupsOf` reads that group's + * certificates, so it will go on naming this room; a parent device that also + * holds this room's key state can see that it no longer names them, and one + * that does not cannot and must not guess. + */ + suspend fun proposeDisavowal( + database: MantraDatabase, + childRoom: LocalChatRoom, + userPublicKey: HexKey, + parentChatRoomId: HexKey + ): FrostSigningSession { + val childChatRoomId = childRoom.chatRoom.id + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException( + "$childChatRoomId has agreed no key state, so it has none to re-announce" + ) + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + val current = child.lineage() + check(current?.names(parentChatRoomId) == true) { + "$childChatRoomId does not name $parentChatRoomId as a parent" + } + + val key = FrostSigningManager.completedKey(database, childChatRoomId) + ?: throw IllegalStateException( + "This device holds no share of $childChatRoomId's key to propose with" + ) + + val remaining = current.minus(parentChatRoomId) + + logger.i( + "Asking $childChatRoomId to stop claiming $parentChatRoomId, leaving " + + "${remaining?.size ?: 0} parents" + ) + + return GroupKeyStateManager.propose( + database = database, + localChatRoom = childRoom, + userPublicKey = userPublicKey, + key = key, + path = path, + lineage = remaining + ) + } + /** * A subgroup as the parent's own record has it, plus whatever this device * happens to hold about it. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index 5953e669..32ee1bd3 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -34,6 +34,8 @@ 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.types.DkgRitualStage +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Participant @@ -41,6 +43,7 @@ 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.dkg.DkgRitualEvents +import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent /** @@ -501,6 +504,326 @@ class SubgroupManagerJvmTest { * room first, because `DkgSession.chatRoomId` is a foreign key onto it. */ /** A ceremony held in the parent's room to make a subgroup run by [admins]. */ + // ---- Adoption: a group that already exists gaining a parent ------------- + + @Test + fun `a group with a key state and an admin in both can be adopted`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + assertNull(refuseAdoptionOf(adopting)) + } + + @Test + fun `a group that has agreed no key state cannot be certified`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + // The room exists; it has never said what it signs with. The certificate + // carries the child's threshold key and there is none to carry, so + // `certifies` would fail its fourth question on every device. + seedRoom(childRoomId) + + assertEquals( + "This group has not agreed what it signs with, so there is nothing to certify.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a member who holds no share of the adopting group's key is refused first`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob), canSign = false) + childWithKeyState() + + assertEquals( + "Only a member who holds a share of that group's key can adopt a group into it.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a non-admin of the adopting group is refused`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob), isAdmin = false) + childWithKeyState() + + assertEquals( + "Only an admin of that group can adopt a group into it.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a group cannot be its own parent`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + assertEquals( + "A group cannot be its own parent.", + SubgroupManager.refuseAdoption( + database = db, + adoptingRoom = adopting, + childChatRoomId = adopting.chatRoom.id, + userPublicKey = user + ) + ) + } + + @Test + fun `a parent already in the lineage is not offered again`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId)) + + assertEquals("That group has already adopted this one.", refuseAdoptionOf(adopting)) + } + + @Test + fun `an offer that already stands is not made twice`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // One certificate per child per parent: a second is a `d`-tag + // replacement of the first rather than a second adoption. + record(certificate()) + + assertEquals( + "That group has already offered to adopt this one.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a group already beneath this one cannot adopt it`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // The adopting group is already the child's child, so adopting it back + // would close a cycle. Advisory -- this device sees only the lineages it + // holds states for -- and the real protection is the child's own quorum. + db.groupKeyStateDao().replace( + GroupKeyState( + chatRoomId = parentRoomId, + dkgSessionId = "parent-ceremony", + thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = parentRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000), + ), + listOf( + GroupKeyStateParent( + chatRoomId = parentRoomId, + parentChatRoomId = childRoomId, + birthCertificateJson = certificate().toJson(), + ) + ) + ) + + assertEquals( + "That group is already a subgroup of this one.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `the picker offers only the rooms this profile can act in`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // A room this device is in but holds no share of, and one it is in but + // does not administer. Neither can carry the signature, so neither is + // offered -- and the filter runs the guard itself rather than a copy of + // its rules. + marmotRoom( + chatRoomId = siblingRoomId, + material = siblingMaterial, + ceremonyId = "sibling-ceremony", + name = "Elders", + members = listOf(alice), + canSign = false, + ) + + assertEquals( + listOf(adopting.chatRoom.id), + SubgroupManager.adoptingCandidatesFor(db, childRoomId, user).map { it.chatRoom.id } + ) + } + + @Test + fun `accepting a parent re-announces the key state with it added`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState() + record(certificate()) + + val session = SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + // Signed in the child's own room, which founding cannot do: the room + // exists, so `propose`'s first branch holds. + assertEquals(childRoomId, session.chatRoomId) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals(GroupKeyStateEvent.KIND, proposed.kind) + assertEquals( + listOf(parentRoomId), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds + ) + } + + @Test + fun `accepting a second parent carries the first one too`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(siblingRoomId)) + record(certificate()) + + val session = SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + // The footgun this guards: a key state is replaceable and the newest wins + // wholesale, so a state naming only the new parent would silently drop + // the one already there. + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals( + listOf(parentRoomId, siblingRoomId).sorted(), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds?.sorted() + ) + } + + @Test + fun `accepting a parent that has not certified this group is refused`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState() + + // No certificate on file. Only a member of the adopting group has one, so + // this is the overlap requirement from the other side. + assertFailsWith { + SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + } + Unit + } + + @Test + fun `disavowing a parent re-announces the key state without it`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(parentRoomId, siblingRoomId)) + + val session = SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals( + listOf(siblingRoomId), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds + ) + } + + @Test + fun `disavowing the only parent leaves a state claiming none`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(parentRoomId)) + + val session = SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + // Nobody's child, which is a state rather than an absence of one. It + // revokes nothing: the certificate is a signature and exists forever. + assertFalse(GroupKeyStateEvent.claimsParentage(proposed.tags)) + assertNull(GroupKeyStateEvent.parseLineage(proposed.tags)) + } + + @Test + fun `disavowing a parent this group never claimed is refused`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(siblingRoomId)) + + assertFailsWith { + SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + } + Unit + } + + private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? = + SubgroupManager.refuseAdoption( + database = db, + adoptingRoom = adopting, + childChatRoomId = childRoomId, + userPublicKey = user + ) + + /** + * The child as an adoption finds it: a real Marmot room of its own, with a + * key state saying what it signs with, and whatever lineage it already has. + */ + private suspend fun childWithKeyState( + parents: List = emptyList() + ): LocalChatRoom { + val room = marmotRoom( + chatRoomId = childRoomId, + material = childMaterial, + ceremonyId = "child-ceremony", + name = "Translators", + members = listOf(alice, bob), + ) + + db.groupKeyStateDao().replace( + GroupKeyState( + chatRoomId = childRoomId, + dkgSessionId = "child-ceremony", + thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(), + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = childRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000), + ), + parents.map { parent -> + GroupKeyStateParent( + chatRoomId = childRoomId, + parentChatRoomId = parent, + birthCertificateJson = certificate( + author = if (parent == parentRoomId) parentMaterial else siblingMaterial, + parentChatRoomId = parent, + ).toJson(), + ) + } + ) + + return room + } + private fun subgroupCeremony(id: String, admins: Set) = ownCeremony(parentRoomId).copy( id = id, @@ -584,15 +907,40 @@ class SubgroupManagerJvmTest { members: List, canSign: Boolean = true, isAdmin: Boolean = true, + ): LocalChatRoom = marmotRoom( + chatRoomId = parentRoomId, + material = parentMaterial, + ceremonyId = "parent-ceremony", + name = "Ekklesia", + members = members, + canSign = canSign, + isAdmin = isAdmin, + ) + + /** + * Any of this app's group rooms, as what one *is*: the admin room derived + * from the group's own key, with its `MarmotGroupData` in the epoch-0 + * extensions. Adoption needs two of them at once -- the adopting group and + * the child -- so this takes the id and the key rather than closing over + * the parent's. + */ + private suspend fun marmotRoom( + chatRoomId: String, + material: KeyMaterial, + ceremonyId: String, + name: String, + members: List, + canSign: Boolean = true, + isAdmin: Boolean = true, ): LocalChatRoom { (members + user).forEach { seedProfile(it) } val admins = if (isAdmin) members + user else members val metadata = MarmotGroupData( - nostrGroupId = parentRoomId, - name = "Ekklesia", - description = SharedKeyDerivation.describe("Admins of Ekklesia."), + nostrGroupId = chatRoomId, + name = name, + description = SharedKeyDerivation.describe("Admins of $name."), adminPubkeys = admins, relays = Relays.DefaultDMRelayList.map { it.url } ) @@ -604,9 +952,9 @@ class SubgroupManagerJvmTest { db.chatRoomDao().upsert( ChatRoom( - id = parentRoomId, + id = chatRoomId, userPublicKey = user, - subject = "Ekklesia", + subject = name, description = metadata.description, mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), ) @@ -620,7 +968,7 @@ class SubgroupManagerJvmTest { (members + user).map { Participant( participantPublicKey = it, - chatRoomId = parentRoomId, + chatRoomId = chatRoomId, relayHint = null, ) } @@ -631,8 +979,8 @@ class SubgroupManagerJvmTest { // matches, which is exactly how a real admin room resolves its key. db.dkgSessionDao().upsert( DkgSession( - id = "parent-ceremony", - chatRoomId = parentRoomId, + id = ceremonyId, + chatRoomId = chatRoomId, coordinatorPublicKey = user, userPublicKey = user, threshold = 2, @@ -641,7 +989,7 @@ class SubgroupManagerJvmTest { hostPublicKey = user, round1Random = "aa".repeat(32), round2AuxRandom = "bb".repeat(32), - thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + thresholdPublicKey = material.thresholdPublicKey.value.toHex(), secretShare = "cc".repeat(32), ) ) @@ -653,7 +1001,7 @@ class SubgroupManagerJvmTest { (members + user).forEachIndexed { index, member -> db.dkgSessionDao().upsert( DkgParticipantMessage( - sessionId = "parent-ceremony", + sessionId = ceremonyId, participantPublicKey = member, kind = DkgRitualEvents.HOST_KEY, payload = "0${index + 1}".repeat(33) @@ -662,7 +1010,7 @@ class SubgroupManagerJvmTest { } } - return assertNotNull(db.chatRoomDao().findChatRoomById(parentRoomId)) + return assertNotNull(db.chatRoomDao().findChatRoomById(chatRoomId)) } private suspend fun seedProfile(publicKey: String) { @@ -716,12 +1064,13 @@ class SubgroupManagerJvmTest { createdAt: Long = 1_700_000_000, author: KeyMaterial = parentMaterial, signer: KeyMaterial = author, - signature: String? = null + signature: String? = null, + parentChatRoomId: String = roomOf(author) ): Event { val subgroupChatRoomId = roomOf(subgroupMaterial) val eventTags = tags ?: SubgroupBirthCertificateEvent.assembleTags( subgroupChatRoomId = subgroupChatRoomId, - parentChatRoomId = parentRoomId, + parentChatRoomId = parentChatRoomId, thresholdPublicKey = subgroupMaterial.thresholdPublicKey.value.toHex(), adminPublicKeys = adminPublicKeys, name = name,