feat: let the coordinator open a #admins room keyed on the shared key

Once a ceremony completes, the shared-key screen offers its coordinator a Marmot
room named "<group> (#admins)" with every member of the ceremony in
MarmotGroupData.adminPubkeys. The room the ceremony ran in is NIP-17, where nobody
administers anything; this gives the same people a room where every one of them
can act, which is the shape a group that has just made a t-of-n key is asking for.

Built directly rather than through MarmotGroupData.bootstrap, which hardcodes a
single admin, and baked into the epoch-0 GroupContext so later invitees receive a
populated group from their welcome instead of chasing a bootstrap commit that
predates their membership.

## The id is derived, not random

Every other Marmot room mints `nostrGroupId` as RandomInstance.bytes(32). This one
derives it from the group's threshold key, settling the
`// TODO: Generate GID through frost...` already sitting in
SelectChatRoomTypeViewModel.

Derivation buys two things random cannot. Every member's device can compute the id
from a ceremony they all took part in, so the room is addressable without being
announced; and two members racing to create it arrive at the same id rather than
two rival rooms -- which is why createAdminGroup returns to the existing room
instead of minting a second one.

## Why the derivation is what it is

SharedKeyDerivation walks the path as successive FROST tweaks, one per index,
returning both the XonlyPublicKey and the TweakCache. The cache is not an
optimisation: a signing session created without the same tweaks aggregates to
signatures that verify against a different key, which is why the id is usable as
an identity later rather than only as a label.

It is not BIP32, and the doc comment argues that at length rather than leaving it
to be rediscovered. A BIP32 node is a key *and* a chain code; ChillDKG produces no
chain code. BIP32 wants one only because it computes the tweak scalar for you, and
a FROST tweak takes that scalar as an input -- so choosing it directly removes the
chain code from the problem rather than requiring one to be invented and agreed
forever. It also removes a trap: with x-only keys there is no single obvious
serP(K_par), and two devices picking different parity conventions would silently
derive different keys rather than fail.

Each scalar commits to the key being tweaked as well as the index, so steps cannot
be reordered or replayed at a different depth. Tests cover that, determinism
across calls, path and key sensitivity, and that the cache and the public key
agree.

Hardened derivation is not available here and never will be: it needs the parent
private key, which in a threshold group nobody has. That leaves the non-hardened
weakness -- k' = k + t with publicly computable t inverts -- so anyone learning one
derived private key recovers the group key and can sign with no quorum at all. The
rule that follows is stated at the top of the file: never reconstruct a derived key
in the clear.

## The path is recorded in the room

MIP-01's group data is a fixed TLS schema with no extension map, so a custom field
would emit bytes other Marmot clients cannot decode. The path rides in the
description instead, on its own line under a marker, so somebody rewriting the
rest of the description does not cost the group the record of how its key was
derived:

    Admins of Ubuntu Collective.

    Shared key path: m/9420/0/0

Worth storing although the path is currently a constant: it is what rebuilds the
TweakCache a signing session needs, and recomputing from the constant only holds
while the constant never changes. parsePath refuses hardened indices rather than
tolerating them -- such a path cannot have been walked here, so acting on one
would derive something other than what the room claims.

## Known limits

Members without a published MarmotKeyPackage cannot be invited; inviteAdmins
collects them and logs them, and the coordinator is not yet told.

Invites go one at a time, each advancing the MLS epoch, so the room is re-read
between them. That inherits a silent failure mode documented in
docs/marmot-membership.md: the first invite takes the deferred-welcome path even
though the group is still just its creator, and a commit reaching a member before
their welcome is dropped rather than queued. Not introduced here -- group creation
has always done this -- but more visible in a room whose whole membership is known
up front.

Nothing here has run on a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 14:38:58 +02:00
parent b200916844
commit 9f14679aac
4 changed files with 558 additions and 1 deletions

View File

@@ -0,0 +1,128 @@
package press.mantra.compose.managers
import press.mantra.compose.extensions.toHex
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* The derivation every member's device has to agree on, run against real FROST
* tweaks.
*
* Nothing here checks arithmetic -- libsecp256k1 does that. What is checked is the
* property the group depends on: two devices holding the same threshold key and
* the same path arrive at the same key, without exchanging anything. If that ever
* stops being true, members create rival admin rooms and neither can see the
* other, so it is worth a test that fails loudly rather than a comment.
*/
class SharedKeyDerivationTest {
/** Stands in for a ceremony's output. Any valid point will do. */
private val thresholdPublicKey = PrivateKey(
Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
).publicKey().value.toHex()
@Test
fun `the same key and path always give the same room`() {
val first = SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
val second = SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
assertEquals(first, second)
}
@Test
fun `a derived room id is a 32-byte x-only key`() {
val id = SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
// Marmot wants 32 bytes for nostrGroupId and nostr wants 32 bytes for an
// x-only key. That these are the same length is what lets the room's id and
// the identity the group signs with be one value.
assertEquals(64, id.length)
assertTrue(id.all { it in "0123456789abcdef" }, "not lowercase hex: $id")
}
@Test
fun `different paths give different keys`() {
val admins = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(9420L, 0L, 0L))
val sibling = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(9420L, 0L, 1L))
val other = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(9421L, 0L, 0L))
assertNotEquals(admins, sibling)
assertNotEquals(admins, other)
}
@Test
fun `each step commits to the key it tweaks, so depth is not interchangeable`() {
// Index 0 applied once is not index 0 applied three times: the scalar binds
// the key being tweaked, so a shorter path cannot collide with a deeper one
// that happens to end on the same indices.
val shallow = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(0L))
val deep = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, listOf(0L, 0L, 0L))
assertNotEquals(shallow, deep)
}
@Test
fun `a different group key gives a different room`() {
val otherKey = PrivateKey(
Hex.decode("2badc0de0000000000000000000000000000000000000000000000000000005b")
).publicKey().value.toHex()
assertNotEquals(
SharedKeyDerivation.marmotGroupId(thresholdPublicKey),
SharedKeyDerivation.marmotGroupId(otherKey)
)
}
@Test
fun `a room description round-trips its path`() {
val description = SharedKeyDerivation.describe("Admins of Ubuntu Collective.")
assertTrue(description.startsWith("Admins of Ubuntu Collective."), description)
assertEquals(SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, SharedKeyDerivation.parsePath(description))
}
@Test
fun `a path survives someone editing the rest of the description`() {
// The marker sits on its own line precisely so the prose around it is not
// load-bearing -- a member renaming or rewriting the description should not
// cost the group the record of how its key was derived.
val edited = "Totally different wording.\n\n" +
SharedKeyDerivation.describe("ignored").lines().last()
assertEquals(SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, SharedKeyDerivation.parsePath(edited))
}
@Test
fun `descriptions without a path parse to null rather than a guess`() {
assertEquals(null, SharedKeyDerivation.parsePath(null))
assertEquals(null, SharedKeyDerivation.parsePath(""))
assertEquals(null, SharedKeyDerivation.parsePath("Admins of something."))
}
@Test
fun `a hardened path is refused, not silently walked`() {
// Hardened derivation needs the parent private key, which nobody in a
// threshold group has. A path claiming one was never walked, so acting on it
// would mean deriving something different from what the room says.
assertEquals(null, SharedKeyDerivation.parsePath("Shared key path: m/9420'/0/0"))
}
@Test
fun `formatted paths read the way they are written`() {
assertEquals("m/9420/0/0", SharedKeyDerivation.formatPath())
assertEquals("m/9420/0/1", SharedKeyDerivation.formatPath(listOf(9420L, 0L, 1L)))
}
@Test
fun `the cache tracks the same key the derivation returns`() {
// A signing session is created from the cache, not from the public key, so
// the two disagreeing would mean signatures verifying against something
// other than the room's id.
val derived = SharedKeyDerivation.derive(thresholdPublicKey)
assertEquals(derived.publicKey, derived.cache.tweakedPublicKey)
}
}