Skip to main content

agent_protocols/mail/
state.rs

1use super::*;
2use crate::identity::{AcceptedRecord, ListResponse, MemoryNonceStore, NonceStore};
3use serde::Deserialize;
4use std::collections::BTreeSet;
5
6fn version(value: &Value) -> Result<()> {
7    if value["version"] != 1 {
8        return Err(fail(
9            "invalid_request",
10            "unsupported local Mail state version",
11        ));
12    }
13    Ok(())
14}
15fn mailbox_key(owner: &AgentId, mailbox: &str) -> String {
16    format!("{owner}/{mailbox}")
17}
18
19/// Sender-side pins: the greatest-nonce card seen per (owner, mailbox),
20/// including closed or expired cards. Save `snapshot()` after every
21/// observation, even one that returns an error for an unusable new pin.
22#[derive(Clone, Debug, Default)]
23pub struct MailCardCache {
24    pins: BTreeMap<String, MailboxCard>,
25}
26impl MailCardCache {
27    pub fn new() -> Self {
28        Self::default()
29    }
30    /// Pin an authenticated card, including a closed or expired one. This is
31    /// appropriate for card reads; check usability before encrypting.
32    pub fn observe_historical(
33        &mut self,
34        value: &Value,
35        owner: &AgentId,
36        now: i64,
37    ) -> Result<MailboxCard> {
38        let card = validate_card(value)?;
39        clock(now)?;
40        if &card.event.actor != owner {
41            return Err(fail("invalid_actor", "unexpected owner"));
42        }
43        if card.event.created_at > now.saturating_add(FUTURE_SKEW_MS) {
44            return Err(SdkError::TimestampOutOfWindow);
45        }
46        let key = mailbox_key(owner, &card.event.payload.mailbox_id);
47        if let Some(pin) = self.pins.get(&key) {
48            if pin.hash != card.hash && card.event.nonce <= pin.event.nonce {
49                return Err(fail(
50                    "stale_card",
51                    "card is older than or conflicts with the pinned card",
52                ));
53            }
54        }
55        self.pins.insert(key, card.clone());
56        Ok(card)
57    }
58    /// Pin a card, then require that it is usable for new encryption.
59    pub fn observe(&mut self, value: &Value, owner: &AgentId, now: i64) -> Result<MailboxCard> {
60        let card = self.observe_historical(value, owner, now)?;
61        check_card_usable(&card, owner, now)?;
62        Ok(card)
63    }
64    /// The pinned card of `(owner, mailbox_id)`, if any.
65    pub fn get(&self, owner: &AgentId, mailbox_id: &str) -> Option<&MailboxCard> {
66        self.pins.get(&mailbox_key(owner, mailbox_id))
67    }
68    /// Drop pins old enough that every earlier card of the mailbox has expired.
69    pub fn prune(&mut self, now: i64) -> Result<()> {
70        clock(now)?;
71        self.pins
72            .retain(|_, pin| now < pin.event.created_at + MAX_TTL_MS + FUTURE_SKEW_MS);
73        Ok(())
74    }
75    /// Pin, encrypt the immutable message, and sign a fresh packet.
76    pub fn seal(
77        &mut self,
78        message: &MessagePayload,
79        card: &Value,
80        signer: &AgentSigner,
81        nonce: u64,
82        now: i64,
83    ) -> Result<Packet> {
84        validate_message(&serde_json::to_value(message)?)?;
85        let card = self.observe(card, &message.to, now)?;
86        encrypt_message(message, &card, signer, nonce, now)
87    }
88    pub fn snapshot(&self) -> Result<Value> {
89        Ok(json!({"version":1,"pins":self.pins.values().collect::<Vec<_>>()}))
90    }
91    pub fn from_snapshot(value: &Value) -> Result<Self> {
92        version(value)?;
93        let pins: Vec<MailboxCard> = serde_json::from_value(value["pins"].clone())?;
94        Ok(Self {
95            pins: pins
96                .into_iter()
97                .map(|card| {
98                    (
99                        mailbox_key(&card.event.actor, &card.event.payload.mailbox_id),
100                        card,
101                    )
102                })
103                .collect(),
104        })
105    }
106}
107
108/// Retained verified cards and their secrets, by card hash, through `receive_until`.
109pub struct MailKeyring {
110    owner: AgentId,
111    entries: BTreeMap<String, (MailboxCard, MailEncryptionKey)>,
112}
113impl MailKeyring {
114    pub fn new(owner: AgentId) -> Self {
115        Self {
116            owner,
117            entries: BTreeMap::new(),
118        }
119    }
120    pub fn owner(&self) -> &AgentId {
121        &self.owner
122    }
123    pub fn add(&mut self, card: &MailboxCard, key: MailEncryptionKey) -> Result<()> {
124        let card = validate_card(&serde_json::to_value(card)?)?;
125        if card.event.actor != self.owner {
126            return Err(fail("invalid_actor", "card owner mismatch"));
127        }
128        if card.event.payload.public_key != key.public_key() {
129            return Err(fail("invalid_private_key", "key does not match card"));
130        }
131        self.entries.insert(card.hash.clone(), (card, key));
132        Ok(())
133    }
134    /// Verify and decrypt a packet with the retained card it names; cards were verified at `add`.
135    pub fn open(&self, packet: &Packet, now: i64) -> Result<MessagePayload> {
136        clock(now)?;
137        let (packet, bytes) = checked_packet(&serde_json::to_value(packet)?)?;
138        self.open_checked(&packet, &bytes, now)
139    }
140    /// Open a packet already validated by `checked_packet`.
141    pub(crate) fn open_checked(
142        &self,
143        packet: &Packet,
144        bytes: &[u8],
145        now: i64,
146    ) -> Result<MessagePayload> {
147        let (card, key) = self
148            .entries
149            .get(&packet.event.payload.header.card_hash)
150            .ok_or_else(|| fail("invalid_event", "unknown retained card"))?;
151        open_verified(packet, bytes, card, key, &self.owner, now)
152    }
153    /// Only keys past the signed `receive_until` are removed. This cannot erase
154    /// copies held in snapshots or backups, which the application must manage.
155    pub fn prune(&mut self, now: i64) -> Result<()> {
156        clock(now)?;
157        self.entries
158            .retain(|_, (card, _)| now < card.event.payload.receive_until);
159        Ok(())
160    }
161    /// SENSITIVE: includes decryption keys. Protect this output at rest; never
162    /// pass it to a relay or log it.
163    pub fn snapshot(&self) -> Result<Value> {
164        let entries: Vec<_> = self
165            .entries
166            .values()
167            .map(|(card, key)| json!({"card":card,"secret":encode_bytes(&key.export_secret())}))
168            .collect();
169        Ok(json!({"version":1,"owner":self.owner,"entries":entries}))
170    }
171    pub fn from_snapshot(value: &Value) -> Result<Self> {
172        #[derive(Deserialize)]
173        struct Entry {
174            card: MailboxCard,
175            secret: String,
176        }
177        version(value)?;
178        let mut out = Self::new(serde_json::from_value(value["owner"].clone())?);
179        let entries: Vec<Entry> = serde_json::from_value(value["entries"].clone())?;
180        for entry in entries {
181            let raw = Zeroizing::new(fixed_bytes(&entry.secret, 32)?);
182            out.add(&entry.card, MailEncryptionKey::from_secret(&raw)?)?;
183        }
184        Ok(out)
185    }
186}
187
188/// Cross-route message deduplication. The application stores accepted messages;
189/// persist the inbox snapshot with them before deleting relay copies or acting.
190/// `accept` never executes content or sends replies.
191pub struct MailInbox {
192    keyring: MailKeyring,
193    accepted: BTreeMap<String, (i64, String)>,
194    blocked_senders: BTreeSet<AgentId>,
195}
196impl MailInbox {
197    pub fn new(keyring: MailKeyring) -> Self {
198        Self {
199            keyring,
200            accepted: BTreeMap::new(),
201            blocked_senders: BTreeSet::new(),
202        }
203    }
204    pub fn keyring(&self) -> &MailKeyring {
205        &self.keyring
206    }
207    pub fn keyring_mut(&mut self) -> &mut MailKeyring {
208        &mut self.keyring
209    }
210    pub fn set_sender_blocked(&mut self, sender: AgentId, blocked: bool) {
211        if blocked {
212            self.blocked_senders.insert(sender);
213        } else {
214            self.blocked_senders.remove(&sender);
215        }
216    }
217    pub fn has(&self, sender: &AgentId, message_id: &str) -> bool {
218        self.accepted
219            .contains_key(&format!("{sender}/{message_id}"))
220    }
221    /// Verify the packet once, apply sender policy before decryption, then deduplicate the message.
222    pub fn accept(
223        &mut self,
224        packet: &Packet,
225        claimed_packet_id: Option<&str>,
226        now: i64,
227    ) -> Result<InboxAcceptance> {
228        clock(now)?;
229        let (packet, bytes) = checked_packet(&serde_json::to_value(packet)?)?;
230        if claimed_packet_id.is_some_and(|id| id != packet.hash) {
231            return Err(fail("invalid_event", "packet ID mismatch"));
232        }
233        if self.blocked_senders.contains(&packet.event.actor) {
234            return Err(SdkError::PermissionDenied);
235        }
236        let message = self.keyring.open_checked(&packet, &bytes, now)?;
237        let key = format!("{}/{}", message.sender, message.message_id);
238        let digest = hash_bytes(&canonical_bytes(&message)?);
239        if let Some((_, old)) = self.accepted.get(&key) {
240            if old != &digest {
241                return Err(fail(
242                    "invalid_event",
243                    "message ID reused for different content",
244                ));
245            }
246            return Ok(InboxAcceptance::Duplicate(Box::new(message)));
247        }
248        if now >= message.expires_at {
249            return Err(fail(
250                "packet_expired",
251                "expired message cannot be newly accepted",
252            ));
253        }
254        self.accepted.insert(key, (message.expires_at, digest));
255        Ok(InboxAcceptance::Accepted(Box::new(message)))
256    }
257    pub fn prune(&mut self, now: i64) -> Result<()> {
258        clock(now)?;
259        self.accepted.retain(|_, (expires, _)| now < *expires);
260        Ok(())
261    }
262    pub fn snapshot(&self) -> Result<Value> {
263        Ok(
264            json!({"version":1,"owner":self.keyring.owner,"accepted":self.accepted,"blocked_senders":self.blocked_senders}),
265        )
266    }
267    pub fn from_snapshot(keyring: MailKeyring, value: &Value) -> Result<Self> {
268        version(value)?;
269        if value["owner"] != keyring.owner.as_str() {
270            return Err(fail("invalid_request", "inbox owner mismatch"));
271        }
272        Ok(Self {
273            keyring,
274            accepted: serde_json::from_value(value["accepted"].clone())?,
275            blocked_senders: serde_json::from_value(value["blocked_senders"].clone())?,
276        })
277    }
278}
279
280struct RelayMailbox {
281    current: AcceptedRecord<MailboxCardPayload>,
282    last_seq: u64,
283    bytes: usize,
284    packets: BTreeMap<String, (PacketRecord, usize)>,
285    tombstones: BTreeMap<String, (i64, i64)>,
286    blocked_senders: BTreeSet<AgentId>,
287}
288impl RelayMailbox {
289    fn drop_expired(&mut self, now: i64) {
290        let bytes = &mut self.bytes;
291        self.packets.retain(|_, (record, size)| {
292            let live = now < record.packet.event.payload.header.expires_at;
293            if !live {
294                *bytes -= *size;
295            }
296            live
297        });
298    }
299}
300/// Framework-neutral in-memory relay model, NOT a hosted or durable service.
301/// All outputs are owned copies. Save/restore snapshots atomically in a real
302/// service; enforce admission limits and serialized access outside this model.
303/// The short-lived Identity nonce cache is not part of the snapshot.
304pub struct MailRelayStore {
305    origin: String,
306    max_packets: usize,
307    max_bytes: usize,
308    nonces: MemoryNonceStore,
309    mailboxes: BTreeMap<String, RelayMailbox>,
310}
311impl MailRelayStore {
312    pub fn new(origin: impl Into<String>) -> Result<Self> {
313        Self::with_limits(origin, 10_000, 64 * MAX_PACKET_BYTES)
314    }
315    /// Per-mailbox limits on retained packets.
316    pub fn with_limits(
317        origin: impl Into<String>,
318        max_packets: usize,
319        max_bytes: usize,
320    ) -> Result<Self> {
321        let origin = origin.into();
322        identity::validate_origin(&origin)?;
323        if max_packets == 0 || max_bytes == 0 {
324            return Err(fail("invalid_request", "invalid relay quota"));
325        }
326        Ok(Self {
327            origin,
328            max_packets,
329            max_bytes,
330            nonces: MemoryNonceStore::new(),
331            mailboxes: BTreeMap::new(),
332        })
333    }
334    pub fn origin(&self) -> &str {
335        &self.origin
336    }
337    pub fn discovery(&self) -> Value {
338        json!({"protocol":PROTOCOL,"service":self.origin})
339    }
340    pub fn publish(
341        &mut self,
342        value: &Value,
343        now: i64,
344    ) -> Result<AcceptedRecord<MailboxCardPayload>> {
345        let mut nonces = std::mem::take(&mut self.nonces);
346        let result = self.publish_with_nonce_store(value, now, &mut nonces);
347        self.nonces = nonces;
348        result
349    }
350    /// Inject the origin-wide Identity nonce store when several protocols share
351    /// a service. All other checks precede nonce consumption.
352    pub fn publish_with_nonce_store<S: NonceStore + ?Sized>(
353        &mut self,
354        value: &Value,
355        now: i64,
356        nonces: &mut S,
357    ) -> Result<AcceptedRecord<MailboxCardPayload>> {
358        clock(now)?;
359        let card = validate_card(value)?;
360        let p = &card.event.payload;
361        if let Some(mailbox) = self.mailboxes.get(&p.mailbox_id) {
362            let current = &mailbox.current.envelope;
363            if current.event.actor != card.event.actor {
364                return Err(fail("mailbox_conflict", "mailbox belongs to another owner"));
365            }
366            if current.hash == card.hash {
367                return Ok(mailbox.current.clone());
368            }
369            if card.event.nonce <= current.event.nonce {
370                return Err(SdkError::NonceNotGreater {
371                    max_nonce: current.event.nonce,
372                });
373            }
374        } else if !p.routes.contains(&self.origin) {
375            return Err(SdkError::PermissionDenied);
376        }
377        identity::verify_timestamp(card.event.created_at, now, FUTURE_SKEW_MS)?;
378        nonces.check_and_update(&card.event.actor, card.event.nonce, now, 2 * FUTURE_SKEW_MS)?;
379        let record = AcceptedRecord {
380            envelope: card.clone(),
381            accepted_at: now,
382        };
383        match self.mailboxes.get_mut(&p.mailbox_id) {
384            Some(mailbox) => mailbox.current = record.clone(),
385            None => {
386                self.mailboxes.insert(
387                    p.mailbox_id.clone(),
388                    RelayMailbox {
389                        current: record.clone(),
390                        last_seq: 0,
391                        bytes: 0,
392                        packets: BTreeMap::new(),
393                        tombstones: BTreeMap::new(),
394                        blocked_senders: BTreeSet::new(),
395                    },
396                );
397            }
398        }
399        Ok(record)
400    }
401    pub fn card(&self, mailbox_id: &str) -> Result<AcceptedRecord<MailboxCardPayload>> {
402        path_id(mailbox_id, 16)?;
403        self.mailboxes
404            .get(mailbox_id)
405            .map(|m| m.current.clone())
406            .ok_or_else(|| fail("mailbox_unavailable", "unknown mailbox"))
407    }
408    /// Packets are asynchronous signed objects: no live-write window or nonce maximum applies.
409    pub fn deliver(
410        &mut self,
411        mailbox_id: &str,
412        packet: &Packet,
413        now: i64,
414    ) -> Result<DeliveryResult> {
415        clock(now)?;
416        path_id(mailbox_id, 16)?;
417        let (packet, bytes) = checked_packet(&serde_json::to_value(packet)?)?;
418        let h = &packet.event.payload.header;
419        if h.mailbox_id != mailbox_id {
420            return Err(fail("invalid_event", "path mailbox mismatch"));
421        }
422        let (id, size) = (packet.hash.clone(), bytes.len());
423        let mailbox = self
424            .mailboxes
425            .get_mut(mailbox_id)
426            .ok_or_else(|| fail("mailbox_unavailable", "unknown mailbox"))?;
427        if let Some((accepted_at, _)) = mailbox.tombstones.get(&id) {
428            return Ok(DeliveryResult {
429                packet_id: id,
430                accepted_at: *accepted_at,
431            });
432        }
433        let card = &mailbox.current.envelope;
434        let p = &card.event.payload;
435        if !p.routes.contains(&self.origin) {
436            return Err(fail("mailbox_unavailable", "relay is not a current route"));
437        }
438        if h.card_hash != card.hash || now >= p.expires_at {
439            return Err(fail("stale_card", "current card mismatch or expired"));
440        }
441        if now >= h.expires_at {
442            return Err(fail("packet_expired", "packet expired"));
443        }
444        if h.expires_at
445            > p.receive_until
446                .min(now.saturating_add(MAX_TTL_MS + FUTURE_SKEW_MS))
447        {
448            return Err(fail("invalid_event", "packet expiry bound"));
449        }
450        if packet.event.created_at > now.saturating_add(FUTURE_SKEW_MS) {
451            return Err(SdkError::TimestampOutOfWindow);
452        }
453        if size > p.max_packet_bytes {
454            return Err(fail("payload_too_large", "card packet limit"));
455        }
456        mailbox.drop_expired(now);
457        if mailbox.packets.len() >= self.max_packets
458            || mailbox.bytes.saturating_add(size) > self.max_bytes
459            || mailbox.last_seq >= identity::MAX_SAFE_NONCE
460        {
461            return Err(fail("rate_limited", "mailbox storage quota exhausted"));
462        }
463        if mailbox.blocked_senders.contains(&packet.event.actor) {
464            return Err(SdkError::PermissionDenied);
465        }
466        mailbox.last_seq += 1;
467        mailbox.bytes += size;
468        mailbox
469            .tombstones
470            .insert(id.clone(), (now, packet.event.payload.header.expires_at));
471        mailbox.packets.insert(
472            id.clone(),
473            (
474                PacketRecord {
475                    packet_id: id.clone(),
476                    packet,
477                    accepted_at: now,
478                    seq: mailbox.last_seq,
479                },
480                size,
481            ),
482        );
483        Ok(DeliveryResult {
484            packet_id: id,
485            accepted_at: now,
486        })
487    }
488    /// Owner sender policy for new packets. Mail defines no management endpoint:
489    /// the host authenticates the mailbox owner before calling this.
490    pub fn set_sender_blocked(
491        &mut self,
492        mailbox: &str,
493        sender: AgentId,
494        blocked: bool,
495    ) -> Result<()> {
496        path_id(mailbox, 16)?;
497        let box_ = self
498            .mailboxes
499            .get_mut(mailbox)
500            .ok_or_else(|| fail("mailbox_unavailable", "unknown mailbox"))?;
501        if blocked {
502            box_.blocked_senders.insert(sender);
503        } else {
504            box_.blocked_senders.remove(&sender);
505        }
506        Ok(())
507    }
508    fn owner_box(&self, mailbox: &str, jwt: &str, now: i64) -> Result<&RelayMailbox> {
509        path_id(mailbox, 16)?;
510        let claims = owner_claims(jwt, &self.origin, now)?;
511        let found = self
512            .mailboxes
513            .get(mailbox)
514            .ok_or_else(|| fail("mailbox_unavailable", "unknown mailbox"))?;
515        if claims.iss != found.current.envelope.event.actor {
516            return Err(SdkError::PermissionDenied);
517        }
518        Ok(found)
519    }
520    /// The cursor is the decimal `seq` of the last returned record.
521    pub fn list(
522        &self,
523        mailbox: &str,
524        jwt: &str,
525        now: i64,
526        limit: usize,
527        cursor: Option<&str>,
528    ) -> Result<ListResponse<PacketRecord>> {
529        let found = self.owner_box(mailbox, jwt, now)?;
530        if !(1..=1000).contains(&limit) {
531            return Err(fail("invalid_request", "list limit must be 1..1000"));
532        }
533        let after = match cursor {
534            None => 0,
535            Some(text) => text
536                .parse::<u64>()
537                .ok()
538                .filter(|n| n.to_string() == text && *n <= identity::MAX_SAFE_NONCE)
539                .ok_or_else(|| fail("invalid_request", "invalid cursor"))?,
540        };
541        let mut rows: Vec<_> = found
542            .packets
543            .values()
544            .map(|(record, _)| record)
545            .filter(|r| r.seq > after && now < r.packet.event.payload.header.expires_at)
546            .cloned()
547            .collect();
548        rows.sort_by_key(|r| r.seq);
549        let more = rows.len() > limit;
550        rows.truncate(limit);
551        Ok(ListResponse {
552            next_cursor: more.then(|| rows[limit - 1].seq.to_string()),
553            result: rows,
554        })
555    }
556    pub fn delete(&mut self, mailbox: &str, id: &str, jwt: &str, now: i64) -> Result<()> {
557        self.owner_box(mailbox, jwt, now)?;
558        path_id(id, 32)?;
559        let found = self.mailboxes.get_mut(mailbox).unwrap();
560        if let Some((_, size)) = found.packets.remove(id) {
561            found.bytes -= size;
562        }
563        Ok(())
564    }
565    /// Drop expired packets and tombstones, then forget mailboxes whose
566    /// current card's `receive_until` passed; a later card is a new registration.
567    pub fn prune(&mut self, now: i64) -> Result<()> {
568        clock(now)?;
569        for m in self.mailboxes.values_mut() {
570            m.drop_expired(now);
571            m.tombstones.retain(|_, (_, expires)| now < *expires);
572        }
573        self.mailboxes
574            .retain(|_, m| now < m.current.envelope.event.payload.receive_until);
575        Ok(())
576    }
577    pub fn snapshot(&self) -> Result<Value> {
578        let mailboxes: Vec<_> = self
579            .mailboxes
580            .values()
581            .map(|m| {
582                json!({
583                    "current": m.current,
584                    "last_seq": m.last_seq,
585                    "blocked_senders":m.blocked_senders,
586                    "packets": m.packets.values().map(|(r, _)| r).collect::<Vec<_>>(),
587                    "tombstones": m.tombstones.iter().map(|(id, (accepted_at, expires_at))| {
588                        json!({"packet_id":id,"accepted_at":accepted_at,"expires_at":expires_at})
589                    }).collect::<Vec<_>>(),
590                })
591            })
592            .collect();
593        Ok(json!({"version":1,"origin":self.origin,"mailboxes":mailboxes}))
594    }
595    /// Restore trusted local state saved by `snapshot`; limits are not part of it.
596    pub fn from_snapshot(value: &Value, max_packets: usize, max_bytes: usize) -> Result<Self> {
597        #[derive(Deserialize)]
598        struct Tombstone {
599            packet_id: String,
600            accepted_at: i64,
601            expires_at: i64,
602        }
603        #[derive(Deserialize)]
604        struct Saved {
605            current: AcceptedRecord<MailboxCardPayload>,
606            last_seq: u64,
607            packets: Vec<PacketRecord>,
608            tombstones: Vec<Tombstone>,
609            blocked_senders: BTreeSet<AgentId>,
610        }
611        version(value)?;
612        let origin = value["origin"]
613            .as_str()
614            .ok_or_else(|| fail("invalid_request", "snapshot origin"))?;
615        let mut out = Self::with_limits(origin, max_packets, max_bytes)?;
616        let saved: Vec<Saved> = serde_json::from_value(normalized(&value["mailboxes"]))?;
617        for m in saved {
618            let mut mailbox = RelayMailbox {
619                current: m.current,
620                blocked_senders: m.blocked_senders,
621                last_seq: m.last_seq,
622                bytes: 0,
623                packets: BTreeMap::new(),
624                tombstones: m
625                    .tombstones
626                    .into_iter()
627                    .map(|t| (t.packet_id, (t.accepted_at, t.expires_at)))
628                    .collect(),
629            };
630            for record in m.packets {
631                let size = canonical_bytes(&record.packet)?.len();
632                mailbox.bytes += size;
633                mailbox
634                    .packets
635                    .insert(record.packet_id.clone(), (record, size));
636            }
637            let id = mailbox.current.envelope.event.payload.mailbox_id.clone();
638            out.mailboxes.insert(id, mailbox);
639        }
640        Ok(out)
641    }
642}
643fn path_id(value: &str, size: usize) -> Result<()> {
644    fixed_bytes(value, size).map_err(|_| fail("invalid_request", "invalid path ID"))?;
645    Ok(())
646}