Skip to main content

agent_protocols/mail/
state.rs

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