saorsa_gossip_groups/
lib.rs1#![warn(missing_docs)]
2
3use saorsa_gossip_types::TopicId;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
12pub enum CipherSuite {
13 MlKem768MlDsa65,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct GroupContext {
20 pub topic_id: TopicId,
22 pub cipher_suite: CipherSuite,
24 pub epoch: u64,
26 presence_exporter: Option<[u8; 32]>,
28}
29
30impl GroupContext {
31 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 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 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 pub fn set_presence_exporter(&mut self, exporter_secret: [u8; 32]) {
68 self.presence_exporter = Some(exporter_secret);
69 }
70
71 pub fn presence_exporter(&self) -> Option<[u8; 32]> {
73 self.presence_exporter
74 }
75
76 pub fn next_epoch(&mut self) {
78 self.epoch += 1;
79 }
80
81 pub fn derive_presence_secret(
94 exporter_context: &[u8; 32],
95 user_id: &[u8],
96 time_slice: u64,
97 ) -> [u8; 32] {
98 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 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 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}