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:
+2
@@ -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.
|
||||
|
||||
+8
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -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
|
||||
|
||||
|
||||
+16
@@ -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.
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user