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