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:
Claude
2026-04-21 00:34:07 +00:00
parent 5705a2b840
commit b039543807
6 changed files with 117 additions and 423 deletions
@@ -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
}