fix: remaining HIGH/MEDIUM bugs - outer epoch fallback, atomic writes, unread UI

- H10: Add outer decryption epoch fallback using retained exporter secrets
- H17: Display unread count badge in MarmotGroupListScreen
- M24: Log corrupted state before deletion in restoreAll
- M25: Atomic write-to-temp-then-rename in AndroidMlsGroupStateStore
- Thread safety: Add Mutex to KeyPackageRotationManager

https://claude.ai/code/session_018gVkmmYgMFtBH7G31pCk9N
This commit is contained in:
Claude
2026-04-07 23:04:29 +00:00
parent 4526beb4be
commit 6e3ad1f86e
5 changed files with 96 additions and 14 deletions
@@ -171,8 +171,7 @@ class MarmotInboundProcessor(
val result =
try {
// Step 1: Outer ChaCha20-Poly1305 decryption
val exporterKey = groupManager.exporterSecret(groupId)
val mlsBytes = GroupEventEncryption.decrypt(groupEvent.encryptedContent(), exporterKey)
val mlsBytes = decryptOuterLayer(groupId, groupEvent.encryptedContent())
// Step 2: Parse the MLS message
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
@@ -362,8 +361,7 @@ class MarmotInboundProcessor(
commitEvent: GroupEvent,
): GroupEventResult =
try {
val exporterKey = groupManager.exporterSecret(groupId)
val mlsBytes = GroupEventEncryption.decrypt(commitEvent.encryptedContent(), exporterKey)
val mlsBytes = decryptOuterLayer(groupId, commitEvent.encryptedContent())
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
when (mlsMessage.wireFormat) {
@@ -401,6 +399,41 @@ class MarmotInboundProcessor(
GroupEventResult.Error(groupId, "Failed to apply commit: ${e.message}", e)
}
/**
* Decrypt the outer ChaCha20-Poly1305 layer, trying the current epoch's
* exporter key first and falling back to retained epoch exporter keys.
*
* After a commit advances the epoch, late-arriving messages encrypted
* with the previous epoch's exporter key would fail without this fallback.
*/
private fun decryptOuterLayer(
groupId: HexKey,
encryptedContent: String,
): ByteArray {
// Try current epoch key first
try {
val exporterKey = groupManager.exporterSecret(groupId)
return GroupEventEncryption.decrypt(encryptedContent, exporterKey)
} catch (_: Exception) {
// Current epoch key failed — try retained epoch keys
}
// Try retained epoch exporter keys (most recent first)
val retainedKeys = groupManager.retainedExporterSecrets(groupId)
for (retainedKey in retainedKeys) {
try {
return GroupEventEncryption.decrypt(encryptedContent, retainedKey)
} catch (_: Exception) {
// This retained key didn't work — try the next one
}
}
// All keys exhausted — throw to let callers produce an error result
throw IllegalStateException(
"Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)",
)
}
private fun hexToBytes(hex: HexKey?): ByteArray {
if (hex == null) return ByteArray(0)
return hex.hexToByteArray()
@@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource
import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Manages KeyPackage creation and rotation lifecycle (MIP-00).
@@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* KeyPackage slots, rotating consumed ones promptly.
*/
class KeyPackageRotationManager {
private val mutex = Mutex()
private val activeBundles = mutableMapOf<String, KeyPackageBundle>()
private val pendingRotations = mutableSetOf<String>()
@@ -118,8 +118,13 @@ class MlsGroupManager(
.map { RetainedEpochSecrets.decodeTls(TlsReader(it)) }
.toMutableList()
}
} catch (_: Exception) {
// Corrupted state — remove it so it doesn't block future joins
} catch (e: Exception) {
// Corrupted state — log and remove it so it doesn't block future joins
Log.e(
"MlsGroupManager",
"Corrupted state for group $nostrGroupId, deleting: ${e.message}",
e,
)
store.delete(nostrGroupId)
}
}