Skip to content

Fix replay deduplication for wrapped messages#316

Open
mubarakcoded wants to merge 2 commits into
masterfrom
fix/replay-dedup-marmot-security-2
Open

Fix replay deduplication for wrapped messages#316
mubarakcoded wants to merge 2 commits into
masterfrom
fix/replay-dedup-marmot-security-2

Conversation

@mubarakcoded

@mubarakcoded mubarakcoded commented May 25, 2026

Copy link
Copy Markdown
Contributor

Closes marmot-protocol/marmot-security#2.

mdk-core previously deduplicated incoming kind:445 messages only by the outer wrapper event ID. An attacker (without the group key) could re-publish the same encrypted MLS payload inside a freshly-signed wrapper — new outer event ID → step-0 dedup misses → MDK re-decrypts and re-emits the same inner message. For application messages this surfaced as duplicate ApplicationMessage deliveries (and overwritten wrapper_event_id / processed_at metadata) to the caller. For commits and proposals it forced redundant re-entry into MLS processing and error-handling paths.

The fix adds a stable post-decrypt dedup key independent of the outer event ID, and lifts the check ahead of decrypt where possible so replays are dropped before paying any MLS cost:

  • Layer A — content-hash dedup, pre-decrypt. process_message now looks up (mls_group_id, SHA-256(event.content)) against processed_messages.message_event_id before attempting to decrypt. Replays whose group is already in local storage are rejected here; no MLS state machinery is touched.
  • Layer A — content-hash dedup, post-decrypt fallback. Same check, repeated after decrypt_message succeeds, for the case where the group wasn't yet in storage during the pre-decrypt pass.
  • Layer B — rumor-ID dedup for application messages. process_application_message rejects an application replay whose inner rumor ID is already in the messages table, returning Unprocessable instead of re-emitting ApplicationMessage.

To populate the content-hash key, every commit/proposal/external-join save-site now stores Some(content_hash_event_id(&event.content)) in ProcessedMessage::message_event_id instead of None. The processed_messages column is unchanged (32-byte BLOB); no DB migration. SHA-256 collision resistance keeps the synthetic content-hash IDs from clashing with real Nostr rumor IDs that already occupy the column.

Breaking Change

mdk-storage-traits adds required MessageStorage::find_processed_message_by_message_event_id.
In-tree memory/sqlite backends are updated in this PR, but external storage backend implementors must add this method when upgrading.

Verification

  • cargo test -p mdk-core --lib replayed_ — both Layer A integration tests pass.
  • cargo test -p mdk-core --lib test_application_replay_with_fresh_wrapper_returns_unprocessable — Layer B regression passes.
  • cargo test -p mdk-core --test storage_traits_memory --test storage_traits_sqlite test_processed_message — new lookup verified on both backends.
  • cargo test -p mdk-core --lib test_content_hash_event_id_is_stable_and_content_sensitive — unit test on the hash helper.
  • just precommit passes

⚠️ This PR fixes a replay-deduplication vulnerability (marmot-protocol/marmot-security#2) where attackers could republish identical encrypted MLS payloads inside fresh outer wrapper events (kind:445) to bypass deduplication and force redundant reprocessing. It introduces layered, payload-level deduplication using content hashes and rumor IDs to prevent replay attacks while maintaining idempotent behavior.

What changed:

  • Added content-hash-based replay detection in process.rs that performs a two-stage check: pre-decrypt lookup in processed_messages by (mls_group_id, content_hash_event_id), and a post-decrypt fallback when the group was not present initially, rejecting replays with different wrapper event IDs.
  • Added rumor-ID-based replay detection in application.rs that looks up existing messages by their decrypted rumor event ID and returns Unprocessable when the same rumor is republished with a different wrapper event ID.
  • Introduced content_hash_event_id() helper function in messages/mod.rs that converts SHA-256(event.content) into a stable synthetic EventId (independent of the outer wrapper event ID).
  • Updated commit.rs and proposal.rs to store content-hash-based synthetic IDs in ProcessedMessage::message_event_id instead of None.
  • Affected crates: mdk-core, mdk-storage-traits, mdk-memory-storage, mdk-sqlite-storage.

Security impact:

  • Implements payload-level deduplication using cryptographic content hashes (SHA-256) to prevent attackers from bypassing the outer-event-ID deduplication gate by re-wrapping identical ciphertext in fresh outer events.
  • Records stable inner message identifiers (rumor ID for applications, content hash for commits/proposals) to prevent duplicate message delivery and metadata mutation.
  • Pre-decrypt checks prevent unnecessary decryption and MLS processing work on known-replayed payloads.

API surface:

  • Breaking change: adds required MessageStorage::find_processed_message_by_message_event_id(mls_group_id, message_event_id) trait method for group-scoped processed-message lookup by stable inner message/content-hash ID; all storage backends must implement this method.
  • Updated process_application_message() signature to return Result<MessageProcessingResult> instead of Result<message_types::Message>.
  • Added new public helper content_hash_event_id(content: &str) -> EventId for converting SHA-256 digests into synthetic event IDs.

Testing:

  • Added unit tests verifying that replaying the same rumor or commit payload with a fresh wrapper event ID results in Unprocessable return and still records a processed-message entry in Processed state.
  • Added test for content-hash stability and sensitivity to input changes.
  • Expanded test coverage for group-scoped find_processed_message_by_message_event_id lookups in memory and SQLite storage backends.

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 17f744ee-6725-48a6-8d1c-40f4952af913

📥 Commits

Reviewing files that changed from the base of the PR and between 0c104c4 and d111728.

📒 Files selected for processing (13)
  • crates/mdk-core/CHANGELOG.md
  • crates/mdk-core/src/messages/application.rs
  • crates/mdk-core/src/messages/commit.rs
  • crates/mdk-core/src/messages/mod.rs
  • crates/mdk-core/src/messages/process.rs
  • crates/mdk-core/src/messages/proposal.rs
  • crates/mdk-core/tests/shared/message_tests.rs
  • crates/mdk-memory-storage/CHANGELOG.md
  • crates/mdk-memory-storage/src/messages.rs
  • crates/mdk-sqlite-storage/CHANGELOG.md
  • crates/mdk-sqlite-storage/src/messages.rs
  • crates/mdk-storage-traits/CHANGELOG.md
  • crates/mdk-storage-traits/src/messages/mod.rs
✅ Files skipped from review due to trivial changes (3)
  • crates/mdk-memory-storage/CHANGELOG.md
  • crates/mdk-sqlite-storage/CHANGELOG.md
  • crates/mdk-core/CHANGELOG.md

📝 Walkthrough

Walkthrough

This PR implements stable, payload-level replay deduplication for MLS messages wrapped in Nostr kind:445 events. It derives a stable inner EventId from payload content, adds a group-scoped storage lookup, inserts early and post-decrypt replay gates that return Unprocessable for replays, and updates processing and tests to persist and query the content-hash ID.

Changes

Ciphertext replay deduplication via stable content hash

Layer / File(s) Summary
Storage trait and implementations
crates/mdk-storage-traits/src/messages/mod.rs, crates/mdk-memory-storage/src/messages.rs, crates/mdk-sqlite-storage/src/messages.rs
Adds find_processed_message_by_message_event_id to MessageStorage and implements it for memory and SQLite to enable group-scoped lookup by a stable inner message EventId.
Content hash stable ID helper
crates/mdk-core/src/messages/mod.rs
Introduces content_hash_event_id that converts the SHA-256 content digest into an EventId and adds a unit test validating determinism and content sensitivity.
Application message replay detection
crates/mdk-core/src/messages/application.rs
process_application_message/process_application_rumor now return MessageProcessingResult, check storage for existing processed messages by rumor/event id, persist a Processed record on replay, and return Unprocessable when re-wrapped under a different wrapper event id. Includes a unit test.
Message processing replay gates
crates/mdk-core/src/messages/process.rs
Adds two gates in process_message_with_context_inner: an early pre-decrypt gate and a post-decrypt gate that both check (mls_group_id, content_hash_event_id), record Processed on replay, and return Unprocessable. Also stores content-hash IDs for proposals/commits and adds tests for replay scenarios.
Commit and proposal hash recording
crates/mdk-core/src/messages/commit.rs, crates/mdk-core/src/messages/proposal.rs
process_commit, eviction handling, and mark_processed now store content_hash_event_id(Some(...)) in processed-message records instead of None.
Tests and changelogs
crates/mdk-core/tests/shared/message_tests.rs, crates/mdk-core/CHANGELOG.md, crates/mdk-memory-storage/CHANGELOG.md, crates/mdk-sqlite-storage/CHANGELOG.md, crates/mdk-storage-traits/CHANGELOG.md
Updates tests to assert group-scoped lookup behavior and adds changelog entries documenting the security fix and required storage API change.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

security, breaking-change, mls-protocol, storage

Suggested reviewers

  • dannym-arx
  • jgmontoya
  • erskingardner
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Fix replay deduplication for wrapped messages' directly and concisely summarizes the main change: addressing replay attacks by improving deduplication logic for wrapped/encrypted MLS messages.
Linked Issues check ✅ Passed The PR comprehensively addresses all coding requirements from issue #2: implements stable post-decrypt dedupe keys (content-hash for commits/proposals, rumor-ID for application messages), adds group-scoped message lookup via new MessageStorage::find_processed_message_by_message_event_id method, prevents duplicate ApplicationMessage returns, and updates all storage backends.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the replay deduplication fix: new content_hash_event_id helper, modified process_* functions for replay detection, storage layer additions, and changelog updates all directly support the stated objective.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
No Sensitive Identifier Leakage ✅ Passed No sensitive identifiers leak through tracing, format!, panic!, or Debug impls. All mls_group_id fields redacted with "[REDACTED]". Error details explicitly marked redacted.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/replay-dedup-marmot-security-2

Comment @coderabbitai help to get the list of available commands and usage tips.

@stage-review

stage-review Bot commented May 25, 2026

Copy link
Copy Markdown

@mubarakcoded

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review.

@github-actions

github-actions Bot commented May 25, 2026

Copy link
Copy Markdown

✅ Coverage: 94.51% → 94.52% (+0.01%)

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

@mubarakcoded: I'll perform a full review of the PR now.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/mdk-core/tests/shared/message_tests.rs (1)

97-100: ⚡ Quick win

Add a wrong-group negative lookup assertion.

Line 98 currently verifies only a missing message_event_id. Add a case using the same message_event_id with a different mls_group_id to confirm strict group scoping.

Suggested test addition
     let result = storage
         .find_processed_message_by_message_event_id(&mls_group_id, &non_existent_id)
         .unwrap();
     assert!(result.is_none());
+
+    // Same message_event_id in a different group should not match
+    let other_group_id = GroupId::from_slice(&[9, 9, 9, 9]);
+    let result = storage
+        .find_processed_message_by_message_event_id(&other_group_id, &message_event_id)
+        .unwrap();
+    assert!(result.is_none());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/mdk-core/tests/shared/message_tests.rs` around lines 97 - 100, Add a
negative lookup asserting group scoping by calling
find_processed_message_by_message_event_id with the same non_existent_id but a
different MLS group id: create or derive a distinct mls_group_id (e.g.,
other_mls_group_id) and call
storage.find_processed_message_by_message_event_id(&other_mls_group_id,
&non_existent_id).unwrap() and assert that the result is None to ensure the
lookup is strict to the group; place this immediately after the existing
assertion that uses mls_group_id to keep the test logic clear.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/mdk-core/src/messages/application.rs`:
- Around line 58-82: The current replay check treats any existing record from
storage().find_message_by_event_id(&group.mls_group_id, &rumor_id) as a replay
and returns MessageProcessingResult::Unprocessable, which also matches valid
“own cached message” paths; fix by binding the found record to a variable
instead of is_some(), inspect its metadata (e.g., originator/sender/wrapper and
processed state) and only consider it a replay if the stored record indicates it
was processed by a different origin/wrapper or has a Processed state that should
block reprocessing; otherwise continue normal ApplicationMessage processing.
Update the logic around find_message_by_event_id, the conditional that currently
creates create_processed_message_record and calls save_processed_message_record,
so you only create/save and return Unprocessable when the stored record actually
represents a true replay (different origin/wrapper or already-final state).

In `@crates/mdk-core/src/messages/process.rs`:
- Around line 444-478: The current replay gate queries
find_processed_message_by_message_event_id(mls_group_id, content_hash_event_id)
and rejects whenever any prior match exists, which also blocks idempotent
reprocessing of the same wrapper event; change the logic to fetch the existing
processed message (if any) and only treat it as a replay if the stored processed
message's message_event_id differs from the current event.id—if they are equal,
allow processing to continue. Update the block around content_hash_event_id,
find_group_by_nostr_group_id, and the subsequent
find_processed_message_by_message_event_id call (and the analogous block at
Lines 513–539) to perform this explicit message_event_id comparison before
creating the processed_message record, saving it via
save_processed_message_record, and returning
MessageProcessingOutcome::without_context(MessageProcessingResult::Unprocessable).

---

Nitpick comments:
In `@crates/mdk-core/tests/shared/message_tests.rs`:
- Around line 97-100: Add a negative lookup asserting group scoping by calling
find_processed_message_by_message_event_id with the same non_existent_id but a
different MLS group id: create or derive a distinct mls_group_id (e.g.,
other_mls_group_id) and call
storage.find_processed_message_by_message_event_id(&other_mls_group_id,
&non_existent_id).unwrap() and assert that the result is None to ensure the
lookup is strict to the group; place this immediately after the existing
assertion that uses mls_group_id to keep the test logic clear.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 439b1b94-d4fb-438d-86a1-23b73c9a72f8

📥 Commits

Reviewing files that changed from the base of the PR and between 25005ea and 0c104c4.

📒 Files selected for processing (9)
  • crates/mdk-core/src/messages/application.rs
  • crates/mdk-core/src/messages/commit.rs
  • crates/mdk-core/src/messages/mod.rs
  • crates/mdk-core/src/messages/process.rs
  • crates/mdk-core/src/messages/proposal.rs
  • crates/mdk-core/tests/shared/message_tests.rs
  • crates/mdk-memory-storage/src/messages.rs
  • crates/mdk-sqlite-storage/src/messages.rs
  • crates/mdk-storage-traits/src/messages/mod.rs

Comment thread crates/mdk-core/src/messages/application.rs Outdated
Comment thread crates/mdk-core/src/messages/process.rs
@mubarakcoded mubarakcoded force-pushed the fix/replay-dedup-marmot-security-2 branch from 0c104c4 to 070acd3 Compare May 25, 2026 06:35
@mubarakcoded mubarakcoded force-pushed the fix/replay-dedup-marmot-security-2 branch from 070acd3 to f96403b Compare May 25, 2026 07:08
@mubarakcoded

Copy link
Copy Markdown
Contributor Author

@claude full review

@mubarakcoded mubarakcoded marked this pull request as ready for review May 25, 2026 10:35

@dannym-arx dannym-arx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused about the separate hash. Nostr IDs are already unique and stable.

they are sha256 of:

json_encode([
  0,
  <pubkey, as a lowercase hex string>,
  <created_at, as a number>,
  <kind, as a number>,
  <tags, as an array of arrays of non-null strings>,
  <content, as a string>
])

https://github.com/nostr-protocol/nips/blob/master/01.md#events-and-signatures

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants