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
7pub const MAX_BODY_BYTES: usize = 1800;
8pub const DM_INNER_DOMAIN: &[u8] = b"nysiris-chat-v1/dm-inner";
9pub const DM_INNER_VERSION: u8 = 1;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Direction {
14    Incoming,
15    Outgoing,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum MessageState {
21    Sent,
22    Queued,
23    Failed,
24}
25
26/// A locally stored chat message. The body is plaintext only inside the
27/// encrypted history journal and in memory.
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ChatMessage {
30    pub msg_id: String,
31    pub conversation_id: String,
32    pub from: String,
33    pub to: String,
34    pub body: String,
35    pub at: u64,
36    pub direction: Direction,
37    pub state: MessageState,
38}
39
40impl ChatMessage {
41    pub fn incoming(from: &str, to: &str, msg_id: &str, body: &str, at: u64) -> Self {
42        Self {
43            msg_id: msg_id.to_lowercase(),
44            conversation_id: dm_conversation_id(from),
45            from: from.to_lowercase(),
46            to: to.to_lowercase(),
47            body: body.to_string(),
48            at,
49            direction: Direction::Incoming,
50            state: MessageState::Sent,
51        }
52    }
53
54    pub fn outgoing(from: &str, to: &str, msg_id: &str, body: &str, at: u64) -> Self {
55        Self {
56            msg_id: msg_id.to_lowercase(),
57            conversation_id: dm_conversation_id(to),
58            from: from.to_lowercase(),
59            to: to.to_lowercase(),
60            body: body.to_string(),
61            at,
62            direction: Direction::Outgoing,
63            state: MessageState::Sent,
64        }
65    }
66
67    pub fn incoming_group(from: &str, gid: &str, msg_id: &str, body: &str, at: u64) -> Self {
68        Self {
69            msg_id: msg_id.to_lowercase(),
70            conversation_id: format!("group:{}", gid.to_lowercase()),
71            from: from.to_lowercase(),
72            to: gid.to_lowercase(),
73            body: body.to_string(),
74            at,
75            direction: Direction::Incoming,
76            state: MessageState::Sent,
77        }
78    }
79
80    pub fn outgoing_group(from: &str, gid: &str, msg_id: &str, body: &str, at: u64) -> Self {
81        Self {
82            msg_id: msg_id.to_lowercase(),
83            conversation_id: format!("group:{}", gid.to_lowercase()),
84            from: from.to_lowercase(),
85            to: gid.to_lowercase(),
86            body: body.to_string(),
87            at,
88            direction: Direction::Outgoing,
89            state: MessageState::Sent,
90        }
91    }
92
93    pub fn peer_pub(&self) -> &str {
94        match self.direction {
95            Direction::Incoming => &self.from,
96            Direction::Outgoing => &self.to,
97        }
98    }
99}
100
101/// Signed plaintext carried inside the sealed DM envelope.
102#[derive(Clone, Debug, Serialize, Deserialize)]
103pub struct DmInner {
104    pub v: u8,
105    pub from: String,
106    pub to: String,
107    pub ts: u64,
108    #[serde(rename = "msgId")]
109    pub msg_id: String,
110    pub body: String,
111    pub sig: String,
112}
113
114fn inner_sign_bytes(
115    from_hex: &str,
116    to_hex: &str,
117    ts: u64,
118    msg_id_hex: &str,
119    body: &str,
120) -> Result<Vec<u8>> {
121    let from = crate::ids::pubkey_bytes(from_hex)?;
122    let to = crate::ids::pubkey_bytes(to_hex)?;
123    let msg_id = msg_id_bytes(msg_id_hex)?;
124    let mut out = Vec::with_capacity(DM_INNER_DOMAIN.len() + 32 + 32 + 8 + 16 + body.len());
125    out.extend_from_slice(DM_INNER_DOMAIN);
126    out.extend_from_slice(&from);
127    out.extend_from_slice(&to);
128    out.extend_from_slice(&ts.to_be_bytes());
129    out.extend_from_slice(&msg_id);
130    out.extend_from_slice(body.as_bytes());
131    Ok(out)
132}
133
134/// Sign and JSON-pack the inner DM envelope.
135pub fn pack_dm_inner(
136    identity: &ChatIdentity,
137    to_pub_hex: &str,
138    msg_id_hex: &str,
139    ts: u64,
140    body: &str,
141) -> Result<String> {
142    if body.is_empty() {
143        return Err(CoreError::InvalidInput("message body is empty".into()));
144    }
145    if body.len() > MAX_BODY_BYTES {
146        return Err(CoreError::InvalidInput(format!(
147            "message body exceeds {MAX_BODY_BYTES} bytes"
148        )));
149    }
150    let from = identity.public_hex();
151    let to = normalize_pubkey_hex(to_pub_hex)?;
152    msg_id_bytes(msg_id_hex)?;
153    let bytes = inner_sign_bytes(&from, &to, ts, msg_id_hex, body)?;
154    let inner = DmInner {
155        v: DM_INNER_VERSION,
156        from,
157        to,
158        ts,
159        msg_id: msg_id_hex.to_lowercase(),
160        body: body.to_string(),
161        sig: identity.sign_hex(&bytes),
162    };
163    Ok(serde_json::to_string(&inner)?)
164}
165
166/// Verify and unpack an inner DM envelope. Fails closed on any defect.
167pub fn unpack_dm_inner(json: &str, own_pub_hex: &str) -> Result<DmInner> {
168    let inner: DmInner = serde_json::from_str(json)?;
169    if inner.v != DM_INNER_VERSION {
170        return Err(CoreError::InvalidInput(format!(
171            "unsupported DM inner version {}",
172            inner.v
173        )));
174    }
175    let from = normalize_pubkey_hex(&inner.from)?;
176    let to = normalize_pubkey_hex(&inner.to)?;
177    let own = normalize_pubkey_hex(own_pub_hex)?;
178    if to != own {
179        return Err(CoreError::InvalidInput(
180            "DM inner envelope is addressed to another key".into(),
181        ));
182    }
183    if inner.body.is_empty() || inner.body.len() > MAX_BODY_BYTES {
184        return Err(CoreError::InvalidInput(
185            "DM body has an invalid size".into(),
186        ));
187    }
188    msg_id_bytes(&inner.msg_id)?;
189    let bytes = inner_sign_bytes(&from, &to, inner.ts, &inner.msg_id, &inner.body)?;
190    ChatIdentity::verify_hex(&from, &bytes, &inner.sig)?;
191    Ok(DmInner { from, to, ..inner })
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn inner_envelope_round_trips_and_binds_recipient() {
200        let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
201        let bob = ChatIdentity::from_secret_bytes([2u8; 32]);
202        let id = "ab".repeat(16);
203        let packed = pack_dm_inner(&alice, &bob.public_hex(), &id, 123, "hello").unwrap();
204        let opened = unpack_dm_inner(&packed, &bob.public_hex()).unwrap();
205        assert_eq!(opened.body, "hello");
206        assert_eq!(opened.from, alice.public_hex());
207        assert!(unpack_dm_inner(&packed, &alice.public_hex()).is_err());
208    }
209
210    #[test]
211    fn tampered_body_fails_verification() {
212        let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
213        let bob = ChatIdentity::from_secret_bytes([2u8; 32]);
214        let id = "ab".repeat(16);
215        let packed = pack_dm_inner(&alice, &bob.public_hex(), &id, 123, "hello").unwrap();
216        let mut value: serde_json::Value = serde_json::from_str(&packed).unwrap();
217        value["body"] = serde_json::json!("hello!");
218        assert!(unpack_dm_inner(&value.to_string(), &bob.public_hex()).is_err());
219    }
220}