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}