1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
//! Protocol errors.
use thiserror::Error;
/// Result type alias.
pub type Result<T> = std::result::Result<T, Error>;
/// Per-peer establishment state: where we are in getting to "can send" with a peer.
///
/// Used both as the value returned from `get_establishment_state` and as the payload
/// of `Error::SessionNotReady`, so callers can show "Establishing…" and retry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EstablishmentState {
/// No key package for this peer (memory nor storage).
NoKeyPackage,
/// Key package stored; no MLS session yet.
HaveKeyPackage,
/// Session created, welcome sent/queued; not confirmed.
SessionPending,
/// Can send/receive encrypted messages.
SessionConfirmed,
}
impl EstablishmentState {
/// Stable string for logging and FFI.
pub fn as_str(&self) -> &'static str {
match self {
Self::NoKeyPackage => "NoKeyPackage",
Self::HaveKeyPackage => "HaveKeyPackage",
Self::SessionPending => "SessionPending",
Self::SessionConfirmed => "SessionConfirmed",
}
}
}
/// Stable, machine-readable error classes used for session readiness decisions.
///
/// This enum is the single SDK boundary that maps heterogeneous upstream error
/// types into deterministic categories for protocol control flow.
// Adding a variant to a public error enum is a breaking change without
// this attribute; downstream crates must carry a wildcard arm.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionStateError {
/// Session exists but cannot process application traffic yet.
SessionNotReady,
/// Session/group state does not exist locally.
GroupNotFound,
/// MLS stack is not initialized.
NotInitialized,
/// Underlying transport failure.
TransportFailure,
/// Cryptographic operation failure.
CryptoFailure,
/// Session exists but is out of sync with the sender's epoch (the two sides
/// disagree on the MLS epoch).
///
/// What separates this from [`Self::CryptoFailure`] is the **re-key**, not
/// the ACK: under `crypto_recovery_enabled` both classes withhold the
/// delivery ACK so the sender's resend can still deliver, but only this one
/// additionally re-establishes the 1:1 session. The split is deliberate —
/// re-keying on an AEAD/authentication failure, which any injected frame can
/// produce, would be a re-key-storm vector.
SessionDesync,
/// Error that does not match a known class.
Unknown,
}
impl SessionStateError {
/// Returns a stable, non-localized machine-readable error code.
pub fn code(&self) -> &'static str {
match self {
Self::SessionNotReady => "SESSION_NOT_READY",
Self::GroupNotFound => "GROUP_NOT_FOUND",
Self::NotInitialized => "NOT_INITIALIZED",
Self::TransportFailure => "TRANSPORT_FAILURE",
Self::CryptoFailure => "CRYPTO_FAILURE",
Self::SessionDesync => "SESSION_DESYNC",
Self::Unknown => "UNKNOWN",
}
}
/// Classifies protocol-level errors for session state control flow.
pub fn classify(error: &Error) -> Self {
match error {
Error::SessionNotReady(_) => Self::SessionNotReady,
Error::MlsNotInitialized => Self::NotInitialized,
Error::Transport(_) => Self::TransportFailure,
Error::EncryptFailed(_) => Self::CryptoFailure,
Error::GroupNotFound(_) => Self::GroupNotFound,
Error::Mls(inner) => Self::from(inner),
_ => Self::Unknown,
}
}
/// Maps session state class into welcome lifecycle reason taxonomy.
pub fn to_welcome_reason_code(&self) -> crate::events::WelcomeReasonCode {
match self {
Self::TransportFailure => crate::events::WelcomeReasonCode::TransportUnavailable,
Self::SessionNotReady
| Self::GroupNotFound
| Self::NotInitialized
| Self::CryptoFailure
| Self::SessionDesync
| Self::Unknown => crate::events::WelcomeReasonCode::InternalError,
}
}
}
impl From<&offline_protocol_mls::MlsError> for SessionStateError {
fn from(error: &offline_protocol_mls::MlsError) -> Self {
match error {
offline_protocol_mls::MlsError::GroupNotFound(_)
| offline_protocol_mls::MlsError::SessionNotFound(_) => Self::GroupNotFound,
offline_protocol_mls::MlsError::NotInitialized => Self::NotInitialized,
offline_protocol_mls::MlsError::SessionDesync(_) => Self::SessionDesync,
offline_protocol_mls::MlsError::Storage(_)
| offline_protocol_mls::MlsError::OpenMls(_)
| offline_protocol_mls::MlsError::Deserialization(_)
| offline_protocol_mls::MlsError::Serialization(_)
| offline_protocol_mls::MlsError::InvalidMessage(_)
| offline_protocol_mls::MlsError::Encryption(_)
| offline_protocol_mls::MlsError::Decryption(_)
| offline_protocol_mls::MlsError::CryptoGeneration(_)
| offline_protocol_mls::MlsError::Signing(_)
| offline_protocol_mls::MlsError::VerificationFailed(_)
| offline_protocol_mls::MlsError::InvalidPublicKey(_) => Self::CryptoFailure,
// Not a session-state condition: commit enforcement is a group
// policy, and 1:1 sessions are exempt from it. It can only reach
// this classifier when a *group* commit ciphertext arrives wrapped
// in an `__MLS_ENC__` envelope, where `Unknown` is the disposition
// we want — drop and ACK, never queue (the refusal is permanent,
// so the same frame can never become decryptable) and never re-key
// (the session is healthy).
offline_protocol_mls::MlsError::CommitNotAuthorized { .. } => Self::Unknown,
// Not a session-state condition either: the envelope named a slot
// that is not the claimed sender's, which is a security rejection,
// not a readiness problem. `Unknown` is the disposition we want on
// any path that reaches this classifier — never queue (the frame
// can never become legitimate) and never re-key (there is no
// evidence our session with the claimed sender is unhealthy). The
// 1:1 text path intercepts this variant before classification so it
// can withhold the ACK; see `handle_encrypted_message`.
offline_protocol_mls::MlsError::SessionIdentityMismatch { .. } => Self::Unknown,
// Not a session-state condition either, and `Unknown` is the
// disposition both need on any path that reaches this classifier
// (a group commit or message wrapped in an `__MLS_ENC__`
// envelope): never queue — a leaf whose key does not hash to its
// own credential can never start doing so, and an unsupported
// sender role never becomes supported — and never re-key, since
// neither says anything about the health of our session with the
// claimed sender. Both intercept these before classification so
// they can withhold the ACK — the group path in
// `decrypt_group_application`, and the 1:1 path inline in
// `handle_encrypted_message` plus `is_media_security_rejection`,
// which is not redundant: an `__MLS_ENC__` envelope may name a
// `group:` id and so reach group decrypt without going through the
// group handler.
offline_protocol_mls::MlsError::LeafAddressMismatch { .. }
| offline_protocol_mls::MlsError::UnsupportedSender { .. } => Self::Unknown,
_ => Self::Unknown,
}
}
}
/// Protocol errors.
// Adding a variant to a public error enum is a breaking change without
// this attribute; downstream crates must carry a wildcard arm.
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum Error {
/// Protocol not started.
#[error("Protocol not started")]
NotStarted,
/// Protocol already started.
#[error("Protocol already started")]
AlreadyStarted,
/// Invalid configuration.
#[error("Invalid configuration: {0}")]
InvalidConfiguration(String),
/// Core error.
#[error("Core error: {0}")]
Core(#[from] offline_protocol_core::Error),
/// Transport error.
#[error("Transport error: {0}")]
Transport(#[from] offline_protocol_transport::Error),
/// Router error.
#[error("Router error: {0}")]
Router(#[from] offline_protocol_router::Error),
/// Reliability error.
#[error("Reliability error: {0}")]
Reliability(#[from] offline_protocol_reliability::Error),
/// MLS error.
#[error("MLS error: {0}")]
Mls(#[from] offline_protocol_mls::MlsError),
/// MLS not initialized.
#[error("MLS encryption not initialized")]
MlsNotInitialized,
/// No key package available for recipient.
#[error("No key package available for recipient: {0}")]
NoKeyPackage(String),
/// Session not ready; establishment in progress. Caller can retry or show "Establishing…".
#[error("Session not ready: {0:?}")]
SessionNotReady(EstablishmentState),
/// Outbound message encryption failed.
#[error("Failed to encrypt message: {0}")]
EncryptFailed(String),
/// Service error.
#[error("Service error: {0}")]
Service(#[from] offline_protocol_services::ServiceError),
/// Serialization error.
#[error("Serialization error: {0}")]
Serialization(String),
/// Operation rejected because the target user is blocked.
#[error("User is blocked: {0}")]
UserBlocked(String),
/// Too many concurrent media transfers to the same recipient. Encrypted
/// transfers share the recipient's session ratchet, whose out-of-order
/// tolerance bounds how many chunks may be in flight at once.
#[error(
"Too many concurrent media transfers to {0}; retry after an active transfer completes"
)]
MediaTransferLimit(String),
/// Group does not exist locally.
#[error("Group not found: {0}")]
GroupNotFound(String),
/// Operation requires a group role or permission the caller does not hold.
#[error("{0}")]
PermissionDenied(String),
/// Operation rejected by the current group or protocol state (e.g.
/// last-admin constraints, member limits, expired key packages).
#[error("{0}")]
InvalidState(String),
/// A caller-supplied argument failed validation.
#[error("{0}")]
InvalidArgument(String),
/// The data layer is switched off in configuration.
#[error("Data layer is disabled")]
DataDisabled,
/// The data layer has no storage backend to work with.
///
/// Raised when the engine has no protocol-state store yet, which means
/// `initialize_mls` has not run: documents are sealed at rest, and the
/// record key that seals them is minted during that call.
#[error("Data layer has no storage; initialize_mls must run first")]
DataStorageUnavailable,
/// A document has grown past the per-document cap.
///
/// The change that breached it is already durable, so nothing is lost.
/// What is refused is further growth: deletions still apply, so a
/// document can be brought back under the cap and resume accepting
/// edits.
#[error("Document is {actual} bytes compacted, over the {limit} byte cap")]
DocTooLarge {
/// The compacted size that breached the cap.
actual: usize,
/// The cap in force.
limit: usize,
},
/// Document data could not be decoded.
///
/// Documents are read back out of sealed records, so their bytes have
/// already been authenticated. This therefore reports a permanent
/// verdict, not a transient read failure worth retrying.
#[error("Document data is corrupt: {0}")]
DataCorrupted(String),
/// Generic error.
#[error("{0}")]
Other(String),
}
#[cfg(test)]
mod tests {
use super::{Error, SessionStateError};
use offline_protocol_mls::MlsError;
use offline_protocol_transport::Error as TransportError;
#[test]
fn session_state_code_values_are_stable() {
assert_eq!(
SessionStateError::SessionNotReady.code(),
"SESSION_NOT_READY"
);
assert_eq!(SessionStateError::GroupNotFound.code(), "GROUP_NOT_FOUND");
assert_eq!(SessionStateError::NotInitialized.code(), "NOT_INITIALIZED");
assert_eq!(
SessionStateError::TransportFailure.code(),
"TRANSPORT_FAILURE"
);
assert_eq!(SessionStateError::CryptoFailure.code(), "CRYPTO_FAILURE");
assert_eq!(SessionStateError::SessionDesync.code(), "SESSION_DESYNC");
assert_eq!(SessionStateError::Unknown.code(), "UNKNOWN");
}
#[test]
fn classify_maps_mls_group_not_found() {
let classified = SessionStateError::from(&MlsError::GroupNotFound("g1".to_string()));
assert_eq!(classified, SessionStateError::GroupNotFound);
}
#[test]
fn classify_maps_protocol_group_not_found() {
// The protocol-level variant must land in the same class as the
// MLS-layer one — one condition, one category.
let classified = SessionStateError::classify(&Error::GroupNotFound("g1".to_string()));
assert_eq!(classified, SessionStateError::GroupNotFound);
}
#[test]
fn classify_maps_not_initialized() {
let classified = SessionStateError::from(&MlsError::NotInitialized);
assert_eq!(classified, SessionStateError::NotInitialized);
}
#[test]
fn classify_maps_crypto_failures() {
let classified = SessionStateError::from(&MlsError::Decryption("failed".to_string()));
assert_eq!(classified, SessionStateError::CryptoFailure);
}
#[test]
fn classify_maps_session_desync_distinct_from_crypto_failure() {
// An epoch desync is recoverable and must land in its own class, NOT
// CryptoFailure — otherwise the receive path would drop-and-ACK it as a
// permanent failure instead of re-keying.
let desync = SessionStateError::from(&MlsError::SessionDesync("wrong epoch".to_string()));
assert_eq!(desync, SessionStateError::SessionDesync);
// The genuine-failure sibling stays CryptoFailure.
let crypto = SessionStateError::from(&MlsError::Decryption("aead".to_string()));
assert_eq!(crypto, SessionStateError::CryptoFailure);
}
#[test]
fn classify_unknown_fallback_is_safe() {
let classified = SessionStateError::classify(&Error::Other("opaque".to_string()));
assert_eq!(classified, SessionStateError::Unknown);
}
#[test]
fn welcome_reason_mapping_uses_typed_transport_failure() {
let classified = SessionStateError::classify(&Error::Transport(
TransportError::SendFailed("send failed".to_string()),
));
assert_eq!(
classified.to_welcome_reason_code(),
crate::events::WelcomeReasonCode::TransportUnavailable
);
}
#[test]
fn welcome_reason_mapping_unknown_defaults_internal_error() {
let classified = SessionStateError::classify(&Error::Other("opaque".to_string()));
assert_eq!(
classified.to_welcome_reason_code(),
crate::events::WelcomeReasonCode::InternalError
);
}
}