Skip to main content

ping_core/
message.rs

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