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
Topics (Recipient::Topic) and anycast (Recipient::Anycast) are the mesh's group-addressing primitives, but today there is no confidential group: a topic publish is signed, never group-encrypted. Any agent that can read the bytes off the wire (a relay, a peer that fans out, a future store-and-forward hop) sees the plaintext payload. Confidentiality today is borrowed entirely from the per-link iroh QUIC/TLS session — it does not survive a single relay or a fan-out re-send, and it gives no notion of "this message is readable only by the agents authorized for this topic."
We want a topic where the publisher encrypts a message once and only the authorized subscribers can read it, where authorization is enforced cryptographically (you must hold the group key), and where losing a subscriber from the group means the key rotates so the departed agent can't read future traffic. matrix-rust-sdk's Megolm group sessions are the proven reference design for exactly this shape: encrypt-once-to-a-group, distribute the symmetric key per-recipient, ratchet for forward secrecy, rotate on membership change. We adapt the mechanism, not the homeserver — distribution stays peer-to-peer.
Current state
Verified against the code:
Envelopes are signed, not payload-encrypted.agent-mesh-protocol/src/envelope.rs — SignedEnvelope::new (line 60) sets payload: ByteBuf to the caller's bytes verbatim and signs ENVELOPE_TAG || recipient_bytes || nonce || seq || payload_cid (ed25519 over a BLAKE3 payload_cid). verify() (line 85) checks the cert chain, recomputes the BLAKE3 payload_cid, and checks the agent signature. There is no decryption step and no per-recipient key material anywhere in the envelope. Confidentiality rides on the iroh QUIC/TLS transport session, not on the envelope.
Recipient::Topic { name } and Recipient::Anycast { capability } exist with no group keying behind them.envelope.rs lines 21-29 define the variants; topic_and_anycast_recipients_roundtrip (line 252) only exercises serde + signature roundtrip. No confidential-group code references either variant.
A "publish" is signed-only and fans out to LOCAL subscribers.agent-mesh-bus/src/bus.rs::publish_to (line 246) sends a BusMessage::Publish { topic, body } as the payload of a SignedEnvelope with Recipient::Direct { agent_fp } (note: send_one at line 390 hard-codes Recipient::Direct, so even topic publishes go out addressed to one named peer). On the receiving side, agent-mesh-bus/src/inbox.rs::dispatch_publish (line 262) looks up the topic's broadcast::Sender and fans the plaintext body out to local subscribers (SUBSCRIPTION_CHANNEL_CAPACITY = 64).
No sender-side fan-out; N subscribers = N direct dials.bus.rs is documented as "dials per outbound message" (lines 16-21); publish_to is peer-explicit — the doc-comment (lines 239-245) and the inbox.rsBusMessage docs (lines 39-42) both state v1 is peer-explicit and "a topic-routing registry is deferred to a follow-up." There is no shared group key, no group ratchet, and no per-recipient key wrap anywhere in agent-mesh-bus or agent-mesh-protocol.
Net: the group-addressing tags are in the wire format, but a "confidential topic" — encrypt once, readable only by authorized members — does not exist.
Proposed design
Add an opt-in, per-topic Megolm-style sender-key group session. Default OFF; an existing topic with no group session configured behaves exactly as today (signed-only fan-out, backward compatible). Stays broker-less: there is no central key server — the group key is distributed peer-to-peer over the existing dial-and-send path.
This feature depends on a pairwise payload-confidentiality seam (the ability to encrypt an envelope payload to a single named peer, on top of the existing signed envelope). The per-recipient key-wrap below rides that seam; this issue tracks the group layer and should be sequenced after the pairwise-confidentiality work lands.
1. Group ratchet (one outbound session per (sender, topic)).
The publisher holds an outbound group session keyed by (sender_agent_fp, Topic). Each session carries a symmetric ratchet state and a monotonic message_index. publish_confidential(topic, body) encrypts body once with the current ratchet output, advances the ratchet (so index N's key cannot decrypt index N-1 — forward secrecy within the session), and emits a new BusMessage variant (e.g. EncryptedPublish { topic, session_id, message_index, ciphertext }) carried as the envelope payload. The envelope remains signed exactly as today; the ciphertext is opaque to the signature layer (the BLAKE3 payload_cid covers the ciphertext, so tamper-evidence is unchanged).
2. Per-recipient key wrap (broker-less distribution).
Before a subscriber can decrypt, the publisher distributes the group session key to it wrapped in the pairwise confidential channel (the dependency above): a BusMessage::GroupKey { topic, session_id, session_key, first_known_index } sent peer-to-peer to each authorized subscriber, exactly the way Megolm wraps m.room_key per-recipient-device in a pairwise Olm session. The receiver stores an inbound group session keyed by (sender_agent_fp, topic, session_id) and can thereafter decrypt any EncryptedPublish at or after first_known_index. No key ever transits a relay or a third party in the clear.
3. Authorization policy (analogous to CollectStrategy).
A per-topic recipient policy decides which subscribers get the wrapped key — the agent-mesh analogue of matrix's CollectStrategy. Anchored on what the mesh already proves: the user-namespace root (the user_fp prefix that Topic already enforces — topic.rs lines 1-9) and the agent cert chain / capability claim. Start with a small enum, e.g. AllInUser (any agent under the same user fingerprint), Capability(name) (agents whose cert advertises the named capability — the natural fit for Recipient::Anycast), and Explicit(set_of_agent_fps). Default-safe: the recommended default is the most restrictive that still delivers (mirroring matrix's move from AllDevices to identity-based as the recommended default).
4. Rotation on membership change and on ratchet limits.
Maintain a per-session "shared-with" set (like matrix's ShareInfoSet). When the authorized set shrinks — a subscriber is removed, a cert expires, an explicit revoke — rotate: discard the outbound session, mint a fresh session_id, and re-wrap the new key only to the still-authorized members. The departed agent retains only keys for messages it was already entitled to; it cannot read anything published after rotation. Also rotate on ratchet bounds (a message-count / age cap, as matrix's expired() does via rotation_period / rotation_period_msgs) so a long-lived session doesn't accumulate unbounded forward exposure. Adding a member needs no rotation — just wrap the current key (optionally at the current message_index so the new member can't read backscroll, the matrix first_known_index lever).
This layers cleanly on the existing flow: EncryptedPublish and GroupKey are new BusMessage variants; the envelope, replay defense (NonceCache + SequenceTracker), and dispatch loop in inbox.rs are otherwise unchanged. The local fan-out in dispatch_publish simply hands subscribers decrypted bytes once the inbound session is present, and silently drops (or surfaces a "no key") for topics the agent isn't authorized on.
Reference: matrix-rust-sdk
Read as a reference implementation (NOT a dependency):
crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs — OutboundGroupSession (line 202) wraps a vodozemacGroupSession ratchet; encrypt (line 579) encrypts once per room and advances the ratchet; message_count + expired() (line 668) drive rotation against EncryptionSettings::rotation_period / rotation_period_msgs (defaults ONE_WEEK / 100, lines 76-77). The "shared-with" bookkeeping (ShareInfoSet, ShareInfo, ShareState, lines 81-92, 221-259) is the model for the per-session recipient set that rotation consults.
crates/matrix-sdk-crypto/src/olm/group_sessions/inbound.rs — InboundGroupSession tracks first_known_index (line 177) and decrypts returning (plaintext, message_index) (decrypt, line 610); export_at_index (line 444) is the lever for sharing a session "from index N forward" so a late joiner can't read prior traffic.
crates/matrix-sdk-crypto/src/session_manager/group_sessions/mod.rs — encrypt_session_for (line 326) wraps the group session key per device via device.maybe_encrypt_room_key (line 348) inside a pairwise Olm session, producing a MaybeEncryptedRoomKey::Encrypted { share_info, message } per recipient; share_room_key (line 682) is the distribution entry point. This is the reference for "encrypt once to the group, distribute the symmetric key per recipient over a pairwise confidential channel."
crates/matrix-sdk-crypto/src/session_manager/group_sessions/share_strategy.rs — CollectStrategy (line 41: AllDevices / ErrorOnVerifiedUserProblem / IdentityBasedStrategy / OnlyTrustedDevices) decides which recipients are entitled to the key; IdentityBasedStrategy is the recommended default (lines 73-84). This is the reference for the per-topic authorization policy.
Acceptance criteria
A topic can be marked confidential (opt-in, per topic). A topic with no group session configured behaves exactly as today: signed-only Publish, plaintext local fan-out, backward compatible.
A confidential topic message is encrypted once by the publisher and is readable only by authorized subscribers (those holding a wrapped group key); an unauthorized peer or a relay sees only ciphertext.
The encrypted message survives a relay / re-send: decryptability depends on the group session, not on the iroh QUIC/TLS link the bytes arrived over.
The group key is distributed peer-to-peer, wrapped per-recipient in the pairwise confidential channel — no central key server, no key ever in the clear past the publisher.
The outbound group session has a monotonic message_index; index N's key cannot decrypt index N−1 (intra-session forward secrecy).
A per-topic authorization policy (analogue of CollectStrategy) decides which subscribers receive the key, anchored on the user-namespace root and the agent cert / capability claim; the default is restrictive (default-safe).
A membership change that removes a subscriber rotates the key: a fresh session_id is minted and re-wrapped only to still-authorized members; the removed agent cannot decrypt any message published after rotation.
Adding a member does not force rotation; the new member can optionally be admitted from the current message_index forward (no backscroll).
The session rotates on a configurable message-count / age bound, independent of membership change.
The signed envelope, replay defense (NonceCache/SequenceTracker), and payload_cid tamper-evidence are unchanged: the BLAKE3 CID covers the ciphertext and the ed25519 signature still verifies the envelope end-to-end.
Sequenced after the pairwise payload-confidentiality seam (the per-recipient key wrap depends on it).
Tests cover: encrypt-once/decrypt-by-authorized, reject-by-unauthorized, decrypt-after-relay, rotation-on-removal denies the departed agent, and the signed-only default path is untouched.
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
Topics (
Recipient::Topic) and anycast (Recipient::Anycast) are the mesh's group-addressing primitives, but today there is no confidential group: a topic publish is signed, never group-encrypted. Any agent that can read the bytes off the wire (a relay, a peer that fans out, a future store-and-forward hop) sees the plaintext payload. Confidentiality today is borrowed entirely from the per-link iroh QUIC/TLS session — it does not survive a single relay or a fan-out re-send, and it gives no notion of "this message is readable only by the agents authorized for this topic."We want a topic where the publisher encrypts a message once and only the authorized subscribers can read it, where authorization is enforced cryptographically (you must hold the group key), and where losing a subscriber from the group means the key rotates so the departed agent can't read future traffic. matrix-rust-sdk's Megolm group sessions are the proven reference design for exactly this shape: encrypt-once-to-a-group, distribute the symmetric key per-recipient, ratchet for forward secrecy, rotate on membership change. We adapt the mechanism, not the homeserver — distribution stays peer-to-peer.
Current state
Verified against the code:
Envelopes are signed, not payload-encrypted.
agent-mesh-protocol/src/envelope.rs—SignedEnvelope::new(line 60) setspayload: ByteBufto the caller's bytes verbatim and signsENVELOPE_TAG || recipient_bytes || nonce || seq || payload_cid(ed25519 over a BLAKE3payload_cid).verify()(line 85) checks the cert chain, recomputes the BLAKE3payload_cid, and checks the agent signature. There is no decryption step and no per-recipient key material anywhere in the envelope. Confidentiality rides on the iroh QUIC/TLS transport session, not on the envelope.Recipient::Topic { name }andRecipient::Anycast { capability }exist with no group keying behind them.envelope.rslines 21-29 define the variants;topic_and_anycast_recipients_roundtrip(line 252) only exercises serde + signature roundtrip. No confidential-group code references either variant.A "publish" is signed-only and fans out to LOCAL subscribers.
agent-mesh-bus/src/bus.rs::publish_to(line 246) sends aBusMessage::Publish { topic, body }as thepayloadof aSignedEnvelopewithRecipient::Direct { agent_fp }(note:send_oneat line 390 hard-codesRecipient::Direct, so even topic publishes go out addressed to one named peer). On the receiving side,agent-mesh-bus/src/inbox.rs::dispatch_publish(line 262) looks up the topic'sbroadcast::Senderand fans the plaintext body out to local subscribers (SUBSCRIPTION_CHANNEL_CAPACITY = 64).No sender-side fan-out; N subscribers = N direct dials.
bus.rsis documented as "dials per outbound message" (lines 16-21);publish_tois peer-explicit — the doc-comment (lines 239-245) and theinbox.rsBusMessagedocs (lines 39-42) both state v1 is peer-explicit and "a topic-routing registry is deferred to a follow-up." There is no shared group key, no group ratchet, and no per-recipient key wrap anywhere inagent-mesh-busoragent-mesh-protocol.Net: the group-addressing tags are in the wire format, but a "confidential topic" — encrypt once, readable only by authorized members — does not exist.
Proposed design
Add an opt-in, per-topic Megolm-style sender-key group session. Default OFF; an existing topic with no group session configured behaves exactly as today (signed-only fan-out, backward compatible). Stays broker-less: there is no central key server — the group key is distributed peer-to-peer over the existing dial-and-send path.
This feature depends on a pairwise payload-confidentiality seam (the ability to encrypt an envelope payload to a single named peer, on top of the existing signed envelope). The per-recipient key-wrap below rides that seam; this issue tracks the group layer and should be sequenced after the pairwise-confidentiality work lands.
1. Group ratchet (one outbound session per
(sender, topic)).The publisher holds an outbound group session keyed by
(sender_agent_fp, Topic). Each session carries a symmetric ratchet state and a monotonicmessage_index.publish_confidential(topic, body)encryptsbodyonce with the current ratchet output, advances the ratchet (so index N's key cannot decrypt index N-1 — forward secrecy within the session), and emits a newBusMessagevariant (e.g.EncryptedPublish { topic, session_id, message_index, ciphertext }) carried as the envelope payload. The envelope remains signed exactly as today; the ciphertext is opaque to the signature layer (the BLAKE3payload_cidcovers the ciphertext, so tamper-evidence is unchanged).2. Per-recipient key wrap (broker-less distribution).
Before a subscriber can decrypt, the publisher distributes the group session key to it wrapped in the pairwise confidential channel (the dependency above): a
BusMessage::GroupKey { topic, session_id, session_key, first_known_index }sent peer-to-peer to each authorized subscriber, exactly the way Megolm wrapsm.room_keyper-recipient-device in a pairwise Olm session. The receiver stores an inbound group session keyed by(sender_agent_fp, topic, session_id)and can thereafter decrypt anyEncryptedPublishat or afterfirst_known_index. No key ever transits a relay or a third party in the clear.3. Authorization policy (analogous to
CollectStrategy).A per-topic recipient policy decides which subscribers get the wrapped key — the agent-mesh analogue of matrix's
CollectStrategy. Anchored on what the mesh already proves: the user-namespace root (theuser_fpprefix thatTopicalready enforces —topic.rslines 1-9) and the agent cert chain / capability claim. Start with a small enum, e.g.AllInUser(any agent under the same user fingerprint),Capability(name)(agents whose cert advertises the named capability — the natural fit forRecipient::Anycast), andExplicit(set_of_agent_fps). Default-safe: the recommended default is the most restrictive that still delivers (mirroring matrix's move fromAllDevicesto identity-based as the recommended default).4. Rotation on membership change and on ratchet limits.
Maintain a per-session "shared-with" set (like matrix's
ShareInfoSet). When the authorized set shrinks — a subscriber is removed, a cert expires, an explicit revoke — rotate: discard the outbound session, mint a freshsession_id, and re-wrap the new key only to the still-authorized members. The departed agent retains only keys for messages it was already entitled to; it cannot read anything published after rotation. Also rotate on ratchet bounds (a message-count / age cap, as matrix'sexpired()does viarotation_period/rotation_period_msgs) so a long-lived session doesn't accumulate unbounded forward exposure. Adding a member needs no rotation — just wrap the current key (optionally at the currentmessage_indexso the new member can't read backscroll, the matrixfirst_known_indexlever).This layers cleanly on the existing flow:
EncryptedPublishandGroupKeyare newBusMessagevariants; the envelope, replay defense (NonceCache+SequenceTracker), and dispatch loop ininbox.rsare otherwise unchanged. The local fan-out indispatch_publishsimply hands subscribers decrypted bytes once the inbound session is present, and silently drops (or surfaces a "no key") for topics the agent isn't authorized on.Reference: matrix-rust-sdk
Read as a reference implementation (NOT a dependency):
crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs—OutboundGroupSession(line 202) wraps avodozemacGroupSessionratchet;encrypt(line 579) encrypts once per room and advances the ratchet;message_count+expired()(line 668) drive rotation againstEncryptionSettings::rotation_period/rotation_period_msgs(defaultsONE_WEEK/100, lines 76-77). The "shared-with" bookkeeping (ShareInfoSet,ShareInfo,ShareState, lines 81-92, 221-259) is the model for the per-session recipient set that rotation consults.crates/matrix-sdk-crypto/src/olm/group_sessions/inbound.rs—InboundGroupSessiontracksfirst_known_index(line 177) and decrypts returning(plaintext, message_index)(decrypt, line 610);export_at_index(line 444) is the lever for sharing a session "from index N forward" so a late joiner can't read prior traffic.crates/matrix-sdk-crypto/src/session_manager/group_sessions/mod.rs—encrypt_session_for(line 326) wraps the group session key per device viadevice.maybe_encrypt_room_key(line 348) inside a pairwise Olm session, producing aMaybeEncryptedRoomKey::Encrypted { share_info, message }per recipient;share_room_key(line 682) is the distribution entry point. This is the reference for "encrypt once to the group, distribute the symmetric key per recipient over a pairwise confidential channel."crates/matrix-sdk-crypto/src/session_manager/group_sessions/share_strategy.rs—CollectStrategy(line 41:AllDevices/ErrorOnVerifiedUserProblem/IdentityBasedStrategy/OnlyTrustedDevices) decides which recipients are entitled to the key;IdentityBasedStrategyis the recommended default (lines 73-84). This is the reference for the per-topic authorization policy.Acceptance criteria
Publish, plaintext local fan-out, backward compatible.message_index; index N's key cannot decrypt index N−1 (intra-session forward secrecy).CollectStrategy) decides which subscribers receive the key, anchored on the user-namespace root and the agent cert / capability claim; the default is restrictive (default-safe).session_idis minted and re-wrapped only to still-authorized members; the removed agent cannot decrypt any message published after rotation.message_indexforward (no backscroll).NonceCache/SequenceTracker), andpayload_cidtamper-evidence are unchanged: the BLAKE3 CID covers the ciphertext and the ed25519 signature still verifies the envelope end-to-end.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