Skip to main content

ping_core/
message.rs

1//! Wire envelope shared by every transport.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    clock::Hlc, conversation::ConversationId, device::DeviceId, identity::UserId, WIRE_VERSION,
7};
8
9/// What an envelope carries. Application bytes are opaque; handshake variants are MLS payloads.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[repr(u8)]
12pub enum MessageKind {
13    Application = 1,
14    Commit = 2,
15    Welcome = 3,
16    Proposal = 4,
17    KeyPackage = 5,
18}
19
20/// On-the-wire envelope. CBOR-encoded.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct MessageEnvelope {
23    pub v: u8,
24    pub conversation_id: ConversationId,
25    pub epoch: u64,
26    pub kind: MessageKind,
27    pub sender_device: DeviceId,
28    pub seq: u64,
29    pub hlc: Hlc,
30    #[serde(with = "serde_bytes")]
31    pub payload: Vec<u8>,
32    pub content_hash: [u8; 32],
33}
34
35impl MessageEnvelope {
36    /// Build an envelope for a handshake message (`Commit` / `Welcome` / `Proposal` /
37    /// `KeyPackage`). The `content_hash` is computed over `SHA-256(kind || payload)` —
38    /// unchanged across the v=1 → v=2 transition because handshake payloads have no
39    /// "plaintext vs ciphertext" distinction.
40    ///
41    /// For `MessageKind::Application` use [`new_application`](Self::new_application)
42    /// instead; passing an application kind here is a bug and tagged by debug_assert.
43    pub fn new(
44        conversation_id: ConversationId,
45        epoch: u64,
46        kind: MessageKind,
47        sender_device: DeviceId,
48        seq: u64,
49        hlc: Hlc,
50        payload: Vec<u8>,
51    ) -> Self {
52        debug_assert!(
53            !matches!(kind, MessageKind::Application),
54            "use MessageEnvelope::new_application for Application kind (CR-6 hashes plaintext)"
55        );
56        let content_hash = hash_handshake(kind, &payload);
57        Self {
58            v: WIRE_VERSION,
59            conversation_id,
60            epoch,
61            kind,
62            sender_device,
63            seq,
64            hlc,
65            payload,
66            content_hash,
67        }
68    }
69
70    /// Build an envelope for an application message ([CR-6]).
71    ///
72    /// `content_hash = SHA-256(plaintext)` — keyed to the *application bytes*, not the MLS
73    /// ciphertext. This is what makes rebase-on-409 clean: a message that gets resent
74    /// against a new epoch keeps the same `content_hash` so app-layer dedup tables remain
75    /// consistent. It also gives every binding identical hashes for the same
76    /// `AppEvent` because CBOR encoding is canonical.
77    ///
78    /// `payload` is the MLS ciphertext (what goes on the wire); `plaintext` is the
79    /// application-defined bytes that were encrypted. The constructor uses `plaintext`
80    /// only for hashing and `payload` for the envelope body.
81    pub fn new_application(
82        conversation_id: ConversationId,
83        epoch: u64,
84        sender_device: DeviceId,
85        seq: u64,
86        hlc: Hlc,
87        payload: Vec<u8>,
88        plaintext: &[u8],
89    ) -> Self {
90        let content_hash = hash_application_plaintext(plaintext);
91        Self {
92            v: WIRE_VERSION,
93            conversation_id,
94            epoch,
95            kind: MessageKind::Application,
96            sender_device,
97            seq,
98            hlc,
99            payload,
100            content_hash,
101        }
102    }
103}
104
105/// SHA-256 over `kind_byte || payload` — the v=1 hash, used by handshake kinds in both
106/// v=1 and v=2 envelopes.
107pub fn hash_handshake(kind: MessageKind, payload: &[u8]) -> [u8; 32] {
108    use sha2::{Digest, Sha256};
109    let mut h = Sha256::new();
110    h.update([kind as u8]);
111    h.update(payload);
112    h.finalize().into()
113}
114
115/// SHA-256 over the raw application plaintext — the v=2 hash for `MessageKind::Application`
116/// per [CR-6]. No kind byte, no envelope framing; cross-binding parity comes from
117/// canonical CBOR of the inner `AppEvent`.
118pub fn hash_application_plaintext(plaintext: &[u8]) -> [u8; 32] {
119    use sha2::{Digest, Sha256};
120    let mut h = Sha256::new();
121    h.update(plaintext);
122    h.finalize().into()
123}
124
125/// Application message handed to the host on receive.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct IncomingMessage {
128    pub conversation_id: ConversationId,
129    pub sender_device: DeviceId,
130    /// Account-level identity of the sender, recovered from the sender's
131    /// MLS leaf `BasicCredential` (the same `UserId` that
132    /// [`crate::conversation::Conversation::members`] reports). Unlike
133    /// `sender_device` (per-device), this is shared by every device a user
134    /// links, so the host can attribute a message to "me" on any device with
135    /// a single equality check (`sender_user_id == my own UserId`) — no
136    /// out-of-band device→account profile mapping required. Empty only if the
137    /// leaf credential is not a `BasicCredential` (never, for this protocol).
138    pub sender_user_id: UserId,
139    pub epoch: u64,
140    pub hlc: Hlc,
141    /// Application-defined plaintext.
142    #[serde(with = "serde_bytes")]
143    pub plaintext: Vec<u8>,
144    pub content_hash: [u8; 32],
145}
146
147/// Application message handed to the SDK to send.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct OutgoingMessage {
150    /// Application-defined plaintext.
151    #[serde(with = "serde_bytes")]
152    pub plaintext: Vec<u8>,
153}