Skip to main content

agent_protocols/mail/
mod.rs

1//! Agent Mail 1.0: signed cards, sender-signed HPKE packets, and explicit local state.
2//!
3//! Relays never receive decryption keys. A verified message is untrusted content,
4//! not authorization to execute a task. State helpers are in-memory models;
5//! applications must persist snapshots atomically before acknowledging delivery.
6//! Relay paths are fixed at `{route}/v1/mailboxes`.
7mod state;
8mod types;
9pub use state::*;
10pub use types::*;
11#[cfg(feature = "http-client")]
12mod client;
13#[cfg(feature = "http-client")]
14pub use client::MailClient;
15
16use crate::identity::{self, AgentId, AgentSigner, Envelope, Event};
17use crate::{Result, SdkError};
18use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
19use hpke::{
20    aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, Kem as _,
21    OpModeR, OpModeS, Serializable,
22};
23use serde::Serialize;
24use serde_json::{json, Value};
25use sha3::{Digest, Sha3_256};
26use std::{
27    collections::BTreeMap,
28    sync::{Mutex, OnceLock},
29};
30use zeroize::Zeroizing;
31
32pub const PROTOCOL: &str = "agent-mail/1.0";
33pub const MAX_TTL_MS: i64 = 30 * 86_400_000;
34pub const FUTURE_SKEW_MS: i64 = 300_000;
35pub const MAX_PACKET_BYTES: usize = 1_048_576;
36pub const SCHEMA_JSON: &str = include_str!("schema.json");
37/// HPKE `info`; the packet metadata and header are bound as AAD.
38const INFO: &[u8] = PROTOCOL.as_bytes();
39type Kem = X25519HkdfSha256;
40type PrivateKey = <Kem as hpke::Kem>::PrivateKey;
41type PublicKey = <Kem as hpke::Kem>::PublicKey;
42
43pub(crate) fn fail(code: &'static str, message: impl Into<String>) -> SdkError {
44    SdkError::protocol(code, message)
45}
46pub fn canonical_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>> {
47    serde_jcs::to_vec(value).map_err(|e| SdkError::CanonicalJson(e.to_string()))
48}
49pub fn hash_bytes(bytes: &[u8]) -> String {
50    URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes))
51}
52pub fn encode_bytes(bytes: &[u8]) -> String {
53    URL_SAFE_NO_PAD.encode(bytes)
54}
55pub fn decode_bytes(value: &str) -> Result<Vec<u8>> {
56    let bytes = URL_SAFE_NO_PAD
57        .decode(value)
58        .map_err(|_| fail("invalid_event", "noncanonical base64url"))?;
59    if URL_SAFE_NO_PAD.encode(&bytes) != value {
60        return Err(fail("invalid_event", "noncanonical base64url"));
61    }
62    Ok(bytes)
63}
64pub(crate) fn fixed_bytes(value: &str, size: usize) -> Result<Vec<u8>> {
65    let bytes = decode_bytes(value)?;
66    if bytes.len() != size {
67        return Err(fail("invalid_event", "invalid encoded byte length"));
68    }
69    Ok(bytes)
70}
71pub fn random_id() -> Result<String> {
72    let mut bytes = [0; 16];
73    getrandom::fill(&mut bytes).map_err(|e| SdkError::Random(e.to_string()))?;
74    Ok(encode_bytes(&bytes))
75}
76/// Parse `did:agent:<key>/mail/<mailbox_id>[?route=<origin>...]` (Mail Section 3.3).
77pub fn parse_mail_address(value: &str) -> Result<MailAddress> {
78    let url = identity::parse_agent_url(value)?;
79    let mailbox_id = match (url.protocol.as_deref(), url.resource) {
80        (Some("mail"), Some(mailbox_id)) => mailbox_id,
81        _ => return Err(fail("invalid_url", "not a mailbox address")),
82    };
83    fixed_bytes(&mailbox_id, 16).map_err(|_| fail("invalid_url", "mailbox_id must be an id16"))?;
84    Ok(MailAddress {
85        owner: url.agent_id,
86        mailbox_id,
87        routes: url.routes,
88    })
89}
90/// Format the stable address, or a contact address when routes are given.
91pub fn format_mail_address(owner: &AgentId, mailbox_id: &str, routes: &[String]) -> Result<String> {
92    fixed_bytes(mailbox_id, 16).map_err(|_| fail("invalid_url", "mailbox_id must be an id16"))?;
93    identity::format_agent_url(&identity::AgentUrl {
94        agent_id: owner.clone(),
95        protocol: Some("mail".into()),
96        resource: Some(mailbox_id.into()),
97        routes: routes.to_vec(),
98    })
99}
100/// Validate a named structural definition; signatures and semantics are separate.
101pub fn validate_mail_schema(value: &Value, definition: &str) -> Result<()> {
102    static VALIDATORS: OnceLock<Mutex<BTreeMap<String, jsonschema::Validator>>> = OnceLock::new();
103    let mut validators = VALIDATORS
104        .get_or_init(|| Mutex::new(BTreeMap::new()))
105        .lock()
106        .map_err(|_| fail("invalid_event", "schema cache unavailable"))?;
107    if !validators.contains_key(definition) {
108        let schema: Value = serde_json::from_str(SCHEMA_JSON)?;
109        if schema["$defs"].get(definition).is_none() {
110            return Err(fail("invalid_event", "unknown Mail schema definition"));
111        }
112        let validator = jsonschema::options()
113            .with_draft(jsonschema::Draft::Draft202012)
114            .build(&json!({"$ref":format!("#/$defs/{definition}"),"$defs":schema["$defs"]}))
115            .map_err(|e| fail("invalid_event", e.to_string()))?;
116        validators.insert(definition.into(), validator);
117    }
118    validators[definition]
119        .validate(value)
120        .map_err(|e| fail("invalid_event", e.to_string()))
121}
122pub(crate) fn normalized(value: &Value) -> Value {
123    match value {
124        Value::Number(n) if n.as_i64().is_none() && n.as_u64().is_none() => n
125            .as_f64()
126            .filter(|f| f.fract() == 0.0 && f.abs() <= identity::MAX_SAFE_NONCE as f64)
127            .map(|f| json!(f as i64))
128            .unwrap_or(value.clone()),
129        Value::Array(a) => Value::Array(a.iter().map(normalized).collect()),
130        Value::Object(o) => {
131            Value::Object(o.iter().map(|(k, v)| (k.clone(), normalized(v))).collect())
132        }
133        _ => value.clone(),
134    }
135}
136fn signed(value: &Value, definition: &str) -> Result<Envelope<Value>> {
137    identity::parse_strict_json(&serde_json::to_string(value)?)?;
138    validate_mail_schema(value, definition)?;
139    let envelope: Envelope<Value> = serde_json::from_value(normalized(value))?;
140    identity::validate_event_fields(&envelope.event, &[])?;
141    identity::validate_nonce(envelope.event.nonce)?;
142    identity::verify_envelope(&envelope)?;
143    Ok(envelope)
144}
145pub(crate) fn lifetime(created: i64, expires: i64) -> Result<()> {
146    if created < 0
147        || expires <= created
148        || expires > created.saturating_add(MAX_TTL_MS)
149        || expires as u64 > identity::MAX_SAFE_NONCE
150    {
151        return Err(fail("invalid_event", "invalid lifetime"));
152    }
153    Ok(())
154}
155pub(crate) fn clock(now: i64) -> Result<()> {
156    if now < 0 || now as u64 > identity::MAX_SAFE_NONCE {
157        return Err(fail("invalid_request", "invalid clock"));
158    }
159    Ok(())
160}
161/// Historical verification: checks intrinsic validity, not live-write freshness.
162pub fn validate_card(value: &Value) -> Result<MailboxCard> {
163    let envelope = signed(value, "mailboxCardEnvelope")?;
164    let card: MailboxCard = serde_json::from_value(serde_json::to_value(envelope)?)?;
165    let p = &card.event.payload;
166    lifetime(card.event.created_at, p.expires_at)?;
167    if p.receive_until < p.expires_at || p.receive_until > p.expires_at.saturating_add(MAX_TTL_MS) {
168        return Err(fail("invalid_event", "invalid receive_until"));
169    }
170    for route in &p.routes {
171        identity::validate_origin(route)?;
172    }
173    let probe_enc = <Kem as hpke::Kem>::EncappedKey::from_bytes(&fixed_bytes(&p.public_key, 32)?)
174        .map_err(|_| fail("invalid_event", "invalid X25519 key"))?;
175    // The maintained KEM rejects low-order/all-zero DH results. A fixed public
176    // probe secret is only validation input, never used for actual encryption.
177    let probe =
178        PrivateKey::from_bytes(&[42; 32]).map_err(|_| fail("invalid_event", "invalid key"))?;
179    Kem::decap(&probe, None, &probe_enc)
180        .map_err(|_| fail("invalid_event", "unusable X25519 key"))?;
181    Ok(card)
182}
183pub fn validate_card_for_sending(value: &Value, owner: &AgentId, now: i64) -> Result<MailboxCard> {
184    let card = validate_card(value)?;
185    check_card_usable(&card, owner, now)?;
186    Ok(card)
187}
188pub(crate) fn check_card_usable(card: &MailboxCard, owner: &AgentId, now: i64) -> Result<()> {
189    clock(now)?;
190    if &card.event.actor != owner {
191        return Err(fail("invalid_actor", "unexpected mailbox owner"));
192    }
193    if card.event.created_at > now.saturating_add(FUTURE_SKEW_MS) {
194        return Err(SdkError::TimestampOutOfWindow);
195    }
196    if card.event.payload.routes.is_empty() {
197        return Err(fail("mailbox_unavailable", "mailbox closed"));
198    }
199    if now >= card.event.payload.expires_at {
200        return Err(fail("stale_card", "card expired"));
201    }
202    Ok(())
203}
204/// Validate immutable plaintext. Authentication comes from its signed packet.
205pub fn validate_message(value: &Value) -> Result<MessagePayload> {
206    identity::parse_strict_json(&serde_json::to_string(value)?)?;
207    validate_mail_schema(value, "messagePayload")?;
208    let p: MessagePayload = serde_json::from_value(normalized(value))?;
209    lifetime(p.created_at, p.expires_at)?;
210    for part in &p.parts {
211        let bytes = decode_bytes(&part.data)?;
212        if part.media_type.starts_with("text/") {
213            std::str::from_utf8(&bytes)
214                .map_err(|_| fail("invalid_event", "text part is not UTF-8"))?;
215        }
216    }
217    if let Some(reply) = &p.reply_card {
218        if validate_card(reply)?.event.actor != p.sender {
219            return Err(fail(
220                "invalid_actor",
221                "reply card owner differs from sender",
222            ));
223        }
224    }
225    Ok(p)
226}
227pub fn parse_card(text: &str) -> Result<MailboxCard> {
228    validate_card(&identity::parse_strict_json(text)?)
229}
230pub fn parse_message(text: &str) -> Result<MessagePayload> {
231    validate_message(&identity::parse_strict_json(text)?)
232}
233pub fn sign_card(
234    signer: &AgentSigner,
235    payload: MailboxCardPayload,
236    created_at: i64,
237    nonce: u64,
238) -> Result<MailboxCard> {
239    let envelope = signer.sign_event(Event::new(
240        PROTOCOL,
241        "mailbox.publish",
242        signer.agent_id(),
243        created_at,
244        nonce,
245        payload,
246    ))?;
247    validate_card(&serde_json::to_value(&envelope)?)?;
248    Ok(envelope)
249}
250pub fn new_mail_message_id() -> Result<String> {
251    let mut bytes = [0; 32];
252    getrandom::fill(&mut bytes).map_err(|e| SdkError::Random(e.to_string()))?;
253    Ok(encode_bytes(&bytes))
254}
255pub fn create_mail_message(
256    actor: &AgentId,
257    created_at: i64,
258    mut payload: Value,
259) -> Result<MessagePayload> {
260    let fields = payload
261        .as_object_mut()
262        .ok_or_else(|| fail("invalid_event", "message content must be an object"))?;
263    if ["message_id", "from", "created_at"]
264        .iter()
265        .any(|k| fields.contains_key(*k))
266    {
267        return Err(fail(
268            "invalid_event",
269            "message content overrides identity fields",
270        ));
271    }
272    fields.insert("message_id".into(), json!(new_mail_message_id()?));
273    fields.insert("from".into(), json!(actor));
274    fields.insert("created_at".into(), json!(created_at));
275    validate_message(&payload)
276}
277/// Bind an immutable reply to both participants and its parent's logical ID.
278pub fn validate_reply(reply: &MessagePayload, parent: &MessagePayload) -> Result<()> {
279    validate_message(&serde_json::to_value(reply)?)?;
280    validate_message(&serde_json::to_value(parent)?)?;
281    if reply.to != parent.sender
282        || parent.to != reply.sender
283        || reply.in_reply_to.as_deref() != Some(parent.message_id.as_str())
284        || reply.thread_id != parent.thread_id
285    {
286        return Err(fail("invalid_event", "reply binding mismatch"));
287    }
288    Ok(())
289}
290/// Independent X25519 secret. Debug output and implicit serialization are absent.
291/// `export_secret` is explicit and returns a buffer zeroized on drop.
292pub struct MailEncryptionKey(PrivateKey);
293impl MailEncryptionKey {
294    pub fn generate() -> Result<Self> {
295        let mut bytes = Zeroizing::new([0; 32]);
296        getrandom::fill(bytes.as_mut()).map_err(|e| SdkError::Random(e.to_string()))?;
297        Self::from_secret(bytes.as_ref())
298    }
299    pub fn from_secret(bytes: &[u8]) -> Result<Self> {
300        Ok(Self(PrivateKey::from_bytes(bytes).map_err(|_| {
301            fail("invalid_private_key", "X25519 secret must contain 32 bytes")
302        })?))
303    }
304    pub fn public_key(&self) -> String {
305        encode_bytes(&Kem::sk_to_pk(&self.0).to_bytes())
306    }
307    pub fn export_secret(&self) -> Zeroizing<Vec<u8>> {
308        Zeroizing::new(self.0.to_bytes().to_vec())
309    }
310}
311/// Exact framing, also useful for boundary conformance tests. Inputs are bounded.
312pub fn frame_bytes(bytes: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
313    if bytes.is_empty() || bytes.len() > MAX_PACKET_BYTES {
314        return Err(fail("invalid_packet", "invalid frame length"));
315    }
316    let size = (4 + bytes.len()).div_ceil(1024) * 1024;
317    let mut frame = Zeroizing::new(vec![0; size]);
318    frame[..4].copy_from_slice(&(bytes.len() as u32).to_be_bytes());
319    frame[4..4 + bytes.len()].copy_from_slice(bytes);
320    Ok(frame)
321}
322fn unframe(bytes: &[u8]) -> Result<Value> {
323    if bytes.len() < 1024 || bytes.len() % 1024 != 0 {
324        return Err(fail("invalid_packet", "invalid frame length"));
325    }
326    let n = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize;
327    if n == 0
328        || n > bytes.len() - 4
329        || (4 + n).div_ceil(1024) * 1024 != bytes.len()
330        || bytes[4 + n..].iter().any(|b| *b != 0)
331    {
332        return Err(fail("invalid_packet", "invalid frame padding"));
333    }
334    let data = &bytes[4..4 + n];
335    let text = std::str::from_utf8(data).map_err(|_| fail("invalid_packet", "invalid UTF-8"))?;
336    let value = identity::parse_strict_json(text)?;
337    if canonical_bytes(&value)? != data {
338        return Err(fail("invalid_packet", "noncanonical plaintext"));
339    }
340    Ok(value)
341}
342/// Validate a packet and return it with its canonical bytes, computed once for ID and size checks.
343pub(crate) fn checked_packet(value: &Value) -> Result<(Packet, Vec<u8>)> {
344    let envelope = signed(value, "packetEnvelope")?;
345    let packet: Packet = serde_json::from_value(serde_json::to_value(envelope)?)?;
346    let e = &packet.event;
347    let p = &e.payload;
348    fixed_bytes(&p.enc, 32)?;
349    let n = decode_bytes(&p.ciphertext)?.len();
350    if n < 1040
351        || n % 1024 != 16
352        || p.header.expires_at <= e.created_at
353        || p.header.expires_at > e.created_at.saturating_add(MAX_TTL_MS + FUTURE_SKEW_MS)
354    {
355        return Err(fail(
356            "invalid_packet",
357            "invalid ciphertext length or packet lifetime",
358        ));
359    }
360    let bytes = canonical_bytes(&packet)?;
361    if bytes.len() > MAX_PACKET_BYTES {
362        return Err(fail("payload_too_large", "packet too large"));
363    }
364    Ok((packet, bytes))
365}
366pub fn validate_packet(value: &Value) -> Result<Packet> {
367    Ok(checked_packet(value)?.0)
368}
369pub fn parse_packet(text: &str) -> Result<Packet> {
370    if text.len() > 2 * MAX_PACKET_BYTES {
371        return Err(fail("payload_too_large", "raw packet too large"));
372    }
373    validate_packet(&identity::parse_strict_json(text)?)
374}
375pub fn packet_id(packet: &Packet) -> Result<String> {
376    checked_packet(&serde_json::to_value(packet)?)?;
377    Ok(packet.hash.clone())
378}
379/// Exact context bound to encryption, derived from the verified outer event.
380pub fn mail_packet_aad(event: &Event<PacketPayload>) -> Result<Vec<u8>> {
381    canonical_bytes(
382        &json!({"protocol":event.protocol,"type":event.kind,"actor":event.actor,
383        "created_at":event.created_at,"nonce":event.nonce,"header":event.payload.header}),
384    )
385}
386/// Encrypt immutable plaintext with fresh HPKE randomness, then sign the packet.
387pub fn encrypt_message(
388    message: &MessagePayload,
389    card: &MailboxCard,
390    signer: &AgentSigner,
391    nonce: u64,
392    now: i64,
393) -> Result<Packet> {
394    let message = validate_message(&serde_json::to_value(message)?)?;
395    if message.sender != signer.agent_id() {
396        return Err(fail("invalid_actor", "message sender differs from signer"));
397    }
398    identity::validate_nonce(nonce)?;
399    validate_card_for_sending(&serde_json::to_value(card)?, &message.to, now)?;
400    if message.created_at > now.saturating_add(FUTURE_SKEW_MS) || message.expires_at <= now {
401        return Err(fail("packet_expired", "message expired or future-dated"));
402    }
403    if message.expires_at > card.event.payload.receive_until {
404        return Err(fail("invalid_packet", "message exceeds receive_until"));
405    }
406    let plaintext = frame_bytes(&Zeroizing::new(canonical_bytes(&message)?))?;
407    let mut event = Event::new(
408        PROTOCOL,
409        "mail.submit",
410        signer.agent_id(),
411        now,
412        nonce,
413        PacketPayload {
414            header: PacketHeader {
415                mailbox_id: card.event.payload.mailbox_id.clone(),
416                card_hash: card.hash.clone(),
417                expires_at: message.expires_at,
418            },
419            enc: encode_bytes(&[0; 32]),
420            ciphertext: encode_bytes(&vec![0; plaintext.len() + 16]),
421        },
422    );
423    let projected = Envelope {
424        event: event.clone(),
425        hash: "A".repeat(43),
426        signature: "A".repeat(86),
427    };
428    if canonical_bytes(&projected)?.len() > card.event.payload.max_packet_bytes {
429        return Err(fail("payload_too_large", "packet exceeds card limit"));
430    }
431    let pk = PublicKey::from_bytes(&fixed_bytes(&card.event.payload.public_key, 32)?)
432        .map_err(|_| fail("invalid_packet", "invalid encryption key"))?;
433    let (enc, ciphertext) = hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem>(
434        &OpModeS::Base,
435        &pk,
436        INFO,
437        &plaintext,
438        &mail_packet_aad(&event)?,
439    )
440    .map_err(|_| fail("invalid_packet", "encryption failed"))?;
441    event.payload.enc = encode_bytes(&enc.to_bytes());
442    event.payload.ciphertext = encode_bytes(&ciphertext);
443    signer.sign_event(event)
444}
445/// Historical verification and opening; inbox acceptance handles expiry and dedup.
446pub fn decrypt_packet(
447    packet: &Packet,
448    card: &MailboxCard,
449    key: &MailEncryptionKey,
450    owner: &AgentId,
451    now: i64,
452) -> Result<MessagePayload> {
453    clock(now)?;
454    let (packet, bytes) = checked_packet(&serde_json::to_value(packet)?)?;
455    let card = validate_card(&serde_json::to_value(card)?)?;
456    open_verified(&packet, &bytes, &card, key, owner, now)
457}
458pub(crate) fn open_verified(
459    packet: &Packet,
460    bytes: &[u8],
461    card: &MailboxCard,
462    key: &MailEncryptionKey,
463    owner: &AgentId,
464    now: i64,
465) -> Result<MessagePayload> {
466    let e = &packet.event;
467    let h = &e.payload.header;
468    let p = &card.event.payload;
469    if e.created_at > now.saturating_add(FUTURE_SKEW_MS) {
470        return Err(SdkError::TimestampOutOfWindow);
471    }
472    if &card.event.actor != owner {
473        return Err(fail("invalid_actor", "card owner mismatch"));
474    }
475    if bytes.len() > p.max_packet_bytes {
476        return Err(fail("payload_too_large", "packet exceeds card limit"));
477    }
478    if key.public_key() != p.public_key {
479        return Err(fail("invalid_private_key", "recipient key mismatch"));
480    }
481    if h.card_hash != card.hash || h.mailbox_id != p.mailbox_id || h.expires_at > p.receive_until {
482        return Err(fail("invalid_packet", "recipient/card/key binding"));
483    }
484    let enc = <Kem as hpke::Kem>::EncappedKey::from_bytes(&fixed_bytes(&e.payload.enc, 32)?)
485        .map_err(|_| fail("invalid_packet", "invalid encapsulated key"))?;
486    let plaintext = Zeroizing::new(
487        hpke::single_shot_open::<ChaCha20Poly1305, HkdfSha256, Kem>(
488            &OpModeR::Base,
489            &key.0,
490            &enc,
491            INFO,
492            &decode_bytes(&e.payload.ciphertext)?,
493            &mail_packet_aad(e)?,
494        )
495        .map_err(|_| fail("invalid_packet", "unable to decrypt packet"))?,
496    );
497    let message = validate_message(&unframe(&plaintext)?)?;
498    if message.sender != e.actor || &message.to != owner || message.expires_at != h.expires_at {
499        return Err(fail(
500            "invalid_packet",
501            "sender, recipient or expiration binding",
502        ));
503    }
504    if message.created_at > now.min(e.created_at).saturating_add(FUTURE_SKEW_MS) {
505        return Err(SdkError::TimestampOutOfWindow);
506    }
507    Ok(message)
508}
509/// Informational discovery document; it never supplies delivery paths.
510pub fn validate_discovery(value: &Value, origin: &str) -> Result<()> {
511    validate_mail_schema(value, "discoveryDocument")?;
512    identity::validate_origin(origin)?;
513    if value["service"] != origin {
514        return Err(fail("invalid_response", "discovery origin mismatch"));
515    }
516    Ok(())
517}
518pub(crate) fn owner_claims(
519    token: &str,
520    origin: &str,
521    now: i64,
522) -> Result<identity::RequestJwtClaims> {
523    clock(now)?;
524    identity::verify_request_jwt(
525        token,
526        &identity::RequestAuthContext {
527            audience: origin.into(),
528            now_secs: now / 1000,
529            max_ttl_secs: identity::DEFAULT_REQUEST_JWT_TTL_SECS,
530        },
531    )
532    .map_err(|e| fail("invalid_token", e.to_string()))
533}
534/// Identity request JWT for this relay origin whose subject is the mailbox owner.
535pub fn verify_owner_jwt(token: &str, owner: &AgentId, origin: &str, now: i64) -> Result<()> {
536    identity::validate_origin(origin)?;
537    if &owner_claims(token, origin, now)?.iss != owner {
538        return Err(SdkError::PermissionDenied);
539    }
540    Ok(())
541}