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    pub fn public_key(&self) -> VerifyingKey {
75        self.signing.verifying_key()
76    }
77
78    pub fn info(&self, last_seen_ms: u64) -> DeviceInfo {
79        DeviceInfo {
80            device_id: self.device_id.clone(),
81            user_id: self.user_id.clone(),
82            label: self.label.clone(),
83            created_at_ms: self.created_at_ms,
84            last_seen_ms,
85            revoked: false,
86        }
87    }
88}
89
90/// Encoded payload presented to a new device during the linking flow.
91///
92/// Carries the existing device's user-identity public key + a signed device-binding for the new
93/// device + the Welcome that admits the new device to the user's DeviceGroup. The whole thing
94/// is HPKE-sealed against the new device's ephemeral pubkey before transmission.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct LinkingTicket {
97    pub v: u8,
98    pub user_id: UserId,
99    #[serde(with = "serde_bytes")]
100    pub user_pubkey: Vec<u8>, // VerifyingKey bytes (32)
101    pub new_device_id: DeviceId,
102    #[serde(with = "serde_bytes")]
103    pub device_binding_sig: Vec<u8>, // identity signature over (user_id || device_id)
104    #[serde(with = "serde_bytes")]
105    pub device_group_welcome: Vec<u8>, // serialized OpenMLS Welcome
106    #[serde(with = "serde_bytes")]
107    pub catchup_snapshot: Vec<u8>, // CBOR-encoded `CatchupSnapshot` per [CR-13] (empty for the no-snapshot case)
108}
109
110// -------------------- CR-7: per-group MLS state snapshot --------------------
111
112/// Soft cap on a CBOR-encoded `GroupStateSnapshot`. Over this we log a warning but
113/// still emit; hosts hitting this regularly should investigate group-size growth.
114pub const GROUP_SNAPSHOT_SOFT_CAP: usize = 128 * 1024;
115
116/// Hard cap on a CBOR-encoded `GroupStateSnapshot`. Over this [`GroupStateSnapshot::encode`]
117/// errors. Matches the recovery-blob cap so a `GroupStateSnapshot` always fits inside
118/// a recovery blob's `device_group_snapshot` field.
119pub const GROUP_SNAPSHOT_HARD_CAP: usize = 256 * 1024;
120
121/// Current `GroupStateSnapshot` format version.
122pub const GROUP_SNAPSHOT_VERSION: u8 = 1;
123
124/// [CR-7] Portable snapshot of an MLS group's OpenMLS state.
125///
126/// Produced by [`Conversation::export_state_snapshot`] on one device, consumed by
127/// [`MessagingClient::import_state_snapshot`] on another to re-attach to the same
128/// group without re-Welcoming. The receiver MUST be the same user identity (the
129/// linking-flow QR handshake or recovery mnemonic provides that).
130///
131/// **What's in:** every OpenMLS storage entry whose key references this group's
132/// `group_id`. Trees, contexts, transcript hashes, message secrets, epoch keys —
133/// everything `MlsGroup::load` needs to reconstruct the live group object.
134///
135/// **What's out:** signature keypairs (recovered devices ALWAYS get a fresh signing
136/// key per [recovery.md §recovery on a new device]); leaf-keyed encryption keypairs
137/// for *other* leaves (those don't have the group_id in their key by definition);
138/// pending KeyPackages (per-device; receiver generates fresh ones on first publish).
139///
140/// **Forward secrecy note:** this snapshot exposes the exporter's view of past epoch
141/// secrets for *this group*. That's intentional for the linking + DG-recovery use
142/// cases (same user, trusted handover via QR / mnemonic). It is NOT used for
143/// peer-conversation history transfer — that goes through HPKE-sealed AppEvent
144/// re-shares per umbrella §15.6. Hosts MUST ensure callers of `export_state_snapshot`
145/// are authenticated to the user's intent.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct GroupStateSnapshot {
148    /// Format version. Bumped on incompatible CBOR-shape changes.
149    pub v: u8,
150    /// Group id this snapshot describes. Sanity-checked on import — passing a
151    /// snapshot for the wrong group is an error.
152    pub group_id: ConversationId,
153    /// OpenMLS storage-provider version at export time. Import refuses mismatched
154    /// versions outright (no automatic forward/back compat).
155    pub openmls_storage_version: u16,
156    /// Wall-clock ms at export. Informational; receivers can warn on stale
157    /// snapshots but the SDK does not act on it.
158    pub snapshot_created_at_ms: u64,
159    /// `(raw_storage_key, raw_storage_value)` pairs lifted from the exporter's
160    /// `MemoryStorage`. The receiver replays them into its own provider via
161    /// `import_entries`.
162    pub entries: Vec<GroupSnapshotEntry>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct GroupSnapshotEntry {
167    #[serde(with = "serde_bytes")]
168    pub key: Vec<u8>,
169    #[serde(with = "serde_bytes")]
170    pub value: Vec<u8>,
171}
172
173impl GroupStateSnapshot {
174    /// CBOR-encode the snapshot. Hard-cap-rejects oversize blobs; logs a warning at
175    /// the soft cap.
176    pub fn encode(&self) -> Result<Vec<u8>> {
177        let bytes = codec::encode(self)?;
178        if bytes.len() > GROUP_SNAPSHOT_HARD_CAP {
179            return Err(Error::Invalid(format!(
180                "group snapshot {} bytes exceeds hard cap {}",
181                bytes.len(),
182                GROUP_SNAPSHOT_HARD_CAP
183            )));
184        }
185        if bytes.len() > GROUP_SNAPSHOT_SOFT_CAP {
186            tracing::warn!(
187                target: "ping_core::device",
188                size = bytes.len(),
189                soft_cap = GROUP_SNAPSHOT_SOFT_CAP,
190                "group snapshot exceeds soft cap"
191            );
192        }
193        Ok(bytes)
194    }
195
196    /// Decode a CBOR-encoded snapshot. Rejects unknown `v` and oversize inputs.
197    pub fn decode(bytes: &[u8]) -> Result<Self> {
198        if bytes.len() > GROUP_SNAPSHOT_HARD_CAP {
199            return Err(Error::Invalid(format!(
200                "group snapshot input {} bytes exceeds hard cap {}",
201                bytes.len(),
202                GROUP_SNAPSHOT_HARD_CAP
203            )));
204        }
205        let snap: Self = codec::decode(bytes)?;
206        if snap.v != GROUP_SNAPSHOT_VERSION {
207            return Err(Error::Invalid(format!(
208                "group snapshot v={} not supported (this SDK supports v={})",
209                snap.v, GROUP_SNAPSHOT_VERSION
210            )));
211        }
212        Ok(snap)
213    }
214}
215
216// -------------------- CR-13: catchup snapshot encoding --------------------
217
218/// Soft cap on a CBOR-encoded `CatchupSnapshot`. Over this we log a warning but still emit;
219/// hosts that hit this regularly should trim the `last_app_events_per_conv` list to fewer
220/// or smaller items.
221pub const CATCHUP_SNAPSHOT_SOFT_CAP: usize = 64 * 1024;
222
223/// Hard cap on a CBOR-encoded `CatchupSnapshot`. Over this [`CatchupSnapshot::encode`]
224/// errors — the linking flow falls back to delivering an empty snapshot and the new device
225/// catches up via the normal sync path.
226pub const CATCHUP_SNAPSHOT_HARD_CAP: usize = 256 * 1024;
227
228/// [CR-13] Catchup snapshot delivered to a newly-linked device.
229///
230/// Populates the `catchup_snapshot` field of [`LinkingTicket`] so the new device boots
231/// with a populated conversation list + last-known plaintext per conversation, instead
232/// of an empty UI until peers re-Welcome.
233///
234/// **`group_state_bytes`** is reserved for the [CR-7] DeviceGroup-snapshot machinery; until
235/// CR-7 lands it's always empty (`Vec::new()`) on every meta. The encoding is forward-
236/// compatible: a snapshot produced today will decode cleanly on a future SDK build that
237/// understands CR-7, and a future-produced snapshot decodes on today's SDK (we just ignore
238/// the populated `group_state_bytes`).
239///
240/// **`last_app_events_per_conv`** is host-supplied — the SDK doesn't store decrypted
241/// plaintext on its own, so the host passes through whatever it wants the new device to
242/// see on launch (typically: the last N AppEvent bytes per conversation).
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct CatchupSnapshot {
245    /// Format version. `1` today; bumped on incompatible CBOR-shape changes.
246    pub v: u8,
247    /// Conversation list + per-conversation MLS state. `group_state_bytes` is empty
248    /// pre-CR-7; the field is reserved so the wire format doesn't churn when CR-7 ships.
249    pub conversation_metas: Vec<CatchupConversationEntry>,
250    /// Last-known decrypted AppEvent bytes per conversation. Host-supplied; opaque to
251    /// the SDK. Each entry is keyed by `ConversationId`.
252    pub last_app_events_per_conv: Vec<CatchupAppEventEntry>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct CatchupConversationEntry {
257    pub conversation_id: ConversationId,
258    pub meta: ConversationMeta,
259    /// CR-7 group snapshot bytes; empty until CR-7 ships. Receivers MUST handle the
260    /// empty case (in which they cannot decrypt that conversation until re-Welcomed).
261    #[serde(with = "serde_bytes")]
262    pub group_state_bytes: Vec<u8>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct CatchupAppEventEntry {
267    pub conversation_id: ConversationId,
268    /// Host-defined plaintext bytes — typically a CBOR-encoded `AppEvent`. The SDK
269    /// never inspects this.
270    #[serde(with = "serde_bytes")]
271    pub app_event_bytes: Vec<u8>,
272}
273
274/// Current `CatchupSnapshot` format version. Independent of the wire `WIRE_VERSION` —
275/// catchup snapshots live inside an HPKE-sealed [`LinkingTicket`], not on the wire.
276pub const CATCHUP_SNAPSHOT_VERSION: u8 = 1;
277
278impl CatchupSnapshot {
279    /// Build an empty snapshot (no metas, no events). Equivalent to "no catchup data."
280    pub fn empty() -> Self {
281        Self {
282            v: CATCHUP_SNAPSHOT_VERSION,
283            conversation_metas: Vec::new(),
284            last_app_events_per_conv: Vec::new(),
285        }
286    }
287
288    /// CBOR-encode for inclusion in a [`LinkingTicket::catchup_snapshot`].
289    ///
290    /// Returns `Err(Error::Invalid)` if the encoded size exceeds
291    /// [`CATCHUP_SNAPSHOT_HARD_CAP`] (256 KB). Sizes between the soft cap (64 KB) and the
292    /// hard cap log a warning but still succeed — they're suboptimal but not refused.
293    pub fn encode(&self) -> Result<Vec<u8>> {
294        let bytes = codec::encode(self)?;
295        if bytes.len() > CATCHUP_SNAPSHOT_HARD_CAP {
296            return Err(Error::Invalid(format!(
297                "catchup snapshot {} bytes exceeds hard cap {} — trim last_app_events_per_conv",
298                bytes.len(),
299                CATCHUP_SNAPSHOT_HARD_CAP
300            )));
301        }
302        if bytes.len() > CATCHUP_SNAPSHOT_SOFT_CAP {
303            tracing::warn!(
304                target: "ping_core::device",
305                size = bytes.len(),
306                soft_cap = CATCHUP_SNAPSHOT_SOFT_CAP,
307                "catchup snapshot exceeds soft cap; new device boot may be sluggish"
308            );
309        }
310        Ok(bytes)
311    }
312
313    /// Decode a CBOR-encoded snapshot. Refuses sizes over [`CATCHUP_SNAPSHOT_HARD_CAP`]
314    /// to bound attacker-controlled allocation; refuses unknown `v` to avoid silently
315    /// dropping fields a future version added.
316    pub fn decode(bytes: &[u8]) -> Result<Self> {
317        if bytes.len() > CATCHUP_SNAPSHOT_HARD_CAP {
318            return Err(Error::Invalid(format!(
319                "catchup snapshot input {} bytes exceeds hard cap {}",
320                bytes.len(),
321                CATCHUP_SNAPSHOT_HARD_CAP
322            )));
323        }
324        let snap: Self = codec::decode(bytes)?;
325        if snap.v != CATCHUP_SNAPSHOT_VERSION {
326            return Err(Error::Invalid(format!(
327                "catchup snapshot v={} not supported (this SDK supports v={})",
328                snap.v, CATCHUP_SNAPSHOT_VERSION
329            )));
330        }
331        Ok(snap)
332    }
333}