ping-openmls-sdk-core 0.7.5

Platform-agnostic OpenMLS-based messaging engine
Documentation
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
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! Device model — every install of the SDK is one device. Devices are first-class MLS members.
//!
//! See `docs/MULTIDEVICE.md` for the full design.

use ed25519_dalek::{SigningKey, VerifyingKey};
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};

use crate::{
    codec,
    conversation::{ConversationId, ConversationMeta},
    error::{Error, Result},
    identity::UserId,
};

/// 32-byte device identifier — SHA-256 of the device's signing public key.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct DeviceId(#[serde(with = "serde_bytes")] pub Vec<u8>);

impl DeviceId {
    pub fn from_pubkey(pk: &VerifyingKey) -> Self {
        DeviceId(codec::sha256(pk.as_bytes()).to_vec())
    }
    pub fn as_hex(&self) -> String {
        hex::encode(&self.0)
    }
}

/// Public-facing device record exposed across the FFI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
    pub device_id: DeviceId,
    pub user_id: UserId,
    pub label: String, // human-readable "iPhone 15", set by the host
    pub created_at_ms: u64,
    pub last_seen_ms: u64,
    pub revoked: bool,
}

/// Local device — owns its signing keypair. Never leaves the device that created it.
pub struct LocalDevice {
    pub device_id: DeviceId,
    pub user_id: UserId,
    pub label: String,
    pub(crate) signing: SigningKey,
    pub created_at_ms: u64,
}

impl std::fmt::Debug for LocalDevice {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LocalDevice")
            .field("device_id", &self.device_id.as_hex())
            .field("user_id", &self.user_id.as_hex())
            .field("label", &self.label)
            .finish()
    }
}

impl LocalDevice {
    pub fn generate(user_id: UserId, label: String, now_ms: u64) -> Self {
        let mut seed = [0u8; 32];
        OsRng.fill_bytes(&mut seed);
        let signing = SigningKey::from_bytes(&seed);
        let device_id = DeviceId::from_pubkey(&signing.verifying_key());
        LocalDevice {
            device_id,
            user_id,
            label,
            signing,
            created_at_ms: now_ms,
        }
    }

    /// Construct a `LocalDevice` from a caller-supplied 32-byte Ed25519
    /// secret key. Used when the host wants the SDK's `device_id` (and
    /// therefore the `sender_device` on every emitted envelope) to
    /// match an externally-owned key — typically the auth layer's
    /// `device_signing_key`, whose public-key SHA-256 the BE uses as
    /// the JWT `device_id` claim.
    ///
    /// Without this path the SDK generates a random signing key on
    /// first init and writes it to storage; the resulting device_id
    /// has no relationship to any other identifier the host already
    /// owns, which forces server-side relax of any `expected_sender_device`
    /// validation. Pass the same 32 bytes here and the SDK's
    /// `sender_device` aligns with the JWT `device_id` claim by
    /// construction.
    pub fn from_signing_secret(
        user_id: UserId,
        label: String,
        now_ms: u64,
        secret_key: &[u8; 32],
    ) -> Self {
        let signing = SigningKey::from_bytes(secret_key);
        let device_id = DeviceId::from_pubkey(&signing.verifying_key());
        LocalDevice {
            device_id,
            user_id,
            label,
            signing,
            created_at_ms: now_ms,
        }
    }

    pub fn public_key(&self) -> VerifyingKey {
        self.signing.verifying_key()
    }

    pub fn info(&self, last_seen_ms: u64) -> DeviceInfo {
        DeviceInfo {
            device_id: self.device_id.clone(),
            user_id: self.user_id.clone(),
            label: self.label.clone(),
            created_at_ms: self.created_at_ms,
            last_seen_ms,
            revoked: false,
        }
    }
}

/// Encoded payload presented to a new device during the linking flow.
///
/// Carries the existing device's user-identity public key + a signed device-binding for the new
/// device + the Welcome that admits the new device to the user's DeviceGroup. The whole thing
/// is HPKE-sealed against the new device's ephemeral pubkey before transmission.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkingTicket {
    pub v: u8,
    pub user_id: UserId,
    #[serde(with = "serde_bytes")]
    pub user_pubkey: Vec<u8>, // VerifyingKey bytes (32)
    pub new_device_id: DeviceId,
    #[serde(with = "serde_bytes")]
    pub device_binding_sig: Vec<u8>, // identity signature over (user_id || device_id)
    #[serde(with = "serde_bytes")]
    pub device_group_welcome: Vec<u8>, // serialized OpenMLS Welcome
    #[serde(with = "serde_bytes")]
    pub catchup_snapshot: Vec<u8>, // CBOR-encoded `CatchupSnapshot` per [CR-13] (empty for the no-snapshot case)
}

// -------------------- CR-7: per-group MLS state snapshot --------------------

/// Soft cap on a CBOR-encoded `GroupStateSnapshot`. Over this we log a warning but
/// still emit; hosts hitting this regularly should investigate group-size growth.
pub const GROUP_SNAPSHOT_SOFT_CAP: usize = 128 * 1024;

/// Hard cap on a CBOR-encoded `GroupStateSnapshot`. Over this [`GroupStateSnapshot::encode`]
/// errors. Matches the recovery-blob cap so a `GroupStateSnapshot` always fits inside
/// a recovery blob's `device_group_snapshot` field.
pub const GROUP_SNAPSHOT_HARD_CAP: usize = 256 * 1024;

/// Current `GroupStateSnapshot` format version.
pub const GROUP_SNAPSHOT_VERSION: u8 = 1;

/// [CR-7] Portable snapshot of an MLS group's OpenMLS state.
///
/// Produced by [`Conversation::export_state_snapshot`] on one device, consumed by
/// [`MessagingClient::import_state_snapshot`] on another to re-attach to the same
/// group without re-Welcoming. The receiver MUST be the same user identity (the
/// linking-flow QR handshake or recovery mnemonic provides that).
///
/// **What's in:** every OpenMLS storage entry whose key references this group's
/// `group_id`. Trees, contexts, transcript hashes, message secrets, epoch keys —
/// everything `MlsGroup::load` needs to reconstruct the live group object.
///
/// **What's out:** signature keypairs (recovered devices ALWAYS get a fresh signing
/// key per [recovery.md §recovery on a new device]); leaf-keyed encryption keypairs
/// for *other* leaves (those don't have the group_id in their key by definition);
/// pending KeyPackages (per-device; receiver generates fresh ones on first publish).
///
/// **Forward secrecy note:** this snapshot exposes the exporter's view of past epoch
/// secrets for *this group*. That's intentional for the linking + DG-recovery use
/// cases (same user, trusted handover via QR / mnemonic). It is NOT used for
/// peer-conversation history transfer — that goes through HPKE-sealed AppEvent
/// re-shares per umbrella §15.6. Hosts MUST ensure callers of `export_state_snapshot`
/// are authenticated to the user's intent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupStateSnapshot {
    /// Format version. Bumped on incompatible CBOR-shape changes.
    pub v: u8,
    /// Group id this snapshot describes. Sanity-checked on import — passing a
    /// snapshot for the wrong group is an error.
    pub group_id: ConversationId,
    /// OpenMLS storage-provider version at export time. Import refuses mismatched
    /// versions outright (no automatic forward/back compat).
    pub openmls_storage_version: u16,
    /// Wall-clock ms at export. Informational; receivers can warn on stale
    /// snapshots but the SDK does not act on it.
    pub snapshot_created_at_ms: u64,
    /// `(raw_storage_key, raw_storage_value)` pairs lifted from the exporter's
    /// `MemoryStorage`. The receiver replays them into its own provider via
    /// `import_entries`.
    pub entries: Vec<GroupSnapshotEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupSnapshotEntry {
    #[serde(with = "serde_bytes")]
    pub key: Vec<u8>,
    #[serde(with = "serde_bytes")]
    pub value: Vec<u8>,
}

impl GroupStateSnapshot {
    /// CBOR-encode the snapshot. Hard-cap-rejects oversize blobs; logs a warning at
    /// the soft cap.
    pub fn encode(&self) -> Result<Vec<u8>> {
        let bytes = codec::encode(self)?;
        if bytes.len() > GROUP_SNAPSHOT_HARD_CAP {
            return Err(Error::Invalid(format!(
                "group snapshot {} bytes exceeds hard cap {}",
                bytes.len(),
                GROUP_SNAPSHOT_HARD_CAP
            )));
        }
        if bytes.len() > GROUP_SNAPSHOT_SOFT_CAP {
            tracing::warn!(
                target: "ping_core::device",
                size = bytes.len(),
                soft_cap = GROUP_SNAPSHOT_SOFT_CAP,
                "group snapshot exceeds soft cap"
            );
        }
        Ok(bytes)
    }

    /// Decode a CBOR-encoded snapshot. Rejects unknown `v` and oversize inputs.
    pub fn decode(bytes: &[u8]) -> Result<Self> {
        if bytes.len() > GROUP_SNAPSHOT_HARD_CAP {
            return Err(Error::Invalid(format!(
                "group snapshot input {} bytes exceeds hard cap {}",
                bytes.len(),
                GROUP_SNAPSHOT_HARD_CAP
            )));
        }
        let snap: Self = codec::decode(bytes)?;
        if snap.v != GROUP_SNAPSHOT_VERSION {
            return Err(Error::Invalid(format!(
                "group snapshot v={} not supported (this SDK supports v={})",
                snap.v, GROUP_SNAPSHOT_VERSION
            )));
        }
        Ok(snap)
    }
}

// -------------------- CR-13: catchup snapshot encoding --------------------

/// Soft cap on a CBOR-encoded `CatchupSnapshot`. Over this we log a warning but still emit;
/// hosts that hit this regularly should trim the `last_app_events_per_conv` list to fewer
/// or smaller items.
pub const CATCHUP_SNAPSHOT_SOFT_CAP: usize = 64 * 1024;

/// Hard cap on a CBOR-encoded `CatchupSnapshot`. Over this [`CatchupSnapshot::encode`]
/// errors — the linking flow falls back to delivering an empty snapshot and the new device
/// catches up via the normal sync path.
pub const CATCHUP_SNAPSHOT_HARD_CAP: usize = 256 * 1024;

/// [CR-13] Catchup snapshot delivered to a newly-linked device.
///
/// Populates the `catchup_snapshot` field of [`LinkingTicket`] so the new device boots
/// with a populated conversation list + last-known plaintext per conversation, instead
/// of an empty UI until peers re-Welcome.
///
/// **`group_state_bytes`** is reserved for the [CR-7] DeviceGroup-snapshot machinery; until
/// CR-7 lands it's always empty (`Vec::new()`) on every meta. The encoding is forward-
/// compatible: a snapshot produced today will decode cleanly on a future SDK build that
/// understands CR-7, and a future-produced snapshot decodes on today's SDK (we just ignore
/// the populated `group_state_bytes`).
///
/// **`last_app_events_per_conv`** is host-supplied — the SDK doesn't store decrypted
/// plaintext on its own, so the host passes through whatever it wants the new device to
/// see on launch (typically: the last N AppEvent bytes per conversation).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatchupSnapshot {
    /// Format version. `1` today; bumped on incompatible CBOR-shape changes.
    pub v: u8,
    /// Conversation list + per-conversation MLS state. `group_state_bytes` is empty
    /// pre-CR-7; the field is reserved so the wire format doesn't churn when CR-7 ships.
    pub conversation_metas: Vec<CatchupConversationEntry>,
    /// Last-known decrypted AppEvent bytes per conversation. Host-supplied; opaque to
    /// the SDK. Each entry is keyed by `ConversationId`.
    pub last_app_events_per_conv: Vec<CatchupAppEventEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatchupConversationEntry {
    pub conversation_id: ConversationId,
    pub meta: ConversationMeta,
    /// CR-7 group snapshot bytes; empty until CR-7 ships. Receivers MUST handle the
    /// empty case (in which they cannot decrypt that conversation until re-Welcomed).
    #[serde(with = "serde_bytes")]
    pub group_state_bytes: Vec<u8>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatchupAppEventEntry {
    pub conversation_id: ConversationId,
    /// Host-defined plaintext bytes — typically a CBOR-encoded `AppEvent`. The SDK
    /// never inspects this.
    #[serde(with = "serde_bytes")]
    pub app_event_bytes: Vec<u8>,
}

/// Current `CatchupSnapshot` format version. Independent of the wire `WIRE_VERSION` —
/// catchup snapshots live inside an HPKE-sealed [`LinkingTicket`], not on the wire.
pub const CATCHUP_SNAPSHOT_VERSION: u8 = 1;

impl CatchupSnapshot {
    /// Build an empty snapshot (no metas, no events). Equivalent to "no catchup data."
    pub fn empty() -> Self {
        Self {
            v: CATCHUP_SNAPSHOT_VERSION,
            conversation_metas: Vec::new(),
            last_app_events_per_conv: Vec::new(),
        }
    }

    /// CBOR-encode for inclusion in a [`LinkingTicket::catchup_snapshot`].
    ///
    /// Returns `Err(Error::Invalid)` if the encoded size exceeds
    /// [`CATCHUP_SNAPSHOT_HARD_CAP`] (256 KB). Sizes between the soft cap (64 KB) and the
    /// hard cap log a warning but still succeed — they're suboptimal but not refused.
    pub fn encode(&self) -> Result<Vec<u8>> {
        let bytes = codec::encode(self)?;
        if bytes.len() > CATCHUP_SNAPSHOT_HARD_CAP {
            return Err(Error::Invalid(format!(
                "catchup snapshot {} bytes exceeds hard cap {} — trim last_app_events_per_conv",
                bytes.len(),
                CATCHUP_SNAPSHOT_HARD_CAP
            )));
        }
        if bytes.len() > CATCHUP_SNAPSHOT_SOFT_CAP {
            tracing::warn!(
                target: "ping_core::device",
                size = bytes.len(),
                soft_cap = CATCHUP_SNAPSHOT_SOFT_CAP,
                "catchup snapshot exceeds soft cap; new device boot may be sluggish"
            );
        }
        Ok(bytes)
    }

    /// CBOR-encode, **guaranteeing** the result fits under [`CATCHUP_SNAPSHOT_HARD_CAP`]
    /// by shedding payload until it does — instead of erroring like [`encode`].
    ///
    /// [`encode`] is the strict encoder used by conformance vectors and callers that
    /// build their own bounded snapshots. `build_linking_ticket`, by contrast, snapshots
    /// **every** conversation the user is in, so a user with many (or large) groups can
    /// blow the 256 KB ticket cap through no fault of the host — and a hard error there
    /// breaks device linking entirely (the new device can't even join the DeviceGroup).
    /// This method makes linking degrade gracefully instead of failing:
    ///
    ///   1. Fast path — if the full snapshot already fits, return it.
    ///   2. Drop `group_state_bytes` (CR-7 per-group MLS state), **largest first**, until
    ///      it fits. An empty `group_state_bytes` is an explicitly-supported receiver case
    ///      (§CR-7): that conversation is caught up via the normal re-Welcome path instead
    ///      of local import. Ping's hosts (web + iOS) already re-admit every chat after
    ///      linking, so dropping this state costs nothing functionally — only the "boot
    ///      already able to decrypt" optimisation for the shed conversations.
    ///   3. If still over (pathological host-supplied `last_app_events`), trim
    ///      `last_app_events_per_conv` from the tail.
    ///   4. If *still* over (thousands of conversation_metas), drop metas from the tail.
    ///      The new device then relies fully on normal sync — identical to the pre-CR-13
    ///      empty-snapshot behaviour.
    ///
    /// Conversation metadata is preserved as long as it fits, so the common large-account
    /// case still boots with a populated conversation list. Whatever is shed is logged.
    pub fn encode_within_cap(mut self) -> Result<Vec<u8>> {
        // 1) Fast path: full snapshot already fits.
        let bytes = codec::encode(&self)?;
        if bytes.len() <= CATCHUP_SNAPSHOT_HARD_CAP {
            if bytes.len() > CATCHUP_SNAPSHOT_SOFT_CAP {
                tracing::warn!(
                    target: "ping_core::device",
                    size = bytes.len(),
                    soft_cap = CATCHUP_SNAPSHOT_SOFT_CAP,
                    "catchup snapshot exceeds soft cap; new device boot may be sluggish"
                );
            }
            return Ok(bytes);
        }

        // 2) Shed group_state_bytes, largest first, until it fits.
        let mut order: Vec<usize> = (0..self.conversation_metas.len()).collect();
        order.sort_by_key(|&i| {
            std::cmp::Reverse(self.conversation_metas[i].group_state_bytes.len())
        });
        let mut dropped_group_states = 0usize;
        for &i in &order {
            if self.conversation_metas[i].group_state_bytes.is_empty() {
                continue;
            }
            self.conversation_metas[i].group_state_bytes = Vec::new();
            dropped_group_states += 1;
            let bytes = codec::encode(&self)?;
            if bytes.len() <= CATCHUP_SNAPSHOT_HARD_CAP {
                tracing::warn!(
                    target: "ping_core::device",
                    size = bytes.len(),
                    hard_cap = CATCHUP_SNAPSHOT_HARD_CAP,
                    dropped_group_states,
                    total_conversations = self.conversation_metas.len(),
                    "catchup snapshot over hard cap; dropped per-group MLS state — those conversations catch up via re-Welcome"
                );
                return Ok(bytes);
            }
        }

        // 3) Still over: trim host-supplied app events from the tail.
        let mut dropped_app_events = 0usize;
        while !self.last_app_events_per_conv.is_empty() {
            self.last_app_events_per_conv.pop();
            dropped_app_events += 1;
            let bytes = codec::encode(&self)?;
            if bytes.len() <= CATCHUP_SNAPSHOT_HARD_CAP {
                tracing::warn!(
                    target: "ping_core::device",
                    size = bytes.len(),
                    dropped_group_states,
                    dropped_app_events,
                    "catchup snapshot over hard cap; also trimmed last_app_events_per_conv"
                );
                return Ok(bytes);
            }
        }

        // 4) Degenerate (metas alone exceed the cap): drop metas from the tail.
        let mut dropped_metas = 0usize;
        while !self.conversation_metas.is_empty() {
            self.conversation_metas.pop();
            dropped_metas += 1;
            let bytes = codec::encode(&self)?;
            if bytes.len() <= CATCHUP_SNAPSHOT_HARD_CAP {
                tracing::warn!(
                    target: "ping_core::device",
                    size = bytes.len(),
                    dropped_group_states,
                    dropped_app_events,
                    dropped_metas,
                    "catchup snapshot over hard cap even without group state; dropped conversation_metas — new device relies on normal sync"
                );
                return Ok(bytes);
            }
        }

        // Everything shed: an empty snapshot always fits (its encoding is a handful
        // of bytes), so this is unreachable in practice — but stay total rather than
        // panicking across the FFI/wasm boundary.
        codec::encode(&self)
    }

    /// Decode a CBOR-encoded snapshot. Refuses sizes over [`CATCHUP_SNAPSHOT_HARD_CAP`]
    /// to bound attacker-controlled allocation; refuses unknown `v` to avoid silently
    /// dropping fields a future version added.
    pub fn decode(bytes: &[u8]) -> Result<Self> {
        if bytes.len() > CATCHUP_SNAPSHOT_HARD_CAP {
            return Err(Error::Invalid(format!(
                "catchup snapshot input {} bytes exceeds hard cap {}",
                bytes.len(),
                CATCHUP_SNAPSHOT_HARD_CAP
            )));
        }
        let snap: Self = codec::decode(bytes)?;
        if snap.v != CATCHUP_SNAPSHOT_VERSION {
            return Err(Error::Invalid(format!(
                "catchup snapshot v={} not supported (this SDK supports v={})",
                snap.v, CATCHUP_SNAPSHOT_VERSION
            )));
        }
        Ok(snap)
    }
}