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}