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:
Vitor Pamplona
2026-04-10 08:50:22 -04:00
committed by GitHub
18 changed files with 470 additions and 189 deletions
@@ -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
@@ -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")
@@ -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
@@ -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)
@@ -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.
@@ -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
} }
@@ -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 =
@@ -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())
@@ -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.
@@ -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()
}
} }
} }
@@ -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)
} }
} }
@@ -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)
@@ -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)
} }
@@ -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)
@@ -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
} }
} }
@@ -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,
@@ -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) {