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>(
69        &self,
70    ) -> Result<T, EnvelopeError> {
71        let bytes = self.payload_bytes()?;
72        serde_json::from_slice(&bytes)
73            .map_err(|e| EnvelopeError::JsonParse(e.to_string()))
74    }
75
76    /// Decodes a single `Signature`'s sig field to raw bytes.
77    pub fn sig_bytes(sig: &Signature) -> Result<Vec<u8>, EnvelopeError> {
78        URL_SAFE_NO_PAD
79            .decode(&sig.sig)
80            .map_err(|e| EnvelopeError::Base64Decode(
81                format!("sig for key {}: {}", sig.keyid, e)
82            ))
83    }
84
85    /// Serializes the envelope to compact JSON bytes.
86    pub fn to_json(&self) -> Result<Vec<u8>, EnvelopeError> {
87        serde_json::to_vec(self)
88            .map_err(|e| EnvelopeError::JsonParse(e.to_string()))
89    }
90
91    /// Parses an envelope from JSON bytes, validating required fields.
92    pub fn from_json(bytes: &[u8]) -> Result<Self, EnvelopeError> {
93        let e: Envelope = serde_json::from_slice(bytes)
94            .map_err(|e| EnvelopeError::JsonParse(e.to_string()))?;
95        if e.payload.is_empty()      { return Err(EnvelopeError::EmptyPayload); }
96        if e.payload_type.is_empty() { return Err(EnvelopeError::EmptyPayloadType); }
97        if e.signatures.is_empty()   { return Err(EnvelopeError::NoSignatures); }
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 { keyid: "key_test".into(), sig: "c2ln".into() }],
117        }
118    }
119
120    #[test]
121    fn payload_bytes_roundtrip() {
122        let original = b"{\"actor\":\"agent://test\"}";
123        let env = Envelope {
124            payload:      URL_SAFE_NO_PAD.encode(original),
125            payload_type: "application/vnd.treeship.action.v1+json".into(),
126            signatures:   vec![],
127        };
128        assert_eq!(env.payload_bytes().unwrap(), original);
129    }
130
131    #[test]
132    fn unmarshal_statement() {
133        let stmt = TestStmt { actor: "agent://test".into() };
134        let json = serde_json::to_vec(&stmt).unwrap();
135        let env  = Envelope {
136            payload:      URL_SAFE_NO_PAD.encode(&json),
137            payload_type: "application/vnd.treeship.action.v1+json".into(),
138            signatures:   vec![],
139        };
140        let decoded: TestStmt = env.unmarshal_statement().unwrap();
141        assert_eq!(decoded, stmt);
142    }
143
144    #[test]
145    fn json_roundtrip() {
146        let env      = make_envelope("{\"actor\":\"agent://test\"}");
147        let json     = env.to_json().unwrap();
148        let restored = Envelope::from_json(&json).unwrap();
149        assert_eq!(restored.payload, env.payload);
150        assert_eq!(restored.payload_type, env.payload_type);
151    }
152
153    #[test]
154    fn from_json_rejects_empty_payload() {
155        let json = br#"{"payload":"","payloadType":"text/plain","signatures":[{"keyid":"k","sig":"s"}]}"#;
156        assert!(Envelope::from_json(json).is_err());
157    }
158
159    #[test]
160    fn from_json_rejects_no_signatures() {
161        let json = br#"{"payload":"YQ","payloadType":"text/plain","signatures":[]}"#;
162        assert!(Envelope::from_json(json).is_err());
163    }
164
165    #[test]
166    fn from_json_rejects_empty_payload_type() {
167        let json = br#"{"payload":"YQ","payloadType":"","signatures":[{"keyid":"k","sig":"s"}]}"#;
168        assert!(Envelope::from_json(json).is_err());
169    }
170}