fix(marmot): revert snapshot-based stage/merge; capture pre-commit key in CommitResult
The snapshot/restore machinery added in the previous commit
(ca988b7) to implement MDK-style `stageCommit` + `mergeStagedCommit`
introduced a tree-rebuild path (RatchetTree.decodeTls followed by a
fresh SecretTree construction) that, in production, caused a
recursive StackOverflowError in `SecretTree.getNodeSecret` during
encryption after a second add-member cycle. The unit test flow did
not exercise it and couldn't reproduce.
Keep the underlying fix — outer-encrypt outbound kind:445 commits
with the PRE-commit (epoch-N) exporter secret — but deliver it with
far less machinery:
- `CommitResult.preCommitExporterSecret` now carries the
`MLS-Exporter("marmot", "group-event", 32)` value captured inside
`MlsGroup.commit()` BEFORE any state mutation. It's the key
existing members still at epoch N hold, so the outer kind:445
wrap with it is decryptable to them (RFC 9420 §12.4 + MDK parity).
- `MlsGroup.commit()` captures this exporter once at the top of the
function, threads it into the returned `CommitResult`.
- `MarmotManager.addMember / removeMember / updateGroupMetadata`
read `commitResult.preCommitExporterSecret` and pass it to
`MarmotOutboundProcessor.buildCommitEvent(..., exporterKey=...)`.
The eager `group.addMember` / etc. continue to advance the local
epoch immediately — the same behavior as before the previous
commit, which was proven safe under production load.
Removed:
- `MlsGroup.stageCommit` / `mergeStagedCommit` / `stageAddMember` /
`stageRemoveMember` and their snapshot/restore helpers.
- `MlsGroupSnapshot` / `StagedCommit` types.
- `MlsGroupManager.stageAddMember` / `stageRemoveMember` /
`stageRotateSigningKey` / `stageUpdateGroupExtensions` /
`mergeStagedCommit`.
- `tree` back to `val` in `MlsGroup`.
Kept from the previous commit:
- `MarmotOutboundProcessor.buildCommitEvent(... exporterKey: ByteArray?)`
with default falling back to `groupManager.exporterSecret(...)`.
- `MlsGroupManager.processCommit` retains epoch secrets AFTER
successful apply (was a secondary bug polluting the retention
window with duplicates of the current key when a commit failed).
- `pushRetainedEpoch` helper that accepts a pre-captured
RetainedEpochSecrets, used throughout instead of the old
`retainEpochSecrets(nostrGroupId, group)`.
Test updated:
- `testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage` now
uses the eager `addMember` path and asserts that the member still
at the old epoch can outer-decrypt with the pre-commit key shipped
back via `CommitResult`. Also decrypts Alice's self-echo and
sends multiple follow-up messages — this is the scenario that
triggered the production stack overflow before the revert.
- `testAddMemberExposesPreCommitExporterSecret` (renamed) confirms
`CommitResult.preCommitExporterSecret` equals the exporter the
group had immediately before the call, and that the post-commit
exporter differs from it (proves we actually rotated).
https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
This commit is contained in:
+20
-27
@@ -178,39 +178,34 @@ class MarmotManager(
|
||||
"KeyPackage credential identity does not match memberPubKey"
|
||||
}
|
||||
|
||||
// Stage-then-merge (MDK / RFC 9420 §12.4): build the kind:445 with the
|
||||
// PRE-commit epoch's exporter secret so that other existing members
|
||||
// can outer-decrypt and process it. Only advance our own epoch once
|
||||
// the framed commit has been handed to the outbound path.
|
||||
val staged = groupManager.stageAddMember(nostrGroupId, keyPackageBytes)
|
||||
// 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 = nostrGroupId,
|
||||
commitBytes = staged.framedCommitBytes,
|
||||
exporterKey = staged.preCommitExporterSecret,
|
||||
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 below.
|
||||
// epoch we've already merged.
|
||||
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
||||
|
||||
val welcomeBytes =
|
||||
staged.welcomeBytes
|
||||
?: throw IllegalStateException(
|
||||
"stageAddMember did not produce a Welcome for $memberPubKey",
|
||||
)
|
||||
val welcomeDelivery =
|
||||
welcomeSender.wrapWelcomeBytes(
|
||||
welcomeBytes = welcomeBytes,
|
||||
welcomeSender.wrapWelcome(
|
||||
commitResult = commitResult,
|
||||
recipientPubKey = memberPubKey,
|
||||
keyPackageEventId = keyPackageEventId,
|
||||
relays = relays,
|
||||
nostrGroupId = nostrGroupId,
|
||||
)
|
||||
|
||||
// Advance local epoch now that the commit + welcome have been built.
|
||||
groupManager.mergeStagedCommit(nostrGroupId, staged)
|
||||
|
||||
return Pair(commitEvent, welcomeDelivery)
|
||||
}
|
||||
|
||||
@@ -286,15 +281,14 @@ class MarmotManager(
|
||||
nostrGroupId: HexKey,
|
||||
targetLeafIndex: Int,
|
||||
): OutboundGroupEvent {
|
||||
val staged = groupManager.stageRemoveMember(nostrGroupId, targetLeafIndex)
|
||||
val commitResult = groupManager.removeMember(nostrGroupId, targetLeafIndex)
|
||||
val commitEvent =
|
||||
outboundProcessor.buildCommitEvent(
|
||||
nostrGroupId = nostrGroupId,
|
||||
commitBytes = staged.framedCommitBytes,
|
||||
exporterKey = staged.preCommitExporterSecret,
|
||||
commitBytes = commitResult.framedCommitBytes,
|
||||
exporterKey = commitResult.preCommitExporterSecret,
|
||||
)
|
||||
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
||||
groupManager.mergeStagedCommit(nostrGroupId, staged)
|
||||
return commitEvent
|
||||
}
|
||||
|
||||
@@ -306,16 +300,15 @@ class MarmotManager(
|
||||
nostrGroupId: HexKey,
|
||||
metadata: MarmotGroupData,
|
||||
): OutboundGroupEvent {
|
||||
val staged =
|
||||
groupManager.stageUpdateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
|
||||
val commitResult =
|
||||
groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
|
||||
val commitEvent =
|
||||
outboundProcessor.buildCommitEvent(
|
||||
nostrGroupId = nostrGroupId,
|
||||
commitBytes = staged.framedCommitBytes,
|
||||
exporterKey = staged.preCommitExporterSecret,
|
||||
commitBytes = commitResult.framedCommitBytes,
|
||||
exporterKey = commitResult.preCommitExporterSecret,
|
||||
)
|
||||
inboundProcessor.markEventProcessed(commitEvent.signedEvent.id)
|
||||
groupManager.mergeStagedCommit(nostrGroupId, staged)
|
||||
return commitEvent
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user