Skip to main content

ping_core/
device.rs

1//! Device model — every install of the SDK is one device. Devices are first-class MLS members.
2//!
3//! See `docs/MULTIDEVICE.md` for the full design.
4
5use ed25519_dalek::{SigningKey, VerifyingKey};
6use rand_core::{OsRng, RngCore};
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    codec,
11    conversation::{ConversationId, ConversationMeta},
12    error::{Error, Result},
13    identity::UserId,
14};
15
16/// 32-byte device identifier — SHA-256 of the device's signing public key.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
18pub struct DeviceId(#[serde(with = "serde_bytes")] pub Vec<u8>);
19
20impl DeviceId {
21    pub fn from_pubkey(pk: &VerifyingKey) -> Self {
22        DeviceId(codec::sha256(pk.as_bytes()).to_vec())
23    }
24    pub fn as_hex(&self) -> String {
25        hex::encode(&self.0)
26    }
27}
28
29/// Public-facing device record exposed across the FFI.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DeviceInfo {
32    pub device_id: DeviceId,
33    pub user_id: UserId,
34    pub label: String, // human-readable "iPhone 15", set by the host
35    pub created_at_ms: u64,
36    pub last_seen_ms: u64,
37    pub revoked: bool,
38}
39
40/// Local device — owns its signing keypair. Never leaves the device that created it.
41pub struct LocalDevice {
42    pub device_id: DeviceId,
43    pub user_id: UserId,
44    pub label: String,
45    pub(crate) signing: SigningKey,
46    pub created_at_ms: u64,
47}
48
49impl std::fmt::Debug for LocalDevice {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("LocalDevice")
52            .field("device_id", &self.device_id.as_hex())
53            .field("user_id", &self.user_id.as_hex())
54            .field("label", &self.label)
55            .finish()
56    }
57}
58
59impl LocalDevice {
60    pub fn generate(user_id: UserId, label: String, now_ms: u64) -> Self {
61        let mut seed = [0u8; 32];
62        OsRng.fill_bytes(&mut seed);
63        let signing = SigningKey::from_bytes(&seed);
64        let device_id = DeviceId::from_pubkey(&signing.verifying_key());
65        LocalDevice {
66            device_id,
67            user_id,
68            label,
69            signing,
70            created_at_ms: now_ms,
71        }
72    }
73
74    /// Construct a `LocalDevice` from a caller-supplied 32-byte Ed25519
75    /// secret key. Used when the host wants the SDK's `device_id` (and
76    /// therefore the `sender_device` on every emitted envelope) to
77    /// match an externally-owned key — typically the auth layer's
78    /// `device_signing_key`, whose public-key SHA-256 the BE uses as
79    /// the JWT `device_id` claim.
80    ///
81    /// Without this path the SDK generates a random signing key on
82    /// first init and writes it to storage; the resulting device_id
83    /// has no relationship to any other identifier the host already
84    /// owns, which forces server-side relax of any `expected_sender_device`
85    /// validation. Pass the same 32 bytes here and the SDK's
86    /// `sender_device` aligns with the JWT `device_id` claim by
87    /// construction.
88    pub fn from_signing_secret(
89        user_id: UserId,
90        label: String,
91        now_ms: u64,
92        secret_key: &[u8; 32],
93    ) -> Self {
94        let signing = SigningKey::from_bytes(secret_key);
95        let device_id = DeviceId::from_pubkey(&signing.verifying_key());
96        LocalDevice {
97            device_id,
98            user_id,
99            label,
100            signing,
101            created_at_ms: now_ms,
102        }
103    }
104
105    pub fn public_key(&self) -> VerifyingKey {
106        self.signing.verifying_key()
107    }
108
109    pub fn info(&self, last_seen_ms: u64) -> DeviceInfo {
110        DeviceInfo {
111            device_id: self.device_id.clone(),
112            user_id: self.user_id.clone(),
113            label: self.label.clone(),
114            created_at_ms: self.created_at_ms,
115            last_seen_ms,
116            revoked: false,
117        }
118    }
119}
120
121/// Encoded payload presented to a new device during the linking flow.
122///
123/// Carries the existing device's user-identity public key + a signed device-binding for the new
124/// device + the Welcome that admits the new device to the user's DeviceGroup. The whole thing
125/// is HPKE-sealed against the new device's ephemeral pubkey before transmission.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct LinkingTicket {
128    pub v: u8,
129    pub user_id: UserId,
130    #[serde(with = "serde_bytes")]
131    pub user_pubkey: Vec<u8>, // VerifyingKey bytes (32)
132    pub new_device_id: DeviceId,
133    #[serde(with = "serde_bytes")]
134    pub device_binding_sig: Vec<u8>, // identity signature over (user_id || device_id)
135    #[serde(with = "serde_bytes")]
136    pub device_group_welcome: Vec<u8>, // serialized OpenMLS Welcome
137    #[serde(with = "serde_bytes")]
138    pub catchup_snapshot: Vec<u8>, // CBOR-encoded `CatchupSnapshot` per [CR-13] (empty for the no-snapshot case)
139}
140
141// -------------------- CR-7: per-group MLS state snapshot --------------------
142
143/// Soft cap on a CBOR-encoded `GroupStateSnapshot`. Over this we log a warning but
144/// still emit; hosts hitting this regularly should investigate group-size growth.
145pub const GROUP_SNAPSHOT_SOFT_CAP: usize = 128 * 1024;
146
147/// Hard cap on a CBOR-encoded `GroupStateSnapshot`. Over this [`GroupStateSnapshot::encode`]
148/// errors. Matches the recovery-blob cap so a `GroupStateSnapshot` always fits inside
149/// a recovery blob's `device_group_snapshot` field.
150pub const GROUP_SNAPSHOT_HARD_CAP: usize = 256 * 1024;
151
152/// Current `GroupStateSnapshot` format version.
153pub const GROUP_SNAPSHOT_VERSION: u8 = 1;
154
155/// [CR-7] Portable snapshot of an MLS group's OpenMLS state.
156///
157/// Produced by [`Conversation::export_state_snapshot`] on one device, consumed by
158/// [`MessagingClient::import_state_snapshot`] on another to re-attach to the same
159/// group without re-Welcoming. The receiver MUST be the same user identity (the
160/// linking-flow QR handshake or recovery mnemonic provides that).
161///
162/// **What's in:** every OpenMLS storage entry whose key references this group's
163/// `group_id`. Trees, contexts, transcript hashes, message secrets, epoch keys —
164/// everything `MlsGroup::load` needs to reconstruct the live group object.
165///
166/// **What's out:** signature keypairs (recovered devices ALWAYS get a fresh signing
167/// key per [recovery.md §recovery on a new device]); leaf-keyed encryption keypairs
168/// for *other* leaves (those don't have the group_id in their key by definition);
169/// pending KeyPackages (per-device; receiver generates fresh ones on first publish).
170///
171/// **Forward secrecy note:** this snapshot exposes the exporter's view of past epoch
172/// secrets for *this group*. That's intentional for the linking + DG-recovery use
173/// cases (same user, trusted handover via QR / mnemonic). It is NOT used for
174/// peer-conversation history transfer — that goes through HPKE-sealed AppEvent
175/// re-shares per umbrella §15.6. Hosts MUST ensure callers of `export_state_snapshot`
176/// are authenticated to the user's intent.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct GroupStateSnapshot {
179    /// Format version. Bumped on incompatible CBOR-shape changes.
180    pub v: u8,
181    /// Group id this snapshot describes. Sanity-checked on import — passing a
182    /// snapshot for the wrong group is an error.
183    pub group_id: ConversationId,
184    /// OpenMLS storage-provider version at export time. Import refuses mismatched
185    /// versions outright (no automatic forward/back compat).
186    pub openmls_storage_version: u16,
187    /// Wall-clock ms at export. Informational; receivers can warn on stale
188    /// snapshots but the SDK does not act on it.
189    pub snapshot_created_at_ms: u64,
190    /// `(raw_storage_key, raw_storage_value)` pairs lifted from the exporter's
191    /// `MemoryStorage`. The receiver replays them into its own provider via
192    /// `import_entries`.
193    pub entries: Vec<GroupSnapshotEntry>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct GroupSnapshotEntry {
198    #[serde(with = "serde_bytes")]
199    pub key: Vec<u8>,
200    #[serde(with = "serde_bytes")]
201    pub value: Vec<u8>,
202}
203
204impl GroupStateSnapshot {
205    /// CBOR-encode the snapshot. Hard-cap-rejects oversize blobs; logs a warning at
206    /// the soft cap.
207    pub fn encode(&self) -> Result<Vec<u8>> {
208        let bytes = codec::encode(self)?;
209        if bytes.len() > GROUP_SNAPSHOT_HARD_CAP {
210            return Err(Error::Invalid(format!(
211                "group snapshot {} bytes exceeds hard cap {}",
212                bytes.len(),
213                GROUP_SNAPSHOT_HARD_CAP
214            )));
215        }
216        if bytes.len() > GROUP_SNAPSHOT_SOFT_CAP {
217            tracing::warn!(
218                target: "ping_core::device",
219                size = bytes.len(),
220                soft_cap = GROUP_SNAPSHOT_SOFT_CAP,
221                "group snapshot exceeds soft cap"
222            );
223        }
224        Ok(bytes)
225    }
226
227    /// Decode a CBOR-encoded snapshot. Rejects unknown `v` and oversize inputs.
228    pub fn decode(bytes: &[u8]) -> Result<Self> {
229        if bytes.len() > GROUP_SNAPSHOT_HARD_CAP {
230            return Err(Error::Invalid(format!(
231                "group snapshot input {} bytes exceeds hard cap {}",
232                bytes.len(),
233                GROUP_SNAPSHOT_HARD_CAP
234            )));
235        }
236        let snap: Self = codec::decode(bytes)?;
237        if snap.v != GROUP_SNAPSHOT_VERSION {
238            return Err(Error::Invalid(format!(
239                "group snapshot v={} not supported (this SDK supports v={})",
240                snap.v, GROUP_SNAPSHOT_VERSION
241            )));
242        }
243        Ok(snap)
244    }
245}
246
247// -------------------- CR-13: catchup snapshot encoding --------------------
248
249/// Soft cap on a CBOR-encoded `CatchupSnapshot`. Over this we log a warning but still emit;
250/// hosts that hit this regularly should trim the `last_app_events_per_conv` list to fewer
251/// or smaller items.
252pub const CATCHUP_SNAPSHOT_SOFT_CAP: usize = 64 * 1024;
253
254/// Hard cap on a CBOR-encoded `CatchupSnapshot`. Over this [`CatchupSnapshot::encode`]
255/// errors — the linking flow falls back to delivering an empty snapshot and the new device
256/// catches up via the normal sync path.
257pub const CATCHUP_SNAPSHOT_HARD_CAP: usize = 256 * 1024;
258
259/// [CR-13] Catchup snapshot delivered to a newly-linked device.
260///
261/// Populates the `catchup_snapshot` field of [`LinkingTicket`] so the new device boots
262/// with a populated conversation list + last-known plaintext per conversation, instead
263/// of an empty UI until peers re-Welcome.
264///
265/// **`group_state_bytes`** is reserved for the [CR-7] DeviceGroup-snapshot machinery; until
266/// CR-7 lands it's always empty (`Vec::new()`) on every meta. The encoding is forward-
267/// compatible: a snapshot produced today will decode cleanly on a future SDK build that
268/// understands CR-7, and a future-produced snapshot decodes on today's SDK (we just ignore
269/// the populated `group_state_bytes`).
270///
271/// **`last_app_events_per_conv`** is host-supplied — the SDK doesn't store decrypted
272/// plaintext on its own, so the host passes through whatever it wants the new device to
273/// see on launch (typically: the last N AppEvent bytes per conversation).
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct CatchupSnapshot {
276    /// Format version. `1` today; bumped on incompatible CBOR-shape changes.
277    pub v: u8,
278    /// Conversation list + per-conversation MLS state. `group_state_bytes` is empty
279    /// pre-CR-7; the field is reserved so the wire format doesn't churn when CR-7 ships.
280    pub conversation_metas: Vec<CatchupConversationEntry>,
281    /// Last-known decrypted AppEvent bytes per conversation. Host-supplied; opaque to
282    /// the SDK. Each entry is keyed by `ConversationId`.
283    pub last_app_events_per_conv: Vec<CatchupAppEventEntry>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct CatchupConversationEntry {
288    pub conversation_id: ConversationId,
289    pub meta: ConversationMeta,
290    /// CR-7 group snapshot bytes; empty until CR-7 ships. Receivers MUST handle the
291    /// empty case (in which they cannot decrypt that conversation until re-Welcomed).
292    #[serde(with = "serde_bytes")]
293    pub group_state_bytes: Vec<u8>,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct CatchupAppEventEntry {
298    pub conversation_id: ConversationId,
299    /// Host-defined plaintext bytes — typically a CBOR-encoded `AppEvent`. The SDK
300    /// never inspects this.
301    #[serde(with = "serde_bytes")]
302    pub app_event_bytes: Vec<u8>,
303}
304
305/// Current `CatchupSnapshot` format version. Independent of the wire `WIRE_VERSION` —
306/// catchup snapshots live inside an HPKE-sealed [`LinkingTicket`], not on the wire.
307pub const CATCHUP_SNAPSHOT_VERSION: u8 = 1;
308
309impl CatchupSnapshot {
310    /// Build an empty snapshot (no metas, no events). Equivalent to "no catchup data."
311    pub fn empty() -> Self {
312        Self {
313            v: CATCHUP_SNAPSHOT_VERSION,
314            conversation_metas: Vec::new(),
315            last_app_events_per_conv: Vec::new(),
316        }
317    }
318
319    /// CBOR-encode for inclusion in a [`LinkingTicket::catchup_snapshot`].
320    ///
321    /// Returns `Err(Error::Invalid)` if the encoded size exceeds
322    /// [`CATCHUP_SNAPSHOT_HARD_CAP`] (256 KB). Sizes between the soft cap (64 KB) and the
323    /// hard cap log a warning but still succeed — they're suboptimal but not refused.
324    pub fn encode(&self) -> Result<Vec<u8>> {
325        let bytes = codec::encode(self)?;
326        if bytes.len() > CATCHUP_SNAPSHOT_HARD_CAP {
327            return Err(Error::Invalid(format!(
328                "catchup snapshot {} bytes exceeds hard cap {} — trim last_app_events_per_conv",
329                bytes.len(),
330                CATCHUP_SNAPSHOT_HARD_CAP
331            )));
332        }
333        if bytes.len() > CATCHUP_SNAPSHOT_SOFT_CAP {
334            tracing::warn!(
335                target: "ping_core::device",
336                size = bytes.len(),
337                soft_cap = CATCHUP_SNAPSHOT_SOFT_CAP,
338                "catchup snapshot exceeds soft cap; new device boot may be sluggish"
339            );
340        }
341        Ok(bytes)
342    }
343
344    /// Decode a CBOR-encoded snapshot. Refuses sizes over [`CATCHUP_SNAPSHOT_HARD_CAP`]
345    /// to bound attacker-controlled allocation; refuses unknown `v` to avoid silently
346    /// dropping fields a future version added.
347    pub fn decode(bytes: &[u8]) -> Result<Self> {
348        if bytes.len() > CATCHUP_SNAPSHOT_HARD_CAP {
349            return Err(Error::Invalid(format!(
350                "catchup snapshot input {} bytes exceeds hard cap {}",
351                bytes.len(),
352                CATCHUP_SNAPSHOT_HARD_CAP
353            )));
354        }
355        let snap: Self = codec::decode(bytes)?;
356        if snap.v != CATCHUP_SNAPSHOT_VERSION {
357            return Err(Error::Invalid(format!(
358                "catchup snapshot v={} not supported (this SDK supports v={})",
359                snap.v, CATCHUP_SNAPSHOT_VERSION
360            )));
361        }
362        Ok(snap)
363    }
364}