1use crate::telemetry::CategorySampler;
4use offline_protocol_mls::MlsError;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum MlsOperationContext {
11 Send,
13 Receive,
15 Initialize,
17 SessionLookup,
19 Welcome,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MlsErrorCategory {
27 SessionStateMissing,
29 NotInitialized,
31 InvalidCiphertext,
33 IdentityMismatch,
35 CryptoFailure,
37 Unknown,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "snake_case")]
44pub enum DecryptionFailureKind {
45 SessionNotFound,
47 NotInitialized,
49 InvalidCiphertext,
51 IdentityMismatch,
53 CryptoFailure,
55 Unknown,
57}
58
59impl DecryptionFailureKind {
60 pub fn from_mls_error(error: &MlsError) -> Self {
62 match error {
63 MlsError::SessionNotFound(_) | MlsError::GroupNotFound(_) => Self::SessionNotFound,
64 MlsError::NotInitialized => Self::NotInitialized,
65 MlsError::InvalidMessage(_)
66 | MlsError::Deserialization(_)
67 | MlsError::Decryption(_) => Self::InvalidCiphertext,
68 MlsError::VerificationFailed(_) | MlsError::InvalidPublicKey(_) => {
69 Self::IdentityMismatch
70 }
71 MlsError::Encryption(_)
72 | MlsError::OpenMls(_)
73 | MlsError::Signing(_)
74 | MlsError::Storage(_)
75 | MlsError::CryptoGeneration(_)
76 | MlsError::SessionDesync(_) => Self::CryptoFailure,
80 _ => Self::Unknown,
81 }
82 }
83
84 pub fn error_category(self) -> MlsErrorCategory {
86 match self {
87 Self::SessionNotFound => MlsErrorCategory::SessionStateMissing,
88 Self::NotInitialized => MlsErrorCategory::NotInitialized,
89 Self::InvalidCiphertext => MlsErrorCategory::InvalidCiphertext,
90 Self::IdentityMismatch => MlsErrorCategory::IdentityMismatch,
91 Self::CryptoFailure => MlsErrorCategory::CryptoFailure,
92 Self::Unknown => MlsErrorCategory::Unknown,
93 }
94 }
95}
96
97#[allow(missing_docs)]
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100#[serde(tag = "type", rename_all = "snake_case")]
101pub enum MlsLifecycleEvent {
102 Initialized {
104 timestamp_ms: i64,
105 session_id: String,
106 group_id: Option<String>,
107 peer_id: Option<String>,
108 context: MlsOperationContext,
109 error_category: Option<MlsErrorCategory>,
110 },
111 EncryptionUsed {
113 timestamp_ms: i64,
114 session_id: String,
115 group_id: Option<String>,
116 peer_id: Option<String>,
117 context: MlsOperationContext,
118 error_category: Option<MlsErrorCategory>,
119 },
120 DecryptionFailed {
122 timestamp_ms: i64,
123 session_id: String,
124 group_id: Option<String>,
125 peer_id: Option<String>,
126 context: MlsOperationContext,
127 error_category: Option<MlsErrorCategory>,
128 failure_kind: DecryptionFailureKind,
129 },
130 SessionMissing {
132 timestamp_ms: i64,
133 session_id: String,
134 group_id: Option<String>,
135 peer_id: Option<String>,
136 context: MlsOperationContext,
137 error_category: Option<MlsErrorCategory>,
138 },
139 SessionEstablishing {
151 timestamp_ms: i64,
152 session_id: String,
153 group_id: Option<String>,
154 peer_id: Option<String>,
155 context: MlsOperationContext,
156 error_category: Option<MlsErrorCategory>,
157 },
158 SessionReady {
160 timestamp_ms: i64,
161 session_id: String,
162 group_id: Option<String>,
163 peer_id: Option<String>,
164 context: MlsOperationContext,
165 error_category: Option<MlsErrorCategory>,
166 },
167}
168
169impl MlsLifecycleEvent {
170 pub fn telemetry_name(&self) -> &'static str {
175 match self {
176 Self::Initialized { .. } => "mls.initialized",
177 Self::EncryptionUsed { .. } => "mls.encryption_used",
178 Self::DecryptionFailed { .. } => "mls.decryption_failed",
179 Self::SessionMissing { .. } => "mls.session_missing",
180 Self::SessionEstablishing { .. } => "mls.session_establishing",
181 Self::SessionReady { .. } => "mls.session_ready",
182 }
183 }
184}
185
186pub trait MlsEventEmitter: Send + Sync {
188 fn emit(&self, event: MlsLifecycleEvent);
190}
191
192#[derive(Debug, Default)]
194pub struct NoopMlsEventEmitter;
195
196impl MlsEventEmitter for NoopMlsEventEmitter {
197 fn emit(&self, _event: MlsLifecycleEvent) {}
198}
199
200#[derive(Debug, Default)]
205pub struct MlsEventRateLimiter {
206 sampler: CategorySampler,
207}
208
209impl MlsEventRateLimiter {
210 pub fn should_emit(&self, event: &MlsLifecycleEvent) -> bool {
212 let now_ms = timestamp_now_ms();
213 match event {
214 MlsLifecycleEvent::DecryptionFailed {
215 peer_id,
216 failure_kind,
217 ..
218 } => {
219 let key = format!(
220 "decryption_failed:{}:{:?}",
221 peer_id.as_deref().unwrap_or("none"),
222 failure_kind
223 );
224 self.sampler.allow(&key, now_ms)
225 }
226 MlsLifecycleEvent::SessionMissing { peer_id, .. } => {
227 let key = format!("session_missing:{}", peer_id.as_deref().unwrap_or("none"));
228 self.sampler.allow(&key, now_ms)
229 }
230 _ => true,
231 }
232 }
233}
234
235pub fn timestamp_now_ms() -> i64 {
237 chrono::Utc::now().timestamp_millis()
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn decryption_failure_classification_is_typed() {
246 assert_eq!(
247 DecryptionFailureKind::from_mls_error(&MlsError::SessionNotFound("p".to_string())),
248 DecryptionFailureKind::SessionNotFound
249 );
250 assert_eq!(
251 DecryptionFailureKind::from_mls_error(&MlsError::NotInitialized),
252 DecryptionFailureKind::NotInitialized
253 );
254 assert_eq!(
255 DecryptionFailureKind::from_mls_error(&MlsError::InvalidMessage("bad".to_string())),
256 DecryptionFailureKind::InvalidCiphertext
257 );
258 assert_eq!(
259 DecryptionFailureKind::from_mls_error(&MlsError::VerificationFailed("sig".to_string())),
260 DecryptionFailureKind::IdentityMismatch
261 );
262 }
263
264 #[test]
265 fn limiter_suppresses_flood() {
266 let limiter = MlsEventRateLimiter::default();
267 let base_ts = 1000_i64;
268 for i in 0..10 {
270 let event = MlsLifecycleEvent::DecryptionFailed {
271 timestamp_ms: base_ts + i,
272 session_id: "s".to_string(),
273 group_id: None,
274 peer_id: Some("peer-a".to_string()),
275 context: MlsOperationContext::Receive,
276 error_category: None,
277 failure_kind: DecryptionFailureKind::InvalidCiphertext,
278 };
279 assert!(limiter.should_emit(&event));
280 }
281 let event = MlsLifecycleEvent::DecryptionFailed {
283 timestamp_ms: base_ts + 10,
284 session_id: "s".to_string(),
285 group_id: None,
286 peer_id: Some("peer-a".to_string()),
287 context: MlsOperationContext::Receive,
288 error_category: None,
289 failure_kind: DecryptionFailureKind::InvalidCiphertext,
290 };
291 assert!(!limiter.should_emit(&event));
292 }
293}