feat: add remove member, edit group metadata, and formatting fixes

- Add removeMember() to MarmotManager and AccountViewModel
- Add updateGroupMetadata() to MarmotManager for MIP-01 name/description
- Add MarmotGroupData.toExtension() for encoding metadata as MLS extension
- Add proposeGroupContextExtensions() to MlsGroup
- Apply spotlessApply formatting fixes
- SecretTree: prune consumed generations below current minimum

https://claude.ai/code/session_018gVkmmYgMFtBH7G31pCk9N
This commit is contained in:
Claude
2026-04-07 23:05:44 +00:00
parent 7d8937ca48
commit 11412b8873
9 changed files with 122 additions and 11 deletions
@@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.marmot
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Subscription state for a single Marmot group.
@@ -152,9 +152,10 @@ class KeyPackageRotationManager {
* Clear a slot from the pending rotation set after a new KeyPackage
* has been published.
*/
fun clearPendingRotation(dTagSlot: String) {
pendingRotations.remove(dTagSlot)
}
suspend fun clearPendingRotation(dTagSlot: String) =
mutex.withLock {
pendingRotations.remove(dTagSlot)
}
/**
* Check if any slots need rotation.
@@ -174,12 +175,14 @@ class KeyPackageRotationManager {
* @param dTagSlot the slot to rotate
* @return the new [KeyPackageBundle] ready for publishing
*/
fun rotateSlot(
suspend fun rotateSlot(
identity: ByteArray,
dTagSlot: String,
): KeyPackageBundle {
val bundle = generateKeyPackage(identity, dTagSlot)
pendingRotations.remove(dTagSlot)
mutex.withLock {
pendingRotations.remove(dTagSlot)
}
return bundle
}
@@ -91,6 +91,45 @@ data class MarmotGroupData(
/** Whether this group has an encrypted image set */
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null
/**
* Encode this MarmotGroupData to TLS wire format bytes.
* Mirrors the [decodeTls] format.
*/
fun encodeTls(): ByteArray {
val writer = TlsWriter()
writer.putUint16(version)
writer.putBytes(nostrGroupId.hexToByteArray())
writer.putOpaque2(name.encodeToByteArray())
writer.putOpaque2(description.encodeToByteArray())
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
val adminBytes = ByteArray(adminPubkeys.size * 32)
adminPubkeys.forEachIndexed { index, key ->
key.hexToByteArray().copyInto(adminBytes, index * 32)
}
writer.putOpaque2(adminBytes)
// Relays: length-prefixed block of length-prefixed UTF-8 strings
val relayWriter = TlsWriter()
for (relay in relays) {
relayWriter.putOpaque2(relay.encodeToByteArray())
}
writer.putOpaque2(relayWriter.toByteArray())
// Optional image fields
writer.putOpaque2(imageHash?.hexToByteArray() ?: ByteArray(0))
writer.putOpaque2(imageKey ?: ByteArray(0))
writer.putOpaque2(imageNonce ?: ByteArray(0))
writer.putOpaque2(imageUploadKey ?: ByteArray(0))
return writer.toByteArray()
}
/**
* Convert this MarmotGroupData to an MLS Extension for use in GroupContextExtensions proposals.
*/
fun toExtension(): Extension = Extension(EXTENSION_ID_INT, encodeTls())
companion object {
const val CURRENT_VERSION = 2
@@ -84,6 +84,11 @@ object CommitOrdering {
class EpochCommitTracker {
private val pendingByGroupEpoch = mutableMapOf<GroupEpochKey, MutableList<GroupEvent>>()
companion object {
/** Maximum number of (group, epoch) entries to track before evicting oldest. */
const val MAX_TRACKED_EPOCHS = 1000
}
/**
* Adds a commit for a given group and epoch.
*
@@ -98,6 +103,17 @@ object CommitOrdering {
) {
val key = GroupEpochKey(groupId, epoch)
pendingByGroupEpoch.getOrPut(key) { mutableListOf() }.add(commit)
// Evict oldest entries if the tracker grows too large
if (pendingByGroupEpoch.size > MAX_TRACKED_EPOCHS) {
val oldestKeys =
pendingByGroupEpoch.keys
.sortedBy { it.epoch }
.take(pendingByGroupEpoch.size - MAX_TRACKED_EPOCHS)
for (oldKey in oldestKeys) {
pendingByGroupEpoch.remove(oldKey)
}
}
}
/**
@@ -304,6 +304,16 @@ class MlsGroup private constructor(
return proposal
}
/**
* Create a GroupContextExtensions proposal to update the group's extensions.
* Used for changing group metadata (name, description, etc.) via MIP-01.
*/
fun proposeGroupContextExtensions(extensions: List<Extension>): Proposal.GroupContextExtensions {
val proposal = Proposal.GroupContextExtensions(extensions)
pendingProposals.add(PendingProposal(proposal, myLeafIndex))
return proposal
}
/**
* Create a PSK proposal to include a pre-shared key in the next epoch.
* The PSK must be registered via registerPsk() before committing.
@@ -64,9 +64,12 @@ class SecretTree(
*/
private val skippedKeys = mutableMapOf<Pair<Int, Int>, KeyNonceGeneration>()
/** Maximum number of skipped key entries to retain (prevents unbounded memory growth). */
private companion object {
/** Maximum number of skipped key entries to retain (prevents unbounded memory growth). */
const val MAX_SKIPPED_KEYS = 1000
/** Maximum consumed generation entries to track per sender before pruning. */
const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000
}
init {
@@ -158,6 +161,12 @@ class SecretTree(
}
senderConsumed.add(generation)
// Prune consumed generations below the current minimum for this sender
if (senderConsumed.size > MAX_CONSUMED_GENERATIONS_PER_SENDER) {
val minGeneration = state.applicationGeneration
senderConsumed.removeAll { it < minGeneration }
}
// Fast-forward the ratchet, caching intermediate key/nonce pairs
var secret = state.applicationSecret
var gen = state.applicationGeneration