ping-openmls-sdk-core 0.5.13

Platform-agnostic OpenMLS-based messaging engine
Documentation
//! 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)
    }

    /// 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)
    }
}