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 -> {
|
||||
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)}…" }
|
||||
}
|
||||
|
||||
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 -> {
|
||||
Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" }
|
||||
}
|
||||
|
||||
+31
-6
@@ -115,6 +115,7 @@ class MarmotManager(
|
||||
|
||||
is GroupEventResult.CommitPending,
|
||||
is GroupEventResult.Duplicate,
|
||||
is GroupEventResult.UndecryptableOuterLayer,
|
||||
is GroupEventResult.Error,
|
||||
-> {}
|
||||
}
|
||||
@@ -178,10 +179,23 @@ class MarmotManager(
|
||||
"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 commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes)
|
||||
// We've already applied this commit locally; prevent the relay-echoed
|
||||
// copy from being re-applied by the inbound pipeline.
|
||||
val commitEvent =
|
||||
outboundProcessor.buildCommitEvent(
|
||||
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)
|
||||
|
||||
val welcomeDelivery =
|
||||
@@ -269,7 +283,12 @@ class MarmotManager(
|
||||
targetLeafIndex: Int,
|
||||
): OutboundGroupEvent {
|
||||
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)
|
||||
return commitEvent
|
||||
}
|
||||
@@ -282,8 +301,14 @@ class MarmotManager(
|
||||
nostrGroupId: HexKey,
|
||||
metadata: MarmotGroupData,
|
||||
): OutboundGroupEvent {
|
||||
val commitResult = groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
|
||||
val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes)
|
||||
val commitResult =
|
||||
groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
|
||||
val commitEvent =
|
||||
outboundProcessor.buildCommitEvent(
|
||||
nostrGroupId = nostrGroupId,
|
||||
commitBytes = commitResult.framedCommitBytes,
|
||||
exporterKey = commitResult.preCommitExporterSecret,
|
||||
)
|
||||
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
||||
return commitEvent
|
||||
}
|
||||
|
||||
+116
-26
@@ -79,6 +79,21 @@ sealed class GroupEventResult {
|
||||
val groupId: HexKey,
|
||||
) : 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.
|
||||
*/
|
||||
@@ -101,6 +116,20 @@ sealed class WelcomeResult {
|
||||
val needsKeyPackageRotation: Boolean,
|
||||
) : 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.
|
||||
*/
|
||||
@@ -176,15 +205,25 @@ class MarmotInboundProcessor(
|
||||
val result =
|
||||
try {
|
||||
// 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
|
||||
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
|
||||
|
||||
when (mlsMessage.wireFormat) {
|
||||
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
|
||||
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
|
||||
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
|
||||
when (mlsMessage.wireFormat) {
|
||||
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
|
||||
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
|
||||
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
|
||||
@@ -245,6 +284,22 @@ class MarmotInboundProcessor(
|
||||
"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.
|
||||
//
|
||||
// The Welcome's "e" tag carries the *Nostr event id* of the
|
||||
@@ -446,7 +501,12 @@ class MarmotInboundProcessor(
|
||||
commitEvent: GroupEvent,
|
||||
): GroupEventResult =
|
||||
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))
|
||||
|
||||
when (mlsMessage.wireFormat) {
|
||||
@@ -467,17 +527,44 @@ class MarmotInboundProcessor(
|
||||
WireFormat.PUBLIC_MESSAGE -> {
|
||||
val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload))
|
||||
val tag = pubMsg.confirmationTag
|
||||
if (tag == null) {
|
||||
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
|
||||
} 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)
|
||||
val currentEpoch = groupManager.getGroup(groupId)?.epoch
|
||||
when {
|
||||
tag == null -> {
|
||||
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
|
||||
}
|
||||
|
||||
// Reject commits that are not for our current epoch.
|
||||
// Happens most commonly when our own already-applied
|
||||
// commit is echoed back from the relay after an app
|
||||
// restart (the in-memory dedup set is cleared), and
|
||||
// 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
|
||||
* 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,
|
||||
encryptedContent: String,
|
||||
): ByteArray {
|
||||
): ByteArray? {
|
||||
// Try current epoch key first
|
||||
try {
|
||||
val exporterKey = groupManager.exporterSecret(groupId)
|
||||
@@ -518,9 +611,6 @@ class MarmotInboundProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
// 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)",
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -121,20 +121,29 @@ class MarmotOutboundProcessor(
|
||||
/**
|
||||
* Build a GroupEvent carrying a Commit for publishing.
|
||||
*
|
||||
* Used after MlsGroupManager.commit() or addMember()/removeMember().
|
||||
* The commit bytes are already MLS-formatted.
|
||||
* Used after MlsGroupManager.stageAddMember()/stageRemoveMember()/etc.
|
||||
* The commit bytes are already MLS-formatted (PublicMessage envelope).
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
suspend fun buildCommitEvent(
|
||||
nostrGroupId: HexKey,
|
||||
commitBytes: ByteArray,
|
||||
exporterKey: ByteArray? = null,
|
||||
): OutboundGroupEvent {
|
||||
// Outer ChaCha20-Poly1305 encryption of the MLS commit
|
||||
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
||||
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey)
|
||||
val outerKey = exporterKey ?: groupManager.exporterSecret(nostrGroupId)
|
||||
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, outerKey)
|
||||
|
||||
// Build the GroupEvent template
|
||||
val template =
|
||||
|
||||
@@ -403,6 +403,13 @@ class MlsGroup private constructor(
|
||||
// (i.e. no remaining member appears in the post-commit admin list).
|
||||
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) }
|
||||
|
||||
// Check if we need an UpdatePath (required unless only SelfRemove)
|
||||
@@ -563,6 +570,7 @@ class MlsGroup private constructor(
|
||||
welcomeBytes = welcomeBytes,
|
||||
groupInfoBytes = null,
|
||||
framedCommitBytes = framedCommitBytes,
|
||||
preCommitExporterSecret = preCommitExporterSecret,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2071,7 +2079,9 @@ class MlsGroup private constructor(
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
proposeAdd(keyPackageBytes)
|
||||
@@ -2080,7 +2090,8 @@ class MlsGroup private constructor(
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
proposeRemove(targetLeafIndex)
|
||||
|
||||
+37
-26
@@ -267,11 +267,9 @@ class MlsGroupManager(
|
||||
suspend fun commit(nostrGroupId: HexKey): CommitResult =
|
||||
mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
|
||||
// Retain current epoch secrets before transition
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
val result = group.commit()
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
result
|
||||
}
|
||||
@@ -292,10 +290,16 @@ class MlsGroupManager(
|
||||
) = mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
|
||||
// Retain current epoch secrets before transition
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
// Capture the outgoing epoch's secrets BEFORE advancing, but only
|
||||
// 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)
|
||||
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
}
|
||||
|
||||
@@ -359,6 +363,12 @@ class MlsGroupManager(
|
||||
|
||||
/**
|
||||
* 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(
|
||||
nostrGroupId: HexKey,
|
||||
@@ -366,14 +376,16 @@ class MlsGroupManager(
|
||||
): CommitResult =
|
||||
mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
val result = group.addMember(keyPackageBytes)
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
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(
|
||||
nostrGroupId: HexKey,
|
||||
@@ -381,8 +393,9 @@ class MlsGroupManager(
|
||||
): CommitResult =
|
||||
mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
val result = group.removeMember(targetLeafIndex)
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
result
|
||||
}
|
||||
@@ -391,31 +404,22 @@ class MlsGroupManager(
|
||||
* Rotate the signing key within a group and commit.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @param nostrGroupId hex-encoded Nostr group ID
|
||||
* @return the [CommitResult] to publish
|
||||
* See [addMember] for the pre-commit exporter key contract.
|
||||
*/
|
||||
suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult =
|
||||
mutex.withLock {
|
||||
val group = requireGroup(nostrGroupId)
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
group.proposeSigningKeyRotation()
|
||||
val result = group.commit()
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
result
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
|
||||
* Creates the proposal, commits it, and persists the new state.
|
||||
*
|
||||
* 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).
|
||||
* See [addMember] for the pre-commit exporter key contract.
|
||||
*/
|
||||
suspend fun updateGroupExtensions(
|
||||
nostrGroupId: HexKey,
|
||||
@@ -428,9 +432,10 @@ class MlsGroupManager(
|
||||
check(!adminsConfigured || group.isLocalAdmin()) {
|
||||
"MIP-01: only admins may update group extensions"
|
||||
}
|
||||
retainEpochSecrets(nostrGroupId, group)
|
||||
val retainedBefore = group.retainedSecrets()
|
||||
group.proposeGroupContextExtensions(extensions)
|
||||
val result = group.commit()
|
||||
pushRetainedEpoch(nostrGroupId, retainedBefore)
|
||||
persistGroup(nostrGroupId)
|
||||
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,
|
||||
group: MlsGroup,
|
||||
retainedBefore: RetainedEpochSecrets,
|
||||
) {
|
||||
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)
|
||||
while (retained.size > EPOCH_RETENTION_WINDOW) {
|
||||
|
||||
@@ -107,6 +107,18 @@ data class CommitResult(
|
||||
* Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)).
|
||||
*/
|
||||
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 {
|
||||
if (this === other) return true
|
||||
|
||||
+41
-34
@@ -47,9 +47,6 @@ class SecretTree(
|
||||
private val encryptionSecret: ByteArray,
|
||||
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) */
|
||||
private val senderState = mutableMapOf<Int, SenderRatchetState>()
|
||||
|
||||
@@ -72,11 +69,6 @@ class SecretTree(
|
||||
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.
|
||||
*/
|
||||
@@ -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 {
|
||||
val nodeIndex = BinaryTree.leafToNode(leafIndex)
|
||||
return getNodeSecret(nodeIndex)
|
||||
}
|
||||
val targetNode = BinaryTree.leafToNode(leafIndex)
|
||||
val rootIdx = BinaryTree.root(leafCount)
|
||||
var currentSecret = encryptionSecret
|
||||
var currentNode = rootIdx
|
||||
|
||||
/**
|
||||
* Recursively derive a node's secret from its parent in the secret tree.
|
||||
*/
|
||||
private fun getNodeSecret(nodeIndex: Int): ByteArray {
|
||||
treeSecrets[nodeIndex]?.let { return it }
|
||||
while (currentNode != targetNode) {
|
||||
val goLeft = targetNode < currentNode
|
||||
val label = if (goLeft) "left" else "right"
|
||||
currentSecret =
|
||||
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))
|
||||
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]!!
|
||||
return currentSecret
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+445
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
||||
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.signers.NostrSignerInternal
|
||||
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
|
||||
fun testFullRoundtripEncryptDecrypt() {
|
||||
runBlocking {
|
||||
|
||||
+5
-4
@@ -225,9 +225,11 @@ class MlsGroupEdgeCaseTest {
|
||||
// 6. Multiple epochs of encrypt/decrypt
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// BUG: processCommit key derivation diverges — commit_secret decryption from
|
||||
// UpdatePath does not correctly derive matching epoch secrets between commit()
|
||||
// and processCommit(). See MlsGroupLifecycleTest.testThreeMemberGroup_SequentialAdditions.
|
||||
// BUG: same empty-commit (no proposals, pure UpdatePath) divergence as
|
||||
// MlsGroupLifecycleTest.testEmptyCommit_AdvancesEpoch. The
|
||||
// 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
|
||||
@Test
|
||||
fun testMultipleEpochTransitions_EncryptDecryptStillWorks() {
|
||||
@@ -242,7 +244,6 @@ class MlsGroupEdgeCaseTest {
|
||||
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
|
||||
assertEquals(6L, alice.epoch)
|
||||
assertEquals(alice.epoch, bob.epoch)
|
||||
|
||||
+5
-7
@@ -176,7 +176,6 @@ class MlsGroupLifecycleTest {
|
||||
// because the commit_secret decryption from the UpdatePath does not
|
||||
// correctly walk the ratchet tree to find the common ancestor's path secret.
|
||||
// This causes AEAD decryption failures on cross-member messages.
|
||||
@Ignore
|
||||
@Test
|
||||
fun testThreeMemberGroup_SequentialAdditions() {
|
||||
// Alice creates the group
|
||||
@@ -243,7 +242,6 @@ class MlsGroupLifecycleTest {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testExternalJoin_ZaraJoinsViaGroupInfo() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -266,7 +264,6 @@ class MlsGroupLifecycleTest {
|
||||
}
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testExternalJoin_ExporterSecretsAgree() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -312,7 +309,6 @@ class MlsGroupLifecycleTest {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testSigningKeyRotation_EpochAdvances() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -342,7 +338,6 @@ class MlsGroupLifecycleTest {
|
||||
}
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testEncryptDecryptAfterSigningKeyRotation() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -412,7 +407,6 @@ class MlsGroupLifecycleTest {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
|
||||
@Ignore
|
||||
@Test
|
||||
fun testPskProposal_EpochAdvancesWithPsk() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
@@ -472,7 +466,11 @@ class MlsGroupLifecycleTest {
|
||||
// 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
|
||||
@Test
|
||||
fun testEmptyCommit_AdvancesEpoch() {
|
||||
|
||||
Reference in New Issue
Block a user