Skip to main content

mur_common/muragent/
dsse.rs

1//! DSSE (Dead Simple Signing Envelope) over in-toto v1 Statement.
2//!
3//! Spec §6.3: signature envelope format for `.muragent`.
4
5use crate::identity::AgentIdentity;
6use crate::muragent::MuragentError;
7use ed25519_dalek::{Signature, Signer, VerifyingKey};
8use serde::{Deserialize, Serialize};
9
10/// DSSE PAE: `"DSSEv1 " || len(payloadType) || " " || payloadType || " " || len(payload) || " " || payload`
11///
12/// All len() calls count UTF-8 bytes (not character count).
13pub fn pae(payload_type: &str, payload: &str) -> Vec<u8> {
14    let mut out = b"DSSEv1 ".to_vec();
15    out.extend_from_slice(payload_type.len().to_string().as_bytes());
16    out.push(b' ');
17    out.extend_from_slice(payload_type.as_bytes());
18    out.push(b' ');
19    out.extend_from_slice(payload.len().to_string().as_bytes());
20    out.push(b' ');
21    out.extend_from_slice(payload.as_bytes());
22    out
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct DsseEnvelope {
27    #[serde(rename = "payloadType")]
28    pub payload_type: String,
29    pub payload: String,
30    pub signatures: Vec<DsseSignature>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct DsseSignature {
35    pub keyid: String,
36    #[serde(rename = "publicKey")]
37    pub public_key: String,
38    pub sig: String,
39}
40
41/// Sign a payload with the agent's Ed25519 identity, returning a DSSE envelope.
42pub fn sign(
43    payload_type: &str,
44    payload_json: &str,
45    identity: &AgentIdentity,
46) -> Result<DsseEnvelope, MuragentError> {
47    use base64::{Engine, engine::general_purpose::STANDARD as B64};
48
49    let pae_bytes = pae(payload_type, payload_json);
50    let signing_key = identity.signing_key();
51    let signature: Signature = signing_key.sign(&pae_bytes);
52
53    let verifying_key = signing_key.verifying_key();
54    let pubkey_bytes = verifying_key.as_bytes();
55    let keyid = keyid_from_pubkey(pubkey_bytes);
56
57    let envelope = DsseEnvelope {
58        payload_type: payload_type.to_string(),
59        payload: B64.encode(payload_json.as_bytes()),
60        signatures: vec![DsseSignature {
61            keyid,
62            public_key: B64.encode(pubkey_bytes),
63            sig: B64.encode(signature.to_bytes()),
64        }],
65    };
66
67    Ok(envelope)
68}
69
70/// Verify a DSSE envelope's first signature.
71/// Uses `verify_strict` (rejects non-canonical encodings and small-order points).
72pub fn verify(envelope: &DsseEnvelope, expected_payload_type: &str) -> Result<(), MuragentError> {
73    use base64::{Engine, engine::general_purpose::STANDARD as B64};
74
75    if envelope.payload_type != expected_payload_type {
76        return Err(MuragentError::DsseError(format!(
77            "payload type mismatch: expected '{}', got '{}'",
78            expected_payload_type, envelope.payload_type
79        )));
80    }
81
82    let payload_bytes = B64
83        .decode(&envelope.payload)
84        .map_err(|e| MuragentError::DsseError(format!("payload base64: {e}")))?;
85    let payload_str = String::from_utf8(payload_bytes)
86        .map_err(|e| MuragentError::DsseError(format!("payload utf-8: {e}")))?;
87
88    if envelope.signatures.is_empty() {
89        return Err(MuragentError::DsseError("no signatures in envelope".into()));
90    }
91
92    let sig_entry = &envelope.signatures[0];
93    let pae_bytes = pae(expected_payload_type, &payload_str);
94
95    let pubkey_bytes = B64
96        .decode(&sig_entry.public_key)
97        .map_err(|e| MuragentError::DsseError(format!("public_key base64: {e}")))?;
98    let pubkey_arr: [u8; 32] = pubkey_bytes
99        .as_slice()
100        .try_into()
101        .map_err(|_| MuragentError::DsseError("public_key not 32 bytes".into()))?;
102    let verifying_key = VerifyingKey::from_bytes(&pubkey_arr)
103        .map_err(|e| MuragentError::DsseError(format!("pubkey decode: {e}")))?;
104
105    let sig_bytes = B64
106        .decode(&sig_entry.sig)
107        .map_err(|e| MuragentError::DsseError(format!("sig base64: {e}")))?;
108    let sig_arr: [u8; 64] = sig_bytes
109        .as_slice()
110        .try_into()
111        .map_err(|_| MuragentError::DsseError("sig not 64 bytes".into()))?;
112    let signature = Signature::from_bytes(&sig_arr);
113
114    verifying_key
115        .verify_strict(&pae_bytes, &signature)
116        .map_err(|e| MuragentError::InvalidSignature(format!("Ed25519 verify_strict: {e}")))?;
117
118    Ok(())
119}
120
121/// Derive keyid from the first 8 hex chars of SHA-256(pubkey).
122fn keyid_from_pubkey(pubkey: &[u8; 32]) -> String {
123    use sha2::Digest;
124    let hash = sha2::Sha256::digest(pubkey);
125    let hex = format!("{:x}", hash);
126    format!("ed25519-{}", &hex[..8])
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::identity::AgentIdentity;
133
134    #[test]
135    fn pae_is_deterministic() {
136        let a = pae("application/vnd.in-toto+json", r#"{"a":1}"#);
137        let b = pae("application/vnd.in-toto+json", r#"{"a":1}"#);
138        assert_eq!(a, b);
139    }
140
141    #[test]
142    fn pae_byte_lengths_not_char_counts() {
143        // "café" = 5 bytes in UTF-8 (é = 2 bytes), 4 chars
144        let pae_bytes = pae("type", "café");
145        let pae_str = String::from_utf8(pae_bytes).unwrap();
146        // The payload length should be 5 (bytes), not 4 (chars)
147        assert!(
148            pae_str.contains(" 5 café"),
149            "payload length should be 5 bytes, got: {pae_str}"
150        );
151    }
152
153    #[test]
154    fn sign_and_verify_roundtrip() {
155        let identity = AgentIdentity::generate();
156        let payload = r#"{"manifest_sha256":"abc123"}"#;
157        let envelope = sign("application/vnd.in-toto+json", payload, &identity).unwrap();
158        verify(&envelope, "application/vnd.in-toto+json").unwrap();
159    }
160
161    #[test]
162    fn verify_rejects_wrong_payload_type() {
163        let identity = AgentIdentity::generate();
164        let envelope = sign("application/vnd.in-toto+json", "{}", &identity).unwrap();
165        assert!(verify(&envelope, "wrong/type").is_err());
166    }
167
168    #[test]
169    fn verify_rejects_tampered_payload() {
170        let identity = AgentIdentity::generate();
171        let mut envelope = sign("application/vnd.in-toto+json", r#"{"a":1}"#, &identity).unwrap();
172        use base64::{Engine, engine::general_purpose::STANDARD as B64};
173        envelope.payload = B64.encode(r#"{"a":2}"#);
174        assert!(verify(&envelope, "application/vnd.in-toto+json").is_err());
175    }
176
177    #[test]
178    fn verify_rejects_empty_signatures() {
179        use base64::{Engine, engine::general_purpose::STANDARD as B64};
180        let envelope = DsseEnvelope {
181            payload_type: "application/vnd.in-toto+json".into(),
182            payload: B64.encode("{}"),
183            signatures: vec![],
184        };
185        assert!(verify(&envelope, "application/vnd.in-toto+json").is_err());
186    }
187}