1use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::canonical::{canonical_json, sha256_json};
15use crate::error::{Error, Result};
16use crate::keys::{PublicKey, Signer};
17
18pub const SCHEMA_VERSION: u32 = 1;
20
21pub const MAX_MESSAGE_BYTES: usize = 64 * 1024;
24
25pub const APPROVE_TTL_SECS: i64 = 600;
27
28pub const GENESIS_PREV: &str =
30 "sha256:0000000000000000000000000000000000000000000000000000000000000000";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum MessageType {
36 Chat,
38 Task,
40 #[serde(alias = "ask")]
42 Question,
43 Reply,
45 Done,
47 Claim,
49 Release,
51 Approve,
53 Deny,
55 Control,
57 System,
59}
60
61impl MessageType {
62 pub const ALL: [MessageType; 11] = [
63 MessageType::Chat,
64 MessageType::Task,
65 MessageType::Question,
66 MessageType::Reply,
67 MessageType::Done,
68 MessageType::Claim,
69 MessageType::Release,
70 MessageType::Approve,
71 MessageType::Deny,
72 MessageType::Control,
73 MessageType::System,
74 ];
75
76 pub fn as_str(&self) -> &'static str {
77 match self {
78 MessageType::Chat => "chat",
79 MessageType::Task => "task",
80 MessageType::Question => "question",
81 MessageType::Reply => "reply",
82 MessageType::Done => "done",
83 MessageType::Claim => "claim",
84 MessageType::Release => "release",
85 MessageType::Approve => "approve",
86 MessageType::Deny => "deny",
87 MessageType::Control => "control",
88 MessageType::System => "system",
89 }
90 }
91}
92
93impl fmt::Display for MessageType {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.write_str(self.as_str())
96 }
97}
98
99impl FromStr for MessageType {
100 type Err = Error;
101 fn from_str(s: &str) -> Result<Self> {
102 match s {
103 "chat" => Ok(MessageType::Chat),
104 "task" => Ok(MessageType::Task),
105 "question" | "ask" => Ok(MessageType::Question),
106 "reply" => Ok(MessageType::Reply),
107 "done" => Ok(MessageType::Done),
108 "claim" => Ok(MessageType::Claim),
109 "release" => Ok(MessageType::Release),
110 "approve" => Ok(MessageType::Approve),
111 "deny" => Ok(MessageType::Deny),
112 "control" => Ok(MessageType::Control),
113 "system" => Ok(MessageType::System),
114 other => Err(Error::Invalid(format!("unknown message type: {other}"))),
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
121#[serde(rename_all = "lowercase")]
122pub enum DataClass {
123 Public,
124 #[default]
125 Internal,
126 Confidential,
127 Pii,
128}
129
130impl DataClass {
131 pub fn as_str(&self) -> &'static str {
132 match self {
133 DataClass::Public => "public",
134 DataClass::Internal => "internal",
135 DataClass::Confidential => "confidential",
136 DataClass::Pii => "pii",
137 }
138 }
139}
140
141impl fmt::Display for DataClass {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 f.write_str(self.as_str())
144 }
145}
146
147impl FromStr for DataClass {
148 type Err = Error;
149 fn from_str(s: &str) -> Result<Self> {
150 match s {
151 "public" => Ok(DataClass::Public),
152 "internal" => Ok(DataClass::Internal),
153 "confidential" => Ok(DataClass::Confidential),
154 "pii" => Ok(DataClass::Pii),
155 other => Err(Error::Invalid(format!("unknown data class: {other}"))),
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
162pub struct AgentInfo {
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub vendor: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub model: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub owner: Option<String>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct Action {
175 pub verb: String,
176 pub target: String,
177 #[serde(default)]
178 pub params: Value,
179}
180
181impl Action {
182 pub fn hash(&self) -> String {
184 sha256_json(&serde_json::to_value(self).expect("action serializes"))
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
191pub struct Content {
192 #[serde(default)]
193 pub text: String,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub action: Option<Action>,
196 #[serde(default)]
197 pub data: Value,
198}
199
200impl Content {
201 pub fn hash(&self) -> String {
202 sha256_json(&serde_json::to_value(self).expect("content serializes"))
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct Message {
209 pub v: u32,
210 pub id: String,
211 pub room: String,
212 #[serde(default)]
214 pub seq: u64,
215 #[serde(default)]
217 pub prev: String,
218 #[serde(default)]
219 pub trace: Option<String>,
220 pub from: String,
221 #[serde(default)]
222 pub agent: Option<AgentInfo>,
223 #[serde(rename = "type")]
224 pub kind: MessageType,
225 #[serde(default)]
226 pub text: String,
227 #[serde(default)]
228 pub action: Option<Action>,
229 #[serde(default)]
230 pub data: Value,
231 #[serde(default)]
232 pub reply_to: Option<String>,
233 #[serde(default)]
234 pub to: Option<String>,
235 #[serde(default)]
236 pub class: DataClass,
237 pub ts: String,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub action_hash: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub expires: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub once: Option<bool>,
247 #[serde(default)]
248 pub sig: String,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub content_hash: Option<String>,
254 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
256 pub tombstone: bool,
257}
258
259#[derive(Debug, Clone, Default)]
262pub struct Draft {
263 pub room: String,
264 pub from: String,
265 pub kind: Option<MessageType>,
266 pub text: String,
267 pub action: Option<Action>,
268 pub data: Value,
269 pub reply_to: Option<String>,
270 pub to: Option<String>,
271 pub trace: Option<String>,
272 pub class: Option<DataClass>,
273 pub agent: Option<AgentInfo>,
274 pub action_hash: Option<String>,
275 pub expires: Option<String>,
276 pub once: Option<bool>,
277}
278
279pub fn new_id() -> String {
281 format!("m_{}", ulid::Ulid::generate())
282}
283
284pub const MAX_CLOCK_SKEW_SECS: i64 = 300;
287
288pub fn now_ts() -> String {
289 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
290}
291
292impl Message {
293 pub fn new(draft: Draft, signer: &dyn Signer) -> Result<Self> {
296 let mut m = Message {
297 v: SCHEMA_VERSION,
298 id: new_id(),
299 room: draft.room,
300 seq: 0,
301 prev: String::new(),
302 trace: draft.trace,
303 from: draft.from,
304 agent: draft.agent,
305 kind: draft.kind.unwrap_or(MessageType::Chat),
306 text: draft.text,
307 action: draft.action,
308 data: if draft.data.is_null() {
309 Value::Null
310 } else {
311 draft.data
312 },
313 reply_to: draft.reply_to,
314 to: draft.to,
315 class: draft.class.unwrap_or_default(),
316 ts: now_ts(),
317 action_hash: draft.action_hash,
318 expires: draft.expires,
319 once: draft.once,
320 sig: String::new(),
321 content_hash: None,
322 tombstone: false,
323 };
324 m.sig = signer.sign(&m.signing_bytes());
325 m.check_size()?;
326 Ok(m)
327 }
328
329 pub fn content(&self) -> Content {
331 Content {
332 text: self.text.clone(),
333 action: self.action.clone(),
334 data: self.data.clone(),
335 }
336 }
337
338 pub fn content_hash(&self) -> String {
341 match &self.content_hash {
342 Some(h) => h.clone(),
343 None => self.content().hash(),
344 }
345 }
346
347 pub fn with_explicit_hash(&self) -> Message {
349 let mut m = self.clone();
350 m.content_hash = Some(self.content_hash());
351 m
352 }
353
354 pub fn signing_bytes(&self) -> Vec<u8> {
357 let v = serde_json::json!({
358 "v": self.v,
359 "id": self.id,
360 "room": self.room,
361 "trace": self.trace,
362 "from": self.from,
363 "agent": self.agent,
364 "type": self.kind,
365 "content_hash": self.content_hash(),
366 "reply_to": self.reply_to,
367 "to": self.to,
368 "class": self.class,
369 "ts": self.ts,
370 "action_hash": self.action_hash,
371 "expires": self.expires,
372 "once": self.once,
373 });
374 canonical_json(&v).into_bytes()
375 }
376
377 pub fn verify(&self, key: &PublicKey) -> Result<()> {
379 key.verify(&self.signing_bytes(), &self.sig)
380 }
381
382 pub fn check_ts(&self, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
389 let t = chrono::DateTime::parse_from_rfc3339(&self.ts)
390 .map_err(|_| Error::Invalid(format!("ts {:?} is not RFC 3339", self.ts)))?
391 .with_timezone(&chrono::Utc);
392 if t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) != self.ts {
393 return Err(Error::Invalid(format!(
394 "ts {:?} must be UTC, whole seconds, ending in Z",
395 self.ts
396 )));
397 }
398 if t > now + chrono::Duration::seconds(MAX_CLOCK_SKEW_SECS) {
399 return Err(Error::Invalid(format!(
400 "ts {} is in the future; check this computer's clock",
401 self.ts
402 )));
403 }
404 Ok(())
405 }
406
407 pub fn envelope(&self) -> Value {
410 serde_json::json!({
411 "v": self.v,
412 "id": self.id,
413 "room": self.room,
414 "seq": self.seq,
415 "prev": self.prev,
416 "trace": self.trace,
417 "from": self.from,
418 "agent": self.agent,
419 "type": self.kind,
420 "content_hash": self.content_hash(),
421 "reply_to": self.reply_to,
422 "to": self.to,
423 "class": self.class,
424 "ts": self.ts,
425 "action_hash": self.action_hash,
426 "expires": self.expires,
427 "once": self.once,
428 "sig": self.sig,
429 })
430 }
431
432 pub fn chain_hash(&self) -> String {
434 sha256_json(&self.envelope())
435 }
436
437 pub fn sequence(&mut self, seq: u64, prev: &str) {
439 self.seq = seq;
440 self.prev = prev.to_string();
441 }
442
443 pub fn is_sequenced(&self) -> bool {
445 self.seq > 0 && !self.prev.is_empty()
446 }
447
448 pub fn check_size(&self) -> Result<()> {
450 let len = serde_json::to_string(self)?.len();
451 if len > MAX_MESSAGE_BYTES {
452 return Err(Error::Invalid(format!(
453 "message is {len} bytes, cap is {MAX_MESSAGE_BYTES}. Send big things by reference."
454 )));
455 }
456 Ok(())
457 }
458
459 pub fn tombstone(&self) -> Message {
462 let mut t = self.clone();
463 t.content_hash = Some(self.content_hash());
464 t.tombstone = true;
465 t.text = String::new();
466 t.action = None;
467 t.data = Value::Null;
468 t
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::keys::{Identity, Kind};
476
477 fn draft(room: &str, from: &str, text: &str) -> Draft {
478 Draft {
479 room: room.into(),
480 from: from.into(),
481 text: text.into(),
482 kind: Some(MessageType::Task),
483 ..Default::default()
484 }
485 }
486
487 #[test]
488 fn new_message_is_signed_and_verifies() {
489 let alice = Identity::generate("alice", Kind::Agent);
490 let m = Message::new(draft("ops", "alice", "deploy?"), &alice).unwrap();
491 assert_eq!(m.v, 1);
492 assert!(m.id.starts_with("m_"));
493 m.verify(&alice.public()).unwrap();
494 let bob = Identity::generate("bob", Kind::Agent);
495 assert!(m.verify(&bob.public()).is_err());
496 }
497
498 #[test]
499 fn ts_must_be_well_formed_and_not_in_the_future() {
500 let alice = Identity::generate("alice", Kind::Agent);
501 let mut m = Message::new(draft("ops", "alice", "hi"), &alice).unwrap();
502 let now = chrono::Utc::now();
503 m.check_ts(now).unwrap();
504 m.ts = "2020-01-01T00:00:00Z".into();
506 m.check_ts(now).unwrap();
507 let soon = now + chrono::Duration::seconds(60);
509 m.ts = soon.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
510 m.check_ts(now).unwrap();
511 m.ts = "2999-01-01T00:00:00Z".into();
512 assert!(m.check_ts(now).is_err());
513 for bad in [
515 "2020-01-01T00:00:00+00:00",
516 "2020-01-01T00:00:00.5Z",
517 "2020-01-01 00:00:00Z",
518 "yesterday",
519 ] {
520 m.ts = bad.into();
521 assert!(m.check_ts(now).is_err(), "{bad}");
522 }
523 }
524
525 #[test]
526 fn sequencing_does_not_break_signature() {
527 let alice = Identity::generate("alice", Kind::Agent);
528 let mut m = Message::new(draft("ops", "alice", "hi"), &alice).unwrap();
529 m.sequence(1, GENESIS_PREV);
530 m.verify(&alice.public()).unwrap();
531 assert!(m.is_sequenced());
532 }
533
534 #[test]
535 fn editing_content_breaks_signature() {
536 let alice = Identity::generate("alice", Kind::Agent);
537 let mut m = Message::new(draft("ops", "alice", "ship v1.2"), &alice).unwrap();
538 m.text = "ship v1.3".into();
539 assert!(m.verify(&alice.public()).is_err());
540 }
541
542 #[test]
543 fn chain_links_and_survives_tombstone() {
544 let alice = Identity::generate("alice", Kind::Agent);
545 let mut a = Message::new(draft("ops", "alice", "first"), &alice).unwrap();
546 a.sequence(1, GENESIS_PREV);
547 let mut b = Message::new(draft("ops", "alice", "second"), &alice).unwrap();
548 b.sequence(2, &a.chain_hash());
549 assert_eq!(b.prev, a.chain_hash());
550 let t = a.tombstone();
553 assert_eq!(t.text, "");
554 assert!(t.tombstone);
555 assert_eq!(t.content_hash(), a.content_hash());
556 assert_eq!(t.chain_hash(), b.prev);
557 t.verify(&alice.public()).unwrap();
558 let v = serde_json::to_value(&t).unwrap();
559 assert!(v.get("content_hash").is_some());
560 let v = serde_json::to_value(&a).unwrap();
561 assert!(v.get("content_hash").is_none());
562 assert!(v.get("tombstone").is_none());
563 }
564
565 #[test]
566 fn json_shape_matches_plan() {
567 let alice = Identity::generate("alice", Kind::Agent);
568 let mut d = draft("ops", "alice", "Deploy api-service v1.2 to prod?");
569 d.kind = Some(MessageType::Question);
570 d.trace = Some("ticket-4711".into());
571 d.action = Some(Action {
572 verb: "deploy".into(),
573 target: "api-service".into(),
574 params: serde_json::json!({"version": "1.2", "env": "prod"}),
575 });
576 let m = Message::new(d, &alice).unwrap();
577 let v = serde_json::to_value(&m).unwrap();
578 for key in [
579 "v", "id", "room", "seq", "prev", "trace", "from", "agent", "type", "text", "action",
580 "data", "reply_to", "to", "class", "ts", "sig",
581 ] {
582 assert!(v.get(key).is_some(), "missing {key}");
583 }
584 assert_eq!(v["type"], "question");
585 assert_eq!(v["class"], "internal");
586 assert!(v.get("action_hash").is_none());
587 let back: Message = serde_json::from_value(v).unwrap();
589 assert_eq!(back, m);
590 let asked: MessageType = serde_json::from_str("\"ask\"").unwrap();
592 assert_eq!(asked, MessageType::Question);
593 }
594
595 #[test]
596 fn size_cap() {
597 let alice = Identity::generate("alice", Kind::Agent);
598 let big = "x".repeat(MAX_MESSAGE_BYTES + 1);
599 assert!(Message::new(draft("ops", "alice", &big), &alice).is_err());
600 }
601}