Skip to main content

offline_protocol/
mls_observability.rs

1//! MLS lifecycle observability primitives.
2
3use crate::telemetry::CategorySampler;
4use offline_protocol_mls::MlsError;
5use serde::Serialize;
6
7/// Operation context for MLS lifecycle events.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum MlsOperationContext {
11    /// Outbound send path.
12    Send,
13    /// Inbound receive path.
14    Receive,
15    /// MLS manager initialization path.
16    Initialize,
17    /// Session lookup or resolution path.
18    SessionLookup,
19    /// Welcome processing path.
20    Welcome,
21}
22
23/// Typed error category for MLS lifecycle events.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MlsErrorCategory {
27    /// Session or group state is missing.
28    SessionStateMissing,
29    /// MLS manager is not initialized.
30    NotInitialized,
31    /// Ciphertext/message shape is invalid.
32    InvalidCiphertext,
33    /// Sender identity or signature verification failed.
34    IdentityMismatch,
35    /// Crypto engine operation failed.
36    CryptoFailure,
37    /// Unknown or uncategorized failure.
38    Unknown,
39}
40
41/// Fine-grained decryption failure classification.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "snake_case")]
44pub enum DecryptionFailureKind {
45    /// Session or group state was not found.
46    SessionNotFound,
47    /// MLS manager is not initialized.
48    NotInitialized,
49    /// Message bytes cannot be decrypted/parsed as valid MLS ciphertext.
50    InvalidCiphertext,
51    /// Signature/identity verification failed.
52    IdentityMismatch,
53    /// Other cryptographic failure.
54    CryptoFailure,
55    /// Fallback class for unsupported variants.
56    Unknown,
57}
58
59impl DecryptionFailureKind {
60    /// Classifies an MLS error into a decryption failure kind.
61    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            // An epoch desync only reaches here on the recovery-disabled
77            // fall-through (recovery-enabled desyncs never take the drop path);
78            // classify it as a crypto failure rather than the opaque `Unknown`.
79            | MlsError::SessionDesync(_) => Self::CryptoFailure,
80            _ => Self::Unknown,
81        }
82    }
83
84    /// Maps a decryption failure kind to its broader telemetry category.
85    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/// Typed MLS lifecycle events for operational observability.
98#[allow(missing_docs)]
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100#[serde(tag = "type", rename_all = "snake_case")]
101pub enum MlsLifecycleEvent {
102    /// MLS subsystem has been initialized and is ready for use.
103    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    /// Encryption path was successfully used.
112    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    /// Decryption failed with a typed failure class.
121    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    /// Session lookup failed or session/group state is missing.
131    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    /// Session lifecycle has reached a ready/usable state.
140    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    /// Returns the stable `snake.dot.case` telemetry name for this event.
152    ///
153    /// Names are compatible with OpenTelemetry naming conventions and are
154    /// considered stable across minor versions of the SDK.
155    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
166/// Sink abstraction for MLS lifecycle events.
167pub trait MlsEventEmitter: Send + Sync {
168    /// Emits a structured MLS lifecycle event.
169    fn emit(&self, event: MlsLifecycleEvent);
170}
171
172/// Default no-op emitter used when observability is not configured.
173#[derive(Debug, Default)]
174pub struct NoopMlsEventEmitter;
175
176impl MlsEventEmitter for NoopMlsEventEmitter {
177    fn emit(&self, _event: MlsLifecycleEvent) {}
178}
179
180/// Best-effort fixed-window limiter to reduce high-volume failure event floods.
181///
182/// Thin wrapper around [`CategorySampler`] that maps MLS lifecycle events to
183/// rate-limit keys.
184#[derive(Debug, Default)]
185pub struct MlsEventRateLimiter {
186    sampler: CategorySampler,
187}
188
189impl MlsEventRateLimiter {
190    /// Returns true if the event passes rate limiting.
191    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
215/// Returns current wall-clock time in Unix milliseconds.
216pub 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        // First 10 DecryptionFailed events for the same peer+kind pass.
249        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        // 11th is suppressed.
262        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}