From 21856628c4a4770eb57a01793cd41c78e650ff57 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Fri, 22 May 2026 13:45:24 +0200 Subject: [PATCH 1/2] feat: deletion APIs + secure_delete for disappearing messages (3/3) Adds local deletion primitives so clients can enforce NIP-40 expiration on their own schedule, and hardens SQLite against forensic recovery of deleted content. New MessageStorage trait methods: - delete_message(group_id, event_id) -> bool - delete_messages_before_timestamp(group_id, before) -> usize - delete_processed_messages_for_group(group_id) -> usize Implementations in mdk-memory-storage and mdk-sqlite-storage. The memory backend scopes messages_cache eviction to the owning group to avoid evicting another group's entry under coincident EventIds. mdk-core exposes matching public methods on MDK. UniFFI surfaces all three over the FFI boundary. SQLite connection init now sets PRAGMA secure_delete = ON so deleted rows (including expired disappearing messages) are overwritten with zeros on disk. Final part of the 3-PR split: - Part 1: #258 (MLS extension v3 wire format) - Part 2: #306 (validation + NIP-40 auto-expiration + tri-state) --- crates/mdk-core/CHANGELOG.md | 1 + crates/mdk-core/src/groups.rs | 50 +++ crates/mdk-memory-storage/CHANGELOG.md | 1 + crates/mdk-memory-storage/src/messages.rs | 312 +++++++++++++++++- crates/mdk-sqlite-storage/CHANGELOG.md | 2 + crates/mdk-sqlite-storage/src/lib.rs | 8 + crates/mdk-sqlite-storage/src/messages.rs | 231 ++++++++++++- crates/mdk-storage-traits/CHANGELOG.md | 2 + crates/mdk-storage-traits/src/messages/mod.rs | 41 ++- crates/mdk-uniffi/src/lib.rs | 47 +++ 10 files changed, 689 insertions(+), 6 deletions(-) diff --git a/crates/mdk-core/CHANGELOG.md b/crates/mdk-core/CHANGELOG.md index fd8e036a..6ff7466a 100644 --- a/crates/mdk-core/CHANGELOG.md +++ b/crates/mdk-core/CHANGELOG.md @@ -91,6 +91,7 @@ - Admin depletion validation: SelfRemove proposals and commits are rejected if they would leave the group with zero admins. ([#236](https://github.com/marmot-protocol/mdk/pull/236)) - Added feature-gated MIP-05 notification request builders that group token tags by notification server, preserve relay hints, chunk requests at 100 tokens per server, and return ready-to-publish gift-wrapped notification batches for `kind:446` delivery. ([#238](https://github.com/marmot-protocol/mdk/pull/238)) - `create_message` now accepts an optional `tags` parameter of type `Vec` for appending allow-listed tags (e.g. NIP-40 `expiration`) to the outer kind:445 wrapper event. The `EventTag` enum enforces at compile time which tags are permitted. ([#248](https://github.com/marmot-protocol/mdk/pull/248)) +- Added `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` public methods on `MDK` for granular message deletion supporting disappearing-message cleanup by the client. ### Fixed diff --git a/crates/mdk-core/src/groups.rs b/crates/mdk-core/src/groups.rs index cee4bedd..09651cb0 100644 --- a/crates/mdk-core/src/groups.rs +++ b/crates/mdk-core/src/groups.rs @@ -2099,6 +2099,56 @@ where .map_err(|e| Error::Group(e.to_string())) } + /// Delete a single message by event ID within a group. + /// + /// Removes the decrypted message content from storage. Does not affect + /// processed message records. + /// + /// Returns `Ok(true)` if the message was found and deleted, `Ok(false)` + /// if no message with the given event ID exists in the group. + /// + /// This is a local-only operation — no MLS proposals or Nostr events + /// are published. + pub fn delete_message(&self, group_id: &GroupId, event_id: &EventId) -> Result { + self.storage() + .delete_message(group_id, event_id) + .map_err(|e| Error::Group(e.to_string())) + } + + /// Delete all messages in a group created before the given timestamp. + /// + /// Intended for disappearing-message cleanup: compute + /// `now - disappearing_message_secs` and pass it as `before`. + /// + /// Returns the number of messages deleted. + /// + /// This is a local-only operation — no MLS proposals or Nostr events + /// are published. + pub fn delete_messages_before_timestamp( + &self, + group_id: &GroupId, + before: Timestamp, + ) -> Result { + self.storage() + .delete_messages_before_timestamp(group_id, before) + .map_err(|e| Error::Group(e.to_string())) + } + + /// Delete all processed message records for a group. + /// + /// Removes tracking metadata from local storage. Previously-seen events + /// may be reprocessed if encountered again. + /// + /// Returns the number of records deleted. + /// + /// This is a local-only operation — no MLS proposals or Nostr events + /// are published. + pub fn delete_processed_messages_for_group(&self, group_id: &GroupId) -> Result { + self.storage() + .delete_processed_messages_for_group(group_id) + .map_err(|e| Error::Group(e.to_string())) + } + /// Delete all local state for a group. /// /// Removes everything MDK stores for this group: messages, processed diff --git a/crates/mdk-memory-storage/CHANGELOG.md b/crates/mdk-memory-storage/CHANGELOG.md index 5c9921e4..1b60791c 100644 --- a/crates/mdk-memory-storage/CHANGELOG.md +++ b/crates/mdk-memory-storage/CHANGELOG.md @@ -56,6 +56,7 @@ - Added `MdkMemoryStorage::signature_key_count` under the `test-utils` feature. Cardinality probe over the signer store for invariant tests that need to observe store growth when the fresh signer's public key is generated internally and therefore cannot be rediscovered via `SignatureKeyPair::read`. ([#262](https://github.com/marmot-protocol/mdk/pull/262)) - Propagated `disappearing_message_secs` field through all `Group` construction sites for the new disappearing messages feature. ([#258](https://github.com/marmot-protocol/mdk/pull/258)) - Implemented `delete_messages_for_group` and `delete_group` for local "clear chat" and "delete chat" operations. Added `clear_group` methods to `MlsGroupData` and `MlsEpochKeyPairs` for bulk group-scoped MLS state cleanup. ([#250](https://github.com/marmot-protocol/mdk/pull/250)) +- Implemented `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` for per-message and bulk expiry-based deletion. - Implemented legacy exporter-secret compatibility storage for the temporary `0.6.x -> 0.7.x` migration window, including snapshot and restore support for preserved pre-0.7.0 group-event secrets. ([#222](https://github.com/marmot-protocol/mdk/pull/222)) ### Fixed diff --git a/crates/mdk-memory-storage/src/messages.rs b/crates/mdk-memory-storage/src/messages.rs index fcaf4e88..ab3fac89 100644 --- a/crates/mdk-memory-storage/src/messages.rs +++ b/crates/mdk-memory-storage/src/messages.rs @@ -3,9 +3,9 @@ use std::collections::HashMap; use mdk_storage_traits::{GroupId, truncate_failure_reason}; -use nostr::{EventId, JsonUtil}; +use nostr::{EventId, JsonUtil, Timestamp}; #[cfg(test)] -use nostr::{Kind, Tags, Timestamp, UnsignedEvent}; +use nostr::{Kind, Tags, UnsignedEvent}; use mdk_storage_traits::groups::GroupStorage; use mdk_storage_traits::messages::MessageStorage; @@ -355,6 +355,107 @@ impl MessageStorage for MdkMemoryStorage { Ok(count) } + + fn delete_message(&self, group_id: &GroupId, event_id: &EventId) -> Result { + let mut guard = self.inner.write(); + let inner = &mut *guard; + + // Remove from per-group index + let mut found = false; + if let Some(group_messages) = inner.messages_by_group_cache.get_mut(group_id) { + found = group_messages.remove(event_id).is_some(); + } + + // Remove from messages_cache only if the entry belongs to the same group + // (prevents evicting another group's message with a coincident EventId). + let belongs_to_group = inner + .messages_cache + .get(event_id) + .is_some_and(|msg| &msg.mls_group_id == group_id); + + if belongs_to_group { + inner.messages_cache.pop(event_id); + found = true; + } + + Ok(found) + } + + fn delete_messages_before_timestamp( + &self, + group_id: &GroupId, + before: Timestamp, + ) -> Result { + let mut guard = self.inner.write(); + let inner = &mut *guard; + + // Collect event IDs of messages to remove from the per-group index + let to_remove: Vec = + if let Some(group_messages) = inner.messages_by_group_cache.get_mut(group_id) { + group_messages + .iter() + .filter(|(_, msg)| msg.created_at < before) + .map(|(eid, _)| *eid) + .collect() + } else { + Vec::new() + }; + + // Also scan messages_cache for orphaned entries (LRU divergence) + let orphaned: Vec = inner + .messages_cache + .iter() + .filter(|(_, msg)| &msg.mls_group_id == group_id && msg.created_at < before) + .map(|(eid, _)| *eid) + .collect(); + + // Merge both sets, dedup via a small set + let mut all_ids: std::collections::HashSet = to_remove.iter().copied().collect(); + all_ids.extend(orphaned.iter().copied()); + + // Remove from per-group index + if let Some(group_messages) = inner.messages_by_group_cache.get_mut(group_id) { + for eid in &all_ids { + group_messages.remove(eid); + } + } + + // Remove from messages_cache only when the entry belongs to this group + // (prevents evicting another group's entry with a coincident EventId). + for eid in &all_ids { + let belongs = inner + .messages_cache + .get(eid) + .is_some_and(|msg| &msg.mls_group_id == group_id); + if belongs { + inner.messages_cache.pop(eid); + } + } + + Ok(all_ids.len()) + } + + fn delete_processed_messages_for_group( + &self, + group_id: &GroupId, + ) -> Result { + let mut guard = self.inner.write(); + let inner = &mut *guard; + + let to_remove: Vec = inner + .processed_messages_cache + .iter() + .filter(|(_, pm)| pm.mls_group_id.as_ref() == Some(group_id)) + .map(|(eid, _)| *eid) + .collect(); + + let count = to_remove.len(); + for event_id in &to_remove { + inner.processed_messages_cache.remove(event_id); + } + + Ok(count) + } } #[cfg(test)] @@ -1336,4 +1437,211 @@ mod tests { "processed message was evicted under cache pressure — replay dedup broken" ); } + + #[test] + fn delete_messages_for_group_preserves_processed_messages() { + let storage = MdkMemoryStorage::default(); + let group_id = GroupId::from_slice(&[10, 20, 30]); + storage + .save_group(create_test_group(group_id.clone())) + .unwrap(); + + let eid = EventId::from_slice(&[1u8; 32]).unwrap(); + storage + .save_message(create_test_message(eid, group_id.clone(), "msg", 100)) + .unwrap(); + + let wrapper_eid = EventId::from_slice(&[0xEEu8; 32]).unwrap(); + let pm = ProcessedMessage { + wrapper_event_id: wrapper_eid, + message_event_id: None, + processed_at: Timestamp::from(100u64), + epoch: Some(1), + mls_group_id: Some(group_id.clone()), + state: ProcessedMessageState::Processed, + failure_reason: None, + }; + storage.save_processed_message(pm).unwrap(); + + storage.delete_messages_for_group(&group_id).unwrap(); + + // Processed message is preserved (deduplication guard) + assert!( + storage + .find_processed_message_by_event_id(&wrapper_eid) + .unwrap() + .is_some() + ); + } + + #[test] + fn delete_single_message() { + let storage = MdkMemoryStorage::default(); + let group_id = GroupId::from_slice(&[10, 20, 30]); + storage + .save_group(create_test_group(group_id.clone())) + .unwrap(); + + let eid1 = EventId::from_slice(&[1u8; 32]).unwrap(); + let eid2 = EventId::from_slice(&[2u8; 32]).unwrap(); + storage + .save_message(create_test_message(eid1, group_id.clone(), "msg1", 100)) + .unwrap(); + storage + .save_message(create_test_message(eid2, group_id.clone(), "msg2", 101)) + .unwrap(); + + let deleted = storage.delete_message(&group_id, &eid1).unwrap(); + assert!(deleted); + + // eid1 is gone, eid2 remains + assert!( + storage + .find_message_by_event_id(&group_id, &eid1) + .unwrap() + .is_none() + ); + assert!( + storage + .find_message_by_event_id(&group_id, &eid2) + .unwrap() + .is_some() + ); + } + + #[test] + fn delete_single_message_not_found() { + let storage = MdkMemoryStorage::default(); + let group_id = GroupId::from_slice(&[10, 20, 30]); + storage + .save_group(create_test_group(group_id.clone())) + .unwrap(); + + let missing_eid = EventId::from_slice(&[0xFFu8; 32]).unwrap(); + let deleted = storage.delete_message(&group_id, &missing_eid).unwrap(); + assert!(!deleted); + } + + #[test] + fn delete_messages_before_timestamp() { + let storage = MdkMemoryStorage::default(); + let group_id = GroupId::from_slice(&[10, 20, 30]); + storage + .save_group(create_test_group(group_id.clone())) + .unwrap(); + + // Create messages at timestamps 100, 200, 300 + let eid1 = EventId::from_slice(&[1u8; 32]).unwrap(); + let eid2 = EventId::from_slice(&[2u8; 32]).unwrap(); + let eid3 = EventId::from_slice(&[3u8; 32]).unwrap(); + storage + .save_message(create_test_message(eid1, group_id.clone(), "old", 100)) + .unwrap(); + storage + .save_message(create_test_message(eid2, group_id.clone(), "mid", 200)) + .unwrap(); + storage + .save_message(create_test_message(eid3, group_id.clone(), "new", 300)) + .unwrap(); + + // Delete messages created before timestamp 250 + let deleted = storage + .delete_messages_before_timestamp(&group_id, Timestamp::from(250u64)) + .unwrap(); + assert_eq!(deleted, 2); + + // Only the newest message remains + assert!( + storage + .find_message_by_event_id(&group_id, &eid1) + .unwrap() + .is_none() + ); + assert!( + storage + .find_message_by_event_id(&group_id, &eid2) + .unwrap() + .is_none() + ); + assert!( + storage + .find_message_by_event_id(&group_id, &eid3) + .unwrap() + .is_some() + ); + } + + #[test] + fn delete_messages_before_timestamp_none_match() { + let storage = MdkMemoryStorage::default(); + let group_id = GroupId::from_slice(&[10, 20, 30]); + storage + .save_group(create_test_group(group_id.clone())) + .unwrap(); + + let eid = EventId::from_slice(&[1u8; 32]).unwrap(); + storage + .save_message(create_test_message(eid, group_id.clone(), "msg", 500)) + .unwrap(); + + // Before = 100, but message is at 500 — nothing deleted + let deleted = storage + .delete_messages_before_timestamp(&group_id, Timestamp::from(100u64)) + .unwrap(); + assert_eq!(deleted, 0); + assert!( + storage + .find_message_by_event_id(&group_id, &eid) + .unwrap() + .is_some() + ); + } + + #[test] + fn delete_processed_messages_for_group() { + let storage = MdkMemoryStorage::default(); + let group_a = GroupId::from_slice(&[1, 1, 1]); + let group_b = GroupId::from_slice(&[2, 2, 2]); + + let pm_a = ProcessedMessage { + wrapper_event_id: EventId::from_slice(&[0xAAu8; 32]).unwrap(), + message_event_id: None, + processed_at: Timestamp::from(100u64), + epoch: Some(1), + mls_group_id: Some(group_a.clone()), + state: ProcessedMessageState::Processed, + failure_reason: None, + }; + let pm_b = ProcessedMessage { + wrapper_event_id: EventId::from_slice(&[0xBBu8; 32]).unwrap(), + message_event_id: None, + processed_at: Timestamp::from(100u64), + epoch: Some(1), + mls_group_id: Some(group_b.clone()), + state: ProcessedMessageState::Processed, + failure_reason: None, + }; + storage.save_processed_message(pm_a).unwrap(); + storage.save_processed_message(pm_b).unwrap(); + + let deleted = storage + .delete_processed_messages_for_group(&group_a) + .unwrap(); + assert_eq!(deleted, 1); + + // group_a's processed message is gone + assert!( + storage + .find_processed_message_by_event_id(&EventId::from_slice(&[0xAAu8; 32]).unwrap()) + .unwrap() + .is_none() + ); + // group_b's processed message still exists + assert!( + storage + .find_processed_message_by_event_id(&EventId::from_slice(&[0xBBu8; 32]).unwrap()) + .unwrap() + .is_some() + ); + } } diff --git a/crates/mdk-sqlite-storage/CHANGELOG.md b/crates/mdk-sqlite-storage/CHANGELOG.md index 47795e88..5a86d5ae 100644 --- a/crates/mdk-sqlite-storage/CHANGELOG.md +++ b/crates/mdk-sqlite-storage/CHANGELOG.md @@ -57,6 +57,8 @@ - Added V006 migration adding `disappearing_message_secs INTEGER` column to the `groups` table for disappearing message support. `NULL` means disabled; a positive integer means messages expire after that many seconds. `row_to_group` updated to read the new column. ([#258](https://github.com/marmot-protocol/mdk/pull/258)) - Implemented `delete_messages_for_group` and `delete_group` for local "clear chat" and "delete chat" operations. `delete_group` runs all deletes in a single `BEGIN IMMEDIATE` transaction covering OpenMLS tables, MDK tables, and `processed_messages`. ([#250](https://github.com/marmot-protocol/mdk/pull/250)) +- Implemented `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` for per-message and bulk expiry-based deletion. +- Enabled `PRAGMA secure_delete = ON` so that deleted data (including expired disappearing messages) is overwritten with zeros on disk, preventing forensic recovery from the database file. - Implemented legacy exporter-secret compatibility storage for the temporary `0.6.x -> 0.7.x` migration window, including read/write support for preserved pre-0.7.0 group-event secrets and snapshot rollback restoration into the legacy compatibility label. ([#222](https://github.com/marmot-protocol/mdk/pull/222)) ### Fixed diff --git a/crates/mdk-sqlite-storage/src/lib.rs b/crates/mdk-sqlite-storage/src/lib.rs index 8628dc26..6079d3a4 100644 --- a/crates/mdk-sqlite-storage/src/lib.rs +++ b/crates/mdk-sqlite-storage/src/lib.rs @@ -615,6 +615,10 @@ impl MdkSqliteStorage { // Enable foreign keys (after encryption is set up) conn.execute_batch("PRAGMA foreign_keys = ON;")?; + // Overwrite deleted content with zeros so that forensic recovery of + // expired/deleted messages from the database file is not possible. + conn.execute_batch("PRAGMA secure_delete = ON;")?; + Ok(conn) } @@ -686,6 +690,10 @@ impl MdkSqliteStorage { // Enable foreign keys connection.execute_batch("PRAGMA foreign_keys = ON;")?; + // Overwrite deleted content with zeros so that forensic recovery of + // expired/deleted messages from the database file is not possible. + connection.execute_batch("PRAGMA secure_delete = ON;")?; + // Run all migrations (both OpenMLS tables and MDK tables) migrations::run_migrations(&mut connection)?; diff --git a/crates/mdk-sqlite-storage/src/messages.rs b/crates/mdk-sqlite-storage/src/messages.rs index 613bc6fb..32fe6a2b 100644 --- a/crates/mdk-sqlite-storage/src/messages.rs +++ b/crates/mdk-sqlite-storage/src/messages.rs @@ -4,7 +4,7 @@ use mdk_storage_traits::messages::MessageStorage; use mdk_storage_traits::messages::error::MessageError; use mdk_storage_traits::messages::types::{Message, ProcessedMessage}; use mdk_storage_traits::truncate_failure_reason; -use nostr::{EventId, JsonUtil}; +use nostr::{EventId, JsonUtil, Timestamp}; use rusqlite::{OptionalExtension, params}; use crate::validation::{ @@ -355,6 +355,50 @@ impl MessageStorage for MdkSqliteStorage { .map_err(into_message_err) }) } + + fn delete_message( + &self, + group_id: &mdk_storage_traits::GroupId, + event_id: &EventId, + ) -> Result { + self.with_connection(|conn| { + let rows = conn + .execute( + "DELETE FROM messages WHERE mls_group_id = ? AND id = ?", + params![group_id.as_slice(), event_id.as_bytes()], + ) + .map_err(into_message_err)?; + + Ok(rows > 0) + }) + } + + fn delete_messages_before_timestamp( + &self, + group_id: &mdk_storage_traits::GroupId, + before: Timestamp, + ) -> Result { + self.with_connection(|conn| { + conn.execute( + "DELETE FROM messages WHERE mls_group_id = ? AND created_at < ?", + params![group_id.as_slice(), before.as_secs()], + ) + .map_err(into_message_err) + }) + } + + fn delete_processed_messages_for_group( + &self, + group_id: &mdk_storage_traits::GroupId, + ) -> Result { + self.with_connection(|conn| { + conn.execute( + "DELETE FROM processed_messages WHERE mls_group_id = ?", + params![group_id.as_slice()], + ) + .map_err(into_message_err) + }) + } } #[cfg(test)] @@ -994,7 +1038,7 @@ mod tests { storage.delete_messages_for_group(&group_id).unwrap(); - // Processed message still exists + // Processed messages are preserved (deduplication guard) assert!( storage .find_processed_message_by_event_id(&wrapper_eid) @@ -1024,4 +1068,187 @@ mod tests { assert!(storage.messages(&group_a, None).unwrap().is_empty()); assert_eq!(storage.messages(&group_b, None).unwrap().len(), 1); } + + #[test] + fn delete_single_message() { + let storage = MdkSqliteStorage::new_in_memory().unwrap(); + let group_id = create_test_group(&storage, &[10, 20, 30]); + let eid1 = create_test_message( + &storage, + &group_id, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let eid2 = create_test_message( + &storage, + &group_id, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + + let deleted = storage.delete_message(&group_id, &eid1).unwrap(); + assert!(deleted); + + // eid1 is gone, eid2 remains + assert!( + storage + .find_message_by_event_id(&group_id, &eid1) + .unwrap() + .is_none() + ); + assert!( + storage + .find_message_by_event_id(&group_id, &eid2) + .unwrap() + .is_some() + ); + } + + #[test] + fn delete_single_message_not_found() { + let storage = MdkSqliteStorage::new_in_memory().unwrap(); + let group_id = create_test_group(&storage, &[10, 20, 30]); + + let missing_eid = + EventId::parse("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + .unwrap(); + let deleted = storage.delete_message(&group_id, &missing_eid).unwrap(); + assert!(!deleted); + } + + #[test] + fn delete_messages_before_timestamp() { + let storage = MdkSqliteStorage::new_in_memory().unwrap(); + let group_id = create_test_group(&storage, &[10, 20, 30]); + + // Create messages with specific timestamps + let pubkey = + PublicKey::parse("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .unwrap(); + let wrapper_event_id = EventId::all_zeros(); + + let eid1 = + EventId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .unwrap(); + let eid2 = + EventId::parse("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + .unwrap(); + let eid3 = + EventId::parse("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + .unwrap(); + + for (eid, ts) in [(eid1, 100u64), (eid2, 200), (eid3, 300)] { + let now = Timestamp::from(ts); + let message = Message { + id: eid, + pubkey, + kind: Kind::from(1u16), + mls_group_id: group_id.clone(), + created_at: now, + processed_at: now, + content: "test".to_string(), + tags: Tags::new(), + event: UnsignedEvent::new( + pubkey, + now, + Kind::from(9u16), + vec![], + "test".to_string(), + ), + wrapper_event_id, + epoch: Some(1), + state: MessageState::Created, + }; + storage.save_message(message).unwrap(); + } + + // Delete messages created before timestamp 250 + let deleted = storage + .delete_messages_before_timestamp(&group_id, Timestamp::from(250u64)) + .unwrap(); + assert_eq!(deleted, 2); + + // Only the newest message remains + assert!( + storage + .find_message_by_event_id(&group_id, &eid1) + .unwrap() + .is_none() + ); + assert!( + storage + .find_message_by_event_id(&group_id, &eid2) + .unwrap() + .is_none() + ); + assert!( + storage + .find_message_by_event_id(&group_id, &eid3) + .unwrap() + .is_some() + ); + } + + #[test] + fn delete_processed_messages_for_group() { + let storage = MdkSqliteStorage::new_in_memory().unwrap(); + let group_a = create_test_group(&storage, &[1, 1, 1]); + let group_b = create_test_group(&storage, &[2, 2, 2]); + + let pm_a_eid = + EventId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .unwrap(); + let pm_b_eid = + EventId::parse("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + .unwrap(); + + let pm_a = ProcessedMessage { + wrapper_event_id: pm_a_eid, + message_event_id: None, + processed_at: Timestamp::now(), + epoch: Some(1), + mls_group_id: Some(group_a.clone()), + state: ProcessedMessageState::Processed, + failure_reason: None, + }; + let pm_b = ProcessedMessage { + wrapper_event_id: pm_b_eid, + message_event_id: None, + processed_at: Timestamp::now(), + epoch: Some(1), + mls_group_id: Some(group_b.clone()), + state: ProcessedMessageState::Processed, + failure_reason: None, + }; + storage.save_processed_message(pm_a).unwrap(); + storage.save_processed_message(pm_b).unwrap(); + + let deleted = storage + .delete_processed_messages_for_group(&group_a) + .unwrap(); + assert_eq!(deleted, 1); + + // group_a's processed message is gone + assert!( + storage + .find_processed_message_by_event_id(&pm_a_eid) + .unwrap() + .is_none() + ); + // group_b's processed message still exists + assert!( + storage + .find_processed_message_by_event_id(&pm_b_eid) + .unwrap() + .is_some() + ); + } + + #[test] + fn secure_delete_pragma_is_enabled() { + let storage = MdkSqliteStorage::new_in_memory().unwrap(); + let value: i64 = storage.with_connection(|conn| { + conn.query_row("PRAGMA secure_delete", [], |row| row.get(0)) + .unwrap() + }); + assert_eq!(value, 1, "PRAGMA secure_delete should be ON (1)"); + } } diff --git a/crates/mdk-storage-traits/CHANGELOG.md b/crates/mdk-storage-traits/CHANGELOG.md index 03d1cd08..aa382d6c 100644 --- a/crates/mdk-storage-traits/CHANGELOG.md +++ b/crates/mdk-storage-traits/CHANGELOG.md @@ -50,6 +50,7 @@ ### Breaking changes - Added `MessageStorage::delete_messages_for_group` and `MdkStorageProvider::delete_group` for local "clear chat" and "delete chat" operations. Storage implementations must add these methods. ([#250](https://github.com/marmot-protocol/mdk/pull/250)) +- Added `MessageStorage::delete_message`, `MessageStorage::delete_messages_before_timestamp`, and `MessageStorage::delete_processed_messages_for_group` to the trait. Storage implementations must add these methods. - Added `GroupStorage::get_group_legacy_exporter_secret` and `GroupStorage::save_group_legacy_exporter_secret` so storage backends can preserve pre-0.7.0 exporter-secret bytes separately during the temporary migration-compatibility window. Storage implementations must add these methods. ([#222](https://github.com/marmot-protocol/mdk/pull/222)) ### Changed @@ -63,6 +64,7 @@ ### Added - `GroupStorage::get_group_legacy_exporter_secret` and `GroupStorage::save_group_legacy_exporter_secret` store and retrieve preserved pre-0.7.0 exporter-secret bytes for the temporary migration-compatibility window, returning a group-scoped secret when one was preserved for that epoch. ([#222](https://github.com/marmot-protocol/mdk/pull/222)) +- Added `MessageStorage::delete_message` for per-message deletion, `MessageStorage::delete_messages_before_timestamp` for bulk expiry-based deletion, and `MessageStorage::delete_processed_messages_for_group` for metadata cleanup. These methods support disappearing-message implementation by the client. ### Fixed diff --git a/crates/mdk-storage-traits/src/messages/mod.rs b/crates/mdk-storage-traits/src/messages/mod.rs index f9251281..e0137625 100644 --- a/crates/mdk-storage-traits/src/messages/mod.rs +++ b/crates/mdk-storage-traits/src/messages/mod.rs @@ -7,7 +7,7 @@ //! Here we also define the storage traits that are used to store and retrieve messages use crate::GroupId; -use nostr::EventId; +use nostr::{EventId, Timestamp}; pub mod error; pub mod types; @@ -114,9 +114,46 @@ pub trait MessageStorage { /// Delete all stored messages for a group. /// /// Removes decrypted message content from local storage. Does not affect - /// processed message records, the group's MLS state, or epoch secrets. + /// processed message records (which act as deduplication guards to prevent + /// reprocessing), the group's MLS state, or epoch secrets. /// /// Returns the number of messages deleted. Deleting messages for a group /// with no messages returns `Ok(0)`. fn delete_messages_for_group(&self, group_id: &GroupId) -> Result; + + /// Delete a single message by event ID within a group. + /// + /// Removes the decrypted message content from local storage. Does not + /// affect processed message records, the group's MLS state, or epoch + /// secrets. + /// + /// Returns `Ok(true)` if the message was found and deleted, `Ok(false)` + /// if no message with the given event ID exists in the group. + fn delete_message(&self, group_id: &GroupId, event_id: &EventId) -> Result; + + /// Delete all messages in a group that were created before the given + /// timestamp. + /// + /// This is intended for disappearing-message cleanup: the caller + /// computes `now - duration` and passes it as `before`. All messages + /// with `created_at < before` are removed. + /// + /// Returns the number of messages deleted. + fn delete_messages_before_timestamp( + &self, + group_id: &GroupId, + before: Timestamp, + ) -> Result; + + /// Delete all processed message records for a group. + /// + /// Removes tracking metadata (wrapper event IDs, epochs, processing + /// state) from local storage. This means previously-seen events may be + /// reprocessed if encountered again. + /// + /// Returns the number of processed message records deleted. + fn delete_processed_messages_for_group( + &self, + group_id: &GroupId, + ) -> Result; } diff --git a/crates/mdk-uniffi/src/lib.rs b/crates/mdk-uniffi/src/lib.rs index 1fed956c..02c33ca5 100644 --- a/crates/mdk-uniffi/src/lib.rs +++ b/crates/mdk-uniffi/src/lib.rs @@ -1251,6 +1251,8 @@ impl Mdk { } /// Delete all locally stored messages for a group. + /// + /// Processed message records are preserved to prevent reprocessing. pub fn delete_messages_for_group(&self, mls_group_id: String) -> Result { let group_id = parse_group_id(&mls_group_id)?; let mdk = self.lock()?; @@ -1258,6 +1260,51 @@ impl Mdk { Ok(count as u32) } + /// Delete a single message by event ID within a group. + /// + /// Returns true if the message was found and deleted, false if not found. + pub fn delete_message( + &self, + mls_group_id: String, + event_id: String, + ) -> Result { + let group_id = parse_group_id(&mls_group_id)?; + let eid = nostr::EventId::parse(&event_id) + .map_err(|e| MdkUniffiError::InvalidInput(e.to_string()))?; + let mdk = self.lock()?; + let deleted = mdk.delete_message(&group_id, &eid)?; + Ok(deleted) + } + + /// Delete all messages in a group created before the given Unix timestamp. + /// + /// Intended for disappearing-message cleanup. Returns the number of + /// messages deleted. + pub fn delete_messages_before_timestamp( + &self, + mls_group_id: String, + before_secs: u64, + ) -> Result { + let group_id = parse_group_id(&mls_group_id)?; + let before = nostr::Timestamp::from(before_secs); + let mdk = self.lock()?; + let count = mdk.delete_messages_before_timestamp(&group_id, before)?; + Ok(count as u32) + } + + /// Delete all processed message records for a group. + /// + /// Returns the number of records deleted. + pub fn delete_processed_messages_for_group( + &self, + mls_group_id: String, + ) -> Result { + let group_id = parse_group_id(&mls_group_id)?; + let mdk = self.lock()?; + let count = mdk.delete_processed_messages_for_group(&group_id)?; + Ok(count as u32) + } + /// Delete all local state for a group. pub fn delete_group(&self, mls_group_id: String) -> Result<(), MdkUniffiError> { let group_id = parse_group_id(&mls_group_id)?; From 9e7965154ed8c4d305de0a76224ab5798e6ac777 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Fri, 22 May 2026 13:52:16 +0200 Subject: [PATCH 2/2] chore: move Part-3 changelog entries from [0.8.0] to Unreleased, add PR refs The cherry-pick from the original combined PR #253 placed the deletion-API and secure_delete entries in the [0.8.0] section because that PR predated the 0.8.0 release cut. Move them to Unreleased and append the #315 PR link, matching the convention used for Part 1 (#258) and Part 2 (#306). Also adds the missing mdk-uniffi changelog entry for the new deletion bindings. --- crates/mdk-core/CHANGELOG.md | 2 +- crates/mdk-memory-storage/CHANGELOG.md | 3 ++- crates/mdk-sqlite-storage/CHANGELOG.md | 5 +++-- crates/mdk-storage-traits/CHANGELOG.md | 4 ++-- crates/mdk-uniffi/CHANGELOG.md | 1 + 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/mdk-core/CHANGELOG.md b/crates/mdk-core/CHANGELOG.md index 6ff7466a..c0f57a8e 100644 --- a/crates/mdk-core/CHANGELOG.md +++ b/crates/mdk-core/CHANGELOG.md @@ -38,6 +38,7 @@ - `create_group` and `update_group_data` now reject `Some(0)` (or `Some(Some(0))` on updates) with `Error::Group`. Callers must use `None` (or `Some(None)` on updates) to disable. Part 2 of #253. ([#306](https://github.com/marmot-protocol/mdk/pull/306)) - Outer kind:445 wrappers built by `build_message_event` (messages, proposals, commits) automatically carry a NIP-40 `expiration` tag when the group has a `disappearing_message_secs` set. If the caller also supplies an expiration tag, the earliest of the two is used — the caller can request a shorter ephemeral lifetime but never extend the group's setting. The event's `created_at` is pinned to the same snapshot used for the expiration math. Part 2 of #253. ([#306](https://github.com/marmot-protocol/mdk/pull/306)) - Added `KeyPackageOptions::existing_d_tag: Option` so callers can supply a previously stored `d` tag value when rotating a KeyPackage. When `Some`, the value is validated (non-empty, exactly 64 ASCII hex digits per MIP-00) and used directly for both the kind:30443 `Tag::identifier(...)` and the returned `KeyPackageEventData::d_tag`; no random `d` is generated. When `None`, the existing random 32-byte hex behavior is preserved. Validation matches the MIP-00 constraint enforced by `parse_key_package`, so caller-supplied values round-trip through publication and re-parsing. The `mdk_core::key_packages::validate_existing_d_tag` helper is also re-exported as `pub` so consumers (and the UniFFI binding) can pre-validate before calling. Closes the ergonomics gap that previously forced consumers (e.g. whitenoise-rs) to post-edit the tag list to keep their NIP-33 addressable slot stable across rotations. ([#303](https://github.com/marmot-protocol/mdk/pull/303)) +- Added `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` public methods on `MDK` for granular message deletion supporting disappearing-message cleanup by the client. Part 3 of #253. ([#315](https://github.com/marmot-protocol/mdk/pull/315)) ### Fixed @@ -91,7 +92,6 @@ - Admin depletion validation: SelfRemove proposals and commits are rejected if they would leave the group with zero admins. ([#236](https://github.com/marmot-protocol/mdk/pull/236)) - Added feature-gated MIP-05 notification request builders that group token tags by notification server, preserve relay hints, chunk requests at 100 tokens per server, and return ready-to-publish gift-wrapped notification batches for `kind:446` delivery. ([#238](https://github.com/marmot-protocol/mdk/pull/238)) - `create_message` now accepts an optional `tags` parameter of type `Vec` for appending allow-listed tags (e.g. NIP-40 `expiration`) to the outer kind:445 wrapper event. The `EventTag` enum enforces at compile time which tags are permitted. ([#248](https://github.com/marmot-protocol/mdk/pull/248)) -- Added `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` public methods on `MDK` for granular message deletion supporting disappearing-message cleanup by the client. ### Fixed diff --git a/crates/mdk-memory-storage/CHANGELOG.md b/crates/mdk-memory-storage/CHANGELOG.md index 1b60791c..92c9b62d 100644 --- a/crates/mdk-memory-storage/CHANGELOG.md +++ b/crates/mdk-memory-storage/CHANGELOG.md @@ -31,6 +31,8 @@ ### Added +- Implemented `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` for per-message and bulk expiry-based deletion. `delete_message` and `delete_messages_before_timestamp` scope `messages_cache` eviction to the owning group so a coincident `EventId` in another group cannot be evicted by accident. Part 3 of #253. ([#315](https://github.com/marmot-protocol/mdk/pull/315)) + ### Fixed - Fixed `MdkMemoryStorage::save_welcome` to reject oversized welcome group metadata and serialized event JSON before caching welcomes, matching SQLite backend payload bounds. ([#276](https://github.com/marmot-protocol/mdk/pull/276)) @@ -56,7 +58,6 @@ - Added `MdkMemoryStorage::signature_key_count` under the `test-utils` feature. Cardinality probe over the signer store for invariant tests that need to observe store growth when the fresh signer's public key is generated internally and therefore cannot be rediscovered via `SignatureKeyPair::read`. ([#262](https://github.com/marmot-protocol/mdk/pull/262)) - Propagated `disappearing_message_secs` field through all `Group` construction sites for the new disappearing messages feature. ([#258](https://github.com/marmot-protocol/mdk/pull/258)) - Implemented `delete_messages_for_group` and `delete_group` for local "clear chat" and "delete chat" operations. Added `clear_group` methods to `MlsGroupData` and `MlsEpochKeyPairs` for bulk group-scoped MLS state cleanup. ([#250](https://github.com/marmot-protocol/mdk/pull/250)) -- Implemented `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` for per-message and bulk expiry-based deletion. - Implemented legacy exporter-secret compatibility storage for the temporary `0.6.x -> 0.7.x` migration window, including snapshot and restore support for preserved pre-0.7.0 group-event secrets. ([#222](https://github.com/marmot-protocol/mdk/pull/222)) ### Fixed diff --git a/crates/mdk-sqlite-storage/CHANGELOG.md b/crates/mdk-sqlite-storage/CHANGELOG.md index 5a86d5ae..8926c589 100644 --- a/crates/mdk-sqlite-storage/CHANGELOG.md +++ b/crates/mdk-sqlite-storage/CHANGELOG.md @@ -33,6 +33,9 @@ ### Added +- Implemented `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` for per-message and bulk expiry-based deletion. Part 3 of #253. ([#315](https://github.com/marmot-protocol/mdk/pull/315)) +- Enabled `PRAGMA secure_delete = ON` on every connection init so deleted data (including expired disappearing messages) is overwritten with zeros on disk, blocking forensic recovery from the database file. Part 3 of #253. ([#315](https://github.com/marmot-protocol/mdk/pull/315)) + ### Fixed - `delete_group` now scrubs `processed_welcomes` rows for the group via a join through `welcomes` on `wrapper_event_id`, ordered before the existing `welcomes` delete. Previously these rows survived deletion, leaking `wrapper_event_id`, `welcome_event_id`, `processed_at`, `state`, and unsanitized `failure_reason` linkable to the deleted group, and tripping the welcome dedup path on re-processing. Closes [marmot-protocol/marmot-security#68](https://github.com/marmot-protocol/marmot-security/issues/68). ([#293](https://github.com/marmot-protocol/mdk/pull/293)) @@ -57,8 +60,6 @@ - Added V006 migration adding `disappearing_message_secs INTEGER` column to the `groups` table for disappearing message support. `NULL` means disabled; a positive integer means messages expire after that many seconds. `row_to_group` updated to read the new column. ([#258](https://github.com/marmot-protocol/mdk/pull/258)) - Implemented `delete_messages_for_group` and `delete_group` for local "clear chat" and "delete chat" operations. `delete_group` runs all deletes in a single `BEGIN IMMEDIATE` transaction covering OpenMLS tables, MDK tables, and `processed_messages`. ([#250](https://github.com/marmot-protocol/mdk/pull/250)) -- Implemented `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group` for per-message and bulk expiry-based deletion. -- Enabled `PRAGMA secure_delete = ON` so that deleted data (including expired disappearing messages) is overwritten with zeros on disk, preventing forensic recovery from the database file. - Implemented legacy exporter-secret compatibility storage for the temporary `0.6.x -> 0.7.x` migration window, including read/write support for preserved pre-0.7.0 group-event secrets and snapshot rollback restoration into the legacy compatibility label. ([#222](https://github.com/marmot-protocol/mdk/pull/222)) ### Fixed diff --git a/crates/mdk-storage-traits/CHANGELOG.md b/crates/mdk-storage-traits/CHANGELOG.md index aa382d6c..e4a2ed0d 100644 --- a/crates/mdk-storage-traits/CHANGELOG.md +++ b/crates/mdk-storage-traits/CHANGELOG.md @@ -29,12 +29,14 @@ - Changed default serde serialization for `Secret` to fail instead of emitting wrapped secret values. Use `Secret::expose_for_serialization()` for deliberate plaintext exports. ([#280](https://github.com/marmot-protocol/mdk/pull/280)) - Added `disappearing_message_secs: Option` field to the `Group` struct. All code that constructs `Group` structs must now provide this field. `None` means messages persist forever; `Some(n)` means messages expire after `n` seconds. ([#253](https://github.com/marmot-protocol/mdk/pull/253)) +- Added `MessageStorage::delete_message`, `MessageStorage::delete_messages_before_timestamp`, and `MessageStorage::delete_processed_messages_for_group` to the trait. Storage implementations must add these methods. Part 3 of #253. ([#315](https://github.com/marmot-protocol/mdk/pull/315)) ### Changed ### Added - Added `MAX_FAILURE_REASON_LEN` (256 bytes) and `truncate_failure_reason(Option) -> Option` so storage backends can defensively cap the length of `failure_reason` values before persistence, with UTF-8-safe truncation that walks back to a valid char boundary. Closes [marmot-protocol/marmot-security#19](https://github.com/marmot-protocol/marmot-security/issues/19). ([#307](https://github.com/marmot-protocol/mdk/pull/307)) +- `MessageStorage::delete_message` enables per-message deletion. `MessageStorage::delete_messages_before_timestamp` enables bulk expiry-based deletion. `MessageStorage::delete_processed_messages_for_group` clears the dedup metadata for a group. These methods support disappearing-message implementation by the client. Part 3 of #253. ([#315](https://github.com/marmot-protocol/mdk/pull/315)) ### Fixed @@ -50,7 +52,6 @@ ### Breaking changes - Added `MessageStorage::delete_messages_for_group` and `MdkStorageProvider::delete_group` for local "clear chat" and "delete chat" operations. Storage implementations must add these methods. ([#250](https://github.com/marmot-protocol/mdk/pull/250)) -- Added `MessageStorage::delete_message`, `MessageStorage::delete_messages_before_timestamp`, and `MessageStorage::delete_processed_messages_for_group` to the trait. Storage implementations must add these methods. - Added `GroupStorage::get_group_legacy_exporter_secret` and `GroupStorage::save_group_legacy_exporter_secret` so storage backends can preserve pre-0.7.0 exporter-secret bytes separately during the temporary migration-compatibility window. Storage implementations must add these methods. ([#222](https://github.com/marmot-protocol/mdk/pull/222)) ### Changed @@ -64,7 +65,6 @@ ### Added - `GroupStorage::get_group_legacy_exporter_secret` and `GroupStorage::save_group_legacy_exporter_secret` store and retrieve preserved pre-0.7.0 exporter-secret bytes for the temporary migration-compatibility window, returning a group-scoped secret when one was preserved for that epoch. ([#222](https://github.com/marmot-protocol/mdk/pull/222)) -- Added `MessageStorage::delete_message` for per-message deletion, `MessageStorage::delete_messages_before_timestamp` for bulk expiry-based deletion, and `MessageStorage::delete_processed_messages_for_group` for metadata cleanup. These methods support disappearing-message implementation by the client. ### Fixed diff --git a/crates/mdk-uniffi/CHANGELOG.md b/crates/mdk-uniffi/CHANGELOG.md index 0d89a4f2..094dae0e 100644 --- a/crates/mdk-uniffi/CHANGELOG.md +++ b/crates/mdk-uniffi/CHANGELOG.md @@ -41,6 +41,7 @@ - Added UniFFI bindings for group capability inspection and upgrades: `group_member_capabilities`, `group_capability_upgrade_status`, and `upgrade_group_capabilities`, plus binding-safe records and enums for member capability snapshots and upgrade readiness. ([#301](https://github.com/marmot-protocol/mdk/pull/301)) - Added the `KeyPackageOptions` UniFFI record (fields: `protected: Boolean`, `existing_d_tag: Option`). Pass a previously stored `d_tag` (the value returned in `KeyPackageResult.d_tag`) via `existing_d_tag` to rotate a KeyPackage while keeping the NIP-33 addressable slot stable — no more post-editing the tag list before signing. The value is validated at the FFI boundary (exactly 64 ASCII hex characters per MIP-00) so callers see `MdkUniffiError.InvalidInput` directly on malformed input. ([#303](https://github.com/marmot-protocol/mdk/pull/303)) +- Added UniFFI bindings for `delete_message`, `delete_messages_before_timestamp`, and `delete_processed_messages_for_group`, exposing the new granular deletion APIs to Kotlin and Swift consumers for disappearing-message cleanup. Part 3 of #253. ([#315](https://github.com/marmot-protocol/mdk/pull/315)) ### Fixed