Skip to content

Commit 4f03025

Browse files
authored
fix: prevent nostr_group_id cache collision and stale key entries in memory storage (#149)
* fix: prevent nostr_group_id cache collision and stale key entries in memory storage * update changelog * fix: remove group ID from error message
1 parent d804be7 commit 4f03025

File tree

3 files changed

+213
-7
lines changed

3 files changed

+213
-7
lines changed

crates/mdk-memory-storage/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343

4444
### Fixed
4545

46+
- **Security (Audit Issue AC)**: Fixed `nostr_group_id` cache collision vulnerability that allowed lookup hijacking and stale key entries. The `save_group` function now rejects saves when `nostr_group_id` already maps to a different `mls_group_id`, and removes stale entries when a group's identifier changes. ([#149](https://github.com/marmot-protocol/mdk/pull/149))
4647
- Fixed compilation errors in `mdk-memory-storage` implementation and tests ([#FIXME](https://github.com/marmot-protocol/mdk/pull/FIXME))
4748
- **Security (Audit Issue 6/Suggestion 6)**: Improved `save_message` performance from O(n) to expected/amortized O(1) by replacing `Vec<Message>` with `HashMap<EventId, Message>` for the messages-by-group cache. This addresses potential DoS risk from high message counts per group (threat model T.10.2 and T.10.4). Fixes [#92](https://github.com/marmot-protocol/mdk/issues/92) ([#134](https://github.com/marmot-protocol/mdk/pull/134))
4849
- **Security (Audit Issue M)**: Fixed messages being overwritten across groups by updating `find_message_by_event_id()` to use group-scoped cache lookups. This prevents an attacker or faulty relay from causing message loss and misattribution across groups by reusing deterministic rumor IDs. ([#124](https://github.com/marmot-protocol/mdk/pull/124))

crates/mdk-memory-storage/src/groups.rs

Lines changed: 205 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,30 @@ use crate::MdkMemoryStorage;
1313

1414
impl GroupStorage for MdkMemoryStorage {
1515
fn save_group(&self, group: Group) -> Result<(), GroupError> {
16-
// Store in the MLS group ID cache
16+
// Acquire both locks to ensure atomic update
17+
let mut groups_cache = self.groups_cache.write();
18+
let mut nostr_id_cache = self.groups_by_nostr_id_cache.write();
19+
20+
// Check if nostr_group_id is already mapped to a different mls_group_id
21+
if let Some(existing_group) = nostr_id_cache.peek(&group.nostr_group_id)
22+
&& existing_group.mls_group_id != group.mls_group_id
1723
{
18-
let mut cache = self.groups_cache.write();
19-
cache.put(group.mls_group_id.clone(), group.clone());
24+
return Err(GroupError::InvalidParameters(
25+
"nostr_group_id already exists for a different group".to_string(),
26+
));
2027
}
2128

22-
// Store in the Nostr group ID cache
29+
// If updating an existing group and nostr_group_id changed, remove stale entry
30+
if let Some(existing_group) = groups_cache.peek(&group.mls_group_id)
31+
&& existing_group.nostr_group_id != group.nostr_group_id
2332
{
24-
let mut cache = self.groups_by_nostr_id_cache.write();
25-
cache.put(group.nostr_group_id, group);
33+
nostr_id_cache.pop(&existing_group.nostr_group_id);
2634
}
2735

36+
// Store in both caches
37+
groups_cache.put(group.mls_group_id.clone(), group.clone());
38+
nostr_id_cache.put(group.nostr_group_id, group);
39+
2840
Ok(())
2941
}
3042

@@ -347,4 +359,191 @@ mod tests {
347359
assert!(result.is_ok());
348360
assert_eq!(result.unwrap().len(), 0); // No results at that offset
349361
}
362+
363+
#[test]
364+
fn test_nostr_group_id_collision_rejected() {
365+
let storage = MdkMemoryStorage::new(MemoryStorage::default());
366+
367+
// Create first group with a specific nostr_group_id
368+
let mls_group_id_1 = GroupId::from_slice(&[1, 2, 3, 4]);
369+
let shared_nostr_group_id = [42u8; 32];
370+
371+
let group1 = Group {
372+
mls_group_id: mls_group_id_1.clone(),
373+
nostr_group_id: shared_nostr_group_id,
374+
name: "Group 1".to_string(),
375+
description: "First group".to_string(),
376+
admin_pubkeys: BTreeSet::new(),
377+
last_message_id: None,
378+
last_message_at: None,
379+
epoch: 0,
380+
state: GroupState::Active,
381+
image_hash: None,
382+
image_key: None,
383+
image_nonce: None,
384+
};
385+
386+
storage.save_group(group1).unwrap();
387+
388+
// Attempt to create a second group with the same nostr_group_id but different mls_group_id
389+
let mls_group_id_2 = GroupId::from_slice(&[5, 6, 7, 8]);
390+
391+
let group2 = Group {
392+
mls_group_id: mls_group_id_2.clone(),
393+
nostr_group_id: shared_nostr_group_id, // Same nostr_group_id - collision!
394+
name: "Group 2".to_string(),
395+
description: "Second group trying to hijack".to_string(),
396+
admin_pubkeys: BTreeSet::new(),
397+
last_message_id: None,
398+
last_message_at: None,
399+
epoch: 0,
400+
state: GroupState::Active,
401+
image_hash: None,
402+
image_key: None,
403+
image_nonce: None,
404+
};
405+
406+
// This should fail because nostr_group_id is already used by a different group
407+
let result = storage.save_group(group2);
408+
assert!(result.is_err());
409+
let err = result.unwrap_err();
410+
assert!(
411+
err.to_string().contains("nostr_group_id already exists"),
412+
"Expected collision error, got: {}",
413+
err
414+
);
415+
416+
// Verify the original group is still intact
417+
let found = storage
418+
.find_group_by_nostr_group_id(&shared_nostr_group_id)
419+
.unwrap()
420+
.unwrap();
421+
assert_eq!(found.mls_group_id, mls_group_id_1);
422+
assert_eq!(found.name, "Group 1");
423+
}
424+
425+
#[test]
426+
fn test_nostr_group_id_update_removes_stale_entry() {
427+
let storage = MdkMemoryStorage::new(MemoryStorage::default());
428+
429+
let mls_group_id = GroupId::from_slice(&[1, 2, 3, 4]);
430+
let old_nostr_group_id = [1u8; 32];
431+
let new_nostr_group_id = [2u8; 32];
432+
433+
// Create group with initial nostr_group_id
434+
let group = Group {
435+
mls_group_id: mls_group_id.clone(),
436+
nostr_group_id: old_nostr_group_id,
437+
name: "Test Group".to_string(),
438+
description: "A test group".to_string(),
439+
admin_pubkeys: BTreeSet::new(),
440+
last_message_id: None,
441+
last_message_at: None,
442+
epoch: 0,
443+
state: GroupState::Active,
444+
image_hash: None,
445+
image_key: None,
446+
image_nonce: None,
447+
};
448+
449+
storage.save_group(group).unwrap();
450+
451+
// Verify group is findable by old nostr_group_id
452+
assert!(
453+
storage
454+
.find_group_by_nostr_group_id(&old_nostr_group_id)
455+
.unwrap()
456+
.is_some()
457+
);
458+
459+
// Update the group with a new nostr_group_id
460+
let updated_group = Group {
461+
mls_group_id: mls_group_id.clone(),
462+
nostr_group_id: new_nostr_group_id,
463+
name: "Test Group Updated".to_string(),
464+
description: "A test group".to_string(),
465+
admin_pubkeys: BTreeSet::new(),
466+
last_message_id: None,
467+
last_message_at: None,
468+
epoch: 1,
469+
state: GroupState::Active,
470+
image_hash: None,
471+
image_key: None,
472+
image_nonce: None,
473+
};
474+
475+
storage.save_group(updated_group).unwrap();
476+
477+
// Old nostr_group_id should no longer find the group (stale entry removed)
478+
assert!(
479+
storage
480+
.find_group_by_nostr_group_id(&old_nostr_group_id)
481+
.unwrap()
482+
.is_none(),
483+
"Old nostr_group_id should not find the group after update"
484+
);
485+
486+
// New nostr_group_id should find the updated group
487+
let found = storage
488+
.find_group_by_nostr_group_id(&new_nostr_group_id)
489+
.unwrap()
490+
.unwrap();
491+
assert_eq!(found.mls_group_id, mls_group_id);
492+
assert_eq!(found.name, "Test Group Updated");
493+
assert_eq!(found.epoch, 1);
494+
}
495+
496+
#[test]
497+
fn test_same_group_update_allowed() {
498+
let storage = MdkMemoryStorage::new(MemoryStorage::default());
499+
500+
let mls_group_id = GroupId::from_slice(&[1, 2, 3, 4]);
501+
let nostr_group_id = [1u8; 32];
502+
503+
// Create initial group
504+
let group = Group {
505+
mls_group_id: mls_group_id.clone(),
506+
nostr_group_id,
507+
name: "Test Group".to_string(),
508+
description: "A test group".to_string(),
509+
admin_pubkeys: BTreeSet::new(),
510+
last_message_id: None,
511+
last_message_at: None,
512+
epoch: 0,
513+
state: GroupState::Active,
514+
image_hash: None,
515+
image_key: None,
516+
image_nonce: None,
517+
};
518+
519+
storage.save_group(group).unwrap();
520+
521+
// Update the same group (same mls_group_id and nostr_group_id)
522+
let updated_group = Group {
523+
mls_group_id: mls_group_id.clone(),
524+
nostr_group_id, // Same nostr_group_id
525+
name: "Updated Group Name".to_string(),
526+
description: "Updated description".to_string(),
527+
admin_pubkeys: BTreeSet::new(),
528+
last_message_id: None,
529+
last_message_at: None,
530+
epoch: 1,
531+
state: GroupState::Active,
532+
image_hash: None,
533+
image_key: None,
534+
image_nonce: None,
535+
};
536+
537+
// This should succeed - updating the same group
538+
let result = storage.save_group(updated_group);
539+
assert!(result.is_ok());
540+
541+
// Verify the update was applied
542+
let found = storage
543+
.find_group_by_mls_group_id(&mls_group_id)
544+
.unwrap()
545+
.unwrap();
546+
assert_eq!(found.name, "Updated Group Name");
547+
assert_eq!(found.epoch, 1);
548+
}
350549
}

crates/mdk-memory-storage/src/messages.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,15 @@ mod tests {
100100
use super::*;
101101

102102
fn create_test_group(group_id: GroupId) -> Group {
103+
// Use the group_id bytes to derive a unique nostr_group_id
104+
let mut nostr_group_id = [0u8; 32];
105+
let group_id_bytes = group_id.as_slice();
106+
nostr_group_id[..group_id_bytes.len().min(32)]
107+
.copy_from_slice(&group_id_bytes[..group_id_bytes.len().min(32)]);
108+
103109
Group {
104110
mls_group_id: group_id.clone(),
105-
nostr_group_id: [0u8; 32],
111+
nostr_group_id,
106112
name: "Test Group".to_string(),
107113
description: "A test group".to_string(),
108114
admin_pubkeys: BTreeSet::new(),

0 commit comments

Comments
 (0)