Skip to main content

agent_mesh_protocol/
envelope.rs

1//! Signed wire envelope. Every message between mesh peers is wrapped
2//! in one of these — the cert chain proves the sender belongs to a
3//! user, the agent signature proves the message wasn't tampered
4//! with, and the payload CID lets receivers reject mismatched bodies
5//! before paying for downstream parsing.
6
7use crate::agent_key::{AgentKey, CertChain, SerdeSig};
8use crate::fingerprint::Fingerprint;
9use crate::signer::MeshSigner;
10use crate::{MeshError, Result};
11use ed25519_dalek::{Verifier, VerifyingKey};
12use rand::RngCore;
13use serde::{Deserialize, Serialize};
14use serde_bytes::ByteBuf;
15
16/// Domain-separation tag for envelope signatures.
17const ENVELOPE_TAG: &[u8] = b"agent-mesh-envelope-v1";
18
19/// Recipient of an envelope — direct peer, named topic, or anycast.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(tag = "kind", rename_all = "snake_case")]
22pub enum Recipient {
23    /// Direct peer, addressed by agent pubkey fingerprint.
24    Direct { agent_fp: Fingerprint },
25    /// Pub/sub topic — a string name, scoped to the sender's user
26    /// namespace.
27    Topic { name: String },
28    /// Anycast: any agent claiming the named capability.
29    Anycast { capability: String },
30}
31
32/// A wire envelope, signed by the sender's agent key.
33///
34/// Fields, in the order they're produced by [`Self::new`]:
35///
36/// * `cert_chain` — proves the sender's agent identity.
37/// * `recipient` — addressing tag.
38/// * `nonce` — 24 random bytes; replay-protection scope.
39/// * `sequence` — monotonic per-session counter.
40/// * `payload_cid` — BLAKE3 of `payload`.
41/// * `payload` — opaque bytes (the actual message).
42/// * `agent_sig` — signature over
43///   `ENVELOPE_TAG || recipient_bytes || nonce || seq || payload_cid`.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct SignedEnvelope {
46    pub cert_chain: CertChain,
47    pub recipient: Recipient,
48    pub nonce: [u8; 24],
49    pub sequence: u64,
50    pub payload_cid: [u8; 32],
51    pub payload: ByteBuf,
52    pub agent_sig: SerdeSig,
53}
54
55impl SignedEnvelope {
56    /// Build and sign a new envelope.
57    ///
58    /// The 24-byte `nonce` is drawn from `rand::thread_rng`; callers
59    /// don't manage it. The `sequence` is supplied by the caller —
60    /// it's session-scoped state, not crate state.
61    pub fn new(sender: &AgentKey, recipient: Recipient, sequence: u64, payload: Vec<u8>) -> Self {
62        // Thin wrapper over the signer seam: an `AgentKey` is a software
63        // `MeshSigner`, and its own cert is the provenance. Kept so existing
64        // callers (and all current tests) are unchanged.
65        Self::new_with_signer(sender, sender.cert().clone(), recipient, sequence, payload)
66    }
67
68    /// Build and sign a new envelope using an explicit [`MeshSigner`] and a
69    /// matching [`CertChain`].
70    ///
71    /// This is the seam that lets a non-exportable / platform key (a phone
72    /// keystore) sign an envelope **without** the raw seed: the `signer`
73    /// produces the signature, and the `cert_chain` carries the provenance
74    /// (issued via [`AgentKey::delegate_external`](crate::AgentKey::delegate_external)).
75    /// For the wire envelope to [`verify`](Self::verify), the signer's
76    /// [`verifying_key`](MeshSigner::verifying_key) must equal
77    /// `cert_chain.agent_pubkey` — verification checks the signature against the
78    /// pubkey *in the cert*, so a mismatched signer simply yields a
79    /// [`MeshError::BadSignature`] at verify time.
80    ///
81    /// [`new`](Self::new) is a thin wrapper over this, passing the `AgentKey`
82    /// as both signer and cert source.
83    pub fn new_with_signer<S: MeshSigner + ?Sized>(
84        signer: &S,
85        cert_chain: CertChain,
86        recipient: Recipient,
87        sequence: u64,
88        payload: Vec<u8>,
89    ) -> Self {
90        let mut nonce = [0u8; 24];
91        rand::thread_rng().fill_bytes(&mut nonce);
92        let payload_cid: [u8; 32] = *blake3::hash(&payload).as_bytes();
93
94        let to_sign = signing_message(&recipient, &nonce, sequence, &payload_cid);
95        let sig = signer.sign(&to_sign);
96
97        Self {
98            cert_chain,
99            recipient,
100            nonce,
101            sequence,
102            payload_cid,
103            payload: ByteBuf::from(payload),
104            agent_sig: SerdeSig(sig),
105        }
106    }
107
108    /// Verify the envelope end-to-end:
109    ///
110    /// 1. Cert chain is valid (user sig over agent metadata).
111    /// 2. `payload_cid` matches the actual `payload` BLAKE3.
112    /// 3. Agent signature is valid over
113    ///    `(recipient, nonce, sequence, payload_cid)`.
114    pub fn verify(&self) -> Result<()> {
115        self.cert_chain.verify()?;
116
117        let actual_cid: [u8; 32] = *blake3::hash(&self.payload).as_bytes();
118        if actual_cid != self.payload_cid {
119            return Err(MeshError::MalformedEnvelope("payload_cid mismatch".into()));
120        }
121
122        let agent_vk = VerifyingKey::from_bytes(&self.cert_chain.agent_pubkey)
123            .map_err(|e| MeshError::InvalidKey(e.to_string()))?;
124        let to_verify = signing_message(
125            &self.recipient,
126            &self.nonce,
127            self.sequence,
128            &self.payload_cid,
129        );
130        agent_vk
131            .verify(&to_verify, &self.agent_sig.0)
132            .map_err(|_| MeshError::BadSignature)?;
133        Ok(())
134    }
135
136    /// Fingerprint of the sending agent.
137    #[must_use]
138    pub fn sender_agent_fp(&self) -> Fingerprint {
139        self.cert_chain.agent_fingerprint()
140    }
141
142    /// Fingerprint of the user the sender belongs to.
143    #[must_use]
144    pub fn sender_user_fp(&self) -> Fingerprint {
145        self.cert_chain.user_fingerprint()
146    }
147}
148
149fn signing_message(
150    recipient: &Recipient,
151    nonce: &[u8; 24],
152    sequence: u64,
153    payload_cid: &[u8; 32],
154) -> Vec<u8> {
155    let recipient_bytes =
156        serde_json::to_vec(recipient).expect("Recipient serializes deterministically");
157    let mut msg = Vec::with_capacity(ENVELOPE_TAG.len() + recipient_bytes.len() + 24 + 8 + 32);
158    msg.extend_from_slice(ENVELOPE_TAG);
159    msg.extend_from_slice(&recipient_bytes);
160    msg.extend_from_slice(nonce);
161    msg.extend_from_slice(&sequence.to_be_bytes());
162    msg.extend_from_slice(payload_cid);
163    msg
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::agent_key::AgentMetadata;
170    use crate::UserKey;
171    use std::collections::HashSet;
172
173    fn fixture_user_and_agent() -> (UserKey, AgentKey) {
174        let user = UserKey::generate();
175        let agent = AgentKey::issue(
176            &user,
177            AgentMetadata {
178                role: "worker".to_string(),
179                host: "test-host".to_string(),
180                capabilities: vec!["test".to_string()],
181                issued_at: "2026-05-28T12:00:00Z".to_string(),
182                expires_at: None,
183                caveats: crate::Caveats::top(),
184            },
185        );
186        (user, agent)
187    }
188
189    fn direct_recipient() -> Recipient {
190        Recipient::Direct {
191            agent_fp: Fingerprint::of_bytes(b"some peer"),
192        }
193    }
194
195    #[test]
196    fn roundtrip_envelope_verifies() {
197        let (_user, agent) = fixture_user_and_agent();
198        let env = SignedEnvelope::new(&agent, direct_recipient(), 1, b"hello".to_vec());
199        env.verify().expect("fresh envelope verifies");
200    }
201
202    #[test]
203    fn tampered_payload_fails_verify() {
204        let (_user, agent) = fixture_user_and_agent();
205        let mut env = SignedEnvelope::new(&agent, direct_recipient(), 1, b"original".to_vec());
206        env.payload = ByteBuf::from(b"tampered".to_vec());
207        let err = env.verify().unwrap_err();
208        match err {
209            MeshError::MalformedEnvelope(_) => {}
210            other => panic!("expected MalformedEnvelope, got {other:?}"),
211        }
212    }
213
214    #[test]
215    fn tampered_recipient_fails_verify() {
216        let (_user, agent) = fixture_user_and_agent();
217        let mut env = SignedEnvelope::new(&agent, direct_recipient(), 1, b"x".to_vec());
218        env.recipient = Recipient::Topic {
219            name: "other".to_string(),
220        };
221        assert!(matches!(env.verify().unwrap_err(), MeshError::BadSignature));
222    }
223
224    #[test]
225    fn tampered_sequence_fails_verify() {
226        let (_user, agent) = fixture_user_and_agent();
227        let mut env = SignedEnvelope::new(&agent, direct_recipient(), 1, b"x".to_vec());
228        env.sequence = 999;
229        assert!(matches!(env.verify().unwrap_err(), MeshError::BadSignature));
230    }
231
232    #[test]
233    fn tampered_nonce_fails_verify() {
234        let (_user, agent) = fixture_user_and_agent();
235        let mut env = SignedEnvelope::new(&agent, direct_recipient(), 1, b"x".to_vec());
236        env.nonce[0] ^= 0xff;
237        assert!(matches!(env.verify().unwrap_err(), MeshError::BadSignature));
238    }
239
240    #[test]
241    fn mismatched_payload_cid_fails() {
242        let (_user, agent) = fixture_user_and_agent();
243        let mut env = SignedEnvelope::new(&agent, direct_recipient(), 1, b"x".to_vec());
244        env.payload_cid[0] ^= 0xff;
245        let err = env.verify().unwrap_err();
246        match err {
247            MeshError::MalformedEnvelope(_) => {}
248            other => panic!("expected MalformedEnvelope, got {other:?}"),
249        }
250    }
251
252    #[test]
253    fn serde_roundtrip_envelope() {
254        let (_user, agent) = fixture_user_and_agent();
255        let env = SignedEnvelope::new(&agent, direct_recipient(), 7, b"payload".to_vec());
256        let json = serde_json::to_string(&env).unwrap();
257        let parsed: SignedEnvelope = serde_json::from_str(&json).unwrap();
258        assert_eq!(parsed, env);
259        parsed.verify().expect("roundtripped envelope verifies");
260    }
261
262    #[test]
263    fn unique_nonces_across_envelopes() {
264        let (_user, agent) = fixture_user_and_agent();
265        let mut seen = HashSet::new();
266        for i in 0..100 {
267            let env = SignedEnvelope::new(&agent, direct_recipient(), i, b"x".to_vec());
268            assert!(seen.insert(env.nonce), "duplicate nonce after {i} draws");
269        }
270    }
271
272    #[test]
273    fn sender_fingerprints_match_cert_chain() {
274        let (user, agent) = fixture_user_and_agent();
275        let env = SignedEnvelope::new(&agent, direct_recipient(), 1, b"x".to_vec());
276        assert_eq!(env.sender_agent_fp(), agent.fingerprint());
277        assert_eq!(env.sender_user_fp(), user.fingerprint());
278    }
279
280    #[test]
281    fn topic_and_anycast_recipients_roundtrip() {
282        let (_user, agent) = fixture_user_and_agent();
283        for r in [
284            Recipient::Topic {
285                name: "drake/work".to_string(),
286            },
287            Recipient::Anycast {
288                capability: "ollama".to_string(),
289            },
290        ] {
291            let env = SignedEnvelope::new(&agent, r.clone(), 1, b"x".to_vec());
292            env.verify().expect("verify");
293            let json = serde_json::to_string(&env).unwrap();
294            let parsed: SignedEnvelope = serde_json::from_str(&json).unwrap();
295            assert_eq!(parsed.recipient, r);
296        }
297    }
298
299    #[test]
300    fn empty_payload_is_legal() {
301        let (_user, agent) = fixture_user_and_agent();
302        let env = SignedEnvelope::new(&agent, direct_recipient(), 1, vec![]);
303        env.verify().expect("empty payload is fine");
304    }
305
306    // ── Signer seam (MeshSigner) — phone enrollment, PR #202 P1 ─────────────
307
308    use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
309
310    /// A signer that holds a bare ed25519 key and is NOT an `AgentKey` —
311    /// stands in for a platform-keystore-backed signer.
312    struct ExternalSigner {
313        signing: SigningKey,
314    }
315
316    impl crate::signer::MeshSigner for ExternalSigner {
317        fn verifying_key(&self) -> VerifyingKey {
318            self.signing.verifying_key()
319        }
320        fn sign(&self, msg: &[u8]) -> Signature {
321            self.signing.sign(msg)
322        }
323    }
324
325    /// The full phone path: a worker delegates to an external pubkey, the
326    /// holder signs an envelope through a `MeshSigner` (never an `AgentKey`),
327    /// and the envelope verifies and roots at the user.
328    #[test]
329    fn external_signer_envelope_verifies_via_seam() {
330        let user = crate::UserKey::generate();
331        let worker = AgentKey::issue(
332            &user,
333            AgentMetadata {
334                role: "worker".into(),
335                host: "test-host".into(),
336                capabilities: vec!["test".into()],
337                issued_at: "2026-05-28T12:00:00Z".into(),
338                expires_at: None,
339                caveats: crate::Caveats::top(),
340            },
341        );
342
343        let phone_seed = [42u8; 32];
344        let phone = ExternalSigner {
345            signing: SigningKey::from_bytes(&phone_seed),
346        };
347        let challenge = worker.possession_challenge(*phone.verifying_key().as_bytes());
348        let proof = phone.signing.sign(&challenge.signing_bytes());
349        let phone_cert = worker
350            .delegate_external(
351                &challenge,
352                &proof,
353                AgentMetadata {
354                    role: "phone".into(),
355                    host: "phone-host".into(),
356                    capabilities: vec!["chat".into()],
357                    issued_at: "2026-05-28T12:00:00Z".into(),
358                    expires_at: None,
359                    caveats: crate::Caveats::top(),
360                },
361            )
362            .expect("external delegation");
363
364        // Sign WITHOUT an AgentKey — purely through the signer seam.
365        let env = SignedEnvelope::new_with_signer(
366            &phone,
367            phone_cert,
368            direct_recipient(),
369            1,
370            b"seam".to_vec(),
371        );
372        env.verify().expect("seam-signed envelope verifies");
373        assert_eq!(env.sender_user_fp(), user.fingerprint());
374    }
375
376    /// A signer whose key does NOT match the paired cert produces an envelope
377    /// that fails verification — the seam can't be abused to forge identity.
378    #[test]
379    fn mismatched_signer_and_cert_fails_verify() {
380        let (_user, agent) = fixture_user_and_agent();
381        // Sign with a stranger's key but ship the agent's cert.
382        let stranger = ExternalSigner {
383            signing: SigningKey::from_bytes(&[7u8; 32]),
384        };
385        let env = SignedEnvelope::new_with_signer(
386            &stranger,
387            agent.cert().clone(),
388            direct_recipient(),
389            1,
390            b"forge".to_vec(),
391        );
392        assert!(matches!(env.verify().unwrap_err(), MeshError::BadSignature));
393    }
394
395    /// `new` and `new_with_signer` are the same operation for an `AgentKey`:
396    /// signing the same bytes yields a verifiable envelope either way.
397    #[test]
398    fn new_is_thin_wrapper_over_new_with_signer() {
399        let (_user, agent) = fixture_user_and_agent();
400        let via_signer = SignedEnvelope::new_with_signer(
401            &agent,
402            agent.cert().clone(),
403            direct_recipient(),
404            5,
405            b"x".to_vec(),
406        );
407        via_signer.verify().expect("signer path verifies");
408        assert_eq!(via_signer.cert_chain, *agent.cert());
409    }
410}