Skip to main content

saorsa_gossip_groups/
lib.rs

1#![warn(missing_docs)]
2
3//! MLS group management
4//!
5//! Manages MLS groups for secure group communication
6
7use saorsa_gossip_types::TopicId;
8use serde::{Deserialize, Serialize};
9
10/// MLS cipher suite (placeholder for saorsa-mls integration)
11#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
12pub enum CipherSuite {
13    /// ML-KEM-768 + ML-DSA-65 (default PQC suite)
14    MlKem768MlDsa65,
15}
16
17/// MLS group context
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct GroupContext {
20    /// Group/Topic identifier
21    pub topic_id: TopicId,
22    /// Cipher suite
23    pub cipher_suite: CipherSuite,
24    /// Current epoch
25    pub epoch: u64,
26    /// Optional MLS exporter secret used for presence beacons
27    presence_exporter: Option<[u8; 32]>,
28}
29
30impl GroupContext {
31    /// Create a new group context
32    pub fn new(topic_id: TopicId) -> Self {
33        Self {
34            topic_id,
35            cipher_suite: CipherSuite::MlKem768MlDsa65,
36            epoch: 0,
37            presence_exporter: None,
38        }
39    }
40
41    /// Create a group context with a known MLS exporter secret
42    pub fn with_presence_exporter(topic_id: TopicId, exporter_secret: [u8; 32]) -> Self {
43        Self {
44            presence_exporter: Some(exporter_secret),
45            ..Self::new(topic_id)
46        }
47    }
48
49    /// Create a new group context from an entity identifier.
50    ///
51    /// This is a convenience constructor that derives the TopicId from the entity_id.
52    /// Equivalent to `GroupContext::new(TopicId::from_entity(entity_id))`
53    ///
54    /// Accepts any type that can be converted to a byte slice (see `TopicId::from_entity`).
55    ///
56    /// # Arguments
57    /// * `entity_id` - Identifier for the entity (channel, project, org, binary ID, etc.)
58    ///
59    /// # Returns
60    /// * `Self` - GroupContext with topic_id derived from entity_id
61    pub fn from_entity(entity_id: impl AsRef<[u8]>) -> Self {
62        let topic_id = TopicId::from_entity(entity_id);
63        Self::new(topic_id)
64    }
65
66    /// Set the MLS exporter secret used for presence beacons.
67    pub fn set_presence_exporter(&mut self, exporter_secret: [u8; 32]) {
68        self.presence_exporter = Some(exporter_secret);
69    }
70
71    /// Get the configured MLS exporter secret, if any.
72    pub fn presence_exporter(&self) -> Option<[u8; 32]> {
73        self.presence_exporter
74    }
75
76    /// Advance to next epoch
77    pub fn next_epoch(&mut self) {
78        self.epoch += 1;
79    }
80
81    /// Derive exporter secret for presence tags
82    ///
83    /// Uses BLAKE3 keyed hash to derive presence tags from MLS exporter secret.
84    /// Per SPEC2 ยง10, presence tags rotate based on time_slice for privacy.
85    ///
86    /// # Arguments
87    /// * `exporter_context` - MLS exporter secret (32 bytes)
88    /// * `user_id` - User's PeerId bytes
89    /// * `time_slice` - Time-based rotation parameter (e.g., hour since epoch)
90    ///
91    /// # Returns
92    /// Derived presence tag (32 bytes)
93    pub fn derive_presence_secret(
94        exporter_context: &[u8; 32],
95        user_id: &[u8],
96        time_slice: u64,
97    ) -> [u8; 32] {
98        // KDF(exporter_secret, user_id || time_slice) using BLAKE3 keyed hash
99        let mut hasher = blake3::Hasher::new_keyed(exporter_context);
100        hasher.update(user_id);
101        hasher.update(&time_slice.to_le_bytes());
102        let hash = hasher.finalize();
103        let mut tag = [0u8; 32];
104        tag.copy_from_slice(&hash.as_bytes()[..32]);
105        tag
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_group_context() {
115        let topic = TopicId::new([1u8; 32]);
116        let mut ctx = GroupContext::new(topic);
117
118        assert_eq!(ctx.epoch, 0);
119        assert!(ctx.presence_exporter().is_none());
120        ctx.next_epoch();
121        assert_eq!(ctx.epoch, 1);
122    }
123
124    #[test]
125    fn test_group_context_with_presence_exporter() {
126        let topic = TopicId::new([9u8; 32]);
127        let secret = [7u8; 32];
128        let ctx = GroupContext::with_presence_exporter(topic, secret);
129        assert_eq!(ctx.presence_exporter(), Some(secret));
130    }
131
132    #[test]
133    fn test_group_context_from_entity() {
134        let entity_id = "channel-general";
135        let ctx = GroupContext::from_entity(entity_id);
136
137        assert_eq!(ctx.epoch, 0);
138        assert!(matches!(ctx.cipher_suite, CipherSuite::MlKem768MlDsa65));
139    }
140
141    #[test]
142    fn test_group_context_from_entity_deterministic() {
143        // Same entity ID should produce same topic ID
144        let entity_id = "project-alpha";
145        let ctx1 = GroupContext::from_entity(entity_id);
146        let ctx2 = GroupContext::from_entity(entity_id);
147
148        assert_eq!(
149            ctx1.topic_id, ctx2.topic_id,
150            "Same entity should produce same topic"
151        );
152    }
153
154    #[test]
155    fn test_group_context_from_entity_vs_new() {
156        // from_entity should be equivalent to new(TopicId::from_entity(...))
157        let entity_id = "org-acme";
158        let ctx_from_entity = GroupContext::from_entity(entity_id);
159        let topic = TopicId::from_entity(entity_id);
160        let ctx_from_new = GroupContext::new(topic);
161
162        assert_eq!(ctx_from_entity.topic_id, ctx_from_new.topic_id);
163        assert_eq!(ctx_from_entity.epoch, ctx_from_new.epoch);
164        assert_eq!(ctx_from_entity.presence_exporter(), None);
165    }
166
167    #[test]
168    fn test_set_presence_exporter() {
169        let topic = TopicId::new([5u8; 32]);
170        let mut ctx = GroupContext::new(topic);
171        assert!(ctx.presence_exporter().is_none());
172        let secret = [11u8; 32];
173        ctx.set_presence_exporter(secret);
174        assert_eq!(ctx.presence_exporter(), Some(secret));
175    }
176
177    #[test]
178    fn test_derive_presence_secret_deterministic() {
179        let exporter = [1u8; 32];
180        let user_id = [2u8; 32];
181        let time_slice = 12345u64;
182
183        let tag1 = GroupContext::derive_presence_secret(&exporter, &user_id, time_slice);
184        let tag2 = GroupContext::derive_presence_secret(&exporter, &user_id, time_slice);
185
186        assert_eq!(tag1, tag2, "Same inputs should produce same tag");
187    }
188
189    #[test]
190    fn test_derive_presence_secret_rotation() {
191        let exporter = [1u8; 32];
192        let user_id = [2u8; 32];
193
194        let tag1 = GroupContext::derive_presence_secret(&exporter, &user_id, 1000);
195        let tag2 = GroupContext::derive_presence_secret(&exporter, &user_id, 1001);
196
197        assert_ne!(
198            tag1, tag2,
199            "Different time slices should produce different tags"
200        );
201    }
202
203    #[test]
204    fn test_derive_presence_secret_user_unique() {
205        let exporter = [1u8; 32];
206        let user1 = [1u8; 32];
207        let user2 = [2u8; 32];
208        let time_slice = 12345u64;
209
210        let tag1 = GroupContext::derive_presence_secret(&exporter, &user1, time_slice);
211        let tag2 = GroupContext::derive_presence_secret(&exporter, &user2, time_slice);
212
213        assert_ne!(tag1, tag2, "Different users should produce different tags");
214    }
215
216    #[test]
217    fn test_derive_presence_secret_exporter_unique() {
218        let exporter1 = [1u8; 32];
219        let exporter2 = [2u8; 32];
220        let user_id = [3u8; 32];
221        let time_slice = 12345u64;
222
223        let tag1 = GroupContext::derive_presence_secret(&exporter1, &user_id, time_slice);
224        let tag2 = GroupContext::derive_presence_secret(&exporter2, &user_id, time_slice);
225
226        assert_ne!(
227            tag1, tag2,
228            "Different exporters should produce different tags"
229        );
230    }
231}