Skip to main content

treeship_core/attestation/
envelope.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use serde::{Deserialize, Serialize};
3
4/// A DSSE envelope. This is the portable artifact unit — everything
5/// Treeship stores, transmits, and verifies is an `Envelope`.
6///
7/// The `payload` field is base64url-encoded statement bytes.
8/// The `signatures` are over `PAE(payloadType, decode(payload))`.
9/// The outer JSON is never signed — only the PAE construction is.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Envelope {
12    /// base64url-encoded statement bytes (compact JSON).
13    pub payload: String,
14
15    /// MIME type of the statement.
16    /// Format: `"application/vnd.treeship.<type>.v1+json"`
17    #[serde(rename = "payloadType")]
18    pub payload_type: String,
19
20    /// Signatures over `PAE(payloadType, decode(payload))`.
21    /// v1: always exactly one Ed25519 signature.
22    pub signatures: Vec<Signature>,
23}
24
25/// One signer's signature over the PAE-encoded envelope content.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Signature {
28    /// Stable key identifier from the keystore.
29    pub keyid: String,
30
31    /// base64url-encoded raw signature bytes (64 bytes for Ed25519).
32    pub sig: String,
33}
34
35/// Errors that can occur when working with envelopes.
36#[derive(Debug)]
37pub enum EnvelopeError {
38    Base64Decode(String),
39    JsonParse(String),
40    EmptyPayload,
41    EmptyPayloadType,
42    NoSignatures,
43}
44
45impl std::fmt::Display for EnvelopeError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Base64Decode(e) => write!(f, "base64 decode: {}", e),
49            Self::JsonParse(e) => write!(f, "json parse: {}", e),
50            Self::EmptyPayload => write!(f, "payload is empty"),
51            Self::EmptyPayloadType => write!(f, "payloadType is empty"),
52            Self::NoSignatures => write!(f, "no signatures in envelope"),
53        }
54    }
55}
56
57impl std::error::Error for EnvelopeError {}
58
59impl Envelope {
60    /// Decodes the base64url payload back to raw bytes.
61    pub fn payload_bytes(&self) -> Result<Vec<u8>, EnvelopeError> {
62        URL_SAFE_NO_PAD
63            .decode(&self.payload)
64            .map_err(|e| EnvelopeError::Base64Decode(e.to_string()))
65    }
66
67    /// Deserializes the payload into type `T`.
68    pub fn unmarshal_statement<T: serde::de::DeserializeOwned>(&self) -> Result<T, EnvelopeError> {
69        let bytes = self.payload_bytes()?;
70        serde_json::from_slice(&bytes).map_err(|e| EnvelopeError::JsonParse(e.to_string()))
71    }
72
73    /// Decodes a single `Signature`'s sig field to raw bytes.
74    pub fn sig_bytes(sig: &Signature) -> Result<Vec<u8>, EnvelopeError> {
75        URL_SAFE_NO_PAD
76            .decode(&sig.sig)
77            .map_err(|e| EnvelopeError::Base64Decode(format!("sig for key {}: {}", sig.keyid, e)))
78    }
79
80    /// Serializes the envelope to compact JSON bytes.
81    pub fn to_json(&self) -> Result<Vec<u8>, EnvelopeError> {
82        serde_json::to_vec(self).map_err(|e| EnvelopeError::JsonParse(e.to_string()))
83    }
84
85    /// Parses an envelope from JSON bytes, validating required fields.
86    pub fn from_json(bytes: &[u8]) -> Result<Self, EnvelopeError> {
87        let e: Envelope =
88            serde_json::from_slice(bytes).map_err(|e| EnvelopeError::JsonParse(e.to_string()))?;
89        if e.payload.is_empty() {
90            return Err(EnvelopeError::EmptyPayload);
91        }
92        if e.payload_type.is_empty() {
93            return Err(EnvelopeError::EmptyPayloadType);
94        }
95        if e.signatures.is_empty() {
96            return Err(EnvelopeError::NoSignatures);
97        }
98        Ok(e)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use serde::{Deserialize, Serialize};
106
107    #[derive(Debug, PartialEq, Serialize, Deserialize)]
108    struct TestStmt {
109        actor: String,
110    }
111
112    fn make_envelope(payload: &str) -> Envelope {
113        Envelope {
114            payload: URL_SAFE_NO_PAD.encode(payload),
115            payload_type: "application/vnd.treeship.action.v1+json".into(),
116            signatures: vec![Signature {
117                keyid: "key_test".into(),
118                sig: "c2ln".into(),
119            }],
120        }
121    }
122
123    #[test]
124    fn payload_bytes_roundtrip() {
125        let original = b"{\"actor\":\"agent://test\"}";
126        let env = Envelope {
127            payload: URL_SAFE_NO_PAD.encode(original),
128            payload_type: "application/vnd.treeship.action.v1+json".into(),
129            signatures: vec![],
130        };
131        assert_eq!(env.payload_bytes().unwrap(), original);
132    }
133
134    #[test]
135    fn unmarshal_statement() {
136        let stmt = TestStmt {
137            actor: "agent://test".into(),
138        };
139        let json = serde_json::to_vec(&stmt).unwrap();
140        let env = Envelope {
141            payload: URL_SAFE_NO_PAD.encode(&json),
142            payload_type: "application/vnd.treeship.action.v1+json".into(),
143            signatures: vec![],
144        };
145        let decoded: TestStmt = env.unmarshal_statement().unwrap();
146        assert_eq!(decoded, stmt);
147    }
148
149    #[test]
150    fn json_roundtrip() {
151        let env = make_envelope("{\"actor\":\"agent://test\"}");
152        let json = env.to_json().unwrap();
153        let restored = Envelope::from_json(&json).unwrap();
154        assert_eq!(restored.payload, env.payload);
155        assert_eq!(restored.payload_type, env.payload_type);
156    }
157
158    #[test]
159    fn from_json_rejects_empty_payload() {
160        let json =
161            br#"{"payload":"","payloadType":"text/plain","signatures":[{"keyid":"k","sig":"s"}]}"#;
162        assert!(Envelope::from_json(json).is_err());
163    }
164
165    #[test]
166    fn from_json_rejects_no_signatures() {
167        let json = br#"{"payload":"YQ","payloadType":"text/plain","signatures":[]}"#;
168        assert!(Envelope::from_json(json).is_err());
169    }
170
171    #[test]
172    fn from_json_rejects_empty_payload_type() {
173        let json = br#"{"payload":"YQ","payloadType":"","signatures":[{"keyid":"k","sig":"s"}]}"#;
174        assert!(Envelope::from_json(json).is_err());
175    }
176}