feat(subgroups): the manager, the two dispatch arms, and the list that reads signatures
Phase 5 of docs/subgroups.md. `SubgroupManager` orchestrates the four steps and reimplements none of them: step 1 is `ChillDkgRitualManager`, steps 2 and 3 are `FrostSigningManager` sessions, step 4 is Phase 6's. What lives here is the order, the guards, and the reading of what a parent has signed. **There is no table of subgroups, and there should not be.** The certificates *are* the record: the group's own signed statement, checkable without a lookup, and held by every device that followed the signing session rather than only by the signers -- `FrostSigningManager.complete` files a `GroupSignedEvent` everywhere. A table beside them would be a second copy that can disagree. So `subgroupsOf` reads the parent's kind-30329 rows, filters every one through `certifies`, groups by child and keeps the newest. That read has to include children this device has no room for. It is the normal position of a parent member who is not in the subgroup, and of everybody between the certificate being signed and the room being created -- so a list built from rooms would be empty exactly when it is most needed. `Subgroup` carries the room when there is one and null when there is not, and its `name` and `adminPublicKeys` are labelled in the type as the **founding** roster: what the parent approved, not who is in the room now. **Two certificates for one child is a normal outcome, not a conflict.** Two parent admins can press the button on the same admin set; the second gets the first's ceremony back but both may still propose, and both sessions can complete. `GroupSignedEvent` is keyed on the event id so the rows coexist, and because both say the same true thing about the same child, which wins does not matter. `certificateFor` takes the newest that verifies and the `d` tag makes them replacements rather than an accumulation. **`refuseCeremonyRoom` is where the collision from Phase 4 is caught.** A NIP-17 room's id is a pure function of its members, so one admin set gets one ceremony room forever. Four refusals, each phrased as what to do: fewer than three admins (the coordinator counts -- they hold a share whether or not anybody ticked them, so asking for three *others* would quietly build a group of four); somebody who is not in the parent; the whole group, which derives the parent's own ceremony room and would make the "child" this very group; and an admin set that already holds a non-failed ceremony, which is the same trap one step removed. **Two dispatch arms, both of the sort that fail silently if forgotten.** `ChatMessage.applyInnerEvent` gains a 30329 arm -- without it every parent member gets a raw-JSON chat bubble per subgroup, which is exactly the failure mode docs/member-chronicle.md reports for an old build meeting a new kind. It verifies rather than trusts, because it is reached both from a completed session, where the signature is checked, and from an arriving inner event, where a member could have sent a rumor of this kind, and it cannot tell which. Unlike the key-state arm it *does* write a line. A key state is standing state whose session already wrote the transcript; a subgroup being born is something that happened, and it happened on behalf of members of the parent room who are not in the child and will otherwise never learn it exists. `TYPE_SUBGROUP_CERTIFIED` joins the transcript's system-line dispatch and the chat-list preview, since a type missing from either renders as a bubble -- silently, and looking exactly like a member having said it. `ProposedEvent` gains a 30329 summary, or the parent's admins approve "Event of kind 30329" with the JSON underneath. It reads as the name and the member count, which are the two things a signer can actually weigh; the id is on the screen and their device has already checked it derives from the key the certificate names. **30329 is deliberately not chroniclable**, with a test saying so and why. Same shape as the key state: signed by the room, verifies perfectly, and standing rather than work. The argument for admitting it is better -- "P certified C" is a fixed historical fact -- and the cost of leaving it out is real, since a member added to a parent afterwards sees an empty subgroup list. It still needs an apply-order slot and a decision about whether a room's chronicle may carry an event its own key did not sign, which no chroniclable kind does. Thirteen tests in `SubgroupManagerJvmTest` over four real FROST groups, weighted to the negative cases the way the chronicle tests are: a certificate signed by another group, one nobody signed, and one whose id does not derive from the key it names are each filed in the parent's room and refused on read; `record` refuses a forgery and a certificate naming no subgroup; two certificates for one child collapse; two children stay two, newest first; and the four refusals each fire. 397 common tests, 701 jvm tests, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import press.mantra.compose.database.model.traits.UserViewableEntity
|
||||
import press.mantra.compose.exceptions.MarmotUnprocessableInnerEventException
|
||||
import press.mantra.compose.managers.ChronicleManager
|
||||
import press.mantra.compose.managers.GroupKeyStateManager
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import com.vitorpamplona.quartz.marmot.GroupEventResult
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
@@ -22,6 +23,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
@@ -351,6 +353,28 @@ data class ChatMessage(
|
||||
const val TYPE_CHRONICLE_SENT = "chronicleSent"
|
||||
const val TYPE_CHRONICLE_RECEIVED = "chronicleReceived"
|
||||
|
||||
/**
|
||||
* The group certified another group as its child.
|
||||
*
|
||||
* A system line rather than a bubble: nobody said it, the group signed
|
||||
* it. It is written in the *parent's* room, which is the only place the
|
||||
* certificate is ever made, and it is there for the members of that room
|
||||
* who are not in the child -- for whom this is the only notice a subgroup
|
||||
* exists at all.
|
||||
*/
|
||||
const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified"
|
||||
|
||||
/**
|
||||
* Every subgroup line, for the one check the transcript dispatches on.
|
||||
*
|
||||
* One member today, and a set anyway. A type missing from here renders as
|
||||
* a chat bubble -- silently, and looking exactly like a member having said
|
||||
* "The group made Translation team a subgroup".
|
||||
*/
|
||||
val SUBGROUP_TYPES = setOf(
|
||||
TYPE_SUBGROUP_CERTIFIED,
|
||||
)
|
||||
|
||||
/**
|
||||
* Every chronicle line, for the one check the transcript dispatches on.
|
||||
*
|
||||
@@ -1240,6 +1264,46 @@ data class ChatMessage(
|
||||
null
|
||||
}
|
||||
|
||||
// The group has certified another group as its child. Verified
|
||||
// again here rather than trusted: this arm is reached both from a
|
||||
// completed signing session, where the signature has already been
|
||||
// checked, and from an arriving inner event, where a member could
|
||||
// have sent a rumor of this kind -- and it cannot tell which.
|
||||
//
|
||||
// Unlike the key state above this *does* write a line. A key state
|
||||
// is standing state whose session already wrote the transcript; a
|
||||
// subgroup being born is something that happened, and it happened
|
||||
// on behalf of members of this room who are not in the child and
|
||||
// will otherwise never be told it exists.
|
||||
//
|
||||
// The name is the one the parent certified, which is the only one
|
||||
// that was ever put to this room -- the child may since have been
|
||||
// renamed, and this line is a record of what was agreed rather
|
||||
// than a view of what is.
|
||||
SubgroupBirthCertificateEvent.KIND -> {
|
||||
SubgroupManager.record(
|
||||
database = database,
|
||||
chatRoomId = groupId,
|
||||
innerEvent = event
|
||||
) ?: return null
|
||||
|
||||
val name = SubgroupBirthCertificateEvent.parseName(event.tags)
|
||||
val admins = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = TYPE_SUBGROUP_CERTIFIED,
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "The group made ${name ?: "a subgroup"} a subgroup, " +
|
||||
"run by $admins of its members"
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
|
||||
@@ -116,7 +116,8 @@ data class LocalChatRoom(
|
||||
if (lastChatMessage.messageType in ChatMessage.DKG_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.FROST_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES
|
||||
lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES
|
||||
) {
|
||||
val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
|
||||
/**
|
||||
* A group making another group, and the four steps that take.
|
||||
*
|
||||
* ```
|
||||
* 1. ChillDKG among the child's admins -> threshold key K, and so C = marmotGroupId(K)
|
||||
* 2. birth certificate, in the parent -> the parent's quorum signs C
|
||||
* 3. key state, among the child's admins -> the child's quorum signs "C signs with K", carrying it
|
||||
* 4. the Marmot room -> somebody creates C and welcomes the admins
|
||||
* ```
|
||||
*
|
||||
* None of that order is a policy choice. Step 2 needs `C`, which does not exist
|
||||
* until step 1 produces `K`; step 3 carries the certificate, so it needs step 2;
|
||||
* and step 4 is gated on the key state for the same reason
|
||||
* `DkgRitualViewModel.createAdminGroup` already is -- a room created before its
|
||||
* group has agreed what it signs with is a room whose founding fact is settled
|
||||
* after the founding.
|
||||
*
|
||||
* This orchestrates; it does not reimplement. Step 1 is `ChillDkgRitualManager`,
|
||||
* steps 2 and 3 are `FrostSigningManager` sessions, and step 4 is
|
||||
* `MarmotGroupCreation`. What lives here is the order, the guards, and the
|
||||
* reading of what the parent has signed.
|
||||
*
|
||||
* ### Only step 1 belongs to the coordinator
|
||||
*
|
||||
* After the ceremony fixes the participant set, every remaining step is open to
|
||||
* somebody else: the certificate to any parent admin holding a share of the
|
||||
* parent's key, and the key state and the room to any of the child's admins, who
|
||||
* by then all hold shares of `K` and know `C`. A coordinator whose phone dies
|
||||
* after the ceremony strands nothing. That is why every function here reads its
|
||||
* state off stored rows rather than off a session object handed down from the
|
||||
* screen that started.
|
||||
*
|
||||
* See `docs/subgroups.md`.
|
||||
*/
|
||||
object SubgroupManager {
|
||||
private const val TAG = "SubgroupManager"
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
/**
|
||||
* The fewest admins a subgroup can be run by, the coordinator included.
|
||||
*
|
||||
* The same floor [press.mantra.compose.database.model.types.ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE]
|
||||
* states and for the same reason: below three a quorum is not a check, since
|
||||
* two admins means every decision needs both of them and one means the
|
||||
* coordinator is deciding alone.
|
||||
*
|
||||
* The coordinator counts. They are a ChillDKG participant by construction and
|
||||
* hold a share whether or not anybody ticked their name, so a picker that
|
||||
* asked for three *others* would be quietly building a group of four.
|
||||
*/
|
||||
const val MINIMUM_ADMINS: Int = 3
|
||||
|
||||
/**
|
||||
* The NIP-17 room a ceremony for these admins would run in.
|
||||
*
|
||||
* Pure, and the same aggregate `NostrNip17Dao.createNip17ChatRoom` and the
|
||||
* inbound path both derive -- which is what makes a ceremony room findable
|
||||
* without being announced, and what makes two members starting the same
|
||||
* subgroup land in one room rather than two.
|
||||
*
|
||||
* It is also where the one hard limit comes from. A NIP-17 room's id is a
|
||||
* pure function of its members, so **one admin set gets one ceremony room,
|
||||
* forever**: picking a set that already holds a completed ceremony would hand
|
||||
* back that ceremony's key, and the "new" subgroup would be the old group
|
||||
* under a new name. [refuseCeremonyRoom] is where that is caught.
|
||||
*/
|
||||
fun ceremonyRoomIdFor(adminPublicKeys: Set<HexKey>, coordinatorPublicKey: HexKey): String =
|
||||
ChatRoom.deriveChatRoomId(adminPublicKeys + coordinatorPublicKey)
|
||||
|
||||
/**
|
||||
* Why these admins cannot hold a ceremony together, or null when they can.
|
||||
*
|
||||
* Read by the picker so a subgroup that could only fail is never offered, and
|
||||
* again before anything is published, because a screen not drawing something
|
||||
* is not a guard.
|
||||
*
|
||||
* The message is the user's, so each one says what to do rather than what is
|
||||
* wrong.
|
||||
*/
|
||||
suspend fun refuseCeremonyRoom(
|
||||
database: MantraDatabase,
|
||||
parentRoom: LocalChatRoom,
|
||||
adminPublicKeys: Set<HexKey>,
|
||||
coordinatorPublicKey: HexKey
|
||||
): String? {
|
||||
val admins = adminPublicKeys + coordinatorPublicKey
|
||||
|
||||
if (admins.size < MINIMUM_ADMINS) {
|
||||
return "A subgroup needs at least $MINIMUM_ADMINS admins. Pick at least " +
|
||||
"${MINIMUM_ADMINS - 1} more people."
|
||||
}
|
||||
|
||||
val members = ChillDkgRitualManager.memberPublicKeys(parentRoom)
|
||||
if (!members.containsAll(admins)) {
|
||||
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.
|
||||
val ceremonyRoomId = ceremonyRoomIdFor(adminPublicKeys, coordinatorPublicKey)
|
||||
val running = database.dkgSessionDao().getLatestSessionForChatRoom(ceremonyRoomId)
|
||||
if (running != null && running.stage != DkgRitualStage.FAILED) {
|
||||
return "These members already hold a shared key together. Change who is in the subgroup."
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: asks the parent's quorum to certify the child.
|
||||
*
|
||||
* Runs in the **parent's** room, so the signature is by the parent's key and
|
||||
* the author is the parent's own room id -- which is what makes a certificate
|
||||
* checkable by anyone holding that id and nothing else.
|
||||
*
|
||||
* [key] is the *child's* completed ceremony, and it is read for what it
|
||||
* produced rather than handed to the session: the parent signs with the
|
||||
* parent's key, resolved from the parent's room the way every other signature
|
||||
* there is. A caller naming a ceremony here would be a caller choosing what
|
||||
* the group signs as.
|
||||
*
|
||||
* A session of one, never batched, for the reason `GroupKeyStateManager.propose`
|
||||
* gives: a batch is only as available as its worst item, and everything after
|
||||
* this depends on it.
|
||||
*/
|
||||
suspend fun proposeBirthCertificate(
|
||||
database: MantraDatabase,
|
||||
parentRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
key: DkgSession,
|
||||
adminPublicKeys: List<HexKey>,
|
||||
name: String,
|
||||
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
|
||||
): FrostSigningSession {
|
||||
val thresholdPublicKey = key.thresholdPublicKey
|
||||
?: throw IllegalStateException(
|
||||
"Ceremony ${key.id} has produced no key for a subgroup to be certified on"
|
||||
)
|
||||
|
||||
val subgroupChatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
|
||||
val parentChatRoomId = parentRoom.chatRoom.id
|
||||
|
||||
require(name.isNotBlank()) { "A subgroup has to be called something" }
|
||||
|
||||
logger.i("Asking $parentChatRoomId to certify $subgroupChatRoomId as its subgroup")
|
||||
|
||||
return FrostSigningManager.proposeSigning(
|
||||
database = database,
|
||||
localChatRoom = parentRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
kind = SubgroupBirthCertificateEvent.KIND,
|
||||
tags = SubgroupBirthCertificateEvent.assembleTags(
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
thresholdPublicKey = thresholdPublicKey,
|
||||
adminPublicKeys = adminPublicKeys,
|
||||
name = name,
|
||||
path = path
|
||||
),
|
||||
content = subgroupChatRoomId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest certificate by [parentChatRoomId] that actually certifies
|
||||
* [subgroupChatRoomId], or null if this device holds none.
|
||||
*
|
||||
* **Two certificates for one child is a normal outcome, not a conflict.** Two
|
||||
* parent admins can press the button on the same admin set: the second lands
|
||||
* in the same ceremony room and gets the first's ceremony back, but both may
|
||||
* go on to propose a certificate and both sessions can complete.
|
||||
* `GroupSignedEvent` is keyed on the event id, so the rows coexist -- and
|
||||
* because both say the same true thing about the same child, which one wins
|
||||
* does not matter. The `d` tag makes them replacements of each other rather
|
||||
* than an accumulation, which is what it is for.
|
||||
*/
|
||||
suspend fun certificateFor(
|
||||
database: MantraDatabase,
|
||||
subgroupChatRoomId: String,
|
||||
parentChatRoomId: String
|
||||
): GroupSignedEvent? =
|
||||
database.groupSignedEventDao()
|
||||
.getByChatRoomIdAndKind(parentChatRoomId, SubgroupBirthCertificateEvent.KIND)
|
||||
.filter {
|
||||
SubgroupBirthCertificateEvent.certifies(
|
||||
event = it.toEvent(),
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
}
|
||||
.maxByOrNull { it.createdAt }
|
||||
|
||||
/**
|
||||
* Step 3: asks the child's quorum to sign its key state, carrying the
|
||||
* certificate.
|
||||
*
|
||||
* Runs where the ceremony ran, because it has to: `GroupKeyStateManager.propose`
|
||||
* and `FrostSigningManager.signingPath` both tie a key state to the room its
|
||||
* ceremony was held in, and the room the state is *about* does not exist yet.
|
||||
*
|
||||
* Refuses without a certificate that passes `certifies`. Every device that
|
||||
* receives the state runs the same check and drops it when it fails, so
|
||||
* proposing one this device would not believe spends a quorum's attention on
|
||||
* a statement nobody will keep.
|
||||
*/
|
||||
suspend fun proposeSubgroupKeyState(
|
||||
database: MantraDatabase,
|
||||
ceremonyRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
key: DkgSession,
|
||||
parentChatRoomId: HexKey,
|
||||
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
|
||||
): FrostSigningSession {
|
||||
val thresholdPublicKey = key.thresholdPublicKey
|
||||
?: throw IllegalStateException("Ceremony ${key.id} has produced no key to sign a state for")
|
||||
|
||||
val subgroupChatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
|
||||
|
||||
val certificate = certificateFor(database, subgroupChatRoomId, parentChatRoomId)
|
||||
?: throw IllegalStateException(
|
||||
"$parentChatRoomId has not certified $subgroupChatRoomId, so its key state " +
|
||||
"cannot name a parent yet"
|
||||
)
|
||||
|
||||
return GroupKeyStateManager.propose(
|
||||
database = database,
|
||||
localChatRoom = ceremonyRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
key = key,
|
||||
path = path,
|
||||
parent = SubgroupParentage(
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
certificate = certificate.toEvent()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A subgroup as the parent's own record has it, plus whatever this device
|
||||
* happens to hold about it.
|
||||
*
|
||||
* [certificate] is the only part that is evidence. [name] and [adminPublicKeys]
|
||||
* come off it too and are the **founding** roster -- what the parent approved,
|
||||
* not who is in the room now -- so a screen showing them must say so or prefer
|
||||
* [room] where there is one.
|
||||
*/
|
||||
data class Subgroup(
|
||||
val chatRoomId: String,
|
||||
val parentChatRoomId: String,
|
||||
val certificate: GroupSignedEvent,
|
||||
/** What the parent certified it as. Frozen; a rename does not reach it. */
|
||||
val name: String?,
|
||||
/** Who the parent certified it for. Frozen; joins and leaves do not reach it. */
|
||||
val adminPublicKeys: List<HexKey>,
|
||||
/** The room, when this device has one -- which is the live answer to both. */
|
||||
val room: LocalChatRoom?,
|
||||
/** The child's key state, when this device holds one. */
|
||||
val keyState: GroupKeyState?,
|
||||
) {
|
||||
/** Whether this device can open the subgroup, rather than only know of it. */
|
||||
val isJoined: Boolean get() = room != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every subgroup [parentChatRoomId] has certified, as far as this device knows.
|
||||
*
|
||||
* Read off the parent's signed certificates rather than off a table of
|
||||
* subgroups. The certificates *are* the record: they are the group's own
|
||||
* signed statement, they are checkable without a lookup, and every device in
|
||||
* the parent room already has them -- `FrostSigningManager.complete` files a
|
||||
* `GroupSignedEvent` on every device that followed the session, not only on
|
||||
* the signers'. A table beside them would be a second copy that can disagree.
|
||||
*
|
||||
* Includes children whose room this device does not have, which is the normal
|
||||
* case for a parent member who is not in the subgroup, and children whose room
|
||||
* nobody has created yet, which is the normal case between steps 2 and 4.
|
||||
*
|
||||
* Newest first, and one entry per child: a second certificate for the same
|
||||
* child replaces the first rather than standing beside it.
|
||||
*/
|
||||
suspend fun subgroupsOf(
|
||||
database: MantraDatabase,
|
||||
parentChatRoomId: String
|
||||
): List<Subgroup> =
|
||||
database.groupSignedEventDao()
|
||||
.getByChatRoomIdAndKind(parentChatRoomId, SubgroupBirthCertificateEvent.KIND)
|
||||
.mapNotNull { signed ->
|
||||
val event = signed.toEvent()
|
||||
val chatRoomId = SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(event.tags)
|
||||
?: return@mapNotNull null
|
||||
|
||||
if (!SubgroupBirthCertificateEvent.certifies(event, chatRoomId, parentChatRoomId)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
signed to chatRoomId
|
||||
}
|
||||
.groupBy { (_, chatRoomId) -> chatRoomId }
|
||||
.mapNotNull { (chatRoomId, certificates) ->
|
||||
val newest = certificates.maxByOrNull { (signed, _) -> signed.createdAt }
|
||||
?: return@mapNotNull null
|
||||
val (signed, _) = newest
|
||||
val event = signed.toEvent()
|
||||
|
||||
Subgroup(
|
||||
chatRoomId = chatRoomId,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
certificate = signed,
|
||||
name = SubgroupBirthCertificateEvent.parseName(event.tags),
|
||||
adminPublicKeys = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags),
|
||||
room = database.chatRoomDao().findChatRoomById(chatRoomId),
|
||||
keyState = database.groupKeyStateDao().getByChatRoomId(chatRoomId),
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.certificate.createdAt }
|
||||
|
||||
/**
|
||||
* The group a room is a subgroup of, verified, or null if it is nobody's
|
||||
* child.
|
||||
*
|
||||
* Off the `GroupKeyState`, which only carries a parent whose certificate was
|
||||
* checked -- see `GroupKeyStateManager.stateFrom`. A room whose state this
|
||||
* device does not hold answers null even when it really is a subgroup, which
|
||||
* is the position a member welcomed in after the founding is in; the
|
||||
* unverified hint in the room's own description is the most they get, and it
|
||||
* must never be written here.
|
||||
*/
|
||||
suspend fun parentOf(database: MantraDatabase, chatRoomId: String): HexKey? =
|
||||
database.groupKeyStateDao().getByChatRoomId(chatRoomId)?.parentChatRoomId
|
||||
|
||||
/**
|
||||
* Files a certificate that arrived, or drops it and says why.
|
||||
*
|
||||
* Reached from two directions and it cannot tell them apart, which is why it
|
||||
* checks. `FrostSigningManager.applySignedEvent` calls it with an event whose
|
||||
* signature has already been verified against the session's key; the inbound
|
||||
* Marmot path calls it with whatever a member put in the room. A member can
|
||||
* send a rumor of this kind, so a certificate is believed here on its own
|
||||
* merits or not at all.
|
||||
*
|
||||
* Nothing is written that is not already on file: the `GroupSignedEvent` row
|
||||
* is `recordSignedEvents`' to write, and the parent link on the child's room
|
||||
* comes off the child's own key state. What this adds is the check and the
|
||||
* refusal.
|
||||
*/
|
||||
suspend fun record(
|
||||
database: MantraDatabase,
|
||||
chatRoomId: String,
|
||||
innerEvent: Event
|
||||
): GroupSignedEvent? {
|
||||
val subgroupChatRoomId =
|
||||
SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(innerEvent.tags)
|
||||
if (subgroupChatRoomId == null) {
|
||||
logger.w("A birth certificate in $chatRoomId names no subgroup; dropping")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!SubgroupBirthCertificateEvent.certifies(innerEvent, subgroupChatRoomId, chatRoomId)) {
|
||||
logger.w(
|
||||
"A birth certificate in $chatRoomId for $subgroupChatRoomId carries no " +
|
||||
"signature by $chatRoomId; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.i("$chatRoomId has certified $subgroupChatRoomId as its subgroup")
|
||||
|
||||
return database.groupSignedEventDao().getById(innerEvent.id)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package press.mantra.compose.text
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
@@ -136,6 +137,24 @@ object ProposedEvent {
|
||||
).joinToString(" · ")
|
||||
)
|
||||
|
||||
// The other thing a group signs that is about groups rather than about
|
||||
// work, and the only one where the *proposal* is the last chance to
|
||||
// check. A parent admin approving this is putting the group's signature
|
||||
// to a room id produced by a ceremony most of them were not in, so the
|
||||
// summary is who it is for and what it is called -- the two things they
|
||||
// can actually weigh. The id is shown by the screen, and their device has
|
||||
// already checked it derives from the key the certificate names.
|
||||
SubgroupBirthCertificateEvent.KIND -> Summary(
|
||||
label = "A new subgroup of this group",
|
||||
detail = listOfNotNull(
|
||||
SubgroupBirthCertificateEvent.parseName(event.tags),
|
||||
SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags)
|
||||
.size
|
||||
.takeIf { it > 0 }
|
||||
?.let { "run by $it members" }
|
||||
).joinToString(" · ")
|
||||
)
|
||||
|
||||
else -> Summary(label = "Event of kind ${event.kind}", detail = event.content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
@@ -285,6 +286,27 @@ fun ChatTranscript(
|
||||
return@items
|
||||
}
|
||||
|
||||
// A subgroup being certified is the group's own
|
||||
// signature, not a member's words, so it gets the
|
||||
// same treatment. Answered and settled because it
|
||||
// reports rather than asks: by the time this line
|
||||
// exists a quorum has already signed, and there is
|
||||
// nothing left for a reader to do about it.
|
||||
//
|
||||
// It leads nowhere for now. What a reader wants
|
||||
// from one is the subgroup, and the subgroups
|
||||
// section of the group's detail screen is where
|
||||
// that lives.
|
||||
if (localChatMessage.chatMessage.messageType in ChatMessage.SUBGROUP_TYPES) {
|
||||
RitualNotice(
|
||||
localChatMessage = localChatMessage,
|
||||
isAnswered = true,
|
||||
isSettled = true,
|
||||
onClick = {}
|
||||
)
|
||||
return@items
|
||||
}
|
||||
|
||||
// Signing lines are the same kind of thing and get
|
||||
// the same treatment -- nobody said them either --
|
||||
// but they lead somewhere else, because what a
|
||||
@@ -670,6 +692,7 @@ private fun RitualNotice(
|
||||
// An invite made and an invite sent are two separate steps on the deferred
|
||||
// path, so they get separate icons -- the whole reason both lines exist is
|
||||
// to be able to see that the first happened and the second did not.
|
||||
ChatMessage.TYPE_SUBGROUP_CERTIFIED -> Icons.Default.AccountTree
|
||||
ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd
|
||||
ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline
|
||||
|
||||
@@ -13,6 +13,7 @@ import press.mantra.compose.nostr.chronicle.tags.ChroniclePageTag
|
||||
import press.mantra.compose.nostr.chronicle.tags.ChronicleRecipientTag
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
@@ -228,6 +229,24 @@ class ChronicleEventTest {
|
||||
assertNull(ChronicleEvent.applyRank(GroupKeyStateEvent.KIND))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chronicle may not carry a birth certificate either`() {
|
||||
// The same shape as the key state above: signed by the room, passes
|
||||
// verification perfectly, and says something standing about groups rather
|
||||
// than carrying the group's work.
|
||||
//
|
||||
// The argument for letting it through is better than it is for a key
|
||||
// state -- "P certified C" is a fixed historical fact, where a key state
|
||||
// decides what a live room signs with -- and the cost of leaving it out
|
||||
// is real: a member added to a parent after a subgroup was made sees an
|
||||
// empty subgroup list. It is still left out here, because admitting it
|
||||
// needs an apply-order slot and a decision about whether a room's
|
||||
// chronicle may carry an event its own key did not sign, which no
|
||||
// chroniclable kind does. See docs/subgroups.md.
|
||||
assertFalse(ChronicleEvent.isChroniclable(SubgroupBirthCertificateEvent.KIND))
|
||||
assertNull(ChronicleEvent.applyRank(SubgroupBirthCertificateEvent.KIND))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chronicle carries documents, not envelopes or protocol`() {
|
||||
assertFalse(ChronicleEvent.isChroniclable(SubmissionEvent.KIND))
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import fr.acinq.bitcoin.crypto.frost.SecretNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.Session
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
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.DkgSession
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
|
||||
/**
|
||||
* What a parent's own record says about its children, and what it refuses to say.
|
||||
*
|
||||
* There is no table of subgroups. The certificates *are* the record -- the
|
||||
* group's own signed statement, checkable without a lookup, and held by every
|
||||
* device that followed the signing session rather than only by the signers. So
|
||||
* everything a parent knows about its children is a read over
|
||||
* `GroupSignedEvent`, and these are the properties that read has to have.
|
||||
*
|
||||
* The negative cases carry the weight, as they do for chronicles. A list built
|
||||
* from signed events is only as safe as its filter: a member can put a rumor of
|
||||
* this kind into the room, and a real certificate signed by some *other* group is
|
||||
* a valid signature over a true-looking statement with no standing behind it.
|
||||
* Neither may reach the list.
|
||||
*
|
||||
* The one positive case that matters as much is a certified child whose room this
|
||||
* device does not have. That is the normal position of a parent member who is not
|
||||
* in the subgroup, and of everybody between the certificate being signed and the
|
||||
* room being created -- so a list that could only show rooms would be empty
|
||||
* exactly when it is most needed.
|
||||
*/
|
||||
class SubgroupManagerJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
/** The parent group, which does the certifying. */
|
||||
private val parentMaterial: KeyMaterial = keyMaterial(
|
||||
"1c0ffee0000000000000000000000000000000000000000000000000000000a1"
|
||||
)
|
||||
|
||||
/** The child. Its own ceremony, its own key, its own room. */
|
||||
private val childMaterial: KeyMaterial = keyMaterial(
|
||||
"2bada550000000000000000000000000000000000000000000000000000000b2"
|
||||
)
|
||||
|
||||
/** A second child, so "one entry per child" can be told from "one entry". */
|
||||
private val siblingMaterial: KeyMaterial = keyMaterial(
|
||||
"3decade0000000000000000000000000000000000000000000000000000000c3"
|
||||
)
|
||||
|
||||
/** A group with no standing here at all. */
|
||||
private val strangerMaterial: KeyMaterial = keyMaterial(
|
||||
"4facade0000000000000000000000000000000000000000000000000000000d4"
|
||||
)
|
||||
|
||||
private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
|
||||
|
||||
private val parentRoomId = roomOf(parentMaterial)
|
||||
private val childRoomId = roomOf(childMaterial)
|
||||
private val siblingRoomId = roomOf(siblingMaterial)
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val alice = KeyPair().pubKey.toHexKey()
|
||||
private val bob = KeyPair().pubKey.toHexKey()
|
||||
private val carol = KeyPair().pubKey.toHexKey()
|
||||
|
||||
@Test
|
||||
fun `a parent lists a child it certified, room or no room`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
record(certificate())
|
||||
|
||||
val subgroups = SubgroupManager.subgroupsOf(db, parentRoomId)
|
||||
|
||||
assertEquals(1, subgroups.size)
|
||||
val subgroup = subgroups.single()
|
||||
|
||||
assertEquals(childRoomId, subgroup.chatRoomId)
|
||||
assertEquals(parentRoomId, subgroup.parentChatRoomId)
|
||||
assertEquals("Translation team", subgroup.name)
|
||||
assertEquals(listOf(user, alice, bob), subgroup.adminPublicKeys)
|
||||
|
||||
// The whole point of reading certificates rather than rooms: this device
|
||||
// holds no room for the child and knows about it anyway. That is the
|
||||
// position of every parent member who is not in the subgroup.
|
||||
assertNull(subgroup.room)
|
||||
assertFalse(subgroup.isJoined)
|
||||
assertNull(subgroup.keyState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a child whose room this device holds reports it`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
seedRoom(childRoomId)
|
||||
record(certificate())
|
||||
|
||||
val subgroup = SubgroupManager.subgroupsOf(db, parentRoomId).single()
|
||||
|
||||
assertTrue(subgroup.isJoined)
|
||||
assertEquals(childRoomId, subgroup.room?.chatRoom?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a certificate signed by another group is not listed`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
|
||||
// A real quorum and a real signature by a group that has no standing to
|
||||
// say anything about this parent's children. Filed in the parent's room
|
||||
// -- which a member can cause -- and refused on read.
|
||||
record(certificate(signer = strangerMaterial, author = strangerMaterial))
|
||||
|
||||
assertTrue(SubgroupManager.subgroupsOf(db, parentRoomId).isEmpty())
|
||||
assertNull(SubgroupManager.certificateFor(db, childRoomId, parentRoomId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a certificate nobody signed is not listed`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
record(certificate(signature = "f".repeat(128)))
|
||||
|
||||
assertTrue(SubgroupManager.subgroupsOf(db, parentRoomId).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a certificate whose id does not derive from the key it names is not listed`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
|
||||
record(
|
||||
certificate(
|
||||
tags = SubgroupBirthCertificateEvent.assembleTags(
|
||||
subgroupChatRoomId = childRoomId,
|
||||
parentChatRoomId = parentRoomId,
|
||||
thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(),
|
||||
adminPublicKeys = listOf(user, alice, bob),
|
||||
name = "Translation team",
|
||||
path = path
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(SubgroupManager.subgroupsOf(db, parentRoomId).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two certificates for one child collapse to the newest`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
|
||||
// Two parent admins pressing the button on the same admin set. Both
|
||||
// sessions can complete and both rows coexist, because GroupSignedEvent is
|
||||
// keyed on the event id -- and both say the same true thing, so which one
|
||||
// wins does not matter. What must not happen is two entries.
|
||||
record(certificate(createdAt = 1_700_000_000, name = "First name"))
|
||||
record(certificate(createdAt = 1_700_000_900, name = "Second name"))
|
||||
|
||||
val subgroups = SubgroupManager.subgroupsOf(db, parentRoomId)
|
||||
|
||||
assertEquals(1, subgroups.size)
|
||||
assertEquals("Second name", subgroups.single().name)
|
||||
assertEquals(
|
||||
"Second name",
|
||||
SubgroupBirthCertificateEvent.parseName(
|
||||
assertNotNull(SubgroupManager.certificateFor(db, childRoomId, parentRoomId)).tags
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two children are two entries, newest first`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
|
||||
record(certificate(createdAt = 1_700_000_000, name = "Older"))
|
||||
record(
|
||||
certificate(
|
||||
createdAt = 1_700_000_900,
|
||||
name = "Newer",
|
||||
subgroupMaterial = siblingMaterial
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(siblingRoomId, childRoomId),
|
||||
SubgroupManager.subgroupsOf(db, parentRoomId).map { it.chatRoomId }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `record refuses a certificate the room did not sign`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
|
||||
// The path a member takes to forge one: send a rumor of kind 30329 into
|
||||
// the room and let the inbound arm file it. `record` is reached with the
|
||||
// same arguments a completed session would use and cannot tell the two
|
||||
// apart, so it checks.
|
||||
val forged = certificate(signer = strangerMaterial, author = strangerMaterial)
|
||||
record(forged)
|
||||
|
||||
assertNull(SubgroupManager.record(db, parentRoomId, forged))
|
||||
assertNotNull(SubgroupManager.record(db, parentRoomId, certificate().also { record(it) }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `record refuses a certificate that names no subgroup`(): Unit = runBlocking {
|
||||
seedRoom(parentRoomId)
|
||||
|
||||
val nameless = certificate(
|
||||
tags = SubgroupBirthCertificateEvent.assembleTags(
|
||||
subgroupChatRoomId = childRoomId,
|
||||
parentChatRoomId = parentRoomId,
|
||||
thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(),
|
||||
adminPublicKeys = listOf(user),
|
||||
name = "Translation team",
|
||||
path = path
|
||||
).filterNot { it[0] == "d" }.toTypedArray()
|
||||
)
|
||||
|
||||
assertNull(SubgroupManager.record(db, parentRoomId, nameless))
|
||||
}
|
||||
|
||||
// ---- the refusals ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a subgroup of fewer than three admins is refused`(): Unit = runBlocking {
|
||||
val parent = parentWith(listOf(alice, bob, carol))
|
||||
|
||||
assertNotNull(refusal(parent, setOf(alice)))
|
||||
assertNull(refusal(parent, setOf(alice, bob)), "the coordinator is the third")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subgroup containing somebody who is not in the group is refused`(): Unit = runBlocking {
|
||||
val parent = parentWith(listOf(alice, bob))
|
||||
val stranger = KeyPair().pubKey.toHexKey()
|
||||
|
||||
assertNotNull(refusal(parent, setOf(alice, stranger)))
|
||||
}
|
||||
|
||||
@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.
|
||||
val parent = parentWith(listOf(alice, bob))
|
||||
|
||||
assertNotNull(refusal(parent, setOf(alice, bob)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an admin set that already holds a ceremony is refused`(): 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.
|
||||
//
|
||||
// Stood up the way `openCeremony` does, room first: 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",
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
assertNotNull(refusal(parent, setOf(alice, bob)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ceremony room is the same whichever way the admins are ordered`() {
|
||||
// Every device has to land on the same room without being told, which is
|
||||
// what makes a ceremony findable at all -- and what makes two members
|
||||
// starting the same subgroup meet rather than fork.
|
||||
assertEquals(
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user),
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(bob, alice), user)
|
||||
)
|
||||
// And the coordinator is in it whether or not they were ticked.
|
||||
assertEquals(
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user),
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob, user), user)
|
||||
)
|
||||
}
|
||||
|
||||
// ---- fixtures ----------------------------------------------------------
|
||||
|
||||
private fun keyMaterial(secret: String): KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(ByteVector32(secret)),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private fun roomOf(material: KeyMaterial): String =
|
||||
SharedKeyDerivation.marmotGroupId(material.thresholdPublicKey.value.toHex(), path)
|
||||
|
||||
private suspend fun refusal(parent: LocalChatRoom, admins: Set<String>): String? =
|
||||
SubgroupManager.refuseCeremonyRoom(
|
||||
database = db,
|
||||
parentRoom = parent,
|
||||
adminPublicKeys = admins,
|
||||
coordinatorPublicKey = user
|
||||
)
|
||||
|
||||
private suspend fun parentWith(members: List<String>): LocalChatRoom {
|
||||
(members + user).forEach { seedProfile(it) }
|
||||
|
||||
return assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = members,
|
||||
subject = "Ekklesia",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun seedProfile(publicKey: String) {
|
||||
if (db.profileDao().getProfileByPublicKey(publicKey) != null) return
|
||||
|
||||
val nostrEventId = publicKey.take(63) + "f"
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun seedRoom(id: String) {
|
||||
seedProfile(user)
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Files a certificate in the parent's room, the way a session would. */
|
||||
private suspend fun record(event: Event) =
|
||||
db.groupSignedEventDao().upsert(
|
||||
GroupSignedEvent.fromEvent(
|
||||
event = event,
|
||||
chatRoomId = parentRoomId,
|
||||
derivationPath = SharedKeyDerivation.formatPath(path),
|
||||
frostSigningSessionId = null,
|
||||
)
|
||||
)
|
||||
|
||||
private fun certificate(
|
||||
subgroupMaterial: KeyMaterial = childMaterial,
|
||||
name: String = "Translation team",
|
||||
adminPublicKeys: List<String> = listOf(user, alice, bob),
|
||||
tags: Array<Array<String>>? = null,
|
||||
createdAt: Long = 1_700_000_000,
|
||||
author: KeyMaterial = parentMaterial,
|
||||
signer: KeyMaterial = author,
|
||||
signature: String? = null
|
||||
): Event {
|
||||
val subgroupChatRoomId = roomOf(subgroupMaterial)
|
||||
val eventTags = tags ?: SubgroupBirthCertificateEvent.assembleTags(
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentRoomId,
|
||||
thresholdPublicKey = subgroupMaterial.thresholdPublicKey.value.toHex(),
|
||||
adminPublicKeys = adminPublicKeys,
|
||||
name = name,
|
||||
path = path
|
||||
)
|
||||
|
||||
val pubKey = SharedKeyDerivation
|
||||
.derive(author.thresholdPublicKey.value.toHex(), path)
|
||||
.hex
|
||||
|
||||
val id = EventHasher.hashId(
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
kind = SubgroupBirthCertificateEvent.KIND,
|
||||
tags = eventTags,
|
||||
content = subgroupChatRoomId
|
||||
)
|
||||
|
||||
return Event(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
kind = SubgroupBirthCertificateEvent.KIND,
|
||||
tags = eventTags,
|
||||
content = subgroupChatRoomId,
|
||||
sig = signature ?: groupSignature(signer, id)
|
||||
)
|
||||
}
|
||||
|
||||
private fun groupSignature(material: KeyMaterial, eventId: String): String {
|
||||
val cache = SharedKeyDerivation
|
||||
.derive(material.thresholdPublicKey.value.toHex(), path)
|
||||
.cache
|
||||
val message = ByteVector(eventId.hexToByteArray())
|
||||
val signerIds = listOf(0, 1)
|
||||
|
||||
val nonces = signerIds.map { signerId ->
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
|
||||
secretShare = material.secretShares[signerId],
|
||||
publicShare = material.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = cache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
}
|
||||
|
||||
val signingSession = Session.create(
|
||||
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { material.publicShares[it] },
|
||||
nParticipants = participants,
|
||||
threshold = threshold,
|
||||
tweakCache = cache,
|
||||
message = message
|
||||
)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
signingSession.sign(
|
||||
nonces[position].first,
|
||||
material.secretShares[signerId],
|
||||
signerId.toUInt()
|
||||
).right!!
|
||||
}
|
||||
|
||||
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user