Merge pull request #2471 from vitorpamplona/claude/debug-group-event-handler-DEcD1
Fix MLS group state divergence and outer-layer decryption issues
This commit is contained in:
+19
@@ -404,6 +404,15 @@ private suspend fun processMarmotWelcomeFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WelcomeResult.AlreadyJoined -> {
|
||||||
|
// Benign replay of a gift-wrapped Welcome (kind:1059) we already
|
||||||
|
// processed in a prior session — the relay is just re-delivering
|
||||||
|
// it after app restart. Log at DEBUG, not WARN.
|
||||||
|
Log.d("MarmotDbg") {
|
||||||
|
"processMarmotWelcomeFlow: already joined group=${result.nostrGroupId.take(8)}… — treating Welcome as replay"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is WelcomeResult.Error -> {
|
is WelcomeResult.Error -> {
|
||||||
Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" }
|
Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" }
|
||||||
}
|
}
|
||||||
@@ -619,6 +628,16 @@ class GroupEventHandler(
|
|||||||
Log.d("MarmotDbg") { "GroupEventHandler.add: Duplicate kind:445 for group=${result.groupId.take(8)}…" }
|
Log.d("MarmotDbg") { "GroupEventHandler.add: Duplicate kind:445 for group=${result.groupId.take(8)}…" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is GroupEventResult.UndecryptableOuterLayer -> {
|
||||||
|
// Expected for commits + application messages from epochs
|
||||||
|
// that predate our join — per MLS forward secrecy we
|
||||||
|
// never held those keys. Not a bug, not a warning.
|
||||||
|
Log.d("MarmotDbg") {
|
||||||
|
"GroupEventHandler.add: undecryptable outer layer for group=${result.groupId.take(8)}… " +
|
||||||
|
"(current + ${result.retainedEpochCount} retained epoch key(s) tried) — likely from before our join"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is GroupEventResult.Error -> {
|
is GroupEventResult.Error -> {
|
||||||
Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" }
|
Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" }
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-6
@@ -115,6 +115,7 @@ class MarmotManager(
|
|||||||
|
|
||||||
is GroupEventResult.CommitPending,
|
is GroupEventResult.CommitPending,
|
||||||
is GroupEventResult.Duplicate,
|
is GroupEventResult.Duplicate,
|
||||||
|
is GroupEventResult.UndecryptableOuterLayer,
|
||||||
is GroupEventResult.Error,
|
is GroupEventResult.Error,
|
||||||
-> {}
|
-> {}
|
||||||
}
|
}
|
||||||
@@ -178,10 +179,23 @@ class MarmotManager(
|
|||||||
"KeyPackage credential identity does not match memberPubKey"
|
"KeyPackage credential identity does not match memberPubKey"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per RFC 9420 §12.4 (and MDK), the outbound kind:445 MUST be
|
||||||
|
// outer-encrypted with the pre-commit (epoch-N) exporter secret so
|
||||||
|
// that other existing members still at epoch N can decrypt and
|
||||||
|
// process the commit. CommitResult.preCommitExporterSecret carries
|
||||||
|
// that key; the local group state has already advanced to N+1 by
|
||||||
|
// the time addMember returns, so we can't read it from the group
|
||||||
|
// any more.
|
||||||
val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes)
|
val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes)
|
||||||
val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes)
|
val commitEvent =
|
||||||
// We've already applied this commit locally; prevent the relay-echoed
|
outboundProcessor.buildCommitEvent(
|
||||||
// copy from being re-applied by the inbound pipeline.
|
nostrGroupId = nostrGroupId,
|
||||||
|
commitBytes = commitResult.framedCommitBytes,
|
||||||
|
exporterKey = commitResult.preCommitExporterSecret,
|
||||||
|
)
|
||||||
|
// The published kind:445 will echo back from the relay — without this
|
||||||
|
// dedup our own inbound pipeline would try to re-apply a commit whose
|
||||||
|
// epoch we've already merged.
|
||||||
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
||||||
|
|
||||||
val welcomeDelivery =
|
val welcomeDelivery =
|
||||||
@@ -269,7 +283,12 @@ class MarmotManager(
|
|||||||
targetLeafIndex: Int,
|
targetLeafIndex: Int,
|
||||||
): OutboundGroupEvent {
|
): OutboundGroupEvent {
|
||||||
val commitResult = groupManager.removeMember(nostrGroupId, targetLeafIndex)
|
val commitResult = groupManager.removeMember(nostrGroupId, targetLeafIndex)
|
||||||
val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes)
|
val commitEvent =
|
||||||
|
outboundProcessor.buildCommitEvent(
|
||||||
|
nostrGroupId = nostrGroupId,
|
||||||
|
commitBytes = commitResult.framedCommitBytes,
|
||||||
|
exporterKey = commitResult.preCommitExporterSecret,
|
||||||
|
)
|
||||||
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
||||||
return commitEvent
|
return commitEvent
|
||||||
}
|
}
|
||||||
@@ -282,8 +301,14 @@ class MarmotManager(
|
|||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
metadata: MarmotGroupData,
|
metadata: MarmotGroupData,
|
||||||
): OutboundGroupEvent {
|
): OutboundGroupEvent {
|
||||||
val commitResult = groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
|
val commitResult =
|
||||||
val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes)
|
groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
|
||||||
|
val commitEvent =
|
||||||
|
outboundProcessor.buildCommitEvent(
|
||||||
|
nostrGroupId = nostrGroupId,
|
||||||
|
commitBytes = commitResult.framedCommitBytes,
|
||||||
|
exporterKey = commitResult.preCommitExporterSecret,
|
||||||
|
)
|
||||||
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
||||||
return commitEvent
|
return commitEvent
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-26
@@ -79,6 +79,21 @@ sealed class GroupEventResult {
|
|||||||
val groupId: HexKey,
|
val groupId: HexKey,
|
||||||
) : GroupEventResult()
|
) : GroupEventResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outer ChaCha20-Poly1305 layer could not be decrypted with the
|
||||||
|
* current-epoch exporter key or any retained prior-epoch key.
|
||||||
|
*
|
||||||
|
* This is the expected outcome whenever a member receives a kind:445
|
||||||
|
* from an epoch before they joined (via Welcome). Per MLS forward
|
||||||
|
* secrecy, the new member never held those keys, so the bytes are
|
||||||
|
* unreadable to them — and that is by design, not an error. Callers
|
||||||
|
* should surface this at DEBUG, not WARN.
|
||||||
|
*/
|
||||||
|
data class UndecryptableOuterLayer(
|
||||||
|
val groupId: HexKey,
|
||||||
|
val retainedEpochCount: Int,
|
||||||
|
) : GroupEventResult()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event could not be processed.
|
* The event could not be processed.
|
||||||
*/
|
*/
|
||||||
@@ -101,6 +116,20 @@ sealed class WelcomeResult {
|
|||||||
val needsKeyPackageRotation: Boolean,
|
val needsKeyPackageRotation: Boolean,
|
||||||
) : WelcomeResult()
|
) : WelcomeResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Welcome was for a group we're already a member of — benign replay.
|
||||||
|
*
|
||||||
|
* Happens after an app restart: the gift-wrapped Welcome (kind:1059) is
|
||||||
|
* still sitting on the relay and gets redelivered, but the KeyPackage
|
||||||
|
* bundle it referenced was already consumed and marked. Rather than
|
||||||
|
* logging a noisy "No matching KeyPackageBundle" error, we detect the
|
||||||
|
* replay up front by checking `groupManager.isMember(hintNostrGroupId)`
|
||||||
|
* and return this result. Callers should log at DEBUG.
|
||||||
|
*/
|
||||||
|
data class AlreadyJoined(
|
||||||
|
val nostrGroupId: HexKey,
|
||||||
|
) : WelcomeResult()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Welcome could not be processed.
|
* The Welcome could not be processed.
|
||||||
*/
|
*/
|
||||||
@@ -176,15 +205,25 @@ class MarmotInboundProcessor(
|
|||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
// Step 1: Outer ChaCha20-Poly1305 decryption
|
// Step 1: Outer ChaCha20-Poly1305 decryption
|
||||||
val mlsBytes = decryptOuterLayer(groupId, groupEvent.encryptedContent())
|
val mlsBytes = tryDecryptOuterLayer(groupId, groupEvent.encryptedContent())
|
||||||
|
if (mlsBytes == null) {
|
||||||
|
// Expected when this kind:445 was encrypted with an epoch
|
||||||
|
// key that predates our join (classical MLS forward
|
||||||
|
// secrecy), or when the sender's epoch has drifted. Not
|
||||||
|
// an error — callers should log at DEBUG.
|
||||||
|
GroupEventResult.UndecryptableOuterLayer(
|
||||||
|
groupId,
|
||||||
|
retainedEpochCount = groupManager.retainedExporterSecrets(groupId).size,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Step 2: Parse the MLS message
|
||||||
|
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
|
||||||
|
|
||||||
// Step 2: Parse the MLS message
|
when (mlsMessage.wireFormat) {
|
||||||
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
|
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
|
||||||
|
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
|
||||||
when (mlsMessage.wireFormat) {
|
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
|
||||||
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
|
}
|
||||||
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
|
|
||||||
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
|
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
|
||||||
@@ -245,6 +284,22 @@ class MarmotInboundProcessor(
|
|||||||
"MarmotInboundProcessor.processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}…"
|
"MarmotInboundProcessor.processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Short-circuit if we're already a member of the group the
|
||||||
|
// Welcome is inviting us to. Happens every time the app
|
||||||
|
// restarts: the gift-wrapped kind:1059 is still on the relay
|
||||||
|
// and gets redelivered, but the KeyPackage bundle it
|
||||||
|
// referenced was already consumed + marked during the first
|
||||||
|
// processing. Without this check the fallthrough below would
|
||||||
|
// log a noisy "No matching KeyPackageBundle" warning for what
|
||||||
|
// is actually a benign replay.
|
||||||
|
if (hintNostrGroupId != null && groupManager.isMember(hintNostrGroupId)) {
|
||||||
|
com.vitorpamplona.quartz.utils.Log
|
||||||
|
.d("MarmotDbg") {
|
||||||
|
"MarmotInboundProcessor.processWelcome: already a member of group=${hintNostrGroupId.take(8)}… — treating Welcome as replay"
|
||||||
|
}
|
||||||
|
return WelcomeResult.AlreadyJoined(hintNostrGroupId)
|
||||||
|
}
|
||||||
|
|
||||||
// Find the KeyPackageBundle that was consumed.
|
// Find the KeyPackageBundle that was consumed.
|
||||||
//
|
//
|
||||||
// The Welcome's "e" tag carries the *Nostr event id* of the
|
// The Welcome's "e" tag carries the *Nostr event id* of the
|
||||||
@@ -446,7 +501,12 @@ class MarmotInboundProcessor(
|
|||||||
commitEvent: GroupEvent,
|
commitEvent: GroupEvent,
|
||||||
): GroupEventResult =
|
): GroupEventResult =
|
||||||
try {
|
try {
|
||||||
val mlsBytes = decryptOuterLayer(groupId, commitEvent.encryptedContent())
|
val mlsBytes =
|
||||||
|
tryDecryptOuterLayer(groupId, commitEvent.encryptedContent())
|
||||||
|
?: return GroupEventResult.UndecryptableOuterLayer(
|
||||||
|
groupId,
|
||||||
|
retainedEpochCount = groupManager.retainedExporterSecrets(groupId).size,
|
||||||
|
)
|
||||||
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
|
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
|
||||||
|
|
||||||
when (mlsMessage.wireFormat) {
|
when (mlsMessage.wireFormat) {
|
||||||
@@ -467,17 +527,44 @@ class MarmotInboundProcessor(
|
|||||||
WireFormat.PUBLIC_MESSAGE -> {
|
WireFormat.PUBLIC_MESSAGE -> {
|
||||||
val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload))
|
val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload))
|
||||||
val tag = pubMsg.confirmationTag
|
val tag = pubMsg.confirmationTag
|
||||||
if (tag == null) {
|
val currentEpoch = groupManager.getGroup(groupId)?.epoch
|
||||||
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
|
when {
|
||||||
} else {
|
tag == null -> {
|
||||||
groupManager.processCommit(
|
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
|
||||||
nostrGroupId = groupId,
|
}
|
||||||
commitBytes = pubMsg.content,
|
|
||||||
senderLeafIndex = pubMsg.sender.leafIndex,
|
// Reject commits that are not for our current epoch.
|
||||||
confirmationTag = tag,
|
// Happens most commonly when our own already-applied
|
||||||
)
|
// commit is echoed back from the relay after an app
|
||||||
val group = groupManager.getGroup(groupId)
|
// restart (the in-memory dedup set is cleared), and
|
||||||
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
|
// the outer layer decrypts via a retained epoch key.
|
||||||
|
// Calling `processCommit` on a past-epoch commit
|
||||||
|
// partially mutates tree / groupContext / epochSecrets
|
||||||
|
// before throwing on the confirmation-tag check,
|
||||||
|
// leaving the local state diverged from every other
|
||||||
|
// member's — they then can't decrypt anything we
|
||||||
|
// send next.
|
||||||
|
currentEpoch != null && pubMsg.epoch < currentEpoch -> {
|
||||||
|
GroupEventResult.Duplicate(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEpoch != null && pubMsg.epoch > currentEpoch -> {
|
||||||
|
GroupEventResult.Error(
|
||||||
|
groupId,
|
||||||
|
"Commit epoch ${pubMsg.epoch} is ahead of local epoch $currentEpoch; ignoring",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
groupManager.processCommit(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
commitBytes = pubMsg.content,
|
||||||
|
senderLeafIndex = pubMsg.sender.leafIndex,
|
||||||
|
confirmationTag = tag,
|
||||||
|
)
|
||||||
|
val group = groupManager.getGroup(groupId)
|
||||||
|
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,11 +582,17 @@ class MarmotInboundProcessor(
|
|||||||
*
|
*
|
||||||
* After a commit advances the epoch, late-arriving messages encrypted
|
* After a commit advances the epoch, late-arriving messages encrypted
|
||||||
* with the previous epoch's exporter key would fail without this fallback.
|
* with the previous epoch's exporter key would fail without this fallback.
|
||||||
|
*
|
||||||
|
* Returns null when neither the current epoch key nor any retained key
|
||||||
|
* decrypts. This happens normally for commits/application messages from
|
||||||
|
* epochs that predate our join (we never held those keys), so callers
|
||||||
|
* should treat null as an expected "nothing to do here" outcome and log
|
||||||
|
* at DEBUG, not as an error.
|
||||||
*/
|
*/
|
||||||
private fun decryptOuterLayer(
|
private fun tryDecryptOuterLayer(
|
||||||
groupId: HexKey,
|
groupId: HexKey,
|
||||||
encryptedContent: String,
|
encryptedContent: String,
|
||||||
): ByteArray {
|
): ByteArray? {
|
||||||
// Try current epoch key first
|
// Try current epoch key first
|
||||||
try {
|
try {
|
||||||
val exporterKey = groupManager.exporterSecret(groupId)
|
val exporterKey = groupManager.exporterSecret(groupId)
|
||||||
@@ -518,9 +611,6 @@ class MarmotInboundProcessor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All keys exhausted — throw to let callers produce an error result
|
return null
|
||||||
throw IllegalStateException(
|
|
||||||
"Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-5
@@ -121,20 +121,29 @@ class MarmotOutboundProcessor(
|
|||||||
/**
|
/**
|
||||||
* Build a GroupEvent carrying a Commit for publishing.
|
* Build a GroupEvent carrying a Commit for publishing.
|
||||||
*
|
*
|
||||||
* Used after MlsGroupManager.commit() or addMember()/removeMember().
|
* Used after MlsGroupManager.stageAddMember()/stageRemoveMember()/etc.
|
||||||
* The commit bytes are already MLS-formatted.
|
* The commit bytes are already MLS-formatted (PublicMessage envelope).
|
||||||
*
|
*
|
||||||
* @param nostrGroupId the Nostr group ID
|
* @param nostrGroupId the Nostr group ID
|
||||||
* @param commitBytes the raw MLS commit bytes from CommitResult
|
* @param commitBytes the framed MLS commit bytes from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.framedCommitBytes]
|
||||||
|
* @param exporterKey optional explicit outer-encryption key. Callers
|
||||||
|
* publishing a Commit MUST pass the **pre-commit** exporter secret
|
||||||
|
* (from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.preCommitExporterSecret])
|
||||||
|
* so that other existing members at epoch N can decrypt and process
|
||||||
|
* the commit. If null, falls back to the current epoch's exporter
|
||||||
|
* secret — which is only correct when the commit has *not* been
|
||||||
|
* applied locally yet (i.e. this call is made before
|
||||||
|
* [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.mergeStagedCommit]).
|
||||||
* @return the signed GroupEvent ready for relay publishing
|
* @return the signed GroupEvent ready for relay publishing
|
||||||
*/
|
*/
|
||||||
suspend fun buildCommitEvent(
|
suspend fun buildCommitEvent(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
commitBytes: ByteArray,
|
commitBytes: ByteArray,
|
||||||
|
exporterKey: ByteArray? = null,
|
||||||
): OutboundGroupEvent {
|
): OutboundGroupEvent {
|
||||||
// Outer ChaCha20-Poly1305 encryption of the MLS commit
|
// Outer ChaCha20-Poly1305 encryption of the MLS commit
|
||||||
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
val outerKey = exporterKey ?: groupManager.exporterSecret(nostrGroupId)
|
||||||
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey)
|
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, outerKey)
|
||||||
|
|
||||||
// Build the GroupEvent template
|
// Build the GroupEvent template
|
||||||
val template =
|
val template =
|
||||||
|
|||||||
@@ -403,6 +403,13 @@ class MlsGroup private constructor(
|
|||||||
// (i.e. no remaining member appears in the post-commit admin list).
|
// (i.e. no remaining member appears in the post-commit admin list).
|
||||||
enforceNoAdminDepletion(proposals)
|
enforceNoAdminDepletion(proposals)
|
||||||
|
|
||||||
|
// Capture the pre-commit exporter secret BEFORE any mutation.
|
||||||
|
// Publishers of the outbound kind:445 MUST outer-encrypt with this
|
||||||
|
// key (epoch N) so that other existing members at epoch N can decrypt
|
||||||
|
// and process the commit. See CommitResult.preCommitExporterSecret.
|
||||||
|
val preCommitExporterSecret =
|
||||||
|
exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
|
||||||
|
|
||||||
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
|
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
|
||||||
|
|
||||||
// Check if we need an UpdatePath (required unless only SelfRemove)
|
// Check if we need an UpdatePath (required unless only SelfRemove)
|
||||||
@@ -563,6 +570,7 @@ class MlsGroup private constructor(
|
|||||||
welcomeBytes = welcomeBytes,
|
welcomeBytes = welcomeBytes,
|
||||||
groupInfoBytes = null,
|
groupInfoBytes = null,
|
||||||
framedCommitBytes = framedCommitBytes,
|
framedCommitBytes = framedCommitBytes,
|
||||||
|
preCommitExporterSecret = preCommitExporterSecret,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2071,7 +2079,9 @@ class MlsGroup private constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a member to the group by their KeyPackage.
|
* Add a member to the group by their KeyPackage.
|
||||||
* Creates and applies a Commit with an Add proposal.
|
* Creates and applies a Commit with an Add proposal. The resulting
|
||||||
|
* [CommitResult.preCommitExporterSecret] is the key the outer kind:445
|
||||||
|
* MUST be encrypted with (RFC 9420 §12.4 + MDK parity).
|
||||||
*/
|
*/
|
||||||
fun addMember(keyPackageBytes: ByteArray): CommitResult {
|
fun addMember(keyPackageBytes: ByteArray): CommitResult {
|
||||||
proposeAdd(keyPackageBytes)
|
proposeAdd(keyPackageBytes)
|
||||||
@@ -2080,7 +2090,8 @@ class MlsGroup private constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a member from the group.
|
* Remove a member from the group.
|
||||||
* Creates and applies a Commit with a Remove proposal.
|
* Creates and applies a Commit with a Remove proposal. See [addMember]
|
||||||
|
* for the pre-commit exporter key contract on the returned [CommitResult].
|
||||||
*/
|
*/
|
||||||
fun removeMember(targetLeafIndex: Int): CommitResult {
|
fun removeMember(targetLeafIndex: Int): CommitResult {
|
||||||
proposeRemove(targetLeafIndex)
|
proposeRemove(targetLeafIndex)
|
||||||
|
|||||||
+37
-26
@@ -267,11 +267,9 @@ class MlsGroupManager(
|
|||||||
suspend fun commit(nostrGroupId: HexKey): CommitResult =
|
suspend fun commit(nostrGroupId: HexKey): CommitResult =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val group = requireGroup(nostrGroupId)
|
val group = requireGroup(nostrGroupId)
|
||||||
|
val retainedBefore = group.retainedSecrets()
|
||||||
// Retain current epoch secrets before transition
|
|
||||||
retainEpochSecrets(nostrGroupId, group)
|
|
||||||
|
|
||||||
val result = group.commit()
|
val result = group.commit()
|
||||||
|
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||||
persistGroup(nostrGroupId)
|
persistGroup(nostrGroupId)
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -292,10 +290,16 @@ class MlsGroupManager(
|
|||||||
) = mutex.withLock {
|
) = mutex.withLock {
|
||||||
val group = requireGroup(nostrGroupId)
|
val group = requireGroup(nostrGroupId)
|
||||||
|
|
||||||
// Retain current epoch secrets before transition
|
// Capture the outgoing epoch's secrets BEFORE advancing, but only
|
||||||
retainEpochSecrets(nostrGroupId, group)
|
// commit them to the retention window once processCommit succeeds —
|
||||||
|
// otherwise a failed commit (e.g. "Duplicate encryption key" on an
|
||||||
|
// add-me relay echo) would pollute the window with a duplicate of
|
||||||
|
// the current epoch key, wasting the finite retention slots.
|
||||||
|
val retainedBefore = group.retainedSecrets()
|
||||||
|
|
||||||
group.processCommit(commitBytes, senderLeafIndex, confirmationTag)
|
group.processCommit(commitBytes, senderLeafIndex, confirmationTag)
|
||||||
|
|
||||||
|
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||||
persistGroup(nostrGroupId)
|
persistGroup(nostrGroupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +363,12 @@ class MlsGroupManager(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a member and create a Commit.
|
* Add a member and create a Commit.
|
||||||
|
*
|
||||||
|
* The returned [CommitResult.preCommitExporterSecret] is the key the
|
||||||
|
* outbound kind:445 MUST be outer-encrypted with (RFC 9420 §12.4 + MDK
|
||||||
|
* parity). The local group state advances to the new epoch before this
|
||||||
|
* function returns; publishers get one shot at the correct pre-commit
|
||||||
|
* key via the [CommitResult] payload.
|
||||||
*/
|
*/
|
||||||
suspend fun addMember(
|
suspend fun addMember(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
@@ -366,14 +376,16 @@ class MlsGroupManager(
|
|||||||
): CommitResult =
|
): CommitResult =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val group = requireGroup(nostrGroupId)
|
val group = requireGroup(nostrGroupId)
|
||||||
retainEpochSecrets(nostrGroupId, group)
|
val retainedBefore = group.retainedSecrets()
|
||||||
val result = group.addMember(keyPackageBytes)
|
val result = group.addMember(keyPackageBytes)
|
||||||
|
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||||
persistGroup(nostrGroupId)
|
persistGroup(nostrGroupId)
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a member and create a Commit.
|
* Remove a member and create a Commit. See [addMember] for the
|
||||||
|
* pre-commit exporter key contract on the returned [CommitResult].
|
||||||
*/
|
*/
|
||||||
suspend fun removeMember(
|
suspend fun removeMember(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
@@ -381,8 +393,9 @@ class MlsGroupManager(
|
|||||||
): CommitResult =
|
): CommitResult =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val group = requireGroup(nostrGroupId)
|
val group = requireGroup(nostrGroupId)
|
||||||
retainEpochSecrets(nostrGroupId, group)
|
val retainedBefore = group.retainedSecrets()
|
||||||
val result = group.removeMember(targetLeafIndex)
|
val result = group.removeMember(targetLeafIndex)
|
||||||
|
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||||
persistGroup(nostrGroupId)
|
persistGroup(nostrGroupId)
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -391,31 +404,22 @@ class MlsGroupManager(
|
|||||||
* Rotate the signing key within a group and commit.
|
* Rotate the signing key within a group and commit.
|
||||||
*
|
*
|
||||||
* Per MIP-00, members SHOULD self-update within 24 hours of joining.
|
* Per MIP-00, members SHOULD self-update within 24 hours of joining.
|
||||||
* This creates an Update proposal with a fresh signing key and commits it.
|
* See [addMember] for the pre-commit exporter key contract.
|
||||||
*
|
|
||||||
* @param nostrGroupId hex-encoded Nostr group ID
|
|
||||||
* @return the [CommitResult] to publish
|
|
||||||
*/
|
*/
|
||||||
suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult =
|
suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val group = requireGroup(nostrGroupId)
|
val group = requireGroup(nostrGroupId)
|
||||||
retainEpochSecrets(nostrGroupId, group)
|
val retainedBefore = group.retainedSecrets()
|
||||||
group.proposeSigningKeyRotation()
|
group.proposeSigningKeyRotation()
|
||||||
val result = group.commit()
|
val result = group.commit()
|
||||||
|
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||||
persistGroup(nostrGroupId)
|
persistGroup(nostrGroupId)
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
|
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
|
||||||
* Creates the proposal, commits it, and persists the new state.
|
* See [addMember] for the pre-commit exporter key contract.
|
||||||
*
|
|
||||||
* Authorization (MIP-01): extension updates require admin privileges once
|
|
||||||
* the group has at least one admin. During bootstrap — before any
|
|
||||||
* `admin_pubkeys` are configured — any member may seed the initial
|
|
||||||
* extension set (e.g. the creator installing the first
|
|
||||||
* [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData] with
|
|
||||||
* themselves as admin).
|
|
||||||
*/
|
*/
|
||||||
suspend fun updateGroupExtensions(
|
suspend fun updateGroupExtensions(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
@@ -428,9 +432,10 @@ class MlsGroupManager(
|
|||||||
check(!adminsConfigured || group.isLocalAdmin()) {
|
check(!adminsConfigured || group.isLocalAdmin()) {
|
||||||
"MIP-01: only admins may update group extensions"
|
"MIP-01: only admins may update group extensions"
|
||||||
}
|
}
|
||||||
retainEpochSecrets(nostrGroupId, group)
|
val retainedBefore = group.retainedSecrets()
|
||||||
group.proposeGroupContextExtensions(extensions)
|
group.proposeGroupContextExtensions(extensions)
|
||||||
val result = group.commit()
|
val result = group.commit()
|
||||||
|
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||||
persistGroup(nostrGroupId)
|
persistGroup(nostrGroupId)
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -564,12 +569,18 @@ class MlsGroupManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun retainEpochSecrets(
|
/**
|
||||||
|
* Push a previously-captured [RetainedEpochSecrets] into the bounded
|
||||||
|
* retention window. Call after the epoch advance has been applied
|
||||||
|
* successfully so that failed commits don't pollute the window with
|
||||||
|
* duplicate current-epoch keys.
|
||||||
|
*/
|
||||||
|
private fun pushRetainedEpoch(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
group: MlsGroup,
|
retainedBefore: RetainedEpochSecrets,
|
||||||
) {
|
) {
|
||||||
val retained = retainedEpochs.getOrPut(nostrGroupId) { mutableListOf() }
|
val retained = retainedEpochs.getOrPut(nostrGroupId) { mutableListOf() }
|
||||||
retained.add(group.retainedSecrets())
|
retained.add(retainedBefore)
|
||||||
|
|
||||||
// Trim to retention window (keep only the most recent N-1 epochs)
|
// Trim to retention window (keep only the most recent N-1 epochs)
|
||||||
while (retained.size > EPOCH_RETENTION_WINDOW) {
|
while (retained.size > EPOCH_RETENTION_WINDOW) {
|
||||||
|
|||||||
@@ -107,6 +107,18 @@ data class CommitResult(
|
|||||||
* Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)).
|
* Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)).
|
||||||
*/
|
*/
|
||||||
val framedCommitBytes: ByteArray = commitBytes,
|
val framedCommitBytes: ByteArray = commitBytes,
|
||||||
|
/**
|
||||||
|
* `MLS-Exporter("marmot", "group-event", 32)` evaluated at the **pre-commit**
|
||||||
|
* epoch (N) — the key the group had when this commit was computed, before
|
||||||
|
* local state advanced to N+1. Publishers of the kind:445 MUST ChaCha20-wrap
|
||||||
|
* the commit with this key (RFC 9420 §12.4 and MDK parity). Using the
|
||||||
|
* post-commit (N+1) key makes the commit unreadable to existing members
|
||||||
|
* still at epoch N — the exact scenario that caused Eden to stall at
|
||||||
|
* epoch 1 and never decrypt any of David's subsequent messages.
|
||||||
|
*
|
||||||
|
* Empty by default for the test-only entry points that don't need it.
|
||||||
|
*/
|
||||||
|
val preCommitExporterSecret: ByteArray = ByteArray(0),
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
|
|||||||
+41
-34
@@ -47,9 +47,6 @@ class SecretTree(
|
|||||||
private val encryptionSecret: ByteArray,
|
private val encryptionSecret: ByteArray,
|
||||||
private val leafCount: Int,
|
private val leafCount: Int,
|
||||||
) {
|
) {
|
||||||
/** Cached tree node secrets, computed lazily */
|
|
||||||
private val treeSecrets = mutableMapOf<Int, ByteArray>()
|
|
||||||
|
|
||||||
/** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */
|
/** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */
|
||||||
private val senderState = mutableMapOf<Int, SenderRatchetState>()
|
private val senderState = mutableMapOf<Int, SenderRatchetState>()
|
||||||
|
|
||||||
@@ -72,11 +69,6 @@ class SecretTree(
|
|||||||
const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000
|
const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
|
||||||
// Seed the root
|
|
||||||
treeSecrets[BinaryTree.root(leafCount)] = encryptionSecret
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the next (key, nonce) for application messages from a given sender.
|
* Get the next (key, nonce) for application messages from a given sender.
|
||||||
*/
|
*/
|
||||||
@@ -252,36 +244,51 @@ class SecretTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the leaf secret from the encryption secret using the tree structure.
|
* Derive the leaf secret from the encryption secret by walking DOWN the
|
||||||
|
* binary tree from the root to the target leaf (RFC 9420 §9).
|
||||||
|
*
|
||||||
|
* At each step we pick left or right based on which subtree contains the
|
||||||
|
* target. In an MLS left-balanced tree the left-subtree node indices are
|
||||||
|
* always strictly less than the current node, and the right-subtree
|
||||||
|
* indices strictly greater — so the direction is simply
|
||||||
|
* `targetNode < currentNode`.
|
||||||
|
*
|
||||||
|
* This also correctly handles non-power-of-2 leaf counts (3, 5, 6, 7, 9
|
||||||
|
* …) where the rightmost leaves live beneath "virtual" intermediate
|
||||||
|
* nodes that are beyond `nodeCount`. Walking down still succeeds because
|
||||||
|
* [BinaryTree.left] / [BinaryTree.right] give the correct descendants
|
||||||
|
* even for virtual nodes, and the target leaf is reachable via a chain
|
||||||
|
* of left/right steps that mixes real and virtual intermediates.
|
||||||
|
*
|
||||||
|
* The previous implementation derived the target's secret from
|
||||||
|
* `BinaryTree.parent(target)` which, for leaves on the right edge of a
|
||||||
|
* non-full tree, returned a high ancestor (often the root) that is NOT
|
||||||
|
* the target's direct parent. Its `left()` and `right()` then pointed
|
||||||
|
* at different nodes entirely, the target stayed un-cached, and the
|
||||||
|
* follow-up `return treeSecrets[nodeIndex]!!` either threw NPE (on
|
||||||
|
* JVM) or — when repeatedly re-entered — recursed into
|
||||||
|
* [getNodeSecret] until the stack was exhausted (on ART).
|
||||||
*/
|
*/
|
||||||
private fun getLeafSecret(leafIndex: Int): ByteArray {
|
private fun getLeafSecret(leafIndex: Int): ByteArray {
|
||||||
val nodeIndex = BinaryTree.leafToNode(leafIndex)
|
val targetNode = BinaryTree.leafToNode(leafIndex)
|
||||||
return getNodeSecret(nodeIndex)
|
val rootIdx = BinaryTree.root(leafCount)
|
||||||
}
|
var currentSecret = encryptionSecret
|
||||||
|
var currentNode = rootIdx
|
||||||
|
|
||||||
/**
|
while (currentNode != targetNode) {
|
||||||
* Recursively derive a node's secret from its parent in the secret tree.
|
val goLeft = targetNode < currentNode
|
||||||
*/
|
val label = if (goLeft) "left" else "right"
|
||||||
private fun getNodeSecret(nodeIndex: Int): ByteArray {
|
currentSecret =
|
||||||
treeSecrets[nodeIndex]?.let { return it }
|
MlsCryptoProvider.expandWithLabel(
|
||||||
|
currentSecret,
|
||||||
|
"tree",
|
||||||
|
label.encodeToByteArray(),
|
||||||
|
MlsCryptoProvider.HASH_OUTPUT_LENGTH,
|
||||||
|
)
|
||||||
|
currentNode = if (goLeft) BinaryTree.left(currentNode) else BinaryTree.right(currentNode)
|
||||||
|
}
|
||||||
|
|
||||||
val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount))
|
return currentSecret
|
||||||
val parentSecret = getNodeSecret(parentIdx)
|
|
||||||
|
|
||||||
// Derive left and right children secrets
|
|
||||||
val leftIdx = BinaryTree.left(parentIdx)
|
|
||||||
val rightIdx = BinaryTree.right(parentIdx)
|
|
||||||
|
|
||||||
val leftSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "left".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
|
||||||
val rightSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "right".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
|
||||||
|
|
||||||
treeSecrets[leftIdx] = leftSecret
|
|
||||||
treeSecrets[rightIdx] = rightSecret
|
|
||||||
|
|
||||||
// Clear parent secret for forward secrecy
|
|
||||||
treeSecrets.remove(parentIdx)
|
|
||||||
|
|
||||||
return treeSecrets[nodeIndex]!!
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+445
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
|||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
||||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
||||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -427,6 +428,450 @@ class MarmotPipelineTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreatorDoesNotDoubleApplyOwnCommitAfterRestart() {
|
||||||
|
// Root cause: after David restarts, his own add-member kind:445 that
|
||||||
|
// got echoed back from the relay is no longer marked as "already
|
||||||
|
// processed" (the dedup set lives in memory and isn't persisted).
|
||||||
|
// The inbound path then outer-decrypts it via a retained epoch key
|
||||||
|
// and feeds the commit into group.processCommit — which is NOT
|
||||||
|
// atomic, so it partially advances David's local state before failing
|
||||||
|
// with "Confirmation tag verification failed" (the tag was computed
|
||||||
|
// at the original epoch; David is already two epochs past).
|
||||||
|
//
|
||||||
|
// After this partial apply, David's epoch / tree / exporter_secret
|
||||||
|
// drift forward by one epoch while Eden and Fred stay put. Any
|
||||||
|
// subsequent message David sends uses a key nobody else has, and
|
||||||
|
// they log "Outer decryption failed with current and N retained
|
||||||
|
// epoch key(s)".
|
||||||
|
runBlocking {
|
||||||
|
val davidStore = TestGroupStateStore()
|
||||||
|
val davidMgr = MlsGroupManager(davidStore)
|
||||||
|
val edenMgr = MlsGroupManager(TestGroupStateStore())
|
||||||
|
val fredMgr = MlsGroupManager(TestGroupStateStore())
|
||||||
|
val davidId = ByteArray(32) { 0xD1.toByte() }
|
||||||
|
val edenId = ByteArray(32) { 0xE2.toByte() }
|
||||||
|
val fredId = ByteArray(32) { 0xF3.toByte() }
|
||||||
|
|
||||||
|
davidMgr.createGroup(groupId, davidId)
|
||||||
|
davidMgr.updateGroupExtensions(
|
||||||
|
groupId,
|
||||||
|
listOf(
|
||||||
|
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||||
|
.MarmotGroupData(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
adminPubkeys = listOf(davidId.toHexKey()),
|
||||||
|
).toExtension(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val davidGroup = davidMgr.getGroup(groupId)!!
|
||||||
|
val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0))
|
||||||
|
val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0))
|
||||||
|
|
||||||
|
val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes())
|
||||||
|
edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle)
|
||||||
|
val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
|
||||||
|
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
|
||||||
|
edenMgr.processCommit(
|
||||||
|
groupId,
|
||||||
|
addFred.commitBytes,
|
||||||
|
davidMgr.getGroup(groupId)!!.leafIndex,
|
||||||
|
ByteArray(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val preRestartEpoch = davidMgr.getGroup(groupId)!!.epoch
|
||||||
|
val preRestartExporter = davidMgr.exporterSecret(groupId)
|
||||||
|
|
||||||
|
// Simulate David's app restart.
|
||||||
|
val davidMgr2 = MlsGroupManager(davidStore)
|
||||||
|
davidMgr2.restoreAll()
|
||||||
|
|
||||||
|
// Now replay David's own echoed add-Eden commit that's still on
|
||||||
|
// the relay. After restart, the inbound dedup set is empty so
|
||||||
|
// the inbound pipeline does the full outer-decrypt via retained
|
||||||
|
// keys + processCommit on an already-applied commit.
|
||||||
|
val inbound =
|
||||||
|
com.vitorpamplona.quartz.marmot
|
||||||
|
.MarmotInboundProcessor(
|
||||||
|
groupManager = davidMgr2,
|
||||||
|
keyPackageRotationManager =
|
||||||
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages
|
||||||
|
.KeyPackageRotationManager(),
|
||||||
|
)
|
||||||
|
val outbound = MarmotOutboundProcessor(davidMgr2)
|
||||||
|
|
||||||
|
// Re-encrypt the add-Eden framed commit with the pre-commit key
|
||||||
|
// so it looks exactly like what the relay would re-deliver.
|
||||||
|
val echoed =
|
||||||
|
outbound.buildCommitEvent(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
commitBytes = addEden.framedCommitBytes,
|
||||||
|
exporterKey = addEden.preCommitExporterSecret,
|
||||||
|
)
|
||||||
|
val result = inbound.processGroupEvent(echoed.signedEvent)
|
||||||
|
assertIs<GroupEventResult.Duplicate>(
|
||||||
|
result,
|
||||||
|
"echoed already-applied commit must be treated as a no-op Duplicate",
|
||||||
|
)
|
||||||
|
|
||||||
|
val postEchoEpoch = davidMgr2.getGroup(groupId)!!.epoch
|
||||||
|
val postEchoExporter = davidMgr2.exporterSecret(groupId)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
preRestartEpoch,
|
||||||
|
postEchoEpoch,
|
||||||
|
"processing our own already-applied commit echo must NOT advance the local epoch",
|
||||||
|
)
|
||||||
|
kotlin.test.assertContentEquals(
|
||||||
|
preRestartExporter,
|
||||||
|
postEchoExporter,
|
||||||
|
"processing our own already-applied commit echo must NOT change the exporter secret",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreatorCanSendMessageAfterRestart() {
|
||||||
|
// Reproduces production symptom: David creates a group, adds Eden and
|
||||||
|
// Fred, sends messages — all fine. After David restarts the app
|
||||||
|
// (simulated here by save-state + fresh MlsGroupManager + restoreAll),
|
||||||
|
// his outbound kind:445 outer ChaCha20 layer suddenly uses a
|
||||||
|
// different key than Eden/Fred, and they fail with
|
||||||
|
// "Outer decryption failed with current and N retained epoch key(s)".
|
||||||
|
runBlocking {
|
||||||
|
val davidStore = TestGroupStateStore()
|
||||||
|
val davidMgr = MlsGroupManager(davidStore)
|
||||||
|
val edenMgr = MlsGroupManager(TestGroupStateStore())
|
||||||
|
val fredMgr = MlsGroupManager(TestGroupStateStore())
|
||||||
|
|
||||||
|
val davidId = ByteArray(32) { 0xD1.toByte() }
|
||||||
|
val edenId = ByteArray(32) { 0xE2.toByte() }
|
||||||
|
val fredId = ByteArray(32) { 0xF3.toByte() }
|
||||||
|
|
||||||
|
davidMgr.createGroup(groupId, davidId)
|
||||||
|
davidMgr.updateGroupExtensions(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
extensions =
|
||||||
|
listOf(
|
||||||
|
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||||
|
.MarmotGroupData(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
adminPubkeys = listOf(davidId.toHexKey()),
|
||||||
|
).toExtension(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val davidGroup = davidMgr.getGroup(groupId)!!
|
||||||
|
val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0))
|
||||||
|
val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0))
|
||||||
|
|
||||||
|
// David adds Eden.
|
||||||
|
val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes())
|
||||||
|
edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle)
|
||||||
|
|
||||||
|
// David adds Fred; Eden processes Alice's add-Fred commit.
|
||||||
|
val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
|
||||||
|
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
|
||||||
|
edenMgr.processCommit(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
commitBytes = addFred.commitBytes,
|
||||||
|
senderLeafIndex = davidMgr.getGroup(groupId)!!.leafIndex,
|
||||||
|
confirmationTag = ByteArray(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-restart sanity: all three share the same outer exporter key.
|
||||||
|
val preRestartDavidKey = davidMgr.exporterSecret(groupId)
|
||||||
|
kotlin.test.assertContentEquals(
|
||||||
|
preRestartDavidKey,
|
||||||
|
edenMgr.exporterSecret(groupId),
|
||||||
|
"Eden's exporter key should match David's before restart",
|
||||||
|
)
|
||||||
|
kotlin.test.assertContentEquals(
|
||||||
|
preRestartDavidKey,
|
||||||
|
fredMgr.exporterSecret(groupId),
|
||||||
|
"Fred's exporter key should match David's before restart",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simulate David's app restart: drop his in-memory manager and
|
||||||
|
// rebuild it from the persisted store.
|
||||||
|
val davidMgr2 = MlsGroupManager(davidStore)
|
||||||
|
davidMgr2.restoreAll()
|
||||||
|
|
||||||
|
val postRestartDavidKey = davidMgr2.exporterSecret(groupId)
|
||||||
|
kotlin.test.assertContentEquals(
|
||||||
|
preRestartDavidKey,
|
||||||
|
postRestartDavidKey,
|
||||||
|
"David's exporter key must survive a save+restore round-trip; " +
|
||||||
|
"otherwise Eden/Fred can't decrypt his next outer layer.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// David sends a message post-restart; Eden and Fred must decrypt.
|
||||||
|
val outbound = MarmotOutboundProcessor(davidMgr2)
|
||||||
|
val msg = "hi after restart"
|
||||||
|
val postRestartEvent =
|
||||||
|
outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray())
|
||||||
|
|
||||||
|
val edenKey = edenMgr.exporterSecret(groupId)
|
||||||
|
val mlsBytesEden = GroupEventEncryption.decrypt(postRestartEvent.signedEvent.content, edenKey)
|
||||||
|
val edenDecrypted = edenMgr.decrypt(groupId, mlsBytesEden)
|
||||||
|
assertEquals(msg, edenDecrypted.content.decodeToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFredEncryptsAfterJoiningThreeMemberGroup() {
|
||||||
|
// Reproduces the production StackOverflowError: the last-joined member
|
||||||
|
// (Fred at leafIndex=2 in a 3-member group) attempts to encrypt his
|
||||||
|
// first message and the SecretTree.getNodeSecret recursion never
|
||||||
|
// terminates.
|
||||||
|
runBlocking {
|
||||||
|
val aliceMgr = createGroupManager()
|
||||||
|
val bobMgr = createGroupManager()
|
||||||
|
val fredMgr = createGroupManager()
|
||||||
|
|
||||||
|
val aliceIdBytes = ByteArray(32) { 0xA1.toByte() }
|
||||||
|
aliceMgr.createGroup(groupId, aliceIdBytes)
|
||||||
|
aliceMgr.updateGroupExtensions(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
extensions =
|
||||||
|
listOf(
|
||||||
|
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||||
|
.MarmotGroupData(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
adminPubkeys = listOf(aliceIdBytes.toHexKey()),
|
||||||
|
).toExtension(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val aliceGroup = aliceMgr.getGroup(groupId)!!
|
||||||
|
val bobBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xB2.toByte() }, ByteArray(0))
|
||||||
|
val fredBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xF3.toByte() }, ByteArray(0))
|
||||||
|
|
||||||
|
// Alice adds Bob.
|
||||||
|
val addBob = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||||
|
bobMgr.processWelcome(addBob.welcomeBytes!!, bobBundle)
|
||||||
|
|
||||||
|
// Alice adds Fred (he joins at leafIndex=2, in a 3-leaf tree).
|
||||||
|
val addFred = aliceMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
|
||||||
|
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
|
||||||
|
// Bob (existing member at the pre-add-Fred epoch) receives and
|
||||||
|
// applies Alice's add-Fred commit to reach the same epoch as
|
||||||
|
// Alice + Fred. Without this, Bob can't decrypt Fred's subsequent
|
||||||
|
// application messages.
|
||||||
|
bobMgr.processCommit(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
commitBytes = addFred.commitBytes,
|
||||||
|
senderLeafIndex = aliceMgr.getGroup(groupId)!!.leafIndex,
|
||||||
|
confirmationTag = ByteArray(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sanity: Fred is at leafIndex=2, leafCount=3.
|
||||||
|
assertEquals(2, fredMgr.getGroup(groupId)!!.leafIndex)
|
||||||
|
|
||||||
|
// This is where production crashed with StackOverflowError.
|
||||||
|
val fredMsg = "hi from fred"
|
||||||
|
val fredCiphertext = fredMgr.encrypt(groupId, fredMsg.encodeToByteArray())
|
||||||
|
|
||||||
|
// And Alice & Bob must be able to decrypt it.
|
||||||
|
val aliceDecrypted = aliceMgr.decrypt(groupId, fredCiphertext)
|
||||||
|
assertEquals(fredMsg, aliceDecrypted.content.decodeToString())
|
||||||
|
val bobDecrypted = bobMgr.decrypt(groupId, fredCiphertext)
|
||||||
|
assertEquals(fredMsg, bobDecrypted.content.decodeToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAddMemberExposesPreCommitExporterSecret() {
|
||||||
|
// Regression: before the fix, MarmotManager.addMember outer-encrypted
|
||||||
|
// the kind:445 commit with the POST-commit (epoch N+1) exporter key,
|
||||||
|
// which meant existing members at epoch N couldn't decrypt it. The
|
||||||
|
// fix captures the pre-commit key inside MlsGroup.commit() and ships
|
||||||
|
// it back via CommitResult.preCommitExporterSecret so MarmotManager
|
||||||
|
// can pass it to the outbound builder.
|
||||||
|
runBlocking {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, "alice".encodeToByteArray())
|
||||||
|
|
||||||
|
val preEpochExporter = manager.exporterSecret(groupId)
|
||||||
|
val preEpoch = manager.getGroup(groupId)!!.epoch
|
||||||
|
|
||||||
|
val bobBundle =
|
||||||
|
manager
|
||||||
|
.getGroup(groupId)!!
|
||||||
|
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||||
|
val result = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||||
|
|
||||||
|
// Local state advanced to N+1.
|
||||||
|
assertEquals(preEpoch + 1, manager.getGroup(groupId)!!.epoch)
|
||||||
|
// But the returned pre-commit exporter secret matches what the
|
||||||
|
// group held BEFORE advancing — the key existing members still at
|
||||||
|
// epoch N are holding.
|
||||||
|
kotlin.test.assertContentEquals(
|
||||||
|
preEpochExporter,
|
||||||
|
result.preCommitExporterSecret,
|
||||||
|
)
|
||||||
|
// The post-commit exporter (what groupManager.exporterSecret now
|
||||||
|
// returns) MUST differ — otherwise we haven't actually rotated.
|
||||||
|
kotlin.test.assertFalse(
|
||||||
|
manager.exporterSecret(groupId).contentEquals(preEpochExporter),
|
||||||
|
"post-commit exporter key must differ from the pre-commit one",
|
||||||
|
)
|
||||||
|
assertNotNull(result.welcomeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage() {
|
||||||
|
// End-to-end regression for the David/Eden/Fred scenario: a group
|
||||||
|
// creator (Alice) adds two members (Bob then Carol) in sequence and
|
||||||
|
// then publishes an application message. Both members MUST be able to
|
||||||
|
// decrypt the message. Before the stage/merge fix, the add-Carol
|
||||||
|
// commit was outer-encrypted with the post-commit (epoch 2) key,
|
||||||
|
// leaving Bob stuck at epoch 1 and unable to decrypt any of Alice's
|
||||||
|
// subsequent application messages.
|
||||||
|
runBlocking {
|
||||||
|
val aliceMgr = createGroupManager()
|
||||||
|
val bobMgr = createGroupManager()
|
||||||
|
val carolMgr = createGroupManager()
|
||||||
|
|
||||||
|
// Use 32-byte identities so they fit the MarmotGroupData
|
||||||
|
// admin_pubkeys 32-byte slots.
|
||||||
|
val aliceIdBytes = ByteArray(32) { 0xA1.toByte() }
|
||||||
|
val bobIdBytes = ByteArray(32) { 0xB2.toByte() }
|
||||||
|
val carolIdBytes = ByteArray(32) { 0xC3.toByte() }
|
||||||
|
aliceMgr.createGroup(groupId, aliceIdBytes)
|
||||||
|
// Install the Marmot group data extension so the Welcome carries
|
||||||
|
// the NostrGroupData (MlsGroupManager.processWelcome requires it).
|
||||||
|
aliceMgr.updateGroupExtensions(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
extensions =
|
||||||
|
listOf(
|
||||||
|
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||||
|
.MarmotGroupData(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
adminPubkeys = listOf(aliceIdBytes.toHexKey()),
|
||||||
|
).toExtension(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val aliceGroup = aliceMgr.getGroup(groupId)!!
|
||||||
|
|
||||||
|
// KeyPackages are just MLS artifacts carrying identity + init key;
|
||||||
|
// createKeyPackage on any group instance produces a usable bundle.
|
||||||
|
val bobBundle = aliceGroup.createKeyPackage(bobIdBytes, ByteArray(0))
|
||||||
|
val carolBundle = aliceGroup.createKeyPackage(carolIdBytes, ByteArray(0))
|
||||||
|
|
||||||
|
val outbound = MarmotOutboundProcessor(aliceMgr)
|
||||||
|
|
||||||
|
// --- Step 1: Alice adds Bob. CommitResult carries the pre-commit
|
||||||
|
// exporter secret so buildCommitEvent can outer-encrypt with
|
||||||
|
// the epoch-N (pre-Bob) key that Bob-less Alice held. ---
|
||||||
|
val addBobResult = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||||
|
val addBobCommitEvent =
|
||||||
|
outbound.buildCommitEvent(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
commitBytes = addBobResult.framedCommitBytes,
|
||||||
|
exporterKey = addBobResult.preCommitExporterSecret,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bob joins via Welcome (emulated: we hand him the Welcome bytes).
|
||||||
|
bobMgr.processWelcome(addBobResult.welcomeBytes!!, bobBundle)
|
||||||
|
|
||||||
|
val epochAfterAddBob = aliceMgr.getGroup(groupId)!!.epoch
|
||||||
|
assertEquals(epochAfterAddBob, bobMgr.getGroup(groupId)!!.epoch)
|
||||||
|
|
||||||
|
// --- Step 2: Alice adds Carol. addCarolResult.preCommitExporterSecret
|
||||||
|
// is the epoch-N (2-member) key that Bob still holds. ---
|
||||||
|
val addCarolResult = aliceMgr.addMember(groupId, carolBundle.keyPackage.toTlsBytes())
|
||||||
|
val addCarolCommitEvent =
|
||||||
|
outbound.buildCommitEvent(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
commitBytes = addCarolResult.framedCommitBytes,
|
||||||
|
exporterKey = addCarolResult.preCommitExporterSecret,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Carol joins via Welcome at the new epoch.
|
||||||
|
carolMgr.processWelcome(addCarolResult.welcomeBytes!!, carolBundle)
|
||||||
|
|
||||||
|
// *** The key assertion ***: Bob (still at epoch 1) receives the
|
||||||
|
// add-Carol kind:445 and must be able to:
|
||||||
|
// (a) outer-decrypt using his current (epoch 1) exporter key,
|
||||||
|
// (b) parse the MlsMessage → PublicMessage → COMMIT,
|
||||||
|
// (c) apply the commit and advance to epoch 2.
|
||||||
|
val bobExporterAtE1 = bobMgr.exporterSecret(groupId)
|
||||||
|
kotlin.test.assertContentEquals(
|
||||||
|
bobExporterAtE1,
|
||||||
|
addCarolResult.preCommitExporterSecret,
|
||||||
|
"Bob's current exporter (pre-add-Carol) must match the " +
|
||||||
|
"pre-commit key used to outer-encrypt the add-Carol commit; " +
|
||||||
|
"otherwise Bob cannot decrypt and will be stuck on the old epoch.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val decryptedMlsBytes =
|
||||||
|
GroupEventEncryption.decrypt(
|
||||||
|
addCarolCommitEvent.signedEvent.content,
|
||||||
|
bobExporterAtE1,
|
||||||
|
)
|
||||||
|
val mlsMessage =
|
||||||
|
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
|
||||||
|
.decodeTls(
|
||||||
|
com.vitorpamplona.quartz.marmot.mls.codec
|
||||||
|
.TlsReader(decryptedMlsBytes),
|
||||||
|
)
|
||||||
|
val publicMessage =
|
||||||
|
com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
|
||||||
|
.decodeTls(
|
||||||
|
com.vitorpamplona.quartz.marmot.mls.codec
|
||||||
|
.TlsReader(mlsMessage.payload),
|
||||||
|
)
|
||||||
|
bobMgr.processCommit(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
commitBytes = publicMessage.content,
|
||||||
|
senderLeafIndex = publicMessage.sender.leafIndex,
|
||||||
|
confirmationTag = publicMessage.confirmationTag!!,
|
||||||
|
)
|
||||||
|
|
||||||
|
val epochAfterAddCarol = aliceMgr.getGroup(groupId)!!.epoch
|
||||||
|
assertEquals(epochAfterAddBob + 1, epochAfterAddCarol)
|
||||||
|
assertEquals(epochAfterAddCarol, bobMgr.getGroup(groupId)!!.epoch)
|
||||||
|
assertEquals(epochAfterAddCarol, carolMgr.getGroup(groupId)!!.epoch)
|
||||||
|
|
||||||
|
// --- Step 3: Alice sends an application message. Both members must decrypt. ---
|
||||||
|
val msg = "Hi from Alice"
|
||||||
|
val appEvent =
|
||||||
|
outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray())
|
||||||
|
|
||||||
|
val bobKey = bobMgr.exporterSecret(groupId)
|
||||||
|
val carolKey = carolMgr.exporterSecret(groupId)
|
||||||
|
val aliceKey = aliceMgr.exporterSecret(groupId)
|
||||||
|
kotlin.test.assertContentEquals(aliceKey, bobKey, "Bob must share Alice's epoch-2 key")
|
||||||
|
kotlin.test.assertContentEquals(aliceKey, carolKey, "Carol must share Alice's epoch-2 key")
|
||||||
|
|
||||||
|
val bobMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, bobKey)
|
||||||
|
val bobDecrypted = bobMgr.decrypt(groupId, bobMlsBytes)
|
||||||
|
assertEquals(msg, bobDecrypted.content.decodeToString())
|
||||||
|
|
||||||
|
val carolMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, carolKey)
|
||||||
|
val carolDecrypted = carolMgr.decrypt(groupId, carolMlsBytes)
|
||||||
|
assertEquals(msg, carolDecrypted.content.decodeToString())
|
||||||
|
|
||||||
|
// --- Alice also receives her own echo (as the app does via the
|
||||||
|
// relay round-trip); this exercises the secretTree-based decrypt
|
||||||
|
// path with myLeafIndex != sender or when sentKeys misses.
|
||||||
|
val aliceMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, aliceKey)
|
||||||
|
val aliceDecrypted = aliceMgr.decrypt(groupId, aliceMlsBytes)
|
||||||
|
assertEquals(msg, aliceDecrypted.content.decodeToString())
|
||||||
|
|
||||||
|
// Send a second and third message to stress the secretTree ratchet
|
||||||
|
// and ensure getNodeSecret stays terminating across generations.
|
||||||
|
for (i in 1..3) {
|
||||||
|
val m = "msg-$i"
|
||||||
|
val ev = outbound.buildGroupEventFromBytes(groupId, m.encodeToByteArray())
|
||||||
|
val keyNow = aliceMgr.exporterSecret(groupId)
|
||||||
|
val bytes = GroupEventEncryption.decrypt(ev.signedEvent.content, keyNow)
|
||||||
|
val dec = bobMgr.decrypt(groupId, bytes)
|
||||||
|
assertEquals(m, dec.content.decodeToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testFullRoundtripEncryptDecrypt() {
|
fun testFullRoundtripEncryptDecrypt() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
+5
-4
@@ -225,9 +225,11 @@ class MlsGroupEdgeCaseTest {
|
|||||||
// 6. Multiple epochs of encrypt/decrypt
|
// 6. Multiple epochs of encrypt/decrypt
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
// BUG: processCommit key derivation diverges — commit_secret decryption from
|
// BUG: same empty-commit (no proposals, pure UpdatePath) divergence as
|
||||||
// UpdatePath does not correctly derive matching epoch secrets between commit()
|
// MlsGroupLifecycleTest.testEmptyCommit_AdvancesEpoch. The
|
||||||
// and processCommit(). See MlsGroupLifecycleTest.testThreeMemberGroup_SequentialAdditions.
|
// SecretTree.getNodeSecret non-full-tree fix doesn't address this — after
|
||||||
|
// 5 empty commits Alice and Bob's ratchet secrets diverge and AEAD
|
||||||
|
// decryption fails with "Tag mismatch". Tracked separately.
|
||||||
@Ignore
|
@Ignore
|
||||||
@Test
|
@Test
|
||||||
fun testMultipleEpochTransitions_EncryptDecryptStillWorks() {
|
fun testMultipleEpochTransitions_EncryptDecryptStillWorks() {
|
||||||
@@ -242,7 +244,6 @@ class MlsGroupEdgeCaseTest {
|
|||||||
bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0))
|
bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(7L, alice.epoch) // epoch 0 + addMember(1) + 5 commits = 6... wait
|
|
||||||
// epoch 0 (create) -> epoch 1 (add bob) -> 5 empty commits = epoch 6
|
// epoch 0 (create) -> epoch 1 (add bob) -> 5 empty commits = epoch 6
|
||||||
assertEquals(6L, alice.epoch)
|
assertEquals(6L, alice.epoch)
|
||||||
assertEquals(alice.epoch, bob.epoch)
|
assertEquals(alice.epoch, bob.epoch)
|
||||||
|
|||||||
+5
-7
@@ -176,7 +176,6 @@ class MlsGroupLifecycleTest {
|
|||||||
// because the commit_secret decryption from the UpdatePath does not
|
// because the commit_secret decryption from the UpdatePath does not
|
||||||
// correctly walk the ratchet tree to find the common ancestor's path secret.
|
// correctly walk the ratchet tree to find the common ancestor's path secret.
|
||||||
// This causes AEAD decryption failures on cross-member messages.
|
// This causes AEAD decryption failures on cross-member messages.
|
||||||
@Ignore
|
|
||||||
@Test
|
@Test
|
||||||
fun testThreeMemberGroup_SequentialAdditions() {
|
fun testThreeMemberGroup_SequentialAdditions() {
|
||||||
// Alice creates the group
|
// Alice creates the group
|
||||||
@@ -243,7 +242,6 @@ class MlsGroupLifecycleTest {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||||
@Ignore
|
|
||||||
@Test
|
@Test
|
||||||
fun testExternalJoin_ZaraJoinsViaGroupInfo() {
|
fun testExternalJoin_ZaraJoinsViaGroupInfo() {
|
||||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||||
@@ -266,7 +264,6 @@ class MlsGroupLifecycleTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||||
@Ignore
|
|
||||||
@Test
|
@Test
|
||||||
fun testExternalJoin_ExporterSecretsAgree() {
|
fun testExternalJoin_ExporterSecretsAgree() {
|
||||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||||
@@ -312,7 +309,6 @@ class MlsGroupLifecycleTest {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||||
@Ignore
|
|
||||||
@Test
|
@Test
|
||||||
fun testSigningKeyRotation_EpochAdvances() {
|
fun testSigningKeyRotation_EpochAdvances() {
|
||||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||||
@@ -342,7 +338,6 @@ class MlsGroupLifecycleTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||||
@Ignore
|
|
||||||
@Test
|
@Test
|
||||||
fun testEncryptDecryptAfterSigningKeyRotation() {
|
fun testEncryptDecryptAfterSigningKeyRotation() {
|
||||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||||
@@ -412,7 +407,6 @@ class MlsGroupLifecycleTest {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||||
@Ignore
|
|
||||||
@Test
|
@Test
|
||||||
fun testPskProposal_EpochAdvancesWithPsk() {
|
fun testPskProposal_EpochAdvancesWithPsk() {
|
||||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||||
@@ -472,7 +466,11 @@ class MlsGroupLifecycleTest {
|
|||||||
// 12. Empty commit (no proposals, just UpdatePath for forward secrecy)
|
// 12. Empty commit (no proposals, just UpdatePath for forward secrecy)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
// BUG: empty-commit (no proposals, pure UpdatePath) diverges Alice's and
|
||||||
|
// Bob's exporter secrets after processCommit. Unrelated to the SecretTree
|
||||||
|
// non-full-tree fix — the other @Ignored "processCommit diverges" tests in
|
||||||
|
// this file and MlsGroupEdgeCaseTest now pass, but this one still fails.
|
||||||
|
// Tracked separately.
|
||||||
@Ignore
|
@Ignore
|
||||||
@Test
|
@Test
|
||||||
fun testEmptyCommit_AdvancesEpoch() {
|
fun testEmptyCommit_AdvancesEpoch() {
|
||||||
|
|||||||
Reference in New Issue
Block a user