Merge pull request #2169 from vitorpamplona/claude/review-marmot-mls-C5whm

Add thread safety and robustness improvements to Marmot MLS implementation
This commit is contained in:
Vitor Pamplona
2026-04-07 19:27:23 -04:00
committed by GitHub
24 changed files with 985 additions and 337 deletions
@@ -54,7 +54,7 @@ class AndroidMlsGroupStateStore(
) = withContext(Dispatchers.IO) {
val file = stateFile(nostrGroupId)
file.parentFile?.mkdirs()
file.writeBytes(encryption.encrypt(state))
atomicWrite(file, encryption.encrypt(state))
}
override suspend fun load(nostrGroupId: String): ByteArray? =
@@ -112,7 +112,7 @@ class AndroidMlsGroupStateStore(
offset += len
}
file.writeBytes(encryption.encrypt(buffer))
atomicWrite(file, encryption.encrypt(buffer))
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> =
@@ -144,4 +144,21 @@ class AndroidMlsGroupStateStore(
}
result
}
/**
* Write data atomically: write to a temp file first, then rename.
* This avoids corrupted state if the app crashes mid-write.
*/
private fun atomicWrite(
target: File,
data: ByteArray,
) {
val tempFile = File(target.parentFile, "${target.name}.tmp")
tempFile.writeBytes(data)
if (!tempFile.renameTo(target)) {
// Fallback: if rename fails (e.g., cross-filesystem), copy and delete
tempFile.copyTo(target, overwrite = true)
tempFile.delete()
}
}
}
@@ -51,23 +51,45 @@ class MarmotGroupEventsEoseManager(
val manager = key.account.marmotManager ?: return emptyList()
if (!key.account.isWriteable()) return emptyList()
// Build Marmot filters (kind:445 per group + kind:30443 own key packages)
val marmotFilters = manager.subscriptionManager.activeGroupFilters()
val ownKeyPackageFilter = manager.subscriptionManager.ownKeyPackageFilter()
val allFilters = marmotFilters + ownKeyPackageFilter
val result = mutableListOf<RelayBasedFilter>()
val fallbackRelays = key.account.homeRelays.flow.value
// Send to the home relays (where group events are relayed)
val relays = key.account.homeRelays.flow.value
if (relays.isEmpty()) return emptyList()
// Per-group kind:445 filters — route each to the group's own relays
val groupStates = manager.subscriptionManager.activeGroupIds()
for (groupId in groupStates) {
val filter =
manager.subscriptionManager.let { sub ->
// Build the filter for this specific group
val groupFilters = sub.activeGroupFilters()
// activeGroupFilters() returns one filter per group; match by tag content
groupFilters.find { f ->
f.tags?.any { it.value?.contains(groupId) == true } == true
}
} ?: continue
return relays.flatMap { relay ->
allFilters.map { filter ->
RelayBasedFilter(
relay = relay,
filter = filter,
)
// Use group-specific relays from MLS metadata; fall back to home relays
val metadata = manager.groupMetadata(groupId)
val groupRelays =
metadata
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer.normalizeOrNull(it)
}?.toSet()
val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays
for (relay in relaysForGroup) {
result.add(RelayBasedFilter(relay = relay, filter = filter))
}
}
// Own KeyPackage filter (kind:30443) — use home relays
if (fallbackRelays.isNotEmpty()) {
val ownKeyPackageFilter = manager.subscriptionManager.ownKeyPackageFilter()
for (relay in fallbackRelays) {
result.add(RelayBasedFilter(relay = relay, filter = ownKeyPackageFilter))
}
}
return result
}
val userJobMap = mutableMapOf<User, List<Job>>()
@@ -1452,7 +1452,7 @@ class AccountViewModel(
description = text,
)
val innerEvent = account.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
val relays = account.outboxRelays.flow.value
val relays = marmotGroupRelays(nostrGroupId)
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
}
@@ -1467,10 +1467,26 @@ class AccountViewModel(
fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
suspend fun leaveMarmotGroup(nostrGroupId: String) {
val relays = account.outboxRelays.flow.value
val relays = marmotGroupRelays(nostrGroupId)
account.leaveMarmotGroup(nostrGroupId, relays)
}
/**
* Get the relay set for a Marmot group from MLS GroupContext metadata.
* Falls back to outbox relays if the group has no configured relays.
*/
private fun marmotGroupRelays(nostrGroupId: String): Set<NormalizedRelayUrl> {
val metadata = account.marmotManager?.groupMetadata(nostrGroupId)
val groupRelays =
metadata
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}?.toSet()
return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value
}
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
suspend fun addMarmotGroupMember(
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
@@ -36,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -44,7 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.random.Random
import java.security.SecureRandom
@Composable
fun CreateGroupScreen(
@@ -54,6 +56,7 @@ fun CreateGroupScreen(
var groupName by remember { mutableStateOf("") }
var isCreating by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
Scaffold(
topBar = {
@@ -62,14 +65,26 @@ fun CreateGroupScreen(
onPost = {
isCreating = true
scope.launch(Dispatchers.IO) {
val nostrGroupId = Random.nextBytes(32).toHexKey()
accountViewModel.createMarmotGroup(nostrGroupId)
if (groupName.isNotBlank()) {
accountViewModel.account.marmotGroupList
.getOrCreateGroup(nostrGroupId)
.displayName.value = groupName
try {
val nostrGroupId = ByteArray(32).also { SecureRandom().nextBytes(it) }.toHexKey()
accountViewModel.createMarmotGroup(nostrGroupId)
if (groupName.isNotBlank()) {
accountViewModel.account.marmotGroupList
.getOrCreateGroup(nostrGroupId)
.displayName.value = groupName
}
nav.nav(Route.MarmotGroupChat(nostrGroupId))
} catch (e: Exception) {
isCreating = false
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to create group: ${e.message}",
Toast.LENGTH_LONG,
).show()
}
}
nav.nav(Route.MarmotGroupChat(nostrGroupId))
}
},
isActive = { !isCreating },
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -30,12 +31,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
@@ -72,6 +75,16 @@ fun MarmotGroupChatView(
WatchLifecycleAndUpdateModel(feedViewModel)
val chatroom =
remember(nostrGroupId) {
accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
}
DisposableEffect(nostrGroupId) {
chatroom.markAsRead()
onDispose { }
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier =
@@ -110,6 +123,7 @@ fun MarmotGroupMessageComposer(
val scope = rememberCoroutineScope()
val messageState = remember { TextFieldState() }
val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } }
val context = LocalContext.current
Column(modifier = EditFieldModifier) {
ThinPaddingTextField(
@@ -130,9 +144,20 @@ fun MarmotGroupMessageComposer(
val text = messageState.text.toString().trim()
if (text.isNotEmpty()) {
scope.launch(Dispatchers.IO) {
accountViewModel.sendMarmotGroupMessage(nostrGroupId, text)
messageState.clearText()
onMessageSent()
try {
accountViewModel.sendMarmotGroupMessage(nostrGroupId, text)
messageState.clearText()
onMessageSent()
} catch (e: Exception) {
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to send message: ${e.message}",
Toast.LENGTH_SHORT,
).show()
}
}
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -52,6 +53,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -83,8 +85,10 @@ fun MarmotGroupInfoScreen(
val groupRelays by chatroom.relays.collectAsStateWithLifecycle()
var members by remember { mutableStateOf(emptyList<GroupMemberInfo>()) }
var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val myPubkey = accountViewModel.account.signer.pubKey
val context = LocalContext.current
LaunchedEffect(nostrGroupId) {
members = accountViewModel.marmotGroupMembers(nostrGroupId)
@@ -222,10 +226,24 @@ fun MarmotGroupInfoScreen(
groupName = displayName ?: "this group",
onConfirm = {
showLeaveDialog = false
isLeaving = true
scope.launch(Dispatchers.IO) {
accountViewModel.leaveMarmotGroup(nostrGroupId)
try {
accountViewModel.leaveMarmotGroup(nostrGroupId)
accountViewModel.account.marmotGroupList.removeGroup(nostrGroupId)
nav.nav(Route.MarmotGroupList)
} catch (e: Exception) {
isLeaving = false
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to leave group: ${e.message}",
Toast.LENGTH_LONG,
).show()
}
}
}
nav.nav(Route.MarmotGroupList)
},
onDismiss = { showLeaveDialog = false },
)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -57,10 +58,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -220,6 +224,7 @@ fun MarmotGroupListItem(
onClick: () -> Unit,
) {
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val unread by chatroom.unreadCount.collectAsStateWithLifecycle()
val newestMessage = chatroom.newestMessage
Row(
@@ -235,7 +240,7 @@ fun MarmotGroupListItem(
Text(
text = displayName ?: "Group ${groupId.take(8)}...",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
fontWeight = if (unread > 0) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -258,11 +263,30 @@ fun MarmotGroupListItem(
}
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = "${chatroom.messages.size} msgs",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (unread > 0) {
Box(
modifier =
Modifier
.size(22.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(
text = if (unread > 99) "99+" else unread.toString(),
color = MaterialTheme.colorScheme.onPrimary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
} else {
Text(
text = "${chatroom.messages.size} msgs",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -105,6 +105,7 @@ class MarmotManager(
}
is GroupEventResult.CommitPending,
is GroupEventResult.Duplicate,
is GroupEventResult.Error,
-> {}
}
@@ -120,6 +121,14 @@ class MarmotManager(
welcomeEvent: WelcomeEvent,
nostrGroupId: HexKey,
): WelcomeResult {
// Validate that the provided nostrGroupId matches the WelcomeEvent's h-tag if present
val eventGroupId = welcomeEvent.nostrGroupId()
if (eventGroupId != null && eventGroupId != nostrGroupId) {
return WelcomeResult.Error(
"nostrGroupId mismatch: expected $nostrGroupId but WelcomeEvent has $eventGroupId",
)
}
val result = inboundProcessor.processWelcome(welcomeEvent, nostrGroupId)
if (result is WelcomeResult.Joined) {
@@ -152,6 +161,20 @@ class MarmotManager(
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
): Pair<OutboundGroupEvent, WelcomeDelivery?> {
// Verify that the KeyPackage credential matches the expected member pubkey
val kp =
com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(keyPackageBytes),
)
val credential = kp.leafNode.credential
require(credential is Credential.Basic) {
"KeyPackage must use BasicCredential"
}
require(credential.identity.toHexKey() == memberPubKey) {
"KeyPackage credential identity does not match memberPubKey"
}
val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes)
val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes)
@@ -182,9 +205,41 @@ class MarmotManager(
* Returns proposal bytes to publish (as a GroupEvent).
*/
suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent {
val proposalBytes = groupManager.leaveGroup(nostrGroupId)
// Build the outbound event BEFORE deleting group state (needs exporter secret)
val group =
groupManager.getGroup(nostrGroupId)
?: throw IllegalStateException("Not a member of group $nostrGroupId")
val proposalBytes = group.selfRemove()
val outboundEvent = outboundProcessor.buildCommitEvent(nostrGroupId, proposalBytes)
// Now clean up group state
groupManager.removeGroupState(nostrGroupId)
subscriptionManager.unsubscribeGroup(nostrGroupId)
return outboundProcessor.buildCommitEvent(nostrGroupId, proposalBytes)
return outboundEvent
}
/**
* Remove a member from a group.
* Returns the commit GroupEvent to publish.
*/
suspend fun removeMember(
nostrGroupId: HexKey,
targetLeafIndex: Int,
): OutboundGroupEvent {
val commitResult = groupManager.removeMember(nostrGroupId, targetLeafIndex)
return outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes)
}
/**
* Update group metadata (name, description, etc.) via a GroupContextExtensions proposal.
* Creates a GCE proposal, commits it, and returns the commit event to publish.
*/
suspend fun updateGroupMetadata(
nostrGroupId: HexKey,
metadata: MarmotGroupData,
): OutboundGroupEvent {
val commitResult = groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
return outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes)
}
// --- KeyPackage Management ---
@@ -47,6 +47,7 @@ class MarmotGroupChatroom(
var relays = MutableStateFlow<List<String>>(emptyList())
var memberCount = MutableStateFlow(0)
var newestMessage: Note? = null
val unreadCount = MutableStateFlow(0)
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
@@ -73,6 +74,7 @@ class MarmotGroupChatroom(
newestMessage = msg
}
unreadCount.value += 1
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
return true
}
@@ -95,6 +97,10 @@ class MarmotGroupChatroom(
return false
}
fun markAsRead() {
unreadCount.value = 0
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = messages.sortedWith(DefaultFeedOrder)
val toKeep =
@@ -59,6 +59,11 @@ class MarmotGroupList {
}
}
fun removeGroup(nostrGroupId: HexKey) {
rooms.remove(nostrGroupId)
_groupListChanges.tryEmit(nostrGroupId)
}
fun allGroupIds(): List<HexKey> {
val result = mutableListOf<HexKey>()
rooms.forEach { key, _ -> result.add(key) }
@@ -71,6 +71,13 @@ sealed class GroupEventResult {
val epoch: Long,
) : GroupEventResult()
/**
* The event was already processed (duplicate).
*/
data class Duplicate(
val groupId: HexKey,
) : GroupEventResult()
/**
* The event could not be processed.
*/
@@ -119,6 +126,16 @@ class MarmotInboundProcessor(
private val keyPackageRotationManager: KeyPackageRotationManager,
) {
private val commitTracker = CommitOrdering.EpochCommitTracker()
private val processedEventIds = LinkedHashSet<String>()
companion object {
private const val MAX_PROCESSED_IDS = 10_000
/**
* Check if an unwrapped event is a Marmot WelcomeEvent.
*/
fun isWelcomeEvent(event: Event): Boolean = event.kind == WelcomeEvent.KIND
}
/**
* Process an inbound GroupEvent (kind:445).
@@ -136,6 +153,13 @@ class MarmotInboundProcessor(
* @return the processing result
*/
suspend fun processGroupEvent(groupEvent: GroupEvent): GroupEventResult {
// Deduplicate already-processed events
val eventId = groupEvent.id
if (eventId in processedEventIds) {
val gId = groupEvent.groupId()
return GroupEventResult.Duplicate(gId ?: "")
}
val groupId =
groupEvent.groupId()
?: return GroupEventResult.Error(null, "GroupEvent missing h tag (group ID)")
@@ -144,22 +168,38 @@ class MarmotInboundProcessor(
return GroupEventResult.Error(groupId, "Not a member of group $groupId")
}
return try {
// Step 1: Outer ChaCha20-Poly1305 decryption
val exporterKey = groupManager.exporterSecret(groupId)
val mlsBytes = GroupEventEncryption.decrypt(groupEvent.encryptedContent(), exporterKey)
val result =
try {
// Step 1: Outer ChaCha20-Poly1305 decryption
val mlsBytes = decryptOuterLayer(groupId, groupEvent.encryptedContent())
// Step 2: Parse the MLS message
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
// Step 2: Parse the MLS message
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
}
} catch (e: Exception) {
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
}
// Track successfully processed events for deduplication
if (result !is GroupEventResult.Error) {
processedEventIds.add(eventId)
// Trim the set if it exceeds the max size
if (processedEventIds.size > MAX_PROCESSED_IDS) {
val iterator = processedEventIds.iterator()
val toRemove = processedEventIds.size - MAX_PROCESSED_IDS
repeat(toRemove) {
iterator.next()
iterator.remove()
}
}
} catch (e: Exception) {
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
}
return result
}
/**
@@ -223,18 +263,18 @@ class MarmotInboundProcessor(
epoch: Long,
): GroupEventResult? {
val winner =
commitTracker.resolve(epoch)
commitTracker.resolve(groupId, epoch)
?: return null
val result = applyCommit(groupId, winner)
commitTracker.clearEpoch(epoch)
commitTracker.clearEpoch(groupId, epoch)
return result
}
/**
* Get all epochs that have pending unresolved commits.
* Get all (group, epoch) keys that have pending unresolved commits.
*/
fun pendingCommitEpochs(): Set<Long> = commitTracker.pendingEpochs()
fun pendingCommitGroupEpochs(): Set<CommitOrdering.GroupEpochKey> = commitTracker.pendingGroupEpochs()
/**
* Clear all pending commit state.
@@ -303,13 +343,13 @@ class MarmotInboundProcessor(
groupManager.getGroup(groupId)
?: return GroupEventResult.Error(groupId, "Group not found")
val currentEpoch = group.epoch
commitTracker.addCommit(currentEpoch, groupEvent)
commitTracker.addCommit(groupId, currentEpoch, groupEvent)
// If this is the only commit for this epoch, apply immediately
val pending = commitTracker.pendingForEpoch(currentEpoch)
val pending = commitTracker.pendingForEpoch(groupId, currentEpoch)
return if (pending.size == 1) {
val result = applyCommit(groupId, groupEvent)
commitTracker.clearEpoch(currentEpoch)
commitTracker.clearEpoch(groupId, currentEpoch)
result
} else {
GroupEventResult.CommitPending(groupId, currentEpoch)
@@ -321,8 +361,7 @@ class MarmotInboundProcessor(
commitEvent: GroupEvent,
): GroupEventResult =
try {
val exporterKey = groupManager.exporterSecret(groupId)
val mlsBytes = GroupEventEncryption.decrypt(commitEvent.encryptedContent(), exporterKey)
val mlsBytes = decryptOuterLayer(groupId, commitEvent.encryptedContent())
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
when (mlsMessage.wireFormat) {
@@ -360,15 +399,43 @@ class MarmotInboundProcessor(
GroupEventResult.Error(groupId, "Failed to apply commit: ${e.message}", e)
}
/**
* Decrypt the outer ChaCha20-Poly1305 layer, trying the current epoch's
* exporter key first and falling back to retained epoch exporter keys.
*
* After a commit advances the epoch, late-arriving messages encrypted
* with the previous epoch's exporter key would fail without this fallback.
*/
private fun decryptOuterLayer(
groupId: HexKey,
encryptedContent: String,
): ByteArray {
// Try current epoch key first
try {
val exporterKey = groupManager.exporterSecret(groupId)
return GroupEventEncryption.decrypt(encryptedContent, exporterKey)
} catch (_: Exception) {
// Current epoch key failed — try retained epoch keys
}
// Try retained epoch exporter keys (most recent first)
val retainedKeys = groupManager.retainedExporterSecrets(groupId)
for (retainedKey in retainedKeys) {
try {
return GroupEventEncryption.decrypt(encryptedContent, retainedKey)
} catch (_: Exception) {
// This retained key didn't work — try the next one
}
}
// All keys exhausted — throw to let callers produce an error result
throw IllegalStateException(
"Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)",
)
}
private fun hexToBytes(hex: HexKey?): ByteArray {
if (hex == null) return ByteArray(0)
return hex.hexToByteArray()
}
companion object {
/**
* Check if an unwrapped event is a Marmot WelcomeEvent.
*/
fun isWelcomeEvent(event: Event): Boolean = event.kind == WelcomeEvent.KIND
}
}
@@ -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.
@@ -52,6 +54,7 @@ data class GroupSubscriptionState(
class MarmotSubscriptionManager(
private val userPubKey: HexKey,
) {
private val mutex = Mutex()
private val groupSubscriptions = mutableMapOf<HexKey, GroupSubscriptionState>()
private var giftWrapSince: Long? = null
@@ -62,10 +65,10 @@ class MarmotSubscriptionManager(
* @param nostrGroupId hex-encoded Nostr group ID
* @param since optional timestamp to resume from (e.g., last seen event)
*/
fun subscribeGroup(
suspend fun subscribeGroup(
nostrGroupId: HexKey,
since: Long? = null,
) {
) = mutex.withLock {
groupSubscriptions[nostrGroupId] =
GroupSubscriptionState(
nostrGroupId = nostrGroupId,
@@ -78,27 +81,29 @@ class MarmotSubscriptionManager(
* Unsubscribe from a group's events.
* Call this when leaving a group.
*/
fun unsubscribeGroup(nostrGroupId: HexKey) {
groupSubscriptions.remove(nostrGroupId)
}
suspend fun unsubscribeGroup(nostrGroupId: HexKey) =
mutex.withLock {
groupSubscriptions.remove(nostrGroupId)
}
/**
* Update the `since` timestamp for a group after processing events.
* This ensures reconnections only fetch newer events.
*/
fun updateGroupSince(
suspend fun updateGroupSince(
nostrGroupId: HexKey,
since: Long,
) {
) = mutex.withLock {
groupSubscriptions[nostrGroupId]?.since = since
}
/**
* Update the `since` timestamp for gift wrap subscriptions.
*/
fun updateGiftWrapSince(since: Long) {
giftWrapSince = since
}
suspend fun updateGiftWrapSince(since: Long) =
mutex.withLock {
giftWrapSince = since
}
/**
* Returns all active group IDs being tracked.
@@ -185,26 +190,32 @@ class MarmotSubscriptionManager(
*
* @param activeGroupIds the set of group IDs from [MlsGroupManager.activeGroupIds]
*/
fun syncWithGroupManager(activeGroupIds: Set<HexKey>) {
// Add new groups
for (groupId in activeGroupIds) {
if (!groupSubscriptions.containsKey(groupId)) {
subscribeGroup(groupId)
suspend fun syncWithGroupManager(activeGroupIds: Set<HexKey>) =
mutex.withLock {
// Add new groups
for (groupId in activeGroupIds) {
if (!groupSubscriptions.containsKey(groupId)) {
groupSubscriptions[groupId] =
GroupSubscriptionState(
nostrGroupId = groupId,
active = true,
)
}
}
// Remove stale groups
val staleGroups = groupSubscriptions.keys - activeGroupIds
for (groupId in staleGroups) {
groupSubscriptions.remove(groupId)
}
}
// Remove stale groups
val staleGroups = groupSubscriptions.keys - activeGroupIds
for (groupId in staleGroups) {
unsubscribeGroup(groupId)
}
}
/**
* Clear all subscription state.
*/
fun clear() {
groupSubscriptions.clear()
giftWrapSince = null
}
suspend fun clear() =
mutex.withLock {
groupSubscriptions.clear()
giftWrapSince = null
}
}
@@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource
import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Manages KeyPackage creation and rotation lifecycle (MIP-00).
@@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* KeyPackage slots, rotating consumed ones promptly.
*/
class KeyPackageRotationManager {
private val mutex = Mutex()
private val activeBundles = mutableMapOf<String, KeyPackageBundle>()
private val pendingRotations = mutableSetOf<String>()
@@ -60,7 +63,7 @@ class KeyPackageRotationManager {
* @param dTagSlot the d-tag slot for addressable replacement
* @return a [KeyPackageBundle] containing the KeyPackage and all private keys
*/
fun generateKeyPackage(
suspend fun generateKeyPackage(
identity: ByteArray,
dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT,
): KeyPackageBundle {
@@ -93,7 +96,9 @@ class KeyPackageRotationManager {
)
val bundle = KeyPackageBundle(keyPackage, initKp.privateKey, encKp.privateKey, sigKp.privateKey)
activeBundles[dTagSlot] = bundle
mutex.withLock {
activeBundles[dTagSlot] = bundle
}
return bundle
}
@@ -117,24 +122,26 @@ class KeyPackageRotationManager {
* The slot will be included in [pendingRotationSlots] and should be
* rotated by the caller.
*/
fun markConsumed(dTagSlot: String) {
activeBundles.remove(dTagSlot)
pendingRotations.add(dTagSlot)
}
suspend fun markConsumed(dTagSlot: String) =
mutex.withLock {
activeBundles.remove(dTagSlot)
pendingRotations.add(dTagSlot)
}
/**
* Mark a slot as consumed by looking up the KeyPackage reference.
*/
fun markConsumedByRef(keyPackageRef: ByteArray) {
val entry =
activeBundles.entries.find { (_, bundle) ->
bundle.keyPackage.reference().contentEquals(keyPackageRef)
suspend fun markConsumedByRef(keyPackageRef: ByteArray) =
mutex.withLock {
val entry =
activeBundles.entries.find { (_, bundle) ->
bundle.keyPackage.reference().contentEquals(keyPackageRef)
}
if (entry != null) {
activeBundles.remove(entry.key)
pendingRotations.add(entry.key)
}
if (entry != null) {
activeBundles.remove(entry.key)
pendingRotations.add(entry.key)
}
}
/**
* Get the d-tag slots that need rotation (KeyPackage was consumed).
@@ -145,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.
@@ -167,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
}
@@ -22,8 +22,10 @@ package com.vitorpamplona.quartz.marmot.mip01Groups
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import com.vitorpamplona.quartz.marmot.mls.tree.Extension
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
/**
@@ -89,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
@@ -67,57 +67,95 @@ object CommitOrdering {
}
/**
* Tracks pending commits per epoch and resolves conflicts.
* Key for tracking pending commits: must be unique per (group, epoch) pair
* since epoch numbers are not globally unique across different groups.
*/
data class GroupEpochKey(
val groupId: String,
val epoch: Long,
)
/**
* Tracks pending commits per (group, epoch) and resolves conflicts.
*
* Accumulate commits as they arrive from relays, then call [resolve]
* to determine which commit wins for each epoch.
* to determine which commit wins for each (group, epoch).
*/
class EpochCommitTracker {
private val pendingByEpoch = mutableMapOf<Long, MutableList<GroupEvent>>()
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 epoch.
* Adds a commit for a given group and epoch.
*
* @param groupId the Nostr group ID
* @param epoch the MLS epoch number this commit targets
* @param commit the GroupEvent containing the commit
*/
fun addCommit(
groupId: String,
epoch: Long,
commit: GroupEvent,
) {
pendingByEpoch.getOrPut(epoch) { mutableListOf() }.add(commit)
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)
}
}
}
/**
* Returns pending commits for a specific epoch.
* Returns pending commits for a specific group and epoch.
*/
fun pendingForEpoch(epoch: Long): List<GroupEvent> = pendingByEpoch[epoch] ?: emptyList()
fun pendingForEpoch(
groupId: String,
epoch: Long,
): List<GroupEvent> = pendingByGroupEpoch[GroupEpochKey(groupId, epoch)] ?: emptyList()
/**
* Resolves the winning commit for a specific epoch.
* Resolves the winning commit for a specific group and epoch.
*
* @param groupId the Nostr group ID
* @param epoch the MLS epoch to resolve
* @return the winning commit, or null if no commits exist for this epoch
* @return the winning commit, or null if no commits exist for this (group, epoch)
*/
fun resolve(epoch: Long): GroupEvent? = selectWinner(pendingByEpoch[epoch] ?: emptyList())
fun resolve(
groupId: String,
epoch: Long,
): GroupEvent? = selectWinner(pendingByGroupEpoch[GroupEpochKey(groupId, epoch)] ?: emptyList())
/**
* Clears pending commits for an epoch after it has been resolved.
* Clears pending commits for a (group, epoch) after it has been resolved.
*/
fun clearEpoch(epoch: Long) {
pendingByEpoch.remove(epoch)
fun clearEpoch(
groupId: String,
epoch: Long,
) {
pendingByGroupEpoch.remove(GroupEpochKey(groupId, epoch))
}
/**
* Returns all epochs that have pending commits.
* Returns all (group, epoch) keys that have pending commits.
*/
fun pendingEpochs(): Set<Long> = pendingByEpoch.keys.toSet()
fun pendingGroupEpochs(): Set<GroupEpochKey> = pendingByGroupEpoch.keys.toSet()
/**
* Clears all pending state.
*/
fun clear() {
pendingByEpoch.clear()
pendingByGroupEpoch.clear()
}
}
}
@@ -31,6 +31,11 @@ class TlsReader(
private var position: Int = 0,
private val limit: Int = data.size,
) {
companion object {
/** Maximum allowed size for a single opaque field (1 MB) */
const val MAX_OPAQUE_SIZE = 1_048_576
}
val remaining: Int get() = limit - position
val hasRemaining: Boolean get() = position < limit
@@ -87,12 +92,18 @@ class TlsReader(
/** Read a variable-length opaque with 2-byte length prefix */
fun readOpaque2(): ByteArray {
val length = readUint16()
require(length <= MAX_OPAQUE_SIZE) {
"Opaque2 length $length exceeds maximum allowed size $MAX_OPAQUE_SIZE"
}
return readBytes(length)
}
/** Read a variable-length opaque with 4-byte length prefix */
fun readOpaque4(): ByteArray {
val length = readUint32().toInt()
require(length <= MAX_OPAQUE_SIZE) {
"Opaque4 length $length exceeds maximum allowed size $MAX_OPAQUE_SIZE"
}
return readBytes(length)
}
@@ -130,6 +141,9 @@ class TlsReader(
/** Read a variable-length opaque with QUIC-style VarInt length prefix */
fun readOpaqueVarInt(): ByteArray {
val length = readVarInt()
require(length <= MAX_OPAQUE_SIZE) {
"OpaqueVarInt length $length exceeds maximum allowed size $MAX_OPAQUE_SIZE"
}
return readBytes(length)
}
@@ -105,6 +105,9 @@ class MlsGroup private constructor(
private val pskStore: MutableMap<String, ByteArray> = mutableMapOf(),
private val pendingProposals: MutableList<PendingProposal> = mutableListOf(),
private val sentKeys: MutableMap<Int, com.vitorpamplona.quartz.marmot.mls.schedule.KeyNonceGeneration> = mutableMapOf(),
/** Staged keys from proposeSigningKeyRotation — only promoted on successful commit */
private var pendingSigningKey: ByteArray? = null,
private var pendingEncryptionKey: ByteArray? = null,
) {
val groupId: ByteArray get() = groupContext.groupId
val epoch: Long get() = groupContext.epoch
@@ -150,6 +153,7 @@ class MlsGroup private constructor(
senderDataSecret = epochSecrets.senderDataSecret,
encryptionSecret = epochSecrets.encryptionSecret,
leafCount = tree.leafCount,
exporterSecret = epochSecrets.exporterSecret,
)
val memberCount: Int
@@ -293,13 +297,23 @@ class MlsGroup private constructor(
val proposal = Proposal.Update(newLeafNode)
pendingProposals.add(PendingProposal(proposal, myLeafIndex))
// Stage the new keys — they take effect when commit() is called
signingPrivateKey = newSigKp.privateKey
encryptionPrivateKey = newEncKp.privateKey
// Stage the new keys — they take effect only when commit() succeeds
pendingSigningKey = newSigKp.privateKey
pendingEncryptionKey = newEncKp.privateKey
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.
@@ -347,11 +361,14 @@ class MlsGroup private constructor(
val addedMembers = mutableListOf<Pair<Int, MlsKeyPackage>>()
for (pending in proposals) {
val p = pending.proposal
// Track added members for Welcome generation (before apply changes leaf count)
if (p is Proposal.Add) {
addedMembers.add(tree.leafCount to p.keyPackage)
// applyProposal calls tree.addLeaf() which returns the actual leaf index
// (may reuse a blank slot instead of appending)
val leafIndex = applyProposalAdd(p)
addedMembers.add(leafIndex to p.keyPackage)
} else {
applyProposal(p, pending.senderLeafIndex)
}
applyProposal(p, pending.senderLeafIndex)
}
// Generate new path secrets on the updated tree
@@ -465,6 +482,12 @@ class MlsGroup private constructor(
null
}
// Promote staged keys from proposeSigningKeyRotation if present
pendingSigningKey?.let { signingPrivateKey = it }
pendingEncryptionKey?.let { encryptionPrivateKey = it }
pendingSigningKey = null
pendingEncryptionKey = null
pendingProposals.clear()
sentKeys.clear()
@@ -478,6 +501,15 @@ class MlsGroup private constructor(
* Encrypt an application message as a PrivateMessage.
*/
fun encrypt(plaintext: ByteArray): ByteArray {
// Trim sentKeys if it grows too large
if (sentKeys.size > MAX_SENT_KEYS) {
val sortedKeys = sentKeys.keys.sorted()
val toRemove = sortedKeys.take(sentKeys.size - MAX_SENT_KEYS)
for (key in toRemove) {
sentKeys.remove(key)
}
}
val kng = secretTree.nextApplicationKeyNonce(myLeafIndex)
sentKeys[kng.generation] = kng
val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, kng.nonce, ByteArray(0), plaintext)
@@ -605,6 +637,13 @@ class MlsGroup private constructor(
senderLeafIndex: Int,
confirmationTag: ByteArray? = null,
) {
require(senderLeafIndex >= 0 && senderLeafIndex < tree.leafCount) {
"Invalid sender leaf index: $senderLeafIndex"
}
require(tree.getLeaf(senderLeafIndex) != null) {
"Sender leaf is blank at index $senderLeafIndex"
}
val commit = Commit.decodeTls(TlsReader(commitBytes))
// Apply proposals (resolve references from pending pool)
@@ -641,6 +680,11 @@ class MlsGroup private constructor(
tree.setLeaf(senderLeafIndex, updatePath.leafNode)
tree.applyUpdatePath(senderLeafIndex, updatePath.nodes)
// Verify parent hash chain (RFC 9420 Section 7.9.2)
require(verifyParentHash(senderLeafIndex, updatePath)) {
"Parent hash verification failed for UpdatePath"
}
// Decrypt path secret from our copath node
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
val myPath = BinaryTree.directPath(myLeafIndex, tree.leafCount)
@@ -726,7 +770,7 @@ class MlsGroup private constructor(
// Verify confirmation tag (RFC 9420 Section 6.1)
if (confirmationTag != null) {
val expectedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
require(confirmationTag.contentEquals(expectedTag)) {
require(constantTimeEquals(confirmationTag, expectedTag)) {
"Confirmation tag verification failed"
}
}
@@ -846,7 +890,7 @@ class MlsGroup private constructor(
for (pskProposal in pskProposals) {
val pskValue =
pskStore[pskProposal.pskId.toHexKey()]
?: ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH) // Unknown PSK = zeros
?: throw IllegalStateException("PSK not found in store: ${pskProposal.pskId.toHexKey()}")
pskSecret = MlsCryptoProvider.hkdfExtract(pskSecret, pskValue)
}
return pskSecret
@@ -978,7 +1022,37 @@ class MlsGroup private constructor(
.MacInstance("HmacSHA256", epochSecrets.membershipKey)
mac.update(authenticatedContentBytes)
val expectedTag = mac.doFinal()
return expectedTag.contentEquals(membershipTag)
return constantTimeEquals(expectedTag, membershipTag)
}
/**
* Constant-time byte array comparison to prevent timing side-channels.
* Returns true only if both arrays have the same length and contents.
*/
private fun constantTimeEquals(
a: ByteArray,
b: ByteArray,
): Boolean {
if (a.size != b.size) return false
var result = 0
for (i in a.indices) {
result = result or (a[i].toInt() xor b[i].toInt())
}
return result == 0
}
/**
* Apply an Add proposal and return the assigned leaf index.
*/
private fun applyProposalAdd(proposal: Proposal.Add): Int {
val lifetime = proposal.keyPackage.leafNode.lifetime
if (lifetime != null) {
val now = TimeUtils.now()
require(now >= lifetime.notBefore && now <= lifetime.notAfter) {
"KeyPackage lifetime expired or not yet valid"
}
}
return tree.addLeaf(proposal.keyPackage.leafNode)
}
private fun applyProposal(
@@ -987,15 +1061,7 @@ class MlsGroup private constructor(
) {
when (proposal) {
is Proposal.Add -> {
// Validate KeyPackage lifetime (RFC 9420 Section 10.1)
val lifetime = proposal.keyPackage.leafNode.lifetime
if (lifetime != null) {
val now = TimeUtils.now()
require(now >= lifetime.notBefore && now <= lifetime.notAfter) {
"KeyPackage lifetime expired or not yet valid"
}
}
tree.addLeaf(proposal.keyPackage.leafNode)
applyProposalAdd(proposal)
}
is Proposal.Remove -> {
@@ -1143,6 +1209,7 @@ class MlsGroup private constructor(
}
companion object {
private const val MAX_SENT_KEYS = 10_000
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0001
private const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0002
private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0003
@@ -1300,7 +1367,7 @@ class MlsGroup private constructor(
// Find our leaf index by matching our signature key
val mySignatureKey = Ed25519.publicFromPrivate(bundle.signaturePrivateKey)
var myLeafIndex = 0
var myLeafIndex = -1
for (i in 0 until tree.leafCount) {
val leaf = tree.getLeaf(i)
if (leaf != null && leaf.signatureKey.contentEquals(mySignatureKey)) {
@@ -1308,13 +1375,15 @@ class MlsGroup private constructor(
break
}
}
require(myLeafIndex >= 0) { "Joiner's signature key not found in ratchet tree" }
// Verify GroupInfo signature using signer's key from the tree
val signerLeaf = tree.getLeaf(groupInfo.signer)
if (signerLeaf != null) {
require(groupInfo.verifySignature(signerLeaf.signatureKey)) {
"Invalid GroupInfo signature"
}
requireNotNull(signerLeaf) {
"Signer leaf is null at index ${groupInfo.signer} — cannot verify GroupInfo signature"
}
require(groupInfo.verifySignature(signerLeaf.signatureKey)) {
"Invalid GroupInfo signature"
}
// Derive epoch secrets directly from memberSecret (RFC 9420 Section 8.3)
@@ -1351,6 +1420,15 @@ class MlsGroup private constructor(
val secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount)
// Compute interim_transcript_hash from confirmed_transcript_hash + confirmation_tag
val confirmMac = MacInstance("HmacSHA256", epochSecrets.confirmationKey)
confirmMac.update(groupContext.confirmedTranscriptHash)
val confirmationTag = confirmMac.doFinal()
val interimInput = TlsWriter()
interimInput.putBytes(groupContext.confirmedTranscriptHash)
interimInput.putOpaqueVarInt(confirmationTag)
val interimTranscriptHash = MlsCryptoProvider.hash(interimInput.toByteArray())
return MlsGroup(
groupContext = groupContext,
tree = tree,
@@ -1360,7 +1438,7 @@ class MlsGroup private constructor(
initSecret = epochSecrets.initSecret,
signingPrivateKey = bundle.signaturePrivateKey,
encryptionPrivateKey = bundle.encryptionPrivateKey,
interimTranscriptHash = ByteArray(0),
interimTranscriptHash = interimTranscriptHash,
)
}
@@ -25,8 +25,13 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule
import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree
import com.vitorpamplona.quartz.marmot.mls.tree.Extension
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* High-level coordinator for MLS group lifecycle and state management.
@@ -77,8 +82,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 2, meaning secrets
* for the current and previous epoch are kept for late-message decryption.
*
* Thread safety: All public methods are suspending and should be called
* from a single coroutine context (e.g., the Account's scope).
* Thread safety: All suspending mutation methods are guarded by a [Mutex]
* to prevent concurrent state corruption. Non-suspending read methods
* ([getGroup], [isMember], [activeGroupIds], [encrypt], [decrypt],
* [decryptOrNull], [exporterSecret]) must be called from the same
* coroutine context that owns this manager (e.g., the Account's scope).
*
* @see MlsGroup The low-level MLS state machine
* @see MlsGroupStateStore Storage abstraction for group state persistence
@@ -86,6 +94,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
class MlsGroupManager(
private val store: MlsGroupStateStore,
) {
private val mutex = Mutex()
private val groups = mutableMapOf<HexKey, MlsGroup>()
private val retainedEpochs = mutableMapOf<HexKey, MutableList<RetainedEpochSecrets>>()
@@ -93,28 +102,34 @@ class MlsGroupManager(
* Restore all groups from persistent storage on startup.
* Call this once during Account initialization.
*/
suspend fun restoreAll() {
val groupIds = store.listGroups()
for (nostrGroupId in groupIds) {
try {
val stateBytes = store.load(nostrGroupId) ?: continue
val state = MlsGroupState.decodeTls(stateBytes)
groups[nostrGroupId] = MlsGroup.restore(state)
suspend fun restoreAll() =
mutex.withLock {
val groupIds = store.listGroups()
for (nostrGroupId in groupIds) {
try {
val stateBytes = store.load(nostrGroupId) ?: continue
val state = MlsGroupState.decodeTls(stateBytes)
groups[nostrGroupId] = MlsGroup.restore(state)
// Restore retained epochs
val retained = store.loadRetainedEpochs(nostrGroupId)
if (retained.isNotEmpty()) {
retainedEpochs[nostrGroupId] =
retained
.map { RetainedEpochSecrets.decodeTls(TlsReader(it)) }
.toMutableList()
// Restore retained epochs
val retained = store.loadRetainedEpochs(nostrGroupId)
if (retained.isNotEmpty()) {
retainedEpochs[nostrGroupId] =
retained
.map { RetainedEpochSecrets.decodeTls(TlsReader(it)) }
.toMutableList()
}
} catch (e: Exception) {
// Corrupted state — log and remove it so it doesn't block future joins
Log.e(
"MlsGroupManager",
"Corrupted state for group $nostrGroupId, deleting: ${e.message}",
e,
)
store.delete(nostrGroupId)
}
} catch (_: Exception) {
// Corrupted state — remove it so it doesn't block future joins
store.delete(nostrGroupId)
}
}
}
/**
* Get an active group by its Nostr group ID.
@@ -145,12 +160,13 @@ class MlsGroupManager(
nostrGroupId: HexKey,
identity: ByteArray,
signingKey: ByteArray? = null,
): MlsGroup {
val group = MlsGroup.create(identity, signingKey)
groups[nostrGroupId] = group
persistGroup(nostrGroupId)
return group
}
): MlsGroup =
mutex.withLock {
val group = MlsGroup.create(identity, signingKey)
groups[nostrGroupId] = group
persistGroup(nostrGroupId)
group
}
// --- Joining ---
@@ -172,16 +188,17 @@ class MlsGroupManager(
nostrGroupId: HexKey,
welcomeBytes: ByteArray,
bundle: KeyPackageBundle,
): MlsGroup {
val group = MlsGroup.processWelcome(welcomeBytes, bundle)
groups[nostrGroupId] = group
): MlsGroup =
mutex.withLock {
val group = MlsGroup.processWelcome(welcomeBytes, bundle)
groups[nostrGroupId] = group
// init_key is consumed — the bundle's initPrivateKey should not be
// reused. Caller must discard the bundle and rotate KeyPackages.
// init_key is consumed — the bundle's initPrivateKey should not be
// reused. Caller must discard the bundle and rotate KeyPackages.
persistGroup(nostrGroupId)
return group
}
persistGroup(nostrGroupId)
group
}
/**
* Join a group via external commit.
@@ -197,12 +214,13 @@ class MlsGroupManager(
groupInfoBytes: ByteArray,
identity: ByteArray,
signingKey: ByteArray? = null,
): Pair<MlsGroup, ByteArray> {
val (group, commitBytes) = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey)
groups[nostrGroupId] = group
persistGroup(nostrGroupId)
return Pair(group, commitBytes)
}
): Pair<MlsGroup, ByteArray> =
mutex.withLock {
val (group, commitBytes) = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey)
groups[nostrGroupId] = group
persistGroup(nostrGroupId)
Pair(group, commitBytes)
}
// --- Epoch Transitions ---
@@ -215,16 +233,17 @@ class MlsGroupManager(
* @param nostrGroupId hex-encoded Nostr group ID
* @return the [CommitResult] to publish
*/
suspend fun commit(nostrGroupId: HexKey): CommitResult {
val group = requireGroup(nostrGroupId)
suspend fun commit(nostrGroupId: HexKey): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
// Retain current epoch secrets before transition
retainEpochSecrets(nostrGroupId, group)
// Retain current epoch secrets before transition
retainEpochSecrets(nostrGroupId, group)
val result = group.commit()
persistGroup(nostrGroupId)
return result
}
val result = group.commit()
persistGroup(nostrGroupId)
result
}
/**
* Process a received Commit, advancing the epoch.
@@ -239,7 +258,7 @@ class MlsGroupManager(
commitBytes: ByteArray,
senderLeafIndex: Int,
confirmationTag: ByteArray? = null,
) {
) = mutex.withLock {
val group = requireGroup(nostrGroupId)
// Retain current epoch secrets before transition
@@ -307,13 +326,14 @@ class MlsGroupManager(
suspend fun addMember(
nostrGroupId: HexKey,
keyPackageBytes: ByteArray,
): CommitResult {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val result = group.addMember(keyPackageBytes)
persistGroup(nostrGroupId)
return result
}
): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val result = group.addMember(keyPackageBytes)
persistGroup(nostrGroupId)
result
}
/**
* Remove a member and create a Commit.
@@ -321,13 +341,14 @@ class MlsGroupManager(
suspend fun removeMember(
nostrGroupId: HexKey,
targetLeafIndex: Int,
): CommitResult {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val result = group.removeMember(targetLeafIndex)
persistGroup(nostrGroupId)
return result
}
): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val result = group.removeMember(targetLeafIndex)
persistGroup(nostrGroupId)
result
}
/**
* Rotate the signing key within a group and commit.
@@ -338,27 +359,59 @@ class MlsGroupManager(
* @param nostrGroupId hex-encoded Nostr group ID
* @return the [CommitResult] to publish
*/
suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
group.proposeSigningKeyRotation()
val result = group.commit()
persistGroup(nostrGroupId)
return result
}
suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
group.proposeSigningKeyRotation()
val result = group.commit()
persistGroup(nostrGroupId)
result
}
/**
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
* Creates the proposal, commits it, and persists the new state.
*/
suspend fun updateGroupExtensions(
nostrGroupId: HexKey,
extensions: List<Extension>,
): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
group.proposeGroupContextExtensions(extensions)
val result = group.commit()
persistGroup(nostrGroupId)
result
}
/**
* Leave a group (self-remove).
* Returns the SelfRemove proposal bytes to publish, then removes
* local state.
*/
suspend fun leaveGroup(nostrGroupId: HexKey): ByteArray {
val group = requireGroup(nostrGroupId)
val proposalBytes = group.selfRemove()
suspend fun leaveGroup(nostrGroupId: HexKey): ByteArray =
mutex.withLock {
val group = requireGroup(nostrGroupId)
val proposalBytes = group.selfRemove()
removeGroupStateUnlocked(nostrGroupId)
proposalBytes
}
/**
* Remove all local state for a group without sending any proposals.
* Used after the leave event has already been built.
*/
suspend fun removeGroupState(nostrGroupId: HexKey) =
mutex.withLock {
removeGroupStateUnlocked(nostrGroupId)
}
private suspend fun removeGroupStateUnlocked(nostrGroupId: HexKey) {
groups.remove(nostrGroupId)
retainedEpochs.remove(nostrGroupId)
store.delete(nostrGroupId)
return proposalBytes
}
// --- Key Export ---
@@ -374,6 +427,33 @@ class MlsGroupManager(
32,
)
/**
* Return exporter secrets from retained epochs for a group.
*
* Used by the inbound processor to attempt outer decryption with
* previous epoch keys when the current epoch's key fails (e.g.,
* after a commit has advanced the epoch but late-arriving messages
* still use the old exporter key).
*
* @param nostrGroupId hex-encoded Nostr group ID
* @return list of retained exporter secrets (most recent first), each
* derived via MLS-Exporter("marmot", "group-event", 32)
*/
fun retainedExporterSecrets(nostrGroupId: HexKey): List<ByteArray> {
val retained = retainedEpochs[nostrGroupId] ?: return emptyList()
return retained
.filter { it.exporterSecret.isNotEmpty() }
.sortedByDescending { it.epoch }
.map { epochSecrets ->
KeySchedule.mlsExporter(
epochSecrets.exporterSecret,
"marmot",
"group-event".encodeToByteArray(),
32,
)
}
}
// --- Private Helpers ---
private fun requireGroup(nostrGroupId: HexKey): MlsGroup =
@@ -171,6 +171,7 @@ data class RetainedEpochSecrets(
val senderDataSecret: ByteArray,
val encryptionSecret: ByteArray,
val leafCount: Int,
val exporterSecret: ByteArray = ByteArray(0),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -185,15 +186,29 @@ data class RetainedEpochSecrets(
writer.putOpaqueVarInt(senderDataSecret)
writer.putOpaqueVarInt(encryptionSecret)
writer.putUint32(leafCount.toLong())
writer.putOpaqueVarInt(exporterSecret)
}
companion object {
fun decodeTls(reader: TlsReader): RetainedEpochSecrets =
RetainedEpochSecrets(
epoch = reader.readUint64(),
senderDataSecret = reader.readOpaqueVarInt(),
encryptionSecret = reader.readOpaqueVarInt(),
leafCount = reader.readUint32().toInt(),
fun decodeTls(reader: TlsReader): RetainedEpochSecrets {
val epoch = reader.readUint64()
val senderDataSecret = reader.readOpaqueVarInt()
val encryptionSecret = reader.readOpaqueVarInt()
val leafCount = reader.readUint32().toInt()
// exporterSecret was added later; tolerate its absence in older serialized data
val exporterSecret =
if (reader.hasRemaining) {
reader.readOpaqueVarInt()
} else {
ByteArray(0)
}
return RetainedEpochSecrets(
epoch = epoch,
senderDataSecret = senderDataSecret,
encryptionSecret = encryptionSecret,
leafCount = leafCount,
exporterSecret = exporterSecret,
)
}
}
}
@@ -118,15 +118,20 @@ data class MlsKeyPackage(
}
companion object {
fun decodeTls(reader: TlsReader): MlsKeyPackage =
MlsKeyPackage(
version = reader.readUint16(),
cipherSuite = reader.readUint16(),
fun decodeTls(reader: TlsReader): MlsKeyPackage {
val version = reader.readUint16()
require(version == 1) { "Unsupported MLS version: $version" }
val cipherSuite = reader.readUint16()
require(cipherSuite == 1) { "Unsupported ciphersuite: $cipherSuite" }
return MlsKeyPackage(
version = version,
cipherSuite = cipherSuite,
initKey = reader.readOpaqueVarInt(),
leafNode = LeafNode.decodeTls(reader),
extensions = reader.readVectorVarInt { Extension.decodeTls(it) },
signature = reader.readOpaqueVarInt(),
)
}
}
}
@@ -56,6 +56,22 @@ class SecretTree(
/** Consumed (sender, generation) pairs for replay detection (RFC 9420 Section 9.1) */
private val consumedGenerations = mutableMapOf<Int, MutableSet<Int>>()
/**
* Cache of key/nonce pairs for skipped generations.
* Key: (leafIndex, generation) -> derived KeyNonceGeneration.
* When fast-forwarding a ratchet, intermediate generations are saved here
* so that out-of-order messages arriving later can still be decrypted.
*/
private val skippedKeys = mutableMapOf<Pair<Int, Int>, KeyNonceGeneration>()
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 {
// Seed the root
treeSecrets[BinaryTree.root(leafCount)] = encryptionSecret
@@ -111,11 +127,27 @@ class SecretTree(
/**
* Get the (key, nonce) for a specific generation, consuming secrets up to that point.
* Used when decrypting an out-of-order message.
*
* When fast-forwarding past intermediate generations, their key/nonce pairs
* are cached in [skippedKeys] so that out-of-order messages arriving later
* can still be decrypted.
*/
fun applicationKeyNonceForGeneration(
leafIndex: Int,
generation: Int,
): KeyNonceGeneration {
// Check skipped keys cache first (out-of-order message for a previously skipped generation)
val cachedKey = skippedKeys.remove(Pair(leafIndex, generation))
if (cachedKey != null) {
// Still mark as consumed for replay detection
val senderConsumed = consumedGenerations.getOrPut(leafIndex) { mutableSetOf() }
require(generation !in senderConsumed) {
"Replay detected: generation $generation from sender $leafIndex already consumed"
}
senderConsumed.add(generation)
return cachedKey
}
val state = getOrInitSender(leafIndex)
require(generation >= state.applicationGeneration) {
@@ -129,10 +161,22 @@ class SecretTree(
}
senderConsumed.add(generation)
// Fast-forward the ratchet
// 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
while (gen < generation) {
// Save the intermediate generation's key/nonce for later out-of-order retrieval
val intermediateKng = deriveKeyNonce(secret, gen)
val cacheKey = Pair(leafIndex, gen)
if (skippedKeys.size < MAX_SKIPPED_KEYS) {
skippedKeys[cacheKey] = intermediateKng
}
secret = MlsCryptoProvider.expandWithLabel(secret, "secret", generationContext(gen), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
gen++
}
@@ -109,6 +109,13 @@ class RatchetTree(
for (i in 0 until _leafCount) {
if (getLeaf(i) == null) {
setLeaf(i, leafNode)
// Blank the direct path (RFC 9420 Section 7.7)
val directPath = BinaryTree.directPath(i, _leafCount)
for (nodeIdx in directPath) {
if (nodeIdx < nodes.size) {
nodes[nodeIdx] = null
}
}
return i
}
}
@@ -121,6 +128,13 @@ class RatchetTree(
nodes.add(null)
}
setLeaf(newLeafIndex, leafNode)
// Blank the direct path for the new leaf
val directPath = BinaryTree.directPath(newLeafIndex, _leafCount)
for (nodeIdx in directPath) {
if (nodeIdx < nodes.size) {
nodes[nodeIdx] = null
}
}
return newLeafIndex
}
@@ -247,18 +261,18 @@ class RatchetTree(
var currentSecret = leafSecret
for (nodeIdx in directPath) {
// path_secret[n] = DeriveSecret(path_secret[n-1], "path")
val pathSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
// node_secret = DeriveSecret(path_secret, "node")
val nodeSecret = MlsCryptoProvider.deriveSecret(pathSecret, "node")
// RFC 9420 Section 7.4: path_secret[0] = leafSecret,
// node_secret[n] = DeriveSecret(path_secret[n], "node")
val nodeSecret = MlsCryptoProvider.deriveSecret(currentSecret, "node")
// Derive HPKE key pair from node_secret
val privateKey = MlsCryptoProvider.expandWithLabel(nodeSecret, "hpke", ByteArray(0), 32)
val publicKey = X25519.publicFromPrivate(privateKey)
results.add(PathSecretAndKey(pathSecret, privateKey, publicKey))
currentSecret = pathSecret
results.add(PathSecretAndKey(currentSecret, privateKey, publicKey))
// path_secret[n+1] = DeriveSecret(path_secret[n], "path")
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
}
return results
@@ -344,9 +358,20 @@ class RatchetTree(
val tree = RatchetTree()
tree.nodes.addAll(nodesList)
// Leaf count is derived from the total serialized node count.
// nodeCount = 2 * leafCount - 1
tree._leafCount = (nodesList.size + 1) / 2
// Leaf count must account for tree trimming: the serialized tree
// may have trailing blank nodes removed. Count actual leaves by
// scanning for the highest occupied leaf position.
var maxLeafIndex = -1
for (i in nodesList.indices) {
if (BinaryTree.isLeaf(i) && nodesList[i] != null) {
maxLeafIndex = BinaryTree.nodeToLeaf(i)
}
}
// Leaf count is at least maxLeafIndex + 1, but also must be at
// least (nodesList.size + 1) / 2 since parent nodes at high indices
// imply leaves beyond the serialized range.
val fromNodes = (nodesList.size + 1) / 2
tree._leafCount = maxOf(fromNodes, maxLeafIndex + 1)
return tree
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.marmot
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -38,65 +39,70 @@ class MarmotSubscriptionManagerTest {
private val groupId2 = "c".repeat(64)
@Test
fun testSubscribeGroup() {
val manager = MarmotSubscriptionManager(userPubKey)
fun testSubscribeGroup() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
manager.subscribeGroup(groupId1)
manager.subscribeGroup(groupId1)
assertTrue(manager.isSubscribed(groupId1))
assertEquals(setOf(groupId1), manager.activeGroupIds())
}
assertTrue(manager.isSubscribed(groupId1))
assertEquals(setOf(groupId1), manager.activeGroupIds())
}
@Test
fun testSubscribeGroupWithSince() {
val manager = MarmotSubscriptionManager(userPubKey)
val since = 1700000000L
fun testSubscribeGroupWithSince() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
val since = 1700000000L
manager.subscribeGroup(groupId1, since)
manager.subscribeGroup(groupId1, since)
assertTrue(manager.isSubscribed(groupId1))
assertTrue(manager.isSubscribed(groupId1))
val filters = manager.activeGroupFilters()
assertEquals(1, filters.size)
assertEquals(since, filters[0].since)
}
val filters = manager.activeGroupFilters()
assertEquals(1, filters.size)
assertEquals(since, filters[0].since)
}
@Test
fun testUnsubscribeGroup() {
val manager = MarmotSubscriptionManager(userPubKey)
fun testUnsubscribeGroup() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
manager.subscribeGroup(groupId1)
manager.unsubscribeGroup(groupId1)
manager.subscribeGroup(groupId1)
manager.unsubscribeGroup(groupId1)
assertFalse(manager.isSubscribed(groupId1))
assertTrue(manager.activeGroupIds().isEmpty())
}
assertFalse(manager.isSubscribed(groupId1))
assertTrue(manager.activeGroupIds().isEmpty())
}
@Test
fun testMultipleGroups() {
val manager = MarmotSubscriptionManager(userPubKey)
fun testMultipleGroups() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
manager.subscribeGroup(groupId1)
manager.subscribeGroup(groupId2)
manager.subscribeGroup(groupId1)
manager.subscribeGroup(groupId2)
assertEquals(setOf(groupId1, groupId2), manager.activeGroupIds())
assertEquals(setOf(groupId1, groupId2), manager.activeGroupIds())
val filters = manager.activeGroupFilters()
assertEquals(2, filters.size)
}
val filters = manager.activeGroupFilters()
assertEquals(2, filters.size)
}
@Test
fun testUpdateGroupSince() {
val manager = MarmotSubscriptionManager(userPubKey)
val newSince = 1700000000L
fun testUpdateGroupSince() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
val newSince = 1700000000L
manager.subscribeGroup(groupId1)
manager.updateGroupSince(groupId1, newSince)
manager.subscribeGroup(groupId1)
manager.updateGroupSince(groupId1, newSince)
val filters = manager.activeGroupFilters()
assertEquals(1, filters.size)
assertEquals(newSince, filters[0].since)
}
val filters = manager.activeGroupFilters()
assertEquals(1, filters.size)
assertEquals(newSince, filters[0].since)
}
@Test
fun testGiftWrapFilter() {
@@ -110,39 +116,42 @@ class MarmotSubscriptionManagerTest {
}
@Test
fun testGiftWrapFilterWithSince() {
val manager = MarmotSubscriptionManager(userPubKey)
val since = 1700000000L
fun testGiftWrapFilterWithSince() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
val since = 1700000000L
manager.updateGiftWrapSince(since)
val filter = manager.giftWrapFilter()
manager.updateGiftWrapSince(since)
val filter = manager.giftWrapFilter()
assertEquals(since, filter.since)
}
assertEquals(since, filter.since)
}
@Test
fun testActiveGroupFiltersContainCorrectKind() {
val manager = MarmotSubscriptionManager(userPubKey)
fun testActiveGroupFiltersContainCorrectKind() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
manager.subscribeGroup(groupId1)
val filters = manager.activeGroupFilters()
manager.subscribeGroup(groupId1)
val filters = manager.activeGroupFilters()
assertEquals(1, filters.size)
assertEquals(listOf(GroupEvent.KIND), filters[0].kinds)
assertNotNull(filters[0].tags)
assertEquals(listOf(groupId1), filters[0].tags!!["h"])
}
assertEquals(1, filters.size)
assertEquals(listOf(GroupEvent.KIND), filters[0].kinds)
assertNotNull(filters[0].tags)
assertEquals(listOf(groupId1), filters[0].tags!!["h"])
}
@Test
fun testBuildFiltersIncludesBothTypes() {
val manager = MarmotSubscriptionManager(userPubKey)
fun testBuildFiltersIncludesBothTypes() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
manager.subscribeGroup(groupId1)
val allFilters = manager.buildFilters()
manager.subscribeGroup(groupId1)
val allFilters = manager.buildFilters()
// Should have 1 group filter + 1 gift wrap filter
assertEquals(2, allFilters.size)
}
// Should have 1 group filter + 1 gift wrap filter
assertEquals(2, allFilters.size)
}
@Test
fun testBuildFiltersWithNoGroupsHasGiftWrapOnly() {
@@ -164,31 +173,33 @@ class MarmotSubscriptionManagerTest {
}
@Test
fun testSyncWithGroupManager() {
val manager = MarmotSubscriptionManager(userPubKey)
fun testSyncWithGroupManager() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
// Start with one group
manager.subscribeGroup(groupId1)
// Start with one group
manager.subscribeGroup(groupId1)
// Sync with group manager that has different groups
manager.syncWithGroupManager(setOf(groupId2))
// Sync with group manager that has different groups
manager.syncWithGroupManager(setOf(groupId2))
// groupId1 should be removed, groupId2 added
assertFalse(manager.isSubscribed(groupId1))
assertTrue(manager.isSubscribed(groupId2))
}
// groupId1 should be removed, groupId2 added
assertFalse(manager.isSubscribed(groupId1))
assertTrue(manager.isSubscribed(groupId2))
}
@Test
fun testClear() {
val manager = MarmotSubscriptionManager(userPubKey)
fun testClear() =
runTest {
val manager = MarmotSubscriptionManager(userPubKey)
manager.subscribeGroup(groupId1)
manager.subscribeGroup(groupId2)
manager.updateGiftWrapSince(1700000000L)
manager.subscribeGroup(groupId1)
manager.subscribeGroup(groupId2)
manager.updateGiftWrapSince(1700000000L)
manager.clear()
manager.clear()
assertTrue(manager.activeGroupIds().isEmpty())
assertNull(manager.giftWrapFilter().since)
}
assertTrue(manager.activeGroupIds().isEmpty())
assertNull(manager.giftWrapFilter().since)
}
}
@@ -73,6 +73,11 @@ actual object X25519 {
val secret = ka.generateSecret()
// Check for small-subgroup attack (RFC 9180 Section 4.1)
require(!secret.all { it == 0.toByte() }) {
"DH produced all-zero shared secret (possible small-subgroup attack)"
}
// XDH returns big-endian, X25519 shared secret is 32 bytes
// Pad or trim to KEY_LENGTH
return if (secret.size == KEY_LENGTH) {
@@ -119,18 +124,20 @@ actual object X25519 {
/**
* Extract raw scalar bytes from JCA XECPrivateKey.
* Scalar is in little-endian byte order per JCA spec.
*/
private fun extractPrivateKeyBytes(privKey: java.security.interfaces.XECPrivateKey): ByteArray {
val scalar = privKey.scalar.orElseThrow { IllegalStateException("No scalar in private key") }
return if (scalar.size == KEY_LENGTH) {
scalar
} else if (scalar.size < KEY_LENGTH) {
// Pad with leading zeros
// Pad with trailing zeros (little-endian: high-order bytes at end)
val result = ByteArray(KEY_LENGTH)
scalar.copyInto(result, KEY_LENGTH - scalar.size)
scalar.copyInto(result, 0)
result
} else {
scalar.copyOfRange(scalar.size - KEY_LENGTH, scalar.size)
// Take only the first KEY_LENGTH bytes (little-endian low-order bytes)
scalar.copyOfRange(0, KEY_LENGTH)
}
}
@@ -153,12 +160,11 @@ actual object X25519 {
private fun bigIntegerToBytes(bi: java.math.BigInteger): ByteArray {
val beBytes = bi.toByteArray()
val result = ByteArray(KEY_LENGTH)
// BigInteger is big-endian, possibly with leading zero byte
val start = if (beBytes.size > KEY_LENGTH) beBytes.size - KEY_LENGTH else 0
val length = minOf(beBytes.size, KEY_LENGTH)
// Reverse into little-endian result
for (i in 0 until length) {
result[i] = beBytes[beBytes.size - 1 - i + start]
// BigInteger is big-endian, possibly with a leading zero sign byte.
// Reverse into little-endian, taking at most KEY_LENGTH bytes from the low end.
val bytesToCopy = minOf(beBytes.size, KEY_LENGTH)
for (i in 0 until bytesToCopy) {
result[i] = beBytes[beBytes.size - 1 - i]
}
return result
}