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}