diff --git a/crates/mdk-core/CHANGELOG.md b/crates/mdk-core/CHANGELOG.md index fd8e036a..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 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..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)) 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..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)) 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..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 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/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 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)?;