Skip to main content

nysiris_core/
message.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{CoreError, Result};
4use crate::identity::ChatIdentity;
5use crate::ids::{dm_conversation_id, msg_id_bytes, normalize_pubkey_hex};
6
7/// Maximum accepted message body, in bytes. Keeps one message inside a single
8/// Nym Sphinx payload after sealing and framing.
9pub const MAX_BODY_BYTES: usize = 1800;
10/// Domain separator mixed into the DM inner-envelope signature.
11pub const DM_INNER_DOMAIN: &[u8] = b"nysiris-chat-v1/dm-inner";
12/// Version of the signed DM inner envelope.
13pub const DM_INNER_VERSION: u8 = 1;
14
15/// Whether a stored message was sent or received by this identity.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum Direction {
19    /// Received from the peer.
20    Incoming,
21    /// Sent by this identity.
22    Outgoing,
23}
24
25/// Delivery state recorded alongside a message.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum MessageState {
29    /// Handed to the transport without an error.
30    Sent,
31    /// Waiting for an outbox retry.
32    Queued,
33    /// The transport rejected the message.
34    Failed,
35}
36
37/// A locally stored chat message. The body is plaintext only inside the
38/// encrypted history journal and in memory.
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub struct ChatMessage {
41    /// Sender-generated message id, lowercase hex.
42    pub msg_id: String,
43    /// `dm:<peer pubkey>` for direct messages, `group:<room id>` for rooms.
44    pub conversation_id: String,
45    /// Sender's ed25519 public key, lowercase hex.
46    pub from: String,
47    /// Recipient public key for DMs, room id for room messages.
48    pub to: String,
49    /// Message text. Never persist this unencrypted.
50    pub body: String,
51    /// Sender-supplied Unix timestamp in milliseconds.
52    pub at: u64,
53    /// Whether this identity sent or received the message.
54    pub direction: Direction,
55    /// Delivery state at the time the record was written.
56    pub state: MessageState,
57}
58
59impl ChatMessage {
60    /// Build a received 1:1 message.
61    pub fn incoming(from: &str, to: &str, msg_id: &str, body: &str, at: u64) -> Self {
62        Self {
63            msg_id: msg_id.to_lowercase(),
64            conversation_id: dm_conversation_id(from),
65            from: from.to_lowercase(),
66            to: to.to_lowercase(),
67            body: body.to_string(),
68            at,
69            direction: Direction::Incoming,
70            state: MessageState::Sent,
71        }
72    }
73
74    /// Build a sent 1:1 message.
75    pub fn outgoing(from: &str, to: &str, msg_id: &str, body: &str, at: u64) -> Self {
76        Self {
77            msg_id: msg_id.to_lowercase(),
78            conversation_id: dm_conversation_id(to),
79            from: from.to_lowercase(),
80            to: to.to_lowercase(),
81            body: body.to_string(),
82            at,
83            direction: Direction::Outgoing,
84            state: MessageState::Sent,
85        }
86    }
87
88    /// Build a received room message. `to` is the room id.
89    pub fn incoming_group(from: &str, gid: &str, msg_id: &str, body: &str, at: u64) -> Self {
90        Self {
91            msg_id: msg_id.to_lowercase(),
92            conversation_id: format!("group:{}", gid.to_lowercase()),
93            from: from.to_lowercase(),
94            to: gid.to_lowercase(),
95            body: body.to_string(),
96            at,
97            direction: Direction::Incoming,
98            state: MessageState::Sent,
99        }
100    }
101
102    /// Build a sent room message. `to` is the room id.
103    pub fn outgoing_group(from: &str, gid: &str, msg_id: &str, body: &str, at: u64) -> Self {
104        Self {
105            msg_id: msg_id.to_lowercase(),
106            conversation_id: format!("group:{}", gid.to_lowercase()),
107            from: from.to_lowercase(),
108            to: gid.to_lowercase(),
109            body: body.to_string(),
110            at,
111            direction: Direction::Outgoing,
112            state: MessageState::Sent,
113        }
114    }
115
116    /// The other party: `from` for received messages, `to` for sent ones.
117    pub fn peer_pub(&self) -> &str {
118        match self.direction {
119            Direction::Incoming => &self.from,
120            Direction::Outgoing => &self.to,
121        }
122    }
123}
124
125/// Signed plaintext carried inside the sealed DM envelope.
126#[derive(Clone, Debug, Serialize, Deserialize)]
127pub struct DmInner {
128    /// Envelope version. Currently `1`.
129    pub v: u8,
130    /// Sender ed25519 public key, lowercase hex.
131    pub from: String,
132    /// Recipient ed25519 public key, lowercase hex.
133    pub to: String,
134    /// Sender-supplied Unix timestamp in milliseconds.
135    pub ts: u64,
136    /// Sender-generated message id, lowercase hex.
137    #[serde(rename = "msgId")]
138    pub msg_id: String,
139    /// Message text.
140    pub body: String,
141    /// ed25519 signature over the domain-separated envelope fields.
142    pub sig: String,
143}
144
145fn inner_sign_bytes(
146    from_hex: &str,
147    to_hex: &str,
148    ts: u64,
149    msg_id_hex: &str,
150    body: &str,
151) -> Result<Vec<u8>> {
152    let from = crate::ids::pubkey_bytes(from_hex)?;
153    let to = crate::ids::pubkey_bytes(to_hex)?;
154    let msg_id = msg_id_bytes(msg_id_hex)?;
155    let mut out = Vec::with_capacity(DM_INNER_DOMAIN.len() + 32 + 32 + 8 + 16 + body.len());
156    out.extend_from_slice(DM_INNER_DOMAIN);
157    out.extend_from_slice(&from);
158    out.extend_from_slice(&to);
159    out.extend_from_slice(&ts.to_be_bytes());
160    out.extend_from_slice(&msg_id);
161    out.extend_from_slice(body.as_bytes());
162    Ok(out)
163}
164
165/// Sign and JSON-pack the inner DM envelope.
166pub fn pack_dm_inner(
167    identity: &ChatIdentity,
168    to_pub_hex: &str,
169    msg_id_hex: &str,
170    ts: u64,
171    body: &str,
172) -> Result<String> {
173    if body.is_empty() {
174        return Err(CoreError::InvalidInput("message body is empty".into()));
175    }
176    if body.len() > MAX_BODY_BYTES {
177        return Err(CoreError::InvalidInput(format!(
178            "message body exceeds {MAX_BODY_BYTES} bytes"
179        )));
180    }
181    let from = identity.public_hex();
182    let to = normalize_pubkey_hex(to_pub_hex)?;
183    msg_id_bytes(msg_id_hex)?;
184    let bytes = inner_sign_bytes(&from, &to, ts, msg_id_hex, body)?;
185    let inner = DmInner {
186        v: DM_INNER_VERSION,
187        from,
188        to,
189        ts,
190        msg_id: msg_id_hex.to_lowercase(),
191        body: body.to_string(),
192        sig: identity.sign_hex(&bytes),
193    };
194    Ok(serde_json::to_string(&inner)?)
195}
196
197/// Verify and unpack an inner DM envelope. Fails closed on any defect.
198pub fn unpack_dm_inner(json: &str, own_pub_hex: &str) -> Result<DmInner> {
199    let inner: DmInner = serde_json::from_str(json)?;
200    if inner.v != DM_INNER_VERSION {
201        return Err(CoreError::InvalidInput(format!(
202            "unsupported DM inner version {}",
203            inner.v
204        )));
205    }
206    let from = normalize_pubkey_hex(&inner.from)?;
207    let to = normalize_pubkey_hex(&inner.to)?;
208    let own = normalize_pubkey_hex(own_pub_hex)?;
209    if to != own {
210        return Err(CoreError::InvalidInput(
211            "DM inner envelope is addressed to another key".into(),
212        ));
213    }
214    if inner.body.is_empty() || inner.body.len() > MAX_BODY_BYTES {
215        return Err(CoreError::InvalidInput(
216            "DM body has an invalid size".into(),
217        ));
218    }
219    msg_id_bytes(&inner.msg_id)?;
220    let bytes = inner_sign_bytes(&from, &to, inner.ts, &inner.msg_id, &inner.body)?;
221    ChatIdentity::verify_hex(&from, &bytes, &inner.sig)?;
222    Ok(DmInner { from, to, ..inner })
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn inner_envelope_round_trips_and_binds_recipient() {
231        let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
232        let bob = ChatIdentity::from_secret_bytes([2u8; 32]);
233        let id = "ab".repeat(16);
234        let packed = pack_dm_inner(&alice, &bob.public_hex(), &id, 123, "hello").unwrap();
235        let opened = unpack_dm_inner(&packed, &bob.public_hex()).unwrap();
236        assert_eq!(opened.body, "hello");
237        assert_eq!(opened.from, alice.public_hex());
238        assert!(unpack_dm_inner(&packed, &alice.public_hex()).is_err());
239    }
240
241    #[test]
242    fn tampered_body_fails_verification() {
243        let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
244        let bob = ChatIdentity::from_secret_bytes([2u8; 32]);
245        let id = "ab".repeat(16);
246        let packed = pack_dm_inner(&alice, &bob.public_hex(), &id, 123, "hello").unwrap();
247        let mut value: serde_json::Value = serde_json::from_str(&packed).unwrap();
248        value["body"] = serde_json::json!("hello!");
249        assert!(unpack_dm_inner(&value.to_string(), &bob.public_hex()).is_err());
250    }
251}