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:
@@ -0,0 +1,207 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.Crypto
|
||||
import fr.acinq.bitcoin.PublicKey
|
||||
import fr.acinq.bitcoin.XonlyPublicKey
|
||||
import fr.acinq.bitcoin.crypto.frost.TweakCache
|
||||
import fr.acinq.bitcoin.utils.Either
|
||||
|
||||
/**
|
||||
* Keys derived from a group's ChillDKG threshold key by FROST tweaks.
|
||||
*
|
||||
* A derived key here is a real key the group can sign with: each step applies an
|
||||
* additive tweak, `K' = K + t·G`, which every signer can apply to their own share
|
||||
* at signing time. The [TweakCache] is what carries that, and a signing session
|
||||
* needs the cache these functions return -- not just the public key.
|
||||
*
|
||||
* Everything is a pure function of the threshold key and the path, so every
|
||||
* member's device computes the same result with no agreement round, and nothing
|
||||
* has to be stored. Rederive rather than persist.
|
||||
*
|
||||
* ### Why this is not BIP32, and why it needs no chain code
|
||||
*
|
||||
* The paths read like BIP32 and are walked the same way, index by index, but a
|
||||
* BIP32 node is a key *and* a chain code and ChillDKG produces no chain code.
|
||||
* BIP32 wants one only because it computes the tweak scalar for you:
|
||||
*
|
||||
* t = HMAC-SHA512(chaincode, serP(K_par) || ser32(i))[0:32]
|
||||
*
|
||||
* A FROST tweak takes that scalar as an input, so choosing it directly removes
|
||||
* the chain code from the problem entirely. It also removes the trap BIP32 would
|
||||
* bring here: 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.
|
||||
*
|
||||
* ### The security property this inherits
|
||||
*
|
||||
* Additive tweaking is exactly what non-hardened BIP32 does, and it carries the
|
||||
* same weakness: `k' = k + t` with a publicly computable `t` inverts, so anyone
|
||||
* who learns one derived private key recovers the threshold key and can sign as
|
||||
* the group without any quorum at all. Hardened derivation is not available --
|
||||
* it needs the parent private key, which by construction nobody has.
|
||||
*
|
||||
* So the rule is: never reconstruct a derived key in the clear. Any path that
|
||||
* could -- an export, a "reveal private key" screen, a test helper -- leaks the
|
||||
* group key, not just the key it appears to expose.
|
||||
*/
|
||||
object SharedKeyDerivation {
|
||||
/**
|
||||
* The path the group's Marmot admin room is derived at. Arbitrary, and has to
|
||||
* stay put: changing it orphans every room already created here, because the
|
||||
* derived key *is* the room's id and how members find it.
|
||||
*/
|
||||
val MARMOT_ADMIN_GROUP_PATH: List<Long> = listOf(9420L, 0L, 0L)
|
||||
|
||||
/**
|
||||
* Domain separation for the tweak scalars. Versioned so a future scheme can
|
||||
* coexist with keys already derived under this one, and distinct from any other
|
||||
* use of the same threshold key.
|
||||
*/
|
||||
private const val TWEAK_TAG = "mantra/shared-key/tweak/v1"
|
||||
|
||||
/**
|
||||
* A key derived from the group's threshold key, and the cache needed to sign
|
||||
* with it.
|
||||
*
|
||||
* [cache] is not an optimisation: a FROST signing session has to be created
|
||||
* with the cache carrying the same tweaks, or the partial signatures aggregate
|
||||
* to something that verifies against the wrong key. It holds no secrets and can
|
||||
* be stored, though rederiving is simpler.
|
||||
*/
|
||||
data class Derived(
|
||||
val cache: TweakCache,
|
||||
val publicKey: XonlyPublicKey
|
||||
) {
|
||||
/** The x-only key as 32-byte hex — the form nostr and Marmot both use. */
|
||||
val hex: HexKey get() = publicKey.value.toHex()
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker a derived room's description carries its path under.
|
||||
*
|
||||
* MIP-01's group data is a fixed TLS schema -- name, description, admins,
|
||||
* relays, image fields, a disappearing-message timer -- with no room for a
|
||||
* custom key. Inventing one would emit bytes other Marmot clients cannot
|
||||
* decode, so the path rides in the description, which is the only free text
|
||||
* MIP-01 offers.
|
||||
*
|
||||
* Worth writing down at all because the path is what rebuilds the [TweakCache]
|
||||
* a signing session needs. Today it could be recomputed from
|
||||
* [MARMOT_ADMIN_GROUP_PATH], but that only holds while the constant never
|
||||
* changes; a room records the path it was actually made under, so a later
|
||||
* scheme can coexist with rooms already created.
|
||||
*/
|
||||
private const val PATH_MARKER = "Shared key path: "
|
||||
|
||||
/** `m/9420/0/0`. Hardened markers are absent on purpose -- see [parsePath]. */
|
||||
fun formatPath(path: List<Long> = MARMOT_ADMIN_GROUP_PATH): String =
|
||||
path.joinToString(separator = "/", prefix = "m/")
|
||||
|
||||
/**
|
||||
* The path a derived room's description records, or null if it carries none.
|
||||
*
|
||||
* Rejects hardened indices rather than tolerating them: hardened derivation
|
||||
* needs the parent private key, which in a threshold group nobody has, so a
|
||||
* path containing one was never walked and nothing should act as if it was.
|
||||
*/
|
||||
fun parsePath(description: String?): List<Long>? {
|
||||
val line = description
|
||||
?.lineSequence()
|
||||
?.firstOrNull { it.trimStart().startsWith(PATH_MARKER) }
|
||||
?: return null
|
||||
|
||||
val path = line.trimStart().removePrefix(PATH_MARKER).trim()
|
||||
if (!path.startsWith("m/")) return null
|
||||
|
||||
return path.removePrefix("m/")
|
||||
.split("/")
|
||||
.map { segment -> segment.toLongOrNull() ?: return null }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A room description that reads as prose and still records the path.
|
||||
*
|
||||
* One line of it, on its own line, so [parsePath] can find it without the rest
|
||||
* of the description constraining what anybody writes there.
|
||||
*/
|
||||
fun describe(purpose: String, path: List<Long> = MARMOT_ADMIN_GROUP_PATH): String =
|
||||
"$purpose\n\n$PATH_MARKER${formatPath(path)}"
|
||||
|
||||
/**
|
||||
* Walks [path] from the group's threshold key, applying one tweak per index.
|
||||
*
|
||||
* Each scalar commits to the key it is being applied to as well as the index,
|
||||
* so the steps cannot be reordered or replayed at a different depth to reach the
|
||||
* same key. x-only throughout, because the outputs are nostr keys.
|
||||
*
|
||||
* @throws IllegalArgumentException if the threshold key is not a valid point, or
|
||||
* a tweak lands on one of the vanishingly rare invalid scalars — both of which
|
||||
* mean the caller is holding something that is not a usable threshold key.
|
||||
*/
|
||||
fun derive(
|
||||
thresholdPublicKey: HexKey,
|
||||
path: List<Long> = MARMOT_ADMIN_GROUP_PATH
|
||||
): Derived {
|
||||
val root = PublicKey(ByteVector(thresholdPublicKey.hexToByteArray()))
|
||||
|
||||
var cache = TweakCache.create(root)
|
||||
var current = XonlyPublicKey(root)
|
||||
|
||||
path.forEach { index ->
|
||||
val scalar = tweakScalar(current, index)
|
||||
|
||||
when (val tweaked = cache.tweak(scalar, isXonly = true)) {
|
||||
is Either.Left -> throw IllegalArgumentException(
|
||||
"Could not derive $path from the group's key at index $index",
|
||||
tweaked.value
|
||||
)
|
||||
|
||||
is Either.Right -> {
|
||||
cache = tweaked.value.first
|
||||
current = tweaked.value.second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Derived(cache = cache, publicKey = current)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Marmot `nostrGroupId` for a room derived at [path].
|
||||
*
|
||||
* Replaces the `RandomInstance.bytes(32)` a Marmot room is normally minted
|
||||
* with. Marmot asks that the id be unpredictable to outsiders, which this is --
|
||||
* it is a public key derived from one the group has never published -- and it
|
||||
* buys two things random cannot: every member can compute it without being told,
|
||||
* and it is a key the group can sign as, so the room's id and the identity
|
||||
* behind it are the same thing.
|
||||
*/
|
||||
fun marmotGroupId(
|
||||
thresholdPublicKey: HexKey,
|
||||
path: List<Long> = MARMOT_ADMIN_GROUP_PATH
|
||||
): HexKey = derive(thresholdPublicKey, path).hex
|
||||
|
||||
/**
|
||||
* The tweak for one step: binds the tag, the key being tweaked and the index, so
|
||||
* the same index at a different point in the walk gives a different scalar.
|
||||
*/
|
||||
private fun tweakScalar(parent: XonlyPublicKey, index: Long): ByteVector32 =
|
||||
ByteVector32(
|
||||
Crypto.sha256(
|
||||
TWEAK_TAG.encodeToByteArray() +
|
||||
parent.value.toByteArray() +
|
||||
byteArrayOf(
|
||||
(index ushr 24).toByte(),
|
||||
(index ushr 16).toByte(),
|
||||
(index ushr 8).toByte(),
|
||||
index.toByte()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -9,17 +9,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -248,7 +251,11 @@ fun DkgRitualScreen(
|
||||
members = dkgRitualUIState.ritualMembers,
|
||||
hostKeyParticipants = dkgRitualUIState.hostKeyParticipants,
|
||||
round1Participants = dkgRitualUIState.round1Participants,
|
||||
round2Participants = dkgRitualUIState.round2Participants
|
||||
round2Participants = dkgRitualUIState.round2Participants,
|
||||
isActionPending = isActionPending,
|
||||
onCreateAdminGroup = {
|
||||
dkgRitualViewModel.createAdminGroup(onNavigateToRoute)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -289,6 +296,8 @@ private fun RitualProgress(
|
||||
hostKeyParticipants: Set<HexKey>,
|
||||
round1Participants: Set<HexKey>,
|
||||
round2Participants: Set<HexKey>,
|
||||
isActionPending: Boolean,
|
||||
onCreateAdminGroup: () -> Unit,
|
||||
) {
|
||||
val stage = session.stage
|
||||
val hostKeyCount = hostKeyParticipants.size
|
||||
@@ -435,6 +444,26 @@ private fun RitualProgress(
|
||||
text = "Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
// Offered to whoever ran the ceremony. Any member's device could
|
||||
// derive the same room id and create it, but one of them has to go
|
||||
// first, and the coordinator is the member the group already watched
|
||||
// do the work.
|
||||
if (session.isCoordinator()) {
|
||||
Button(
|
||||
onClick = { onCreateAdminGroup() },
|
||||
enabled = !isActionPending,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (isActionPending) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Icon(Icons.Default.Groups, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = "Create the #admins group")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,23 @@ import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
@@ -216,6 +233,179 @@ class DkgRitualViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the group's admin room: a Marmot/MLS chat whose every member is an
|
||||
* admin, keyed on an id derived from the shared key.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Derived rather than random, unlike every other Marmot room. Every member's
|
||||
* device can compute the id from the ceremony they all took part in, so the
|
||||
* room is addressable without being announced, and two members racing to create
|
||||
* it produce the same id instead of two rival rooms -- which is why this returns
|
||||
* early to the existing room rather than minting a second one.
|
||||
*/
|
||||
fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) {
|
||||
if (isActionPending.value) return
|
||||
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
|
||||
val thresholdPublicKey = loaded.session?.thresholdPublicKey ?: return
|
||||
|
||||
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
|
||||
if (nostrPrivateKey == null) {
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
val keyPair = KeyPair(privKey = nostrPrivateKey.value.toByteArray())
|
||||
val groupId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
|
||||
|
||||
// Everyone the ceremony was run with, this device included. These are the
|
||||
// admins: the whole point of the room is that the members who hold shares of
|
||||
// the key can all act in it.
|
||||
val members = ChillDkgRitualManager.memberPublicKeys(loaded.localChatRoom)
|
||||
val name = "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)"
|
||||
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Derived ids make this reachable twice -- a second tap, or another
|
||||
// member having got there first. Joining what exists beats minting a
|
||||
// rival group on the same id.
|
||||
val existing = chatRepository.getChatRoomByIdentifier(groupId)
|
||||
if (existing != null) {
|
||||
isActionPending.value = false
|
||||
withContext(Main) {
|
||||
onNavigateToRoute(
|
||||
ChatRoomMessagingRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = existing.chatRoom.id,
|
||||
relayHint = null
|
||||
)
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val relays = Relays.DefaultDMRelayList.map { it.url }
|
||||
|
||||
// Built directly rather than through MarmotGroupData.bootstrap, which
|
||||
// hardcodes a single admin. Baked into the epoch-0 GroupContext so later
|
||||
// invitees get a populated group from their welcome instead of chasing a
|
||||
// bootstrap commit that predates their membership.
|
||||
val metadata = MarmotGroupData(
|
||||
nostrGroupId = groupId,
|
||||
name = name,
|
||||
// Carries the derivation path. MIP-01 has no field for it, and the
|
||||
// path is what rebuilds the TweakCache a signing session needs --
|
||||
// recomputable from the constant only for as long as the constant
|
||||
// never changes.
|
||||
description = SharedKeyDerivation.describe(
|
||||
purpose = "Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}."
|
||||
),
|
||||
adminPubkeys = members.toList(),
|
||||
relays = relays
|
||||
)
|
||||
|
||||
val signingKeyPair = Ed25519.generateKeyPair()
|
||||
val group = MlsGroup.create(keyPair.pubKey, signingKeyPair.privateKey, listOf(metadata.toExtension()))
|
||||
|
||||
// Keyed on the Marmot nostrGroupId, not MlsGroup's own groupId: they are
|
||||
// unrelated 32-byte values, and every inbound path resolves rooms by the
|
||||
// former.
|
||||
val localChatRoom = chatRepository.getOrCreateChatRoom(
|
||||
chatRoomId = groupId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
relayHint = null,
|
||||
defaultSubject = name,
|
||||
description = metadata.description,
|
||||
mlsGroupState = group.saveState().encodeTls().toHex()
|
||||
)
|
||||
|
||||
if (localChatRoom == null) {
|
||||
isActionPending.value = false
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't create the admin group. Please try again.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val notAdded = inviteAdmins(groupId, members.filterNot { it == activeUserPublicKey })
|
||||
|
||||
isActionPending.value = false
|
||||
|
||||
if (notAdded.isNotEmpty()) {
|
||||
logger.w("Admin group $groupId created without ${notAdded.size} member(s): $notAdded")
|
||||
}
|
||||
|
||||
withContext(Main) {
|
||||
onNavigateToRoute(
|
||||
ChatRoomMessagingRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
relayHint = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds each member to the freshly created admin room, returning those who could
|
||||
* not be added.
|
||||
*
|
||||
* A Marmot invite needs the invitee's published key package, so a member who has
|
||||
* never published one cannot be added here and has to be invited later. That is
|
||||
* reported rather than treated as failure: a room with most of the group in it
|
||||
* is more useful than no room.
|
||||
*/
|
||||
private suspend fun inviteAdmins(groupId: String, peers: List<HexKey>): List<HexKey> {
|
||||
val keyPackages = coroutineScope {
|
||||
peers.map { publicKey ->
|
||||
async {
|
||||
publicKey to withTimeoutOrNull(KEY_PACKAGE_LOOKUP_TIMEOUT) {
|
||||
chatRepository.observeMarmotKeyPackageForPublicKey(publicKey)
|
||||
.filterNotNull()
|
||||
.first()
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
val notAdded = mutableListOf<HexKey>()
|
||||
|
||||
keyPackages.forEach { (publicKey, keyPackage) ->
|
||||
if (keyPackage == null) {
|
||||
logger.w("No key package for $publicKey; leaving them out of $groupId")
|
||||
notAdded.add(publicKey)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
// Re-read between invites: each one advances the MLS epoch and persists
|
||||
// new state, so a snapshot taken before the previous invite would build
|
||||
// this commit on state the group has already left.
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(groupId)
|
||||
if (localChatRoom == null) {
|
||||
logger.e("Admin group $groupId disappeared mid-invite")
|
||||
notAdded.add(publicKey)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
runCatching {
|
||||
chatRepository.inviteMember(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = publicKey,
|
||||
peerKeyPackage = keyPackage
|
||||
)
|
||||
}.onFailure {
|
||||
logger.e("Failed to invite $publicKey to admin group $groupId", it)
|
||||
notAdded.add(publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
return notAdded
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
messageObserver?.cancel()
|
||||
super.onCleared()
|
||||
@@ -224,6 +414,9 @@ class DkgRitualViewModel(
|
||||
companion object {
|
||||
private const val TAG = "DkgRitualViewModel"
|
||||
|
||||
/** Matches SelectChatRoomTypeViewModel: relays are not always prompt. */
|
||||
private const val KEY_PACKAGE_LOOKUP_TIMEOUT = 10_000L
|
||||
|
||||
fun factory(
|
||||
chatRoomId: String,
|
||||
activeUserPublicKey: HexKey,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user