Merge pull request #2202 from vitorpamplona/claude/review-marmot-mls-nFJQr
RFC 9420 compliance: encryption, commit ordering, and thread safety
This commit is contained in:
@@ -1865,7 +1865,7 @@ class Account(
|
|||||||
* Check if a KeyPackage has been published, either locally generated
|
* Check if a KeyPackage has been published, either locally generated
|
||||||
* in this session or found in the local cache from a previous session.
|
* in this session or found in the local cache from a previous session.
|
||||||
*/
|
*/
|
||||||
fun hasPublishedKeyPackage(): Boolean {
|
suspend fun hasPublishedKeyPackage(): Boolean {
|
||||||
// Check in-memory bundles first (current session)
|
// Check in-memory bundles first (current session)
|
||||||
val manager = marmotManager
|
val manager = marmotManager
|
||||||
if (manager != null && manager.hasActiveKeyPackages()) return true
|
if (manager != null && manager.hasActiveKeyPackages()) return true
|
||||||
|
|||||||
+11
-1
@@ -43,7 +43,17 @@ class AndroidMlsGroupStateStore(
|
|||||||
private val rootDir: File,
|
private val rootDir: File,
|
||||||
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
|
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
|
||||||
) : MlsGroupStateStore {
|
) : MlsGroupStateStore {
|
||||||
private fun groupDir(nostrGroupId: String): File = File(rootDir, "mls_groups/$nostrGroupId")
|
private fun groupDir(nostrGroupId: String): File {
|
||||||
|
// Validate nostrGroupId is a hex string to prevent path traversal
|
||||||
|
require(nostrGroupId.matches(HEX_PATTERN)) {
|
||||||
|
"Invalid nostrGroupId: must be a hex string"
|
||||||
|
}
|
||||||
|
return File(rootDir, "mls_groups/$nostrGroupId")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
|
||||||
|
}
|
||||||
|
|
||||||
private fun stateFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "state")
|
private fun stateFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "state")
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -55,12 +55,12 @@ class MarmotGroupEventsEoseManager(
|
|||||||
val fallbackRelays = key.account.homeRelays.flow.value
|
val fallbackRelays = key.account.homeRelays.flow.value
|
||||||
|
|
||||||
// Per-group kind:445 filters — route each to the group's own relays
|
// Per-group kind:445 filters — route each to the group's own relays
|
||||||
val groupStates = manager.subscriptionManager.activeGroupIds()
|
val groupStates = manager.subscriptionManager.activeGroupIdsSnapshot()
|
||||||
for (groupId in groupStates) {
|
for (groupId in groupStates) {
|
||||||
val filter =
|
val filter =
|
||||||
manager.subscriptionManager.let { sub ->
|
manager.subscriptionManager.let { sub ->
|
||||||
// Build the filter for this specific group
|
// Build the filter for this specific group
|
||||||
val groupFilters = sub.activeGroupFilters()
|
val groupFilters = sub.activeGroupFiltersSnapshot()
|
||||||
// activeGroupFilters() returns one filter per group; match by tag content
|
// activeGroupFilters() returns one filter per group; match by tag content
|
||||||
groupFilters.find { f ->
|
groupFilters.find { f ->
|
||||||
f.tags?.any { it.value?.contains(groupId) == true } == true
|
f.tags?.any { it.value?.contains(groupId) == true } == true
|
||||||
|
|||||||
+1
-1
@@ -1464,7 +1464,7 @@ class AccountViewModel(
|
|||||||
account.publishMarmotKeyPackage()
|
account.publishMarmotKeyPackage()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
|
suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
|
||||||
|
|
||||||
suspend fun leaveMarmotGroup(nostrGroupId: String) {
|
suspend fun leaveMarmotGroup(nostrGroupId: String) {
|
||||||
val relays = marmotGroupRelays(nostrGroupId)
|
val relays = marmotGroupRelays(nostrGroupId)
|
||||||
|
|||||||
+2
-2
@@ -301,13 +301,13 @@ class MarmotManager(
|
|||||||
/**
|
/**
|
||||||
* Check if KeyPackage rotation is needed.
|
* Check if KeyPackage rotation is needed.
|
||||||
*/
|
*/
|
||||||
fun needsKeyPackageRotation(): Boolean = keyPackageRotationManager.needsRotation()
|
suspend fun needsKeyPackageRotation(): Boolean = keyPackageRotationManager.needsRotation()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if there are active (locally generated) KeyPackages.
|
* Check if there are active (locally generated) KeyPackages.
|
||||||
* Returns true if at least one KeyPackage has been generated and not yet consumed.
|
* Returns true if at least one KeyPackage has been generated and not yet consumed.
|
||||||
*/
|
*/
|
||||||
fun hasActiveKeyPackages(): Boolean = keyPackageRotationManager.hasActiveKeyPackages()
|
suspend fun hasActiveKeyPackages(): Boolean = keyPackageRotationManager.hasActiveKeyPackages()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific group membership exists.
|
* Check if a specific group membership exists.
|
||||||
|
|||||||
+35
-16
@@ -126,6 +126,7 @@ class MarmotInboundProcessor(
|
|||||||
private val keyPackageRotationManager: KeyPackageRotationManager,
|
private val keyPackageRotationManager: KeyPackageRotationManager,
|
||||||
) {
|
) {
|
||||||
private val commitTracker = CommitOrdering.EpochCommitTracker()
|
private val commitTracker = CommitOrdering.EpochCommitTracker()
|
||||||
|
private val processedIdsLock = Any()
|
||||||
private val processedEventIds = LinkedHashSet<String>()
|
private val processedEventIds = LinkedHashSet<String>()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -153,11 +154,13 @@ class MarmotInboundProcessor(
|
|||||||
* @return the processing result
|
* @return the processing result
|
||||||
*/
|
*/
|
||||||
suspend fun processGroupEvent(groupEvent: GroupEvent): GroupEventResult {
|
suspend fun processGroupEvent(groupEvent: GroupEvent): GroupEventResult {
|
||||||
// Deduplicate already-processed events
|
// Deduplicate already-processed events (thread-safe)
|
||||||
val eventId = groupEvent.id
|
val eventId = groupEvent.id
|
||||||
if (eventId in processedEventIds) {
|
synchronized(processedIdsLock) {
|
||||||
val gId = groupEvent.groupId()
|
if (eventId in processedEventIds) {
|
||||||
return GroupEventResult.Duplicate(gId ?: "")
|
val gId = groupEvent.groupId()
|
||||||
|
return GroupEventResult.Duplicate(gId ?: "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val groupId =
|
val groupId =
|
||||||
@@ -185,8 +188,8 @@ class MarmotInboundProcessor(
|
|||||||
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
|
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track successfully processed events for deduplication
|
// Track ALL processed events for deduplication (including errors to prevent replay DoS)
|
||||||
if (result !is GroupEventResult.Error) {
|
synchronized(processedIdsLock) {
|
||||||
processedEventIds.add(eventId)
|
processedEventIds.add(eventId)
|
||||||
// Trim the set if it exceeds the max size
|
// Trim the set if it exceeds the max size
|
||||||
if (processedEventIds.size > MAX_PROCESSED_IDS) {
|
if (processedEventIds.size > MAX_PROCESSED_IDS) {
|
||||||
@@ -223,8 +226,19 @@ class MarmotInboundProcessor(
|
|||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
): WelcomeResult =
|
): WelcomeResult =
|
||||||
try {
|
try {
|
||||||
|
// Validate the caller-provided nostrGroupId matches the Welcome event's own h tag
|
||||||
|
val eventGroupId = welcomeEvent.nostrGroupId()
|
||||||
|
if (eventGroupId != null && eventGroupId != nostrGroupId) {
|
||||||
|
return WelcomeResult.Error(
|
||||||
|
"nostrGroupId mismatch: caller=$nostrGroupId, event=$eventGroupId",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val welcomeBytes = Base64.decode(welcomeEvent.welcomeBase64())
|
val welcomeBytes = Base64.decode(welcomeEvent.welcomeBase64())
|
||||||
val keyPackageEventId = welcomeEvent.keyPackageEventId()
|
val keyPackageEventId = welcomeEvent.keyPackageEventId()
|
||||||
|
if (keyPackageEventId == null) {
|
||||||
|
return WelcomeResult.Error("WelcomeEvent missing KeyPackage event ID tag")
|
||||||
|
}
|
||||||
|
|
||||||
// Find the KeyPackageBundle that was consumed
|
// Find the KeyPackageBundle that was consumed
|
||||||
val bundle =
|
val bundle =
|
||||||
@@ -381,14 +395,19 @@ class MarmotInboundProcessor(
|
|||||||
|
|
||||||
WireFormat.PUBLIC_MESSAGE -> {
|
WireFormat.PUBLIC_MESSAGE -> {
|
||||||
val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload))
|
val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload))
|
||||||
groupManager.processCommit(
|
val tag = pubMsg.confirmationTag
|
||||||
nostrGroupId = groupId,
|
if (tag == null) {
|
||||||
commitBytes = pubMsg.content,
|
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
|
||||||
senderLeafIndex = pubMsg.sender.leafIndex,
|
} else {
|
||||||
confirmationTag = pubMsg.confirmationTag,
|
groupManager.processCommit(
|
||||||
)
|
nostrGroupId = groupId,
|
||||||
val group = groupManager.getGroup(groupId)
|
commitBytes = pubMsg.content,
|
||||||
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
|
senderLeafIndex = pubMsg.sender.leafIndex,
|
||||||
|
confirmationTag = tag,
|
||||||
|
)
|
||||||
|
val group = groupManager.getGroup(groupId)
|
||||||
|
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -413,7 +432,7 @@ class MarmotInboundProcessor(
|
|||||||
// Try current epoch key first
|
// Try current epoch key first
|
||||||
try {
|
try {
|
||||||
val exporterKey = groupManager.exporterSecret(groupId)
|
val exporterKey = groupManager.exporterSecret(groupId)
|
||||||
return GroupEventEncryption.decrypt(encryptedContent, exporterKey)
|
return GroupEventEncryption.decrypt(encryptedContent, exporterKey, groupId)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Current epoch key failed — try retained epoch keys
|
// Current epoch key failed — try retained epoch keys
|
||||||
}
|
}
|
||||||
@@ -422,7 +441,7 @@ class MarmotInboundProcessor(
|
|||||||
val retainedKeys = groupManager.retainedExporterSecrets(groupId)
|
val retainedKeys = groupManager.retainedExporterSecrets(groupId)
|
||||||
for (retainedKey in retainedKeys) {
|
for (retainedKey in retainedKeys) {
|
||||||
try {
|
try {
|
||||||
return GroupEventEncryption.decrypt(encryptedContent, retainedKey)
|
return GroupEventEncryption.decrypt(encryptedContent, retainedKey, groupId)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// This retained key didn't work — try the next one
|
// This retained key didn't work — try the next one
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -90,7 +90,7 @@ class MarmotOutboundProcessor(
|
|||||||
|
|
||||||
// Step 2: Outer ChaCha20-Poly1305 encryption
|
// Step 2: Outer ChaCha20-Poly1305 encryption
|
||||||
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
||||||
val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey)
|
val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey, nostrGroupId)
|
||||||
|
|
||||||
// Step 3: Build the GroupEvent template
|
// Step 3: Build the GroupEvent template
|
||||||
val template =
|
val template =
|
||||||
@@ -125,7 +125,7 @@ class MarmotOutboundProcessor(
|
|||||||
): OutboundGroupEvent {
|
): OutboundGroupEvent {
|
||||||
// Outer ChaCha20-Poly1305 encryption of the MLS commit
|
// Outer ChaCha20-Poly1305 encryption of the MLS commit
|
||||||
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
||||||
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey)
|
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey, nostrGroupId)
|
||||||
|
|
||||||
// Build the GroupEvent template
|
// Build the GroupEvent template
|
||||||
val template =
|
val template =
|
||||||
|
|||||||
+37
-13
@@ -107,21 +107,24 @@ class MarmotSubscriptionManager(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all active group IDs being tracked.
|
* Returns all active group IDs being tracked.
|
||||||
|
* Non-suspend snapshot version for callers that cannot be suspend (e.g., filter builders).
|
||||||
*/
|
*/
|
||||||
fun activeGroupIds(): Set<HexKey> = groupSubscriptions.filter { it.value.active }.keys
|
fun activeGroupIdsSnapshot(): Set<HexKey> = groupSubscriptions.filter { it.value.active }.keys.toSet()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all active group IDs being tracked.
|
||||||
|
*/
|
||||||
|
suspend fun activeGroupIds(): Set<HexKey> = mutex.withLock { groupSubscriptions.filter { it.value.active }.keys.toSet() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a group is currently subscribed.
|
* Check if a group is currently subscribed.
|
||||||
*/
|
*/
|
||||||
fun isSubscribed(nostrGroupId: HexKey): Boolean = groupSubscriptions[nostrGroupId]?.active == true
|
suspend fun isSubscribed(nostrGroupId: HexKey): Boolean = mutex.withLock { groupSubscriptions[nostrGroupId]?.active == true }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build filters for all active group subscriptions.
|
* Non-suspend snapshot version of activeGroupFilters for callers that cannot be suspend.
|
||||||
*
|
|
||||||
* Returns one [Filter] per active group (kind:445 filtered by `h` tag),
|
|
||||||
* using the tracked `since` timestamp for pagination.
|
|
||||||
*/
|
*/
|
||||||
fun activeGroupFilters(): List<Filter> =
|
fun activeGroupFiltersSnapshot(): List<Filter> =
|
||||||
groupSubscriptions.values
|
groupSubscriptions.values
|
||||||
.filter { it.active }
|
.filter { it.active }
|
||||||
.map { state ->
|
.map { state ->
|
||||||
@@ -132,17 +135,38 @@ class MarmotSubscriptionManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build filters for all active group subscriptions.
|
||||||
|
*
|
||||||
|
* Returns one [Filter] per active group (kind:445 filtered by `h` tag),
|
||||||
|
* using the tracked `since` timestamp for pagination.
|
||||||
|
*/
|
||||||
|
suspend fun activeGroupFilters(): List<Filter> =
|
||||||
|
mutex.withLock {
|
||||||
|
groupSubscriptions.values
|
||||||
|
.filter { it.active }
|
||||||
|
.map { state ->
|
||||||
|
if (state.since != null) {
|
||||||
|
MarmotFilters.groupEventsByGroupIdSince(state.nostrGroupId, state.since!!)
|
||||||
|
} else {
|
||||||
|
MarmotFilters.groupEventsByGroupId(state.nostrGroupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the gift wrap filter for receiving Welcome messages.
|
* Build the gift wrap filter for receiving Welcome messages.
|
||||||
*
|
*
|
||||||
* Returns a single filter for kind:1059 addressed to the user's pubkey,
|
* Returns a single filter for kind:1059 addressed to the user's pubkey,
|
||||||
* using the tracked `since` timestamp for pagination.
|
* using the tracked `since` timestamp for pagination.
|
||||||
*/
|
*/
|
||||||
fun giftWrapFilter(): Filter =
|
suspend fun giftWrapFilter(): Filter =
|
||||||
if (giftWrapSince != null) {
|
mutex.withLock {
|
||||||
MarmotFilters.giftWrapsForUserSince(userPubKey, giftWrapSince!!)
|
if (giftWrapSince != null) {
|
||||||
} else {
|
MarmotFilters.giftWrapsForUserSince(userPubKey, giftWrapSince!!)
|
||||||
MarmotFilters.giftWrapsForUser(userPubKey)
|
} else {
|
||||||
|
MarmotFilters.giftWrapsForUser(userPubKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,7 +198,7 @@ class MarmotSubscriptionManager(
|
|||||||
* The platform layer should send these filters to the relay client
|
* The platform layer should send these filters to the relay client
|
||||||
* whenever subscriptions change or on reconnection.
|
* whenever subscriptions change or on reconnection.
|
||||||
*/
|
*/
|
||||||
fun buildFilters(): List<Filter> {
|
suspend fun buildFilters(): List<Filter> {
|
||||||
val filters = mutableListOf<Filter>()
|
val filters = mutableListOf<Filter>()
|
||||||
filters.addAll(activeGroupFilters())
|
filters.addAll(activeGroupFilters())
|
||||||
filters.add(giftWrapFilter())
|
filters.add(giftWrapFilter())
|
||||||
|
|||||||
+9
-7
@@ -106,15 +106,17 @@ class KeyPackageRotationManager {
|
|||||||
* Get the active bundle for a d-tag slot.
|
* Get the active bundle for a d-tag slot.
|
||||||
* Used when processing a Welcome that references one of our KeyPackages.
|
* Used when processing a Welcome that references one of our KeyPackages.
|
||||||
*/
|
*/
|
||||||
fun getBundle(dTagSlot: String): KeyPackageBundle? = activeBundles[dTagSlot]
|
suspend fun getBundle(dTagSlot: String): KeyPackageBundle? = mutex.withLock { activeBundles[dTagSlot] }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the bundle whose KeyPackage reference matches the given ref.
|
* Find the bundle whose KeyPackage reference matches the given ref.
|
||||||
* Used when we receive a Welcome and need to find the matching bundle.
|
* Used when we receive a Welcome and need to find the matching bundle.
|
||||||
*/
|
*/
|
||||||
fun findBundleByRef(keyPackageRef: ByteArray): KeyPackageBundle? =
|
suspend fun findBundleByRef(keyPackageRef: ByteArray): KeyPackageBundle? =
|
||||||
activeBundles.values.find { bundle ->
|
mutex.withLock {
|
||||||
bundle.keyPackage.reference().contentEquals(keyPackageRef)
|
activeBundles.values.find { bundle ->
|
||||||
|
bundle.keyPackage.reference().contentEquals(keyPackageRef)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,7 +148,7 @@ class KeyPackageRotationManager {
|
|||||||
/**
|
/**
|
||||||
* Get the d-tag slots that need rotation (KeyPackage was consumed).
|
* Get the d-tag slots that need rotation (KeyPackage was consumed).
|
||||||
*/
|
*/
|
||||||
fun pendingRotationSlots(): Set<String> = pendingRotations.toSet()
|
suspend fun pendingRotationSlots(): Set<String> = mutex.withLock { pendingRotations.toSet() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear a slot from the pending rotation set after a new KeyPackage
|
* Clear a slot from the pending rotation set after a new KeyPackage
|
||||||
@@ -160,13 +162,13 @@ class KeyPackageRotationManager {
|
|||||||
/**
|
/**
|
||||||
* Check if any slots need rotation.
|
* Check if any slots need rotation.
|
||||||
*/
|
*/
|
||||||
fun needsRotation(): Boolean = pendingRotations.isNotEmpty()
|
suspend fun needsRotation(): Boolean = mutex.withLock { pendingRotations.isNotEmpty() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if there are any active (non-consumed) KeyPackage bundles.
|
* Check if there are any active (non-consumed) KeyPackage bundles.
|
||||||
* Returns true if at least one slot has been generated and not yet consumed.
|
* Returns true if at least one slot has been generated and not yet consumed.
|
||||||
*/
|
*/
|
||||||
fun hasActiveKeyPackages(): Boolean = activeBundles.isNotEmpty()
|
suspend fun hasActiveKeyPackages(): Boolean = mutex.withLock { activeBundles.isNotEmpty() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotate a consumed slot: generate a new KeyPackage for the same d-tag.
|
* Rotate a consumed slot: generate a new KeyPackage for the same d-tag.
|
||||||
|
|||||||
+19
-8
@@ -82,6 +82,7 @@ object CommitOrdering {
|
|||||||
* to determine which commit wins for each (group, epoch).
|
* to determine which commit wins for each (group, epoch).
|
||||||
*/
|
*/
|
||||||
class EpochCommitTracker {
|
class EpochCommitTracker {
|
||||||
|
private val lock = Any()
|
||||||
private val pendingByGroupEpoch = mutableMapOf<GroupEpochKey, MutableList<GroupEvent>>()
|
private val pendingByGroupEpoch = mutableMapOf<GroupEpochKey, MutableList<GroupEvent>>()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -100,7 +101,7 @@ object CommitOrdering {
|
|||||||
groupId: String,
|
groupId: String,
|
||||||
epoch: Long,
|
epoch: Long,
|
||||||
commit: GroupEvent,
|
commit: GroupEvent,
|
||||||
) {
|
) = synchronized(lock) {
|
||||||
val key = GroupEpochKey(groupId, epoch)
|
val key = GroupEpochKey(groupId, epoch)
|
||||||
pendingByGroupEpoch.getOrPut(key) { mutableListOf() }.add(commit)
|
pendingByGroupEpoch.getOrPut(key) { mutableListOf() }.add(commit)
|
||||||
|
|
||||||
@@ -122,7 +123,10 @@ object CommitOrdering {
|
|||||||
fun pendingForEpoch(
|
fun pendingForEpoch(
|
||||||
groupId: String,
|
groupId: String,
|
||||||
epoch: Long,
|
epoch: Long,
|
||||||
): List<GroupEvent> = pendingByGroupEpoch[GroupEpochKey(groupId, epoch)] ?: emptyList()
|
): List<GroupEvent> =
|
||||||
|
synchronized(lock) {
|
||||||
|
pendingByGroupEpoch[GroupEpochKey(groupId, epoch)]?.toList() ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the winning commit for a specific group and epoch.
|
* Resolves the winning commit for a specific group and epoch.
|
||||||
@@ -134,7 +138,10 @@ object CommitOrdering {
|
|||||||
fun resolve(
|
fun resolve(
|
||||||
groupId: String,
|
groupId: String,
|
||||||
epoch: Long,
|
epoch: Long,
|
||||||
): GroupEvent? = selectWinner(pendingByGroupEpoch[GroupEpochKey(groupId, epoch)] ?: emptyList())
|
): GroupEvent? =
|
||||||
|
synchronized(lock) {
|
||||||
|
selectWinner(pendingByGroupEpoch[GroupEpochKey(groupId, epoch)] ?: emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears pending commits for a (group, epoch) after it has been resolved.
|
* Clears pending commits for a (group, epoch) after it has been resolved.
|
||||||
@@ -142,20 +149,24 @@ object CommitOrdering {
|
|||||||
fun clearEpoch(
|
fun clearEpoch(
|
||||||
groupId: String,
|
groupId: String,
|
||||||
epoch: Long,
|
epoch: Long,
|
||||||
) {
|
) = synchronized(lock) {
|
||||||
pendingByGroupEpoch.remove(GroupEpochKey(groupId, epoch))
|
pendingByGroupEpoch.remove(GroupEpochKey(groupId, epoch))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all (group, epoch) keys that have pending commits.
|
* Returns all (group, epoch) keys that have pending commits.
|
||||||
*/
|
*/
|
||||||
fun pendingGroupEpochs(): Set<GroupEpochKey> = pendingByGroupEpoch.keys.toSet()
|
fun pendingGroupEpochs(): Set<GroupEpochKey> =
|
||||||
|
synchronized(lock) {
|
||||||
|
pendingByGroupEpoch.keys.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all pending state.
|
* Clears all pending state.
|
||||||
*/
|
*/
|
||||||
fun clear() {
|
fun clear() =
|
||||||
pendingByGroupEpoch.clear()
|
synchronized(lock) {
|
||||||
}
|
pendingByGroupEpoch.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -36,26 +36,27 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
* Since the MLS engine is not yet integrated, this helper accepts the 32-byte key as a parameter.
|
* Since the MLS engine is not yet integrated, this helper accepts the 32-byte key as a parameter.
|
||||||
*/
|
*/
|
||||||
object GroupEventEncryption {
|
object GroupEventEncryption {
|
||||||
private val EMPTY_AAD = ByteArray(0)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts an MLS message for a GroupEvent.
|
* Encrypts an MLS message for a GroupEvent.
|
||||||
*
|
*
|
||||||
* @param mlsMessageBytes the raw MLS message bytes to encrypt
|
* @param mlsMessageBytes the raw MLS message bytes to encrypt
|
||||||
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
|
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
|
||||||
|
* @param nostrGroupId hex-encoded group ID bound to the ciphertext via AAD
|
||||||
* @return base64-encoded string containing nonce(12) || ciphertext || tag(16)
|
* @return base64-encoded string containing nonce(12) || ciphertext || tag(16)
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
fun encrypt(
|
fun encrypt(
|
||||||
mlsMessageBytes: ByteArray,
|
mlsMessageBytes: ByteArray,
|
||||||
groupKey: ByteArray,
|
groupKey: ByteArray,
|
||||||
|
nostrGroupId: String = "",
|
||||||
): String {
|
): String {
|
||||||
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
|
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
|
||||||
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
|
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val aad = nostrGroupId.encodeToByteArray()
|
||||||
val nonce = RandomInstance.bytes(GroupEvent.NONCE_LENGTH)
|
val nonce = RandomInstance.bytes(GroupEvent.NONCE_LENGTH)
|
||||||
val ciphertextWithTag = ChaCha20Poly1305.encrypt(mlsMessageBytes, EMPTY_AAD, nonce, groupKey)
|
val ciphertextWithTag = ChaCha20Poly1305.encrypt(mlsMessageBytes, aad, nonce, groupKey)
|
||||||
|
|
||||||
// Prepend nonce to ciphertext+tag
|
// Prepend nonce to ciphertext+tag
|
||||||
val payload = ByteArray(nonce.size + ciphertextWithTag.size)
|
val payload = ByteArray(nonce.size + ciphertextWithTag.size)
|
||||||
@@ -70,6 +71,7 @@ object GroupEventEncryption {
|
|||||||
*
|
*
|
||||||
* @param encryptedContentBase64 base64-encoded content from the GroupEvent
|
* @param encryptedContentBase64 base64-encoded content from the GroupEvent
|
||||||
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
|
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
|
||||||
|
* @param nostrGroupId hex-encoded group ID bound to the ciphertext via AAD
|
||||||
* @return decrypted MLS message bytes
|
* @return decrypted MLS message bytes
|
||||||
* @throws IllegalStateException if authentication fails
|
* @throws IllegalStateException if authentication fails
|
||||||
* @throws IllegalArgumentException if content is malformed
|
* @throws IllegalArgumentException if content is malformed
|
||||||
@@ -78,6 +80,7 @@ object GroupEventEncryption {
|
|||||||
fun decrypt(
|
fun decrypt(
|
||||||
encryptedContentBase64: String,
|
encryptedContentBase64: String,
|
||||||
groupKey: ByteArray,
|
groupKey: ByteArray,
|
||||||
|
nostrGroupId: String = "",
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
|
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
|
||||||
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
|
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
|
||||||
@@ -88,9 +91,10 @@ object GroupEventEncryption {
|
|||||||
"Payload too short: ${payload.size} bytes, minimum ${GroupEvent.MIN_CONTENT_LENGTH}"
|
"Payload too short: ${payload.size} bytes, minimum ${GroupEvent.MIN_CONTENT_LENGTH}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val aad = nostrGroupId.encodeToByteArray()
|
||||||
val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH)
|
val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH)
|
||||||
val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size)
|
val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size)
|
||||||
|
|
||||||
return ChaCha20Poly1305.decrypt(ciphertextWithTag, EMPTY_AAD, nonce, groupKey)
|
return ChaCha20Poly1305.decrypt(ciphertextWithTag, aad, nonce, groupKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -91,9 +91,11 @@ object TokenEncryption {
|
|||||||
val padding = RandomInstance.bytes(PADDED_PAYLOAD_SIZE - paddingStart)
|
val padding = RandomInstance.bytes(PADDED_PAYLOAD_SIZE - paddingStart)
|
||||||
padding.copyInto(payload, paddingStart)
|
padding.copyInto(payload, paddingStart)
|
||||||
|
|
||||||
// Generate ephemeral keypair for ECDH
|
// Generate ephemeral keypair for ECDH (x-only 32-byte pubkey)
|
||||||
val ephemeralPrivKey = RandomInstance.bytes(32)
|
val ephemeralPrivKey = RandomInstance.bytes(32)
|
||||||
val ephemeralPubKey = Secp256k1Instance.compressedPubKeyFor(ephemeralPrivKey)
|
val compressedPubKey = Secp256k1Instance.compressedPubKeyFor(ephemeralPrivKey)
|
||||||
|
// Extract the 32-byte x-only public key by dropping the SEC1 prefix byte
|
||||||
|
val ephemeralPubKey = compressedPubKey.copyOfRange(1, 33)
|
||||||
|
|
||||||
// ECDH: shared_x = sha256(ephemeral_privkey * server_pubkey)
|
// ECDH: shared_x = sha256(ephemeral_privkey * server_pubkey)
|
||||||
val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey)
|
val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey)
|
||||||
|
|||||||
+8
-5
@@ -73,7 +73,10 @@ object MlsCryptoProvider {
|
|||||||
* opaque context<V> = Context;
|
* opaque context<V> = Context;
|
||||||
* } KDFLabel;
|
* } KDFLabel;
|
||||||
* ```
|
* ```
|
||||||
* Label and context lengths use QUIC-style variable-length integer encoding.
|
* Label and context use TLS variable-length vectors with fixed-width
|
||||||
|
* length prefixes per RFC 9420 Section 8:
|
||||||
|
* opaque label<V> uses a 1-byte length prefix (opaque<7..255>)
|
||||||
|
* opaque context<V> uses a 4-byte length prefix (opaque<0..2^32-1>)
|
||||||
*/
|
*/
|
||||||
fun expandWithLabel(
|
fun expandWithLabel(
|
||||||
secret: ByteArray,
|
secret: ByteArray,
|
||||||
@@ -84,8 +87,8 @@ object MlsCryptoProvider {
|
|||||||
val fullLabel = "MLS 1.0 $label".encodeToByteArray()
|
val fullLabel = "MLS 1.0 $label".encodeToByteArray()
|
||||||
val hkdfLabel = TlsWriter(4 + fullLabel.size + context.size)
|
val hkdfLabel = TlsWriter(4 + fullLabel.size + context.size)
|
||||||
hkdfLabel.putUint16(length)
|
hkdfLabel.putUint16(length)
|
||||||
hkdfLabel.putOpaqueVarInt(fullLabel)
|
hkdfLabel.putOpaque1(fullLabel)
|
||||||
hkdfLabel.putOpaqueVarInt(context)
|
hkdfLabel.putOpaque4(context)
|
||||||
return hkdfExpand(secret, hkdfLabel.toByteArray(), length)
|
return hkdfExpand(secret, hkdfLabel.toByteArray(), length)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +106,8 @@ object MlsCryptoProvider {
|
|||||||
val fullLabel = prefix + label
|
val fullLabel = prefix + label
|
||||||
val hkdfLabel = TlsWriter(4 + fullLabel.size + context.size)
|
val hkdfLabel = TlsWriter(4 + fullLabel.size + context.size)
|
||||||
hkdfLabel.putUint16(length)
|
hkdfLabel.putUint16(length)
|
||||||
hkdfLabel.putOpaqueVarInt(fullLabel)
|
hkdfLabel.putOpaque1(fullLabel)
|
||||||
hkdfLabel.putOpaqueVarInt(context)
|
hkdfLabel.putOpaque4(context)
|
||||||
return hkdfExpand(secret, hkdfLabel.toByteArray(), length)
|
return hkdfExpand(secret, hkdfLabel.toByteArray(), length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+252
-98
@@ -356,20 +356,23 @@ class MlsGroup private constructor(
|
|||||||
// Check if we need an UpdatePath (required unless only SelfRemove)
|
// Check if we need an UpdatePath (required unless only SelfRemove)
|
||||||
val needsPath = proposals.any { it.proposal !is Proposal.SelfRemove }
|
val needsPath = proposals.any { it.proposal !is Proposal.SelfRemove }
|
||||||
|
|
||||||
// Apply proposals to tree FIRST (RFC 9420 Section 12.4.1)
|
// Apply proposals to tree FIRST (RFC 9420 Section 12.4.2)
|
||||||
// The UpdatePath is computed after proposals are applied.
|
// Order: Updates/Removes first, then Adds (so blank slots are freed before reuse)
|
||||||
val addedMembers = mutableListOf<Pair<Int, MlsKeyPackage>>()
|
val addedMembers = mutableListOf<Pair<Int, MlsKeyPackage>>()
|
||||||
|
val addProposals = mutableListOf<PendingProposal>()
|
||||||
for (pending in proposals) {
|
for (pending in proposals) {
|
||||||
val p = pending.proposal
|
if (pending.proposal is Proposal.Add) {
|
||||||
if (p is Proposal.Add) {
|
addProposals.add(pending)
|
||||||
// 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 {
|
} else {
|
||||||
applyProposal(p, pending.senderLeafIndex)
|
applyProposal(pending.proposal, pending.senderLeafIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Apply Adds after Removes/Updates
|
||||||
|
for (pending in addProposals) {
|
||||||
|
val p = pending.proposal as Proposal.Add
|
||||||
|
val leafIndex = applyProposalAdd(p)
|
||||||
|
addedMembers.add(leafIndex to p.keyPackage)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate new path secrets on the updated tree
|
// Generate new path secrets on the updated tree
|
||||||
val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
||||||
@@ -498,7 +501,7 @@ class MlsGroup private constructor(
|
|||||||
// --- Message Encryption ---
|
// --- Message Encryption ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt an application message as a PrivateMessage.
|
* Encrypt an application message as a PrivateMessage (RFC 9420 Section 6.3).
|
||||||
*/
|
*/
|
||||||
fun encrypt(plaintext: ByteArray): ByteArray {
|
fun encrypt(plaintext: ByteArray): ByteArray {
|
||||||
// Trim sentKeys if it grows too large
|
// Trim sentKeys if it grows too large
|
||||||
@@ -512,32 +515,46 @@ class MlsGroup private constructor(
|
|||||||
|
|
||||||
val kng = secretTree.nextApplicationKeyNonce(myLeafIndex)
|
val kng = secretTree.nextApplicationKeyNonce(myLeafIndex)
|
||||||
sentKeys[kng.generation] = kng
|
sentKeys[kng.generation] = kng
|
||||||
val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, kng.nonce, ByteArray(0), plaintext)
|
|
||||||
|
|
||||||
// Encrypt sender data
|
// Generate 4-byte reuse_guard and XOR into the first 4 nonce bytes (RFC 9420 §6.3.1)
|
||||||
|
val reuseGuard = MlsCryptoProvider.randomBytes(REUSE_GUARD_LENGTH)
|
||||||
|
val guardedNonce = kng.nonce.copyOf()
|
||||||
|
for (i in 0 until REUSE_GUARD_LENGTH) {
|
||||||
|
guardedNonce[i] = (guardedNonce[i].toInt() xor reuseGuard[i].toInt()).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build PrivateContentAAD (RFC 9420 §6.3.2)
|
||||||
|
val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, ByteArray(0))
|
||||||
|
val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, guardedNonce, contentAad, plaintext)
|
||||||
|
|
||||||
|
// Build sender data plaintext: leaf_index || generation || reuse_guard
|
||||||
|
val senderDataWriter = TlsWriter()
|
||||||
|
senderDataWriter.putUint32(myLeafIndex.toLong())
|
||||||
|
senderDataWriter.putUint32(kng.generation.toLong())
|
||||||
|
senderDataWriter.putBytes(reuseGuard)
|
||||||
|
val senderDataPlain = senderDataWriter.toByteArray()
|
||||||
|
|
||||||
|
// Derive sender data key/nonce using ciphertext sample (RFC 9420 §6.3.1)
|
||||||
|
val ciphertextSample = ciphertext.copyOfRange(0, minOf(ciphertext.size, MlsCryptoProvider.AEAD_KEY_LENGTH))
|
||||||
val senderDataKey =
|
val senderDataKey =
|
||||||
MlsCryptoProvider.expandWithLabel(
|
MlsCryptoProvider.expandWithLabel(
|
||||||
epochSecrets.senderDataSecret,
|
epochSecrets.senderDataSecret,
|
||||||
"key",
|
"key",
|
||||||
ByteArray(0),
|
ciphertextSample,
|
||||||
MlsCryptoProvider.AEAD_KEY_LENGTH,
|
MlsCryptoProvider.AEAD_KEY_LENGTH,
|
||||||
)
|
)
|
||||||
val senderDataNonce =
|
val senderDataNonce =
|
||||||
MlsCryptoProvider.expandWithLabel(
|
MlsCryptoProvider.expandWithLabel(
|
||||||
epochSecrets.senderDataSecret,
|
epochSecrets.senderDataSecret,
|
||||||
"nonce",
|
"nonce",
|
||||||
ByteArray(0),
|
ciphertextSample,
|
||||||
MlsCryptoProvider.AEAD_NONCE_LENGTH,
|
MlsCryptoProvider.AEAD_NONCE_LENGTH,
|
||||||
)
|
)
|
||||||
|
|
||||||
val senderDataWriter =
|
// Build SenderDataAAD (RFC 9420 §6.3.1)
|
||||||
com.vitorpamplona.quartz.marmot.mls.codec
|
val senderDataAad = buildSenderDataAAD(groupId, epoch, ContentType.APPLICATION)
|
||||||
.TlsWriter()
|
|
||||||
senderDataWriter.putUint32(myLeafIndex.toLong())
|
|
||||||
senderDataWriter.putUint32(kng.generation.toLong())
|
|
||||||
val senderDataPlain = senderDataWriter.toByteArray()
|
|
||||||
val encryptedSenderData =
|
val encryptedSenderData =
|
||||||
MlsCryptoProvider.aeadEncrypt(senderDataKey, senderDataNonce, ByteArray(0), senderDataPlain)
|
MlsCryptoProvider.aeadEncrypt(senderDataKey, senderDataNonce, senderDataAad, senderDataPlain)
|
||||||
|
|
||||||
val msg =
|
val msg =
|
||||||
PrivateMessage(
|
PrivateMessage(
|
||||||
@@ -564,7 +581,7 @@ class MlsGroup private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt an application message from a PrivateMessage.
|
* Decrypt an application message from a PrivateMessage (RFC 9420 Section 6.3).
|
||||||
* @throws IllegalArgumentException if the message format is invalid
|
* @throws IllegalArgumentException if the message format is invalid
|
||||||
* @throws javax.crypto.AEADBadTagException if decryption fails
|
* @throws javax.crypto.AEADBadTagException if decryption fails
|
||||||
*/
|
*/
|
||||||
@@ -582,26 +599,40 @@ class MlsGroup private constructor(
|
|||||||
"Message group ID doesn't match current group"
|
"Message group ID doesn't match current group"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt sender data
|
// Derive sender data key/nonce using ciphertext sample (RFC 9420 §6.3.1)
|
||||||
|
val ciphertextSample =
|
||||||
|
privMsg.ciphertext.copyOfRange(0, minOf(privMsg.ciphertext.size, MlsCryptoProvider.AEAD_KEY_LENGTH))
|
||||||
val senderDataKey =
|
val senderDataKey =
|
||||||
MlsCryptoProvider.expandWithLabel(
|
MlsCryptoProvider.expandWithLabel(
|
||||||
epochSecrets.senderDataSecret,
|
epochSecrets.senderDataSecret,
|
||||||
"key",
|
"key",
|
||||||
ByteArray(0),
|
ciphertextSample,
|
||||||
MlsCryptoProvider.AEAD_KEY_LENGTH,
|
MlsCryptoProvider.AEAD_KEY_LENGTH,
|
||||||
)
|
)
|
||||||
val senderDataNonce =
|
val senderDataNonce =
|
||||||
MlsCryptoProvider.expandWithLabel(
|
MlsCryptoProvider.expandWithLabel(
|
||||||
epochSecrets.senderDataSecret,
|
epochSecrets.senderDataSecret,
|
||||||
"nonce",
|
"nonce",
|
||||||
ByteArray(0),
|
ciphertextSample,
|
||||||
MlsCryptoProvider.AEAD_NONCE_LENGTH,
|
MlsCryptoProvider.AEAD_NONCE_LENGTH,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Build SenderDataAAD and decrypt sender data (RFC 9420 §6.3.1)
|
||||||
|
val senderDataAad = buildSenderDataAAD(privMsg.groupId, privMsg.epoch, privMsg.contentType)
|
||||||
val senderDataPlain =
|
val senderDataPlain =
|
||||||
MlsCryptoProvider.aeadDecrypt(senderDataKey, senderDataNonce, ByteArray(0), privMsg.encryptedSenderData)
|
MlsCryptoProvider.aeadDecrypt(senderDataKey, senderDataNonce, senderDataAad, privMsg.encryptedSenderData)
|
||||||
val senderReader = TlsReader(senderDataPlain)
|
val senderReader = TlsReader(senderDataPlain)
|
||||||
val senderLeafIndex = senderReader.readUint32().toInt()
|
val senderLeafIndex = senderReader.readUint32().toInt()
|
||||||
val generation = senderReader.readUint32().toInt()
|
val generation = senderReader.readUint32().toInt()
|
||||||
|
val reuseGuard = senderReader.readBytes(REUSE_GUARD_LENGTH)
|
||||||
|
|
||||||
|
// Validate sender leaf index and membership (RFC 9420 §6.3.1)
|
||||||
|
require(senderLeafIndex in 0 until tree.leafCount) {
|
||||||
|
"Sender leaf index $senderLeafIndex out of range [0, ${tree.leafCount})"
|
||||||
|
}
|
||||||
|
require(tree.getLeaf(senderLeafIndex) != null) {
|
||||||
|
"Sender leaf is blank at index $senderLeafIndex (not a group member)"
|
||||||
|
}
|
||||||
|
|
||||||
// Get the key/nonce for this sender+generation
|
// Get the key/nonce for this sender+generation
|
||||||
// If we sent this message ourselves, use the cached key to avoid ratchet conflict
|
// If we sent this message ourselves, use the cached key to avoid ratchet conflict
|
||||||
@@ -612,8 +643,15 @@ class MlsGroup private constructor(
|
|||||||
secretTree.applicationKeyNonceForGeneration(senderLeafIndex, generation)
|
secretTree.applicationKeyNonceForGeneration(senderLeafIndex, generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt content
|
// Apply reuse_guard XOR to nonce (RFC 9420 §6.3.1)
|
||||||
val plaintext = MlsCryptoProvider.aeadDecrypt(kng.key, kng.nonce, ByteArray(0), privMsg.ciphertext)
|
val guardedNonce = kng.nonce.copyOf()
|
||||||
|
for (i in 0 until REUSE_GUARD_LENGTH) {
|
||||||
|
guardedNonce[i] = (guardedNonce[i].toInt() xor reuseGuard[i].toInt()).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt content with PrivateContentAAD (RFC 9420 §6.3.2)
|
||||||
|
val contentAad = buildPrivateContentAAD(privMsg.groupId, privMsg.epoch, privMsg.contentType, privMsg.authenticatedData)
|
||||||
|
val plaintext = MlsCryptoProvider.aeadDecrypt(kng.key, guardedNonce, contentAad, privMsg.ciphertext)
|
||||||
|
|
||||||
return DecryptedMessage(
|
return DecryptedMessage(
|
||||||
senderLeafIndex = senderLeafIndex,
|
senderLeafIndex = senderLeafIndex,
|
||||||
@@ -635,7 +673,7 @@ class MlsGroup private constructor(
|
|||||||
fun processCommit(
|
fun processCommit(
|
||||||
commitBytes: ByteArray,
|
commitBytes: ByteArray,
|
||||||
senderLeafIndex: Int,
|
senderLeafIndex: Int,
|
||||||
confirmationTag: ByteArray? = null,
|
confirmationTag: ByteArray,
|
||||||
) {
|
) {
|
||||||
val commit = Commit.decodeTls(TlsReader(commitBytes))
|
val commit = Commit.decodeTls(TlsReader(commitBytes))
|
||||||
|
|
||||||
@@ -660,14 +698,17 @@ class MlsGroup private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply proposals (resolve references from pending pool)
|
// Apply proposals (resolve references from pending pool)
|
||||||
|
// Collect all resolved proposals for key schedule computation
|
||||||
|
val resolvedProposals = mutableListOf<Proposal>()
|
||||||
for (proposalOrRef in commit.proposals) {
|
for (proposalOrRef in commit.proposals) {
|
||||||
when (proposalOrRef) {
|
when (proposalOrRef) {
|
||||||
is ProposalOrRef.Inline -> {
|
is ProposalOrRef.Inline -> {
|
||||||
applyProposal(proposalOrRef.proposal, senderLeafIndex)
|
applyProposal(proposalOrRef.proposal, senderLeafIndex)
|
||||||
|
resolvedProposals.add(proposalOrRef.proposal)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ProposalOrRef.Reference -> {
|
is ProposalOrRef.Reference -> {
|
||||||
// Resolve proposal by reference hash from pending proposals
|
// Resolve proposal by reference hash from pending proposals (RFC 9420 §12.4.2)
|
||||||
val refHash = proposalOrRef.proposalRef
|
val refHash = proposalOrRef.proposalRef
|
||||||
val resolved =
|
val resolved =
|
||||||
pendingProposals.find { pending ->
|
pendingProposals.find { pending ->
|
||||||
@@ -675,9 +716,11 @@ class MlsGroup private constructor(
|
|||||||
val hash = MlsCryptoProvider.refHash("MLS 1.0 Proposal Reference", proposalBytes)
|
val hash = MlsCryptoProvider.refHash("MLS 1.0 Proposal Reference", proposalBytes)
|
||||||
hash.contentEquals(refHash)
|
hash.contentEquals(refHash)
|
||||||
}
|
}
|
||||||
if (resolved != null) {
|
requireNotNull(resolved) {
|
||||||
applyProposal(resolved.proposal, resolved.senderLeafIndex)
|
"Commit references unknown proposal (ref not found in pending proposals)"
|
||||||
}
|
}
|
||||||
|
applyProposal(resolved.proposal, resolved.senderLeafIndex)
|
||||||
|
resolvedProposals.add(resolved.proposal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,11 +733,15 @@ class MlsGroup private constructor(
|
|||||||
require(verifyLeafNodeSignature(updatePath.leafNode, groupId, senderLeafIndex)) {
|
require(verifyLeafNodeSignature(updatePath.leafNode, groupId, senderLeafIndex)) {
|
||||||
"Invalid LeafNode signature in UpdatePath"
|
"Invalid LeafNode signature in UpdatePath"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture sibling tree hashes BEFORE applying UpdatePath (needed for parent hash verification)
|
||||||
|
val preUpdateSiblingHashes = capturePreUpdateSiblingHashes(senderLeafIndex)
|
||||||
|
|
||||||
tree.setLeaf(senderLeafIndex, updatePath.leafNode)
|
tree.setLeaf(senderLeafIndex, updatePath.leafNode)
|
||||||
tree.applyUpdatePath(senderLeafIndex, updatePath.nodes)
|
tree.applyUpdatePath(senderLeafIndex, updatePath.nodes)
|
||||||
|
|
||||||
// Verify parent hash chain (RFC 9420 Section 7.9.2)
|
// Verify parent hash chain (RFC 9420 Section 7.9.2) using pre-update sibling hashes
|
||||||
require(verifyParentHash(senderLeafIndex, updatePath)) {
|
require(verifyParentHash(senderLeafIndex, updatePath, preUpdateSiblingHashes)) {
|
||||||
"Parent hash verification failed for UpdatePath"
|
"Parent hash verification failed for UpdatePath"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,10 +771,12 @@ class MlsGroup private constructor(
|
|||||||
ct.ciphertext,
|
ct.ciphertext,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Derive remaining path secrets up to root
|
// Derive remaining path secrets from common ancestor up to root.
|
||||||
val remainingPath = directPath.drop(commonAncestorIdx)
|
// pathSecret is the secret AT commonAncestorIdx, so we derive
|
||||||
|
// (directPath.size - commonAncestorIdx - 1) more steps to reach root.
|
||||||
|
val remainingSteps = directPath.size - commonAncestorIdx - 1
|
||||||
var currentSecret = pathSecret
|
var currentSecret = pathSecret
|
||||||
for (nodeIdx in remainingPath) {
|
repeat(remainingSteps) {
|
||||||
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
|
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
|
||||||
}
|
}
|
||||||
commitSecret = currentSecret
|
commitSecret = currentSecret
|
||||||
@@ -755,19 +804,12 @@ class MlsGroup private constructor(
|
|||||||
confirmedTranscriptHash = newConfirmedTranscriptHash,
|
confirmedTranscriptHash = newConfirmedTranscriptHash,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compute PSK secret from any PSK proposals in the commit
|
// Compute PSK secret from all resolved proposals (both inline and by-reference)
|
||||||
val commitPskProposals =
|
val pskSecret = computePskSecret(resolvedProposals)
|
||||||
commit.proposals.mapNotNull {
|
|
||||||
when (it) {
|
|
||||||
is ProposalOrRef.Inline -> it.proposal
|
|
||||||
is ProposalOrRef.Reference -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val pskSecret = computePskSecret(commitPskProposals)
|
|
||||||
|
|
||||||
// Check for ExternalInit proposal — overrides init_secret (RFC 9420 Section 8.3)
|
// Check for ExternalInit proposal — overrides init_secret (RFC 9420 Section 8.3)
|
||||||
val externalInitProposal =
|
val externalInitProposal =
|
||||||
commitPskProposals.filterIsInstance<Proposal.ExternalInit>().firstOrNull()
|
resolvedProposals.filterIsInstance<Proposal.ExternalInit>().firstOrNull()
|
||||||
val effectiveInitSecret =
|
val effectiveInitSecret =
|
||||||
if (externalInitProposal != null) {
|
if (externalInitProposal != null) {
|
||||||
deriveExternalInitSecret(externalInitProposal.kemOutput)
|
deriveExternalInitSecret(externalInitProposal.kemOutput)
|
||||||
@@ -780,19 +822,16 @@ class MlsGroup private constructor(
|
|||||||
initSecret = epochSecrets.initSecret
|
initSecret = epochSecrets.initSecret
|
||||||
secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount)
|
secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount)
|
||||||
|
|
||||||
// Verify confirmation tag (RFC 9420 Section 6.1)
|
// Verify confirmation tag (RFC 9420 Section 6.1) — mandatory for all commits
|
||||||
if (confirmationTag != null) {
|
val expectedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
|
||||||
val expectedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
|
require(constantTimeEquals(confirmationTag, expectedTag)) {
|
||||||
require(constantTimeEquals(confirmationTag, expectedTag)) {
|
"Confirmation tag verification failed"
|
||||||
"Confirmation tag verification failed"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update interim_transcript_hash for next epoch
|
// Update interim_transcript_hash for next epoch (reuse verified expectedTag)
|
||||||
val computedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
|
|
||||||
val interimInput = TlsWriter()
|
val interimInput = TlsWriter()
|
||||||
interimInput.putBytes(newConfirmedTranscriptHash)
|
interimInput.putBytes(newConfirmedTranscriptHash)
|
||||||
interimInput.putOpaqueVarInt(computedTag)
|
interimInput.putOpaqueVarInt(expectedTag)
|
||||||
interimTranscriptHash = MlsCryptoProvider.hash(interimInput.toByteArray())
|
interimTranscriptHash = MlsCryptoProvider.hash(interimInput.toByteArray())
|
||||||
|
|
||||||
pendingProposals.clear()
|
pendingProposals.clear()
|
||||||
@@ -918,31 +957,23 @@ class MlsGroup private constructor(
|
|||||||
private fun buildConfirmedTranscriptHashInput(
|
private fun buildConfirmedTranscriptHashInput(
|
||||||
commit: Commit,
|
commit: Commit,
|
||||||
senderLeafIndex: Int,
|
senderLeafIndex: Int,
|
||||||
): ByteArray {
|
): ByteArray = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, groupId, epoch)
|
||||||
val writer = TlsWriter()
|
|
||||||
// wire_format: PublicMessage = 1
|
|
||||||
writer.putUint16(WireFormat.PUBLIC_MESSAGE.value)
|
|
||||||
// FramedContent: group_id, epoch, sender, authenticated_data, content_type, content
|
|
||||||
writer.putOpaqueVarInt(groupId)
|
|
||||||
writer.putUint64(epoch)
|
|
||||||
// Sender: member type (1) + leaf_index
|
|
||||||
writer.putUint8(1) // SenderType.MEMBER
|
|
||||||
writer.putUint32(senderLeafIndex.toLong())
|
|
||||||
writer.putOpaqueVarInt(ByteArray(0)) // authenticated_data (empty)
|
|
||||||
writer.putUint8(ContentType.COMMIT.value) // content_type
|
|
||||||
commit.encodeTls(writer) // content = Commit
|
|
||||||
// signature (placeholder — in a full implementation this would be the actual signature)
|
|
||||||
writer.putOpaqueVarInt(ByteArray(0))
|
|
||||||
return writer.toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeConfirmationTag(
|
/**
|
||||||
confirmationKey: ByteArray,
|
* Capture sibling tree hashes BEFORE the UpdatePath is applied.
|
||||||
confirmedTranscriptHash: ByteArray,
|
* Returns a map of direct-path-index to sibling tree hash.
|
||||||
): ByteArray {
|
*/
|
||||||
val mac = MacInstance("HmacSHA256", confirmationKey)
|
private fun capturePreUpdateSiblingHashes(senderLeafIndex: Int): Map<Int, ByteArray> {
|
||||||
mac.update(confirmedTranscriptHash)
|
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
||||||
return mac.doFinal()
|
val nodeCount = BinaryTree.nodeCount(tree.leafCount)
|
||||||
|
val result = mutableMapOf<Int, ByteArray>()
|
||||||
|
for ((i, _) in directPath.withIndex()) {
|
||||||
|
val childIdx =
|
||||||
|
if (i == 0) BinaryTree.leafToNode(senderLeafIndex) else directPath[i - 1]
|
||||||
|
val siblingIdx = BinaryTree.sibling(childIdx, nodeCount)
|
||||||
|
result[i] = tree.treeHashNode(siblingIdx)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -956,18 +987,26 @@ class MlsGroup private constructor(
|
|||||||
* opaque parent_hash<V>; // parent's own parent_hash
|
* opaque parent_hash<V>; // parent's own parent_hash
|
||||||
* opaque original_sibling_tree_hash<V>; // tree hash of the sibling subtree
|
* opaque original_sibling_tree_hash<V>; // tree hash of the sibling subtree
|
||||||
* }
|
* }
|
||||||
|
*
|
||||||
|
* @param preUpdateSiblingHashes sibling hashes captured BEFORE the UpdatePath was applied
|
||||||
*/
|
*/
|
||||||
private fun verifyParentHash(
|
private fun verifyParentHash(
|
||||||
senderLeafIndex: Int,
|
senderLeafIndex: Int,
|
||||||
updatePath: UpdatePath,
|
updatePath: UpdatePath,
|
||||||
|
preUpdateSiblingHashes: Map<Int, ByteArray>,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
||||||
if (directPath.isEmpty()) return true
|
if (directPath.isEmpty()) return true
|
||||||
|
|
||||||
val leafParentHash = updatePath.leafNode.parentHash ?: return true
|
// COMMIT leaf nodes MUST have a non-empty parentHash (RFC 9420 §7.9.2)
|
||||||
if (leafParentHash.isEmpty()) return true
|
val leafParentHash = updatePath.leafNode.parentHash
|
||||||
|
if (updatePath.leafNode.leafNodeSource == LeafNodeSource.COMMIT) {
|
||||||
val nodeCount = BinaryTree.nodeCount(tree.leafCount)
|
requireNotNull(leafParentHash) { "Commit LeafNode missing parentHash" }
|
||||||
|
require(leafParentHash.isNotEmpty()) { "Commit LeafNode has empty parentHash" }
|
||||||
|
} else {
|
||||||
|
// Non-commit leaf nodes may not have parentHash
|
||||||
|
if (leafParentHash == null || leafParentHash.isEmpty()) return true
|
||||||
|
}
|
||||||
|
|
||||||
// Walk up the direct path, verifying each parent_hash link
|
// Walk up the direct path, verifying each parent_hash link
|
||||||
var expectedParentHash = leafParentHash
|
var expectedParentHash = leafParentHash
|
||||||
@@ -975,14 +1014,8 @@ class MlsGroup private constructor(
|
|||||||
val pathNode = tree.getNode(pathNodeIdx) ?: continue
|
val pathNode = tree.getNode(pathNodeIdx) ?: continue
|
||||||
|
|
||||||
if (pathNode is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
|
if (pathNode is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
|
||||||
// Compute the parent hash for this node
|
// Use the pre-update sibling tree hash (captured before UpdatePath was applied)
|
||||||
val childIdx =
|
val siblingHash = preUpdateSiblingHashes[i] ?: continue
|
||||||
if (i == 0) BinaryTree.leafToNode(senderLeafIndex) else directPath[i - 1]
|
|
||||||
val siblingIdx = BinaryTree.sibling(childIdx, nodeCount)
|
|
||||||
|
|
||||||
// original_sibling_tree_hash = tree hash of the sibling subtree
|
|
||||||
// (computed BEFORE the UpdatePath was applied)
|
|
||||||
val siblingHash = tree.treeHashNode(siblingIdx)
|
|
||||||
|
|
||||||
// ParentHashInput
|
// ParentHashInput
|
||||||
val phi = TlsWriter()
|
val phi = TlsWriter()
|
||||||
@@ -1038,6 +1071,53 @@ class MlsGroup private constructor(
|
|||||||
return constantTimeEquals(expectedTag, membershipTag)
|
return constantTimeEquals(expectedTag, membershipTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build PrivateContentAAD (RFC 9420 §6.3.2):
|
||||||
|
* ```
|
||||||
|
* struct {
|
||||||
|
* opaque group_id<V>;
|
||||||
|
* uint64 epoch;
|
||||||
|
* ContentType content_type;
|
||||||
|
* opaque authenticated_data<V>;
|
||||||
|
* } PrivateContentAAD;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
private fun buildPrivateContentAAD(
|
||||||
|
groupId: ByteArray,
|
||||||
|
epoch: Long,
|
||||||
|
contentType: ContentType,
|
||||||
|
authenticatedData: ByteArray,
|
||||||
|
): ByteArray {
|
||||||
|
val writer = TlsWriter()
|
||||||
|
writer.putOpaqueVarInt(groupId)
|
||||||
|
writer.putUint64(epoch)
|
||||||
|
writer.putUint8(contentType.value)
|
||||||
|
writer.putOpaqueVarInt(authenticatedData)
|
||||||
|
return writer.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SenderDataAAD (RFC 9420 §6.3.1):
|
||||||
|
* ```
|
||||||
|
* struct {
|
||||||
|
* opaque group_id<V>;
|
||||||
|
* uint64 epoch;
|
||||||
|
* ContentType content_type;
|
||||||
|
* } SenderDataAAD;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
private fun buildSenderDataAAD(
|
||||||
|
groupId: ByteArray,
|
||||||
|
epoch: Long,
|
||||||
|
contentType: ContentType,
|
||||||
|
): ByteArray {
|
||||||
|
val writer = TlsWriter()
|
||||||
|
writer.putOpaqueVarInt(groupId)
|
||||||
|
writer.putUint64(epoch)
|
||||||
|
writer.putUint8(contentType.value)
|
||||||
|
return writer.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constant-time byte array comparison to prevent timing side-channels.
|
* Constant-time byte array comparison to prevent timing side-channels.
|
||||||
* Returns true only if both arrays have the same length and contents.
|
* Returns true only if both arrays have the same length and contents.
|
||||||
@@ -1058,14 +1138,27 @@ class MlsGroup private constructor(
|
|||||||
* Apply an Add proposal and return the assigned leaf index.
|
* Apply an Add proposal and return the assigned leaf index.
|
||||||
*/
|
*/
|
||||||
private fun applyProposalAdd(proposal: Proposal.Add): Int {
|
private fun applyProposalAdd(proposal: Proposal.Add): Int {
|
||||||
val lifetime = proposal.keyPackage.leafNode.lifetime
|
val leafNode = proposal.keyPackage.leafNode
|
||||||
|
|
||||||
|
// Validate lifetime
|
||||||
|
val lifetime = leafNode.lifetime
|
||||||
if (lifetime != null) {
|
if (lifetime != null) {
|
||||||
val now = TimeUtils.now()
|
val now = TimeUtils.now()
|
||||||
require(now >= lifetime.notBefore && now <= lifetime.notAfter) {
|
require(now >= lifetime.notBefore && now <= lifetime.notAfter) {
|
||||||
"KeyPackage lifetime expired or not yet valid"
|
"KeyPackage lifetime expired or not yet valid"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tree.addLeaf(proposal.keyPackage.leafNode)
|
|
||||||
|
// Validate capabilities (RFC 9420 §12.1.1)
|
||||||
|
val caps = leafNode.capabilities
|
||||||
|
require(1 in caps.versions) {
|
||||||
|
"KeyPackage does not support MLS protocol version 1"
|
||||||
|
}
|
||||||
|
require(1 in caps.ciphersuites) {
|
||||||
|
"KeyPackage does not support ciphersuite 0x0001"
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree.addLeaf(leafNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyProposal(
|
private fun applyProposal(
|
||||||
@@ -1149,11 +1242,9 @@ class MlsGroup private constructor(
|
|||||||
groupContext = groupContext,
|
groupContext = groupContext,
|
||||||
extensions = listOf(ratchetTreeExtension),
|
extensions = listOf(ratchetTreeExtension),
|
||||||
confirmationTag =
|
confirmationTag =
|
||||||
MlsCryptoProvider.expandWithLabel(
|
computeConfirmationTag(
|
||||||
epochSecrets.confirmationKey,
|
epochSecrets.confirmationKey,
|
||||||
"confirmation",
|
|
||||||
groupContext.confirmedTranscriptHash,
|
groupContext.confirmedTranscriptHash,
|
||||||
MlsCryptoProvider.HASH_OUTPUT_LENGTH,
|
|
||||||
),
|
),
|
||||||
signer = myLeafIndex,
|
signer = myLeafIndex,
|
||||||
signature = ByteArray(0),
|
signature = ByteArray(0),
|
||||||
@@ -1223,7 +1314,45 @@ class MlsGroup private constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_SENT_KEYS = 10_000
|
private const val MAX_SENT_KEYS = 10_000
|
||||||
|
private const val REUSE_GUARD_LENGTH = 4
|
||||||
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0001
|
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0001
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build ConfirmedTranscriptHashInput (RFC 9420 Section 8.2) — static version
|
||||||
|
* usable from both instance methods and companion object factory methods.
|
||||||
|
*/
|
||||||
|
private fun buildConfirmedTranscriptHashInput(
|
||||||
|
commit: Commit,
|
||||||
|
senderLeafIndex: Int,
|
||||||
|
groupId: ByteArray,
|
||||||
|
epoch: Long,
|
||||||
|
): ByteArray {
|
||||||
|
val writer = TlsWriter()
|
||||||
|
writer.putUint16(WireFormat.PUBLIC_MESSAGE.value)
|
||||||
|
writer.putOpaqueVarInt(groupId)
|
||||||
|
writer.putUint64(epoch)
|
||||||
|
writer.putUint8(1) // SenderType.MEMBER
|
||||||
|
writer.putUint32(senderLeafIndex.toLong())
|
||||||
|
writer.putOpaqueVarInt(ByteArray(0)) // authenticated_data
|
||||||
|
writer.putUint8(ContentType.COMMIT.value)
|
||||||
|
commit.encodeTls(writer)
|
||||||
|
writer.putOpaqueVarInt(ByteArray(0)) // signature placeholder
|
||||||
|
return writer.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute confirmation_tag = MAC(confirmation_key, confirmed_transcript_hash).
|
||||||
|
* Static version usable from companion object factory methods.
|
||||||
|
*/
|
||||||
|
private fun computeConfirmationTag(
|
||||||
|
confirmationKey: ByteArray,
|
||||||
|
confirmedTranscriptHash: ByteArray,
|
||||||
|
): ByteArray {
|
||||||
|
val mac = MacInstance("HmacSHA256", confirmationKey)
|
||||||
|
mac.update(confirmedTranscriptHash)
|
||||||
|
return mac.doFinal()
|
||||||
|
}
|
||||||
|
|
||||||
private const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0002
|
private const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0002
|
||||||
private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0003
|
private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0003
|
||||||
private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004
|
private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004
|
||||||
@@ -1481,6 +1610,15 @@ class MlsGroup private constructor(
|
|||||||
?: throw IllegalArgumentException("GroupInfo missing ratchet_tree extension")
|
?: throw IllegalArgumentException("GroupInfo missing ratchet_tree extension")
|
||||||
val tree = RatchetTree.decodeTls(TlsReader(ratchetTreeExt.extensionData))
|
val tree = RatchetTree.decodeTls(TlsReader(ratchetTreeExt.extensionData))
|
||||||
|
|
||||||
|
// Verify GroupInfo signature (RFC 9420 Section 12.4.3.1)
|
||||||
|
val signerLeaf = tree.getLeaf(groupInfo.signer)
|
||||||
|
requireNotNull(signerLeaf) {
|
||||||
|
"Signer leaf is null at index ${groupInfo.signer} — cannot verify GroupInfo signature"
|
||||||
|
}
|
||||||
|
require(groupInfo.verifySignature(signerLeaf.signatureKey)) {
|
||||||
|
"Invalid GroupInfo signature in externalJoin"
|
||||||
|
}
|
||||||
|
|
||||||
// Extract external_pub from extensions
|
// Extract external_pub from extensions
|
||||||
val externalPubExt =
|
val externalPubExt =
|
||||||
groupInfo.extensions.find { it.extensionType == EXTERNAL_PUB_EXTENSION_TYPE }
|
groupInfo.extensions.find { it.extensionType == EXTERNAL_PUB_EXTENSION_TYPE }
|
||||||
@@ -1568,12 +1706,21 @@ class MlsGroup private constructor(
|
|||||||
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update transcript hashes for external join commit
|
||||||
|
val confirmedTranscriptHashInput =
|
||||||
|
buildConfirmedTranscriptHashInput(commit, myLeafIndex, groupContext.groupId, groupContext.epoch)
|
||||||
|
val confirmedInput = TlsWriter()
|
||||||
|
confirmedInput.putBytes(ByteArray(0)) // initial interim transcript hash
|
||||||
|
confirmedInput.putBytes(confirmedTranscriptHashInput)
|
||||||
|
val newConfirmedTranscriptHash = MlsCryptoProvider.hash(confirmedInput.toByteArray())
|
||||||
|
|
||||||
val newTreeHash = tree.treeHash()
|
val newTreeHash = tree.treeHash()
|
||||||
val newEpoch = groupContext.epoch + 1
|
val newEpoch = groupContext.epoch + 1
|
||||||
val newGroupContext =
|
val newGroupContext =
|
||||||
groupContext.copy(
|
groupContext.copy(
|
||||||
epoch = newEpoch,
|
epoch = newEpoch,
|
||||||
treeHash = newTreeHash,
|
treeHash = newTreeHash,
|
||||||
|
confirmedTranscriptHash = newConfirmedTranscriptHash,
|
||||||
)
|
)
|
||||||
|
|
||||||
val keySchedule = KeySchedule(newGroupContext.toTlsBytes())
|
val keySchedule = KeySchedule(newGroupContext.toTlsBytes())
|
||||||
@@ -1581,6 +1728,13 @@ class MlsGroup private constructor(
|
|||||||
|
|
||||||
val secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount)
|
val secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount)
|
||||||
|
|
||||||
|
// Compute interim transcript hash
|
||||||
|
val confirmationTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
|
||||||
|
val interimInput = TlsWriter()
|
||||||
|
interimInput.putBytes(newConfirmedTranscriptHash)
|
||||||
|
interimInput.putOpaqueVarInt(confirmationTag)
|
||||||
|
val interimTranscriptHash = MlsCryptoProvider.hash(interimInput.toByteArray())
|
||||||
|
|
||||||
val group =
|
val group =
|
||||||
MlsGroup(
|
MlsGroup(
|
||||||
groupContext = newGroupContext,
|
groupContext = newGroupContext,
|
||||||
@@ -1591,7 +1745,7 @@ class MlsGroup private constructor(
|
|||||||
initSecret = epochSecrets.initSecret,
|
initSecret = epochSecrets.initSecret,
|
||||||
signingPrivateKey = sigKp.privateKey,
|
signingPrivateKey = sigKp.privateKey,
|
||||||
encryptionPrivateKey = encKp.privateKey,
|
encryptionPrivateKey = encKp.privateKey,
|
||||||
interimTranscriptHash = ByteArray(0),
|
interimTranscriptHash = interimTranscriptHash,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Pair(group, commitBytes)
|
return Pair(group, commitBytes)
|
||||||
|
|||||||
+57
-24
@@ -257,7 +257,7 @@ class MlsGroupManager(
|
|||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
commitBytes: ByteArray,
|
commitBytes: ByteArray,
|
||||||
senderLeafIndex: Int,
|
senderLeafIndex: Int,
|
||||||
confirmationTag: ByteArray? = null,
|
confirmationTag: ByteArray,
|
||||||
) = mutex.withLock {
|
) = mutex.withLock {
|
||||||
val group = requireGroup(nostrGroupId)
|
val group = requireGroup(nostrGroupId)
|
||||||
|
|
||||||
@@ -272,43 +272,49 @@ class MlsGroupManager(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt an application message.
|
* Encrypt an application message.
|
||||||
|
* Synchronized to prevent nonce reuse from concurrent encryption.
|
||||||
*/
|
*/
|
||||||
fun encrypt(
|
suspend fun encrypt(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
plaintext: ByteArray,
|
plaintext: ByteArray,
|
||||||
): ByteArray = requireGroup(nostrGroupId).encrypt(plaintext)
|
): ByteArray =
|
||||||
|
mutex.withLock {
|
||||||
|
requireGroup(nostrGroupId).encrypt(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt an application message.
|
* Decrypt an application message.
|
||||||
*
|
*
|
||||||
* Tries the current epoch first, then falls back to retained epoch
|
* Tries the current epoch first, then falls back to retained epoch
|
||||||
* secrets for late-arriving messages from previous epochs.
|
* secrets for late-arriving messages from previous epochs.
|
||||||
|
* Synchronized to prevent concurrent state corruption.
|
||||||
*/
|
*/
|
||||||
fun decrypt(
|
suspend fun decrypt(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
messageBytes: ByteArray,
|
messageBytes: ByteArray,
|
||||||
): DecryptedMessage {
|
): DecryptedMessage =
|
||||||
val group = requireGroup(nostrGroupId)
|
mutex.withLock {
|
||||||
|
val group = requireGroup(nostrGroupId)
|
||||||
|
|
||||||
// Try current epoch
|
// Try current epoch
|
||||||
val current = group.decryptOrNull(messageBytes)
|
val current = group.decryptOrNull(messageBytes)
|
||||||
if (current != null) return current
|
if (current != null) return@withLock current
|
||||||
|
|
||||||
// Try retained epochs
|
// Try retained epochs
|
||||||
val retained = retainedEpochs[nostrGroupId] ?: emptyList()
|
val retained = retainedEpochs[nostrGroupId] ?: emptyList()
|
||||||
for (epochSecrets in retained) {
|
for (epochSecrets in retained) {
|
||||||
val result = tryDecryptWithRetainedEpoch(messageBytes, epochSecrets)
|
val result = tryDecryptWithRetainedEpoch(messageBytes, epochSecrets)
|
||||||
if (result != null) return result
|
if (result != null) return@withLock result
|
||||||
|
}
|
||||||
|
|
||||||
|
// No epoch could decrypt — rethrow from current epoch for diagnostics
|
||||||
|
group.decrypt(messageBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No epoch could decrypt — rethrow from current epoch for diagnostics
|
|
||||||
return group.decrypt(messageBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt with null return on failure.
|
* Decrypt with null return on failure.
|
||||||
*/
|
*/
|
||||||
fun decryptOrNull(
|
suspend fun decryptOrNull(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
messageBytes: ByteArray,
|
messageBytes: ByteArray,
|
||||||
): DecryptedMessage? =
|
): DecryptedMessage? =
|
||||||
@@ -510,35 +516,59 @@ class MlsGroupManager(
|
|||||||
.decodeTls(TlsReader(mlsMsg.payload))
|
.decodeTls(TlsReader(mlsMsg.payload))
|
||||||
if (privMsg.epoch != retained.epoch) return null
|
if (privMsg.epoch != retained.epoch) return null
|
||||||
|
|
||||||
// Decrypt sender data
|
// Derive sender data key/nonce using ciphertext sample (RFC 9420 §6.3.1)
|
||||||
|
val ciphertextSample =
|
||||||
|
privMsg.ciphertext.copyOfRange(0, minOf(privMsg.ciphertext.size, MlsCryptoProvider.AEAD_KEY_LENGTH))
|
||||||
val senderDataKey =
|
val senderDataKey =
|
||||||
MlsCryptoProvider.expandWithLabel(
|
MlsCryptoProvider.expandWithLabel(
|
||||||
retained.senderDataSecret,
|
retained.senderDataSecret,
|
||||||
"key",
|
"key",
|
||||||
ByteArray(0),
|
ciphertextSample,
|
||||||
MlsCryptoProvider.AEAD_KEY_LENGTH,
|
MlsCryptoProvider.AEAD_KEY_LENGTH,
|
||||||
)
|
)
|
||||||
val senderDataNonce =
|
val senderDataNonce =
|
||||||
MlsCryptoProvider.expandWithLabel(
|
MlsCryptoProvider.expandWithLabel(
|
||||||
retained.senderDataSecret,
|
retained.senderDataSecret,
|
||||||
"nonce",
|
"nonce",
|
||||||
ByteArray(0),
|
ciphertextSample,
|
||||||
MlsCryptoProvider.AEAD_NONCE_LENGTH,
|
MlsCryptoProvider.AEAD_NONCE_LENGTH,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Build SenderDataAAD
|
||||||
|
val senderDataAad = TlsWriter()
|
||||||
|
senderDataAad.putOpaqueVarInt(privMsg.groupId)
|
||||||
|
senderDataAad.putUint64(privMsg.epoch)
|
||||||
|
senderDataAad.putUint8(privMsg.contentType.value)
|
||||||
|
|
||||||
val senderDataPlain =
|
val senderDataPlain =
|
||||||
MlsCryptoProvider.aeadDecrypt(
|
MlsCryptoProvider.aeadDecrypt(
|
||||||
senderDataKey,
|
senderDataKey,
|
||||||
senderDataNonce,
|
senderDataNonce,
|
||||||
ByteArray(0),
|
senderDataAad.toByteArray(),
|
||||||
privMsg.encryptedSenderData,
|
privMsg.encryptedSenderData,
|
||||||
)
|
)
|
||||||
val senderReader = TlsReader(senderDataPlain)
|
val senderReader = TlsReader(senderDataPlain)
|
||||||
val senderLeafIndex = senderReader.readUint32().toInt()
|
val senderLeafIndex = senderReader.readUint32().toInt()
|
||||||
val generation = senderReader.readUint32().toInt()
|
val generation = senderReader.readUint32().toInt()
|
||||||
|
val reuseGuard = senderReader.readBytes(REUSE_GUARD_LENGTH)
|
||||||
|
|
||||||
val kng = secretTree.applicationKeyNonceForGeneration(senderLeafIndex, generation)
|
val kng = secretTree.applicationKeyNonceForGeneration(senderLeafIndex, generation)
|
||||||
|
|
||||||
|
// Apply reuse_guard XOR to nonce
|
||||||
|
val guardedNonce = kng.nonce.copyOf()
|
||||||
|
for (i in 0 until REUSE_GUARD_LENGTH) {
|
||||||
|
guardedNonce[i] = (guardedNonce[i].toInt() xor reuseGuard[i].toInt()).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build PrivateContentAAD
|
||||||
|
val contentAad = TlsWriter()
|
||||||
|
contentAad.putOpaqueVarInt(privMsg.groupId)
|
||||||
|
contentAad.putUint64(privMsg.epoch)
|
||||||
|
contentAad.putUint8(privMsg.contentType.value)
|
||||||
|
contentAad.putOpaqueVarInt(privMsg.authenticatedData)
|
||||||
|
|
||||||
val plaintext =
|
val plaintext =
|
||||||
MlsCryptoProvider.aeadDecrypt(kng.key, kng.nonce, ByteArray(0), privMsg.ciphertext)
|
MlsCryptoProvider.aeadDecrypt(kng.key, guardedNonce, contentAad.toByteArray(), privMsg.ciphertext)
|
||||||
|
|
||||||
DecryptedMessage(
|
DecryptedMessage(
|
||||||
senderLeafIndex = senderLeafIndex,
|
senderLeafIndex = senderLeafIndex,
|
||||||
@@ -556,5 +586,8 @@ class MlsGroupManager(
|
|||||||
* MLS forward secrecy guarantees mean we want to limit this window.
|
* MLS forward secrecy guarantees mean we want to limit this window.
|
||||||
*/
|
*/
|
||||||
const val EPOCH_RETENTION_WINDOW = 2
|
const val EPOCH_RETENTION_WINDOW = 2
|
||||||
|
|
||||||
|
/** Size of reuse_guard in PrivateMessage (RFC 9420 §6.3.1) */
|
||||||
|
private const val REUSE_GUARD_LENGTH = 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -122,7 +122,9 @@ data class MlsKeyPackage(
|
|||||||
val version = reader.readUint16()
|
val version = reader.readUint16()
|
||||||
require(version == 1) { "Unsupported MLS version: $version" }
|
require(version == 1) { "Unsupported MLS version: $version" }
|
||||||
val cipherSuite = reader.readUint16()
|
val cipherSuite = reader.readUint16()
|
||||||
require(cipherSuite in 1..0xFFFF) { "Invalid ciphersuite: $cipherSuite" }
|
require(cipherSuite == 1) {
|
||||||
|
"Unsupported ciphersuite: $cipherSuite (only MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = 0x0001 is supported)"
|
||||||
|
}
|
||||||
return MlsKeyPackage(
|
return MlsKeyPackage(
|
||||||
version = version,
|
version = version,
|
||||||
cipherSuite = cipherSuite,
|
cipherSuite = cipherSuite,
|
||||||
|
|||||||
+2
-2
@@ -45,8 +45,8 @@ enum class ProposalType(
|
|||||||
EXTERNAL_INIT(6),
|
EXTERNAL_INIT(6),
|
||||||
GROUP_CONTEXT_EXTENSIONS(7),
|
GROUP_CONTEXT_EXTENSIONS(7),
|
||||||
|
|
||||||
// Marmot custom proposal types
|
// Marmot custom proposal types (private-use range 0xF000-0xFFFF)
|
||||||
SELF_REMOVE(0x0008),
|
SELF_REMOVE(0xF001),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -100,11 +100,28 @@ class RatchetTree(
|
|||||||
return (node as? TreeNode.Leaf)?.leafNode
|
return (node as? TreeNode.Leaf)?.leafNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that the given encryption key is not already used by another leaf (RFC 9420 §7.3).
|
||||||
|
*/
|
||||||
|
private fun requireUniqueEncryptionKey(
|
||||||
|
leafNode: LeafNode,
|
||||||
|
excludeLeafIndex: Int = -1,
|
||||||
|
) {
|
||||||
|
for (i in 0 until _leafCount) {
|
||||||
|
if (i == excludeLeafIndex) continue
|
||||||
|
val existing = getLeaf(i) ?: continue
|
||||||
|
require(!existing.encryptionKey.contentEquals(leafNode.encryptionKey)) {
|
||||||
|
"Duplicate encryption key: leaf $i already uses this key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new leaf to the tree. Returns the leaf index.
|
* Add a new leaf to the tree. Returns the leaf index.
|
||||||
* First tries to reuse a blank leaf slot, otherwise appends.
|
* First tries to reuse a blank leaf slot, otherwise appends.
|
||||||
*/
|
*/
|
||||||
fun addLeaf(leafNode: LeafNode): Int {
|
fun addLeaf(leafNode: LeafNode): Int {
|
||||||
|
requireUniqueEncryptionKey(leafNode)
|
||||||
// Find first blank leaf
|
// Find first blank leaf
|
||||||
for (i in 0 until _leafCount) {
|
for (i in 0 until _leafCount) {
|
||||||
if (getLeaf(i) == null) {
|
if (getLeaf(i) == null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user