You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
agent-mesh's wire envelope is signed but not encrypted. The payload travels as plaintext inside SignedEnvelope; confidentiality today is supplied entirely by the iroh QUIC/TLS transport session that carries the envelope. For a direct LAN dial — dialer to acceptor, no middleman — that is exactly right and we should not change it. The peers complete a same-user cert-chain handshake over an authenticated QUIC link, and the bytes on the wire are protected by TLS. No payload encryption is needed and adding it by default would be cost with no benefit.
That guarantee evaporates the moment an envelope leaves the direct link:
a relay / store-and-forward hop sees the full plaintext payload (the relay terminates one QUIC session and originates another);
a persisted outbox or any at-rest spool holds plaintext;
a fan-out / topic / anycast delivery path multiplies the number of processes that can read the body;
once the mesh becomes data-agnostic (Agent Mesh should be data agnostic #38 — arbitrary payloads, latent-state blobs, multiple transports), "the body is small text someone might glance at" stops being a safe assumption; the payload may be a model's private state.
In all of those cases transport-session confidentiality is structurally insufficient: it protects each hop, not the payload, so any intermediary is in the trust boundary. The fix is an optional confidential-envelope variant that encrypts the payload to the recipient agent's key, so a payload survives an untrusted intermediary unreadable — independent of how many transport sessions it crossed.
This is the 1:1 ("Olm") foundation. A later group-session issue builds fan-out key distribution on top of it.
Current state
Verified against agent-mesh-protocol/src/envelope.rs:
SignedEnvelope.payload is plaintext: pub payload: ByteBuf (serde_bytes::ByteBuf, line 50). It is never transformed — SignedEnvelope::new (line 60) stores ByteBuf::from(payload) directly.
payload_cid is BLAKE3(payload) (*blake3::hash(&payload).as_bytes(), line 63), and verify() recomputes it and rejects a mismatch (lines 88-91).
The agent signature covers ENVELOPE_TAG || recipient_bytes || nonce || sequence || payload_cid and not the payload bytes directly — see signing_message() (lines 120-135): ENVELOPE_TAG (b"agent-mesh-envelope-v1", line 16) ||serde_json::to_vec(recipient)|| 24-byte nonce||sequence.to_be_bytes()|| 32-byte payload_cid. The payload is bound transitively through payload_cid, which is why verify() must check the CID before trusting the signature.
Recipient (lines 19-29) has three variants: Direct { agent_fp: Fingerprint }, Topic { name: String }, Anycast { capability: String }.
Verified across the crate and workspace — there are no payload-encryption primitives anywhere:
grep -rniE 'aead|chacha|x25519|hkdf|kdf|encrypt|decrypt|cipher|crypto_box|ecdh|sealed|noise' over agent-mesh-protocol/src/ returns only Box<CertChain> heap-boxing in agent_key.rs — no crypto.
grep -rniE 'chacha20poly1305|aes-gcm|x25519-dalek|aead|hkdf|crypto_box|snow|noise' over every Cargo.toml in the workspace returns nothing. The protocol crate's crypto dependencies are exactly ed25519-dalek, blake3, rand, zeroize, ssh-key (see agent-mesh-protocol/Cargo.toml) — signing and hashing only, no AEAD, no Diffie-Hellman, no KDF.
Confidentiality is the iroh QUIC/TLS transport session only: agent-mesh-transport depends on iroh = "0.98" with features = ["tls-ring"] (workspace Cargo.toml line 68). The app-level handshake in agent-mesh-transport/src/handshake.rs rides on top of QUIC + ALPN and exchanges cert chains; it establishes authenticated same-user trust, not payload encryption — its own doc comment notes "What QUIC alone doesn't enforce is the auto-team rule."
So: sign-only envelope, confidentiality borrowed from the transport hop. Correct for direct dials, inadequate for any intermediary.
Proposed design
Add an optional confidential-envelope variant. Default OFF; direct-LAN QUIC stays the happy path and the existing SignedEnvelope byte layout and verification are unchanged.
Shape. Introduce a sibling type (e.g. ConfidentialEnvelope) or an enum that wraps the existing one, rather than mutating SignedEnvelope's fields. The body is replaced by a ciphertext blob plus the small public material a recipient needs to derive the shared secret:
payload becomes opaque ciphertext produced by an AEAD (e.g. ChaCha20-Poly1305 or XChaCha20-Poly1305 for a 24-byte nonce, matching the envelope's existing nonce width).
key agreement: X25519 ECDH to the recipient agent's encryption key, run through a KDF (HKDF) to derive the AEAD key. Recipient resolution stays via Recipient::Direct { agent_fp }; Topic/Anycast are out of scope for 1:1 and belong to the group-session follow-on.
recipients need an X25519 encryption key distinct from the ed25519 signing key. The cleanest path is to publish a per-agent X25519 public key alongside the agent's cert (derive deterministically or carry it in agent metadata), so encrypting to an agent needs no extra round trip. This is the agent-mesh analogue of Matrix publishing per-device Curve25519 keys.
Sign and encrypt — order and justification. Use encrypt-then-sign: the ed25519 agent signature covers ENVELOPE_TAG_CONFIDENTIAL || recipient_bytes || nonce || sequence || ciphertext_cid where ciphertext_cid = BLAKE3(ciphertext). This keeps the exact structure agent-mesh already has (signature binds metadata + a CID of the body) so the existing two-step verify() discipline — check CID, then check signature — carries over unchanged, and an intermediary or recipient can verify sender authenticity and reject tampering without being able to decrypt. (Sign-then-encrypt would hide the signature inside the ciphertext, forcing decryption before any authenticity check and breaking the relay's ability to drop forged traffic cheaply.) The AEAD additionally provides integrity of the plaintext to the holder of the key; bind the ed25519 signature into the AEAD associated-data so the two layers can't be independently spliced. Use a distinct domain-separation tag (agent-mesh-envelope-confidential-v1) so a confidential envelope can never be confused with or downgraded to a plaintext one.
Default-safe / opt-in. Gate the variant behind a cargo feature (e.g. confidential) and a per-send choice. Callers on the direct-LAN path keep emitting SignedEnvelope with zero behavior change. The confidential variant is selected for relayed / stored / fan-out sends. Document the threat-model delta explicitly: with the confidential envelope, an intermediary (relay, outbox, spool) learns the recipient, nonce, sequence, sender cert chain, and ciphertext length — but not the payload; without it, an intermediary learns the full plaintext.
This is intentionally the 1:1 foundation. The group/fan-out session (one symmetric session key shared to N recipients, ratcheted) is a separate issue layered on this seam.
Reference: matrix-rust-sdk
matrix-rust-sdk is a reference implementation to learn from, not a dependency to add, and agent-mesh deliberately rejects its homeserver/broker model — the design above stays broker-less, LAN-first, and capability-scoped. What is worth borrowing is the canonical "encrypt a payload to a recipient device independent of transport" construction (Olm 1:1):
crates/matrix-sdk-crypto/src/olm/mod.rs — module surface: Account, Session, and the algorithm identity OlmV1Curve25519AesSha2 (Curve25519 ECDH + AES + SHA2). The actual ratchet primitive is delegated to the vodozemac crate (pub use vodozemac::Curve25519PublicKey), which is the same separation-of-concerns we want: a thin policy/envelope layer over a vetted crypto core.
crates/matrix-sdk-crypto/src/olm/account.rs — the X3DH-style asynchronous key agreement that lets you encrypt to a recipient who is offline: a long-term Curve25519 identity key (Account doc, line ~461), a pool of signed one-time keys (generate_one_time_keys, line 522; signed_one_time_keys), and a signed fallback key (generate_fallback_key_if_needed, line 607; fallback_key_expired cites the X3DH spec at line 623). Published keys are ed25519-signed for authenticity — the same sign-the-encryption-key pattern we need so an attacker can't substitute their own X25519 key.
crates/matrix-sdk-crypto/src/olm/session.rs — the established 1:1 session: Session::encrypt_helper/Session::decrypt (lines ~123 and ~80) operate on an OlmMessage over an InnerSession (the vodozemac double ratchet). For agent-mesh's first cut, a single ECDH-derived AEAD key (no per-message ratchet) is a defensible MVP; the ratchet is the natural upgrade and Matrix shows where that boundary sits.
Takeaways for agent-mesh: (1) keep a distinct encryption key per agent, signed by the identity key, so encrypting to an agent is offline-safe and unspoofable; (2) keep the AEAD/ratchet primitive in a vetted external crate and own only the envelope/policy layer; (3) name the algorithm in the wire format (as OlmV1Curve25519AesSha2 does) so it is upgradeable. None of this requires a homeserver — Matrix's broker only brokers key publication, which in agent-mesh is already solved by the cert-chain handshake and per-agent metadata.
Acceptance criteria
A new optional ConfidentialEnvelope variant (or enum wrapper) exists in agent-mesh-protocol, gated behind a cargo feature; default builds remain unchanged and ship no AEAD/X25519 code.
The payload is encrypted to the recipient agent via X25519 ECDH + HKDF + an AEAD (e.g. (X)ChaCha20-Poly1305); plaintext payloads never appear in the confidential variant on the wire or at rest.
The ed25519 agent signature covers a distinct domain-separation tag plus recipient || nonce || sequence || ciphertext_cid, and the AEAD associated-data binds the signature so the two layers cannot be spliced; verify() checks the CID before the signature, mirroring SignedEnvelope.
A payload survives an untrusted relay unreadable: a test simulates a relay/store-and-forward hop that holds and forwards a confidential envelope and asserts (a) the relay cannot recover the plaintext, (b) the relay can still verify sender authenticity and reject a tampered envelope, and (c) the intended recipient decrypts byte-exact.
Default behavior unchanged: existing SignedEnvelope::new/verify byte layout, signing tuple, and all existing tests in envelope.rs pass untouched; the direct-LAN QUIC path emits plaintext-payload SignedEnvelope exactly as today.
Per-agent X25519 encryption key material is defined (derivation or metadata field) and is ed25519-signed/cert-bound so an attacker cannot substitute their own encryption key; a test covers encrypt-to-recipient where the recipient is identified only by Recipient::Direct { agent_fp }.
The algorithm/version is named in the wire format and a downgrade from confidential to plaintext is rejected (distinct tag enforced).
Docs record the threat-model delta: what an intermediary learns with vs. without the confidential envelope, and the explicit statement that this is the 1:1 foundation for a later group-session issue.
Meta · risk: high (per repo CLAUDE.md) · follow-up from the matrix-rust-sdk ↔ agent-mesh architectural comparison (matrix-rust-sdk is a reference implementation, not a dependency).
File/line references were drafted against a recent checkout; line numbers are indicative — symbols are authoritative (grep by name). Substance verified against origin/main @ f63c55f.
Motivation
agent-mesh's wire envelope is signed but not encrypted. The payload travels as plaintext inside
SignedEnvelope; confidentiality today is supplied entirely by the iroh QUIC/TLS transport session that carries the envelope. For a direct LAN dial — dialer to acceptor, no middleman — that is exactly right and we should not change it. The peers complete a same-user cert-chain handshake over an authenticated QUIC link, and the bytes on the wire are protected by TLS. No payload encryption is needed and adding it by default would be cost with no benefit.That guarantee evaporates the moment an envelope leaves the direct link:
In all of those cases transport-session confidentiality is structurally insufficient: it protects each hop, not the payload, so any intermediary is in the trust boundary. The fix is an optional confidential-envelope variant that encrypts the payload to the recipient agent's key, so a payload survives an untrusted intermediary unreadable — independent of how many transport sessions it crossed.
This is the 1:1 ("Olm") foundation. A later group-session issue builds fan-out key distribution on top of it.
Current state
Verified against
agent-mesh-protocol/src/envelope.rs:SignedEnvelope.payloadis plaintext:pub payload: ByteBuf(serde_bytes::ByteBuf, line 50). It is never transformed —SignedEnvelope::new(line 60) storesByteBuf::from(payload)directly.payload_cidisBLAKE3(payload)(*blake3::hash(&payload).as_bytes(), line 63), andverify()recomputes it and rejects a mismatch (lines 88-91).ENVELOPE_TAG || recipient_bytes || nonce || sequence || payload_cidand not the payload bytes directly — seesigning_message()(lines 120-135):ENVELOPE_TAG(b"agent-mesh-envelope-v1", line 16)||serde_json::to_vec(recipient)||24-bytenonce||sequence.to_be_bytes()||32-bytepayload_cid. The payload is bound transitively throughpayload_cid, which is whyverify()must check the CID before trusting the signature.Recipient(lines 19-29) has three variants:Direct { agent_fp: Fingerprint },Topic { name: String },Anycast { capability: String }.Verified across the crate and workspace — there are no payload-encryption primitives anywhere:
grep -rniE 'aead|chacha|x25519|hkdf|kdf|encrypt|decrypt|cipher|crypto_box|ecdh|sealed|noise'overagent-mesh-protocol/src/returns onlyBox<CertChain>heap-boxing inagent_key.rs— no crypto.grep -rniE 'chacha20poly1305|aes-gcm|x25519-dalek|aead|hkdf|crypto_box|snow|noise'over everyCargo.tomlin the workspace returns nothing. The protocol crate's crypto dependencies are exactlyed25519-dalek,blake3,rand,zeroize,ssh-key(seeagent-mesh-protocol/Cargo.toml) — signing and hashing only, no AEAD, no Diffie-Hellman, no KDF.agent-mesh-transportdepends oniroh = "0.98"withfeatures = ["tls-ring"](workspaceCargo.tomlline 68). The app-level handshake inagent-mesh-transport/src/handshake.rsrides on top of QUIC + ALPN and exchanges cert chains; it establishes authenticated same-user trust, not payload encryption — its own doc comment notes "What QUIC alone doesn't enforce is the auto-team rule."So: sign-only envelope, confidentiality borrowed from the transport hop. Correct for direct dials, inadequate for any intermediary.
Proposed design
Add an optional confidential-envelope variant. Default OFF; direct-LAN QUIC stays the happy path and the existing
SignedEnvelopebyte layout and verification are unchanged.Shape. Introduce a sibling type (e.g.
ConfidentialEnvelope) or an enum that wraps the existing one, rather than mutatingSignedEnvelope's fields. The body is replaced by a ciphertext blob plus the small public material a recipient needs to derive the shared secret:payloadbecomes opaque ciphertext produced by an AEAD (e.g. ChaCha20-Poly1305 or XChaCha20-Poly1305 for a 24-byte nonce, matching the envelope's existing nonce width).Recipient::Direct { agent_fp };Topic/Anycastare out of scope for 1:1 and belong to the group-session follow-on.Sign and encrypt — order and justification. Use encrypt-then-sign: the ed25519 agent signature covers
ENVELOPE_TAG_CONFIDENTIAL || recipient_bytes || nonce || sequence || ciphertext_cidwhereciphertext_cid = BLAKE3(ciphertext). This keeps the exact structure agent-mesh already has (signature binds metadata + a CID of the body) so the existing two-stepverify()discipline — check CID, then check signature — carries over unchanged, and an intermediary or recipient can verify sender authenticity and reject tampering without being able to decrypt. (Sign-then-encrypt would hide the signature inside the ciphertext, forcing decryption before any authenticity check and breaking the relay's ability to drop forged traffic cheaply.) The AEAD additionally provides integrity of the plaintext to the holder of the key; bind the ed25519 signature into the AEAD associated-data so the two layers can't be independently spliced. Use a distinct domain-separation tag (agent-mesh-envelope-confidential-v1) so a confidential envelope can never be confused with or downgraded to a plaintext one.Default-safe / opt-in. Gate the variant behind a cargo feature (e.g.
confidential) and a per-send choice. Callers on the direct-LAN path keep emittingSignedEnvelopewith zero behavior change. The confidential variant is selected for relayed / stored / fan-out sends. Document the threat-model delta explicitly: with the confidential envelope, an intermediary (relay, outbox, spool) learns therecipient,nonce,sequence, sender cert chain, and ciphertext length — but not the payload; without it, an intermediary learns the full plaintext.This is intentionally the 1:1 foundation. The group/fan-out session (one symmetric session key shared to N recipients, ratcheted) is a separate issue layered on this seam.
Reference: matrix-rust-sdk
matrix-rust-sdk is a reference implementation to learn from, not a dependency to add, and agent-mesh deliberately rejects its homeserver/broker model — the design above stays broker-less, LAN-first, and capability-scoped. What is worth borrowing is the canonical "encrypt a payload to a recipient device independent of transport" construction (Olm 1:1):
crates/matrix-sdk-crypto/src/olm/mod.rs— module surface:Account,Session, and the algorithm identityOlmV1Curve25519AesSha2(Curve25519 ECDH + AES + SHA2). The actual ratchet primitive is delegated to thevodozemaccrate (pub use vodozemac::Curve25519PublicKey), which is the same separation-of-concerns we want: a thin policy/envelope layer over a vetted crypto core.crates/matrix-sdk-crypto/src/olm/account.rs— the X3DH-style asynchronous key agreement that lets you encrypt to a recipient who is offline: a long-term Curve25519 identity key (Accountdoc, line ~461), a pool of signed one-time keys (generate_one_time_keys, line 522;signed_one_time_keys), and a signed fallback key (generate_fallback_key_if_needed, line 607;fallback_key_expiredcites the X3DH spec at line 623). Published keys are ed25519-signed for authenticity — the same sign-the-encryption-key pattern we need so an attacker can't substitute their own X25519 key.crates/matrix-sdk-crypto/src/olm/session.rs— the established 1:1 session:Session::encrypt_helper/Session::decrypt(lines ~123 and ~80) operate on anOlmMessageover anInnerSession(the vodozemac double ratchet). For agent-mesh's first cut, a single ECDH-derived AEAD key (no per-message ratchet) is a defensible MVP; the ratchet is the natural upgrade and Matrix shows where that boundary sits.Takeaways for agent-mesh: (1) keep a distinct encryption key per agent, signed by the identity key, so encrypting to an agent is offline-safe and unspoofable; (2) keep the AEAD/ratchet primitive in a vetted external crate and own only the envelope/policy layer; (3) name the algorithm in the wire format (as
OlmV1Curve25519AesSha2does) so it is upgradeable. None of this requires a homeserver — Matrix's broker only brokers key publication, which in agent-mesh is already solved by the cert-chain handshake and per-agent metadata.Acceptance criteria
ConfidentialEnvelopevariant (or enum wrapper) exists inagent-mesh-protocol, gated behind a cargo feature; default builds remain unchanged and ship no AEAD/X25519 code.recipient || nonce || sequence || ciphertext_cid, and the AEAD associated-data binds the signature so the two layers cannot be spliced;verify()checks the CID before the signature, mirroringSignedEnvelope.SignedEnvelope::new/verifybyte layout, signing tuple, and all existing tests inenvelope.rspass untouched; the direct-LAN QUIC path emits plaintext-payloadSignedEnvelopeexactly as today.Recipient::Direct { agent_fp }.Relationships
Meta · risk: high (per repo
CLAUDE.md) · follow-up from the matrix-rust-sdk ↔ agent-mesh architectural comparison (matrix-rust-sdk is a reference implementation, not a dependency).🤖 Generated with Claude Code