Skip to main content

mur_common/bridge/
envelope.rs

1use ed25519_dalek::Signer;
2use serde::{Deserialize, Serialize};
3
4/// Bridge-signed wrapper around an A2A payload. `payload` is the *already-
5/// canonical* JSON-serialized A2A `JsonRpcRequest`; the bridge canonicalizes
6/// (sorted keys, no whitespace) BEFORE construction. Verification re-uses
7/// these exact bytes — never re-canonicalize on receive.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct SignedEnvelope {
10    #[serde(with = "serde_bytes")]
11    pub payload: Vec<u8>,
12    #[serde(with = "serde_bytes")]
13    pub sig: Vec<u8>,
14    pub key_version: u32,
15    pub bridge_pubkey_multibase: String,
16}
17
18impl SignedEnvelope {
19    pub fn canonical_payload_for_signing(&self) -> &[u8] {
20        &self.payload
21    }
22}
23
24#[derive(thiserror::Error, Debug)]
25pub enum EnvelopeError {
26    #[error("multibase decode: {0}")]
27    Multibase(#[from] crate::identity::IdentityError),
28    #[error("bad sig length: expected 64, got {0}")]
29    BadSigLen(usize),
30    #[error("signature does not verify")]
31    SignatureMismatch,
32    #[error("untrusted peer")]
33    UntrustedPeer,
34}
35
36/// Domain-separation tag for bridge envelope signatures.
37///
38/// The agent's identity key also signs other things (notably `.muragent` DSSE
39/// statements), so a distinct, fixed-length prefix keeps a signature made in one
40/// context from ever verifying in another. It also lets us fold `key_version`
41/// into the signed bytes — `key_version` lives outside `payload` on the wire, so
42/// without this an attacker could replay a captured envelope with `key_version`
43/// flipped and defeat a `TrustedPeer` version pin.
44const ENVELOPE_DOMAIN: &[u8] = b"mur-bridge-envelope-v1";
45
46/// The exact bytes covered by an envelope signature: a fixed-length domain tag,
47/// the little-endian `key_version`, then the canonical payload. All fields ahead
48/// of `payload` are fixed-length, so the encoding is unambiguous.
49fn signing_bytes(payload: &[u8], key_version: u32) -> Vec<u8> {
50    let mut out = Vec::with_capacity(ENVELOPE_DOMAIN.len() + 4 + payload.len());
51    out.extend_from_slice(ENVELOPE_DOMAIN);
52    out.extend_from_slice(&key_version.to_le_bytes());
53    out.extend_from_slice(payload);
54    out
55}
56
57/// Sign a canonical-JSON payload with the bridge's identity key.
58///
59/// `payload` MUST already be canonicalized (sorted keys, no whitespace) —
60/// this function does NOT re-canonicalize. The signature covers the domain tag,
61/// `key_version`, and `payload` (see [`signing_bytes`]); verifiers reconstruct
62/// those exact bytes and never re-canonicalize the payload on receive.
63pub fn sign_payload(
64    payload: Vec<u8>,
65    identity: &crate::identity::AgentIdentity,
66    key_version: u32,
67) -> SignedEnvelope {
68    let sig = identity
69        .signing_key()
70        .sign(&signing_bytes(&payload, key_version));
71    SignedEnvelope {
72        payload,
73        sig: sig.to_bytes().to_vec(),
74        key_version,
75        bridge_pubkey_multibase: crate::identity::encode_pubkey(&identity.verifying_key()),
76    }
77}
78
79/// Verify the envelope's signature against an expected pubkey (multibase).
80///
81/// This is the low-level check: it does not consult any trust list. Callers
82/// that need authorization (peer-must-be-trusted) should layer
83/// `verify_inbound_envelope` on top of this.
84pub fn verify_envelope_with_pubkey(
85    env: &SignedEnvelope,
86    expected_pubkey: &str,
87) -> Result<(), EnvelopeError> {
88    use ed25519_dalek::{Signature, VerifyingKey};
89    if env.sig.len() != 64 {
90        return Err(EnvelopeError::BadSigLen(env.sig.len()));
91    }
92    let pub_bytes = crate::identity::decode_pubkey(expected_pubkey)?;
93    let vk = VerifyingKey::from_bytes(&pub_bytes).map_err(|_| EnvelopeError::SignatureMismatch)?;
94    let sig_arr: [u8; 64] = env.sig.as_slice().try_into().unwrap();
95    let sig = Signature::from_bytes(&sig_arr);
96    // Reconstruct the exact signed bytes (domain || key_version || payload).
97    // Because key_version is folded in here, mutating it on the wire breaks the
98    // signature — which is what makes a TrustedPeer version pin enforceable.
99    let msg = signing_bytes(&env.payload, env.key_version);
100    vk.verify_strict(&msg, &sig)
101        .map_err(|_| EnvelopeError::SignatureMismatch)?;
102    Ok(())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    #[test]
109    fn canonical_payload_is_passthrough() {
110        let payload = serde_json::json!({"a": 1}).to_string().into_bytes();
111        let e = SignedEnvelope {
112            payload: payload.clone(),
113            sig: vec![0u8; 64],
114            key_version: 1,
115            bridge_pubkey_multibase: "z".into(),
116        };
117        assert_eq!(e.canonical_payload_for_signing(), payload.as_slice());
118    }
119
120    #[test]
121    fn sign_then_verify_round_trips() {
122        use crate::identity::AgentIdentity;
123        let id = AgentIdentity::generate();
124        let env = sign_payload(b"hello".to_vec(), &id, 7);
125        assert_eq!(env.key_version, 7);
126        verify_envelope_with_pubkey(&env, &env.bridge_pubkey_multibase).unwrap();
127    }
128
129    #[test]
130    fn verify_with_wrong_pubkey_fails() {
131        use crate::identity::{AgentIdentity, encode_pubkey};
132        let a = AgentIdentity::generate();
133        let b = AgentIdentity::generate();
134        let env = sign_payload(b"x".to_vec(), &a, 0);
135        let pub_b = encode_pubkey(&b.verifying_key());
136        assert!(matches!(
137            verify_envelope_with_pubkey(&env, &pub_b).unwrap_err(),
138            EnvelopeError::SignatureMismatch
139        ));
140    }
141
142    #[test]
143    fn tampered_payload_fails() {
144        use crate::identity::AgentIdentity;
145        let id = AgentIdentity::generate();
146        let mut env = sign_payload(b"orig".to_vec(), &id, 0);
147        env.payload = b"tamper".to_vec();
148        let pub_ = env.bridge_pubkey_multibase.clone();
149        assert!(matches!(
150            verify_envelope_with_pubkey(&env, &pub_).unwrap_err(),
151            EnvelopeError::SignatureMismatch
152        ));
153    }
154
155    #[test]
156    fn tampered_key_version_fails() {
157        // key_version is now covered by the signature, so flipping it on the
158        // wire (to slip past a TrustedPeer version pin) invalidates the envelope.
159        use crate::identity::AgentIdentity;
160        let id = AgentIdentity::generate();
161        let mut env = sign_payload(b"orig".to_vec(), &id, 3);
162        let pub_ = env.bridge_pubkey_multibase.clone();
163        verify_envelope_with_pubkey(&env, &pub_).expect("untampered verifies");
164        env.key_version = 4;
165        assert!(matches!(
166            verify_envelope_with_pubkey(&env, &pub_).unwrap_err(),
167            EnvelopeError::SignatureMismatch
168        ));
169    }
170}