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 SessionReady {
141 timestamp_ms: i64,
142 session_id: String,
143 group_id: Option<String>,
144 peer_id: Option<String>,
145 context: MlsOperationContext,
146 error_category: Option<MlsErrorCategory>,
147 },
148}
149
150impl MlsLifecycleEvent {
151 pub fn telemetry_name(&self) -> &'static str {
156 match self {
157 Self::Initialized { .. } => "mls.initialized",
158 Self::EncryptionUsed { .. } => "mls.encryption_used",
159 Self::DecryptionFailed { .. } => "mls.decryption_failed",
160 Self::SessionMissing { .. } => "mls.session_missing",
161 Self::SessionReady { .. } => "mls.session_ready",
162 }
163 }
164}
165
166pub trait MlsEventEmitter: Send + Sync {
168 fn emit(&self, event: MlsLifecycleEvent);
170}
171
172#[derive(Debug, Default)]
174pub struct NoopMlsEventEmitter;
175
176impl MlsEventEmitter for NoopMlsEventEmitter {
177 fn emit(&self, _event: MlsLifecycleEvent) {}
178}
179
180#[derive(Debug, Default)]
185pub struct MlsEventRateLimiter {
186 sampler: CategorySampler,
187}
188
189impl MlsEventRateLimiter {
190 pub fn should_emit(&self, event: &MlsLifecycleEvent) -> bool {
192 let now_ms = timestamp_now_ms();
193 match event {
194 MlsLifecycleEvent::DecryptionFailed {
195 peer_id,
196 failure_kind,
197 ..
198 } => {
199 let key = format!(
200 "decryption_failed:{}:{:?}",
201 peer_id.as_deref().unwrap_or("none"),
202 failure_kind
203 );
204 self.sampler.allow(&key, now_ms)
205 }
206 MlsLifecycleEvent::SessionMissing { peer_id, .. } => {
207 let key = format!("session_missing:{}", peer_id.as_deref().unwrap_or("none"));
208 self.sampler.allow(&key, now_ms)
209 }
210 _ => true,
211 }
212 }
213}
214
215pub fn timestamp_now_ms() -> i64 {
217 chrono::Utc::now().timestamp_millis()
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn decryption_failure_classification_is_typed() {
226 assert_eq!(
227 DecryptionFailureKind::from_mls_error(&MlsError::SessionNotFound("p".to_string())),
228 DecryptionFailureKind::SessionNotFound
229 );
230 assert_eq!(
231 DecryptionFailureKind::from_mls_error(&MlsError::NotInitialized),
232 DecryptionFailureKind::NotInitialized
233 );
234 assert_eq!(
235 DecryptionFailureKind::from_mls_error(&MlsError::InvalidMessage("bad".to_string())),
236 DecryptionFailureKind::InvalidCiphertext
237 );
238 assert_eq!(
239 DecryptionFailureKind::from_mls_error(&MlsError::VerificationFailed("sig".to_string())),
240 DecryptionFailureKind::IdentityMismatch
241 );
242 }
243
244 #[test]
245 fn limiter_suppresses_flood() {
246 let limiter = MlsEventRateLimiter::default();
247 let base_ts = 1000_i64;
248 for i in 0..10 {
250 let event = MlsLifecycleEvent::DecryptionFailed {
251 timestamp_ms: base_ts + i,
252 session_id: "s".to_string(),
253 group_id: None,
254 peer_id: Some("peer-a".to_string()),
255 context: MlsOperationContext::Receive,
256 error_category: None,
257 failure_kind: DecryptionFailureKind::InvalidCiphertext,
258 };
259 assert!(limiter.should_emit(&event));
260 }
261 let event = MlsLifecycleEvent::DecryptionFailed {
263 timestamp_ms: base_ts + 10,
264 session_id: "s".to_string(),
265 group_id: None,
266 peer_id: Some("peer-a".to_string()),
267 context: MlsOperationContext::Receive,
268 error_category: None,
269 failure_kind: DecryptionFailureKind::InvalidCiphertext,
270 };
271 assert!(!limiter.should_emit(&event));
272 }
273}