1use std::fmt;
24
25use exo_core::{Did, Signature, Timestamp};
26use serde::{
27 Deserialize, Deserializer, Serialize,
28 de::{self, SeqAccess, Visitor},
29};
30
31use crate::error::MessagingError;
32
33pub const ENVELOPE_SIGNING_DOMAIN: &str = "exo.messaging.envelope.v1";
35const ENVELOPE_SIGNING_SCHEMA_VERSION_LEGACY: u16 = 1;
36const ENVELOPE_SIGNING_SCHEMA_VERSION_KDF_VERSIONED: u16 = 2;
37pub const KDF_VERSION_LEGACY_UNSALTED: u16 = 1;
39pub const KDF_VERSION_TRANSCRIPT_SALTED: u16 = 2;
41pub const MAX_ENVELOPE_CIPHERTEXT_LEN: usize = 16 * 1024 * 1024;
42
43#[derive(Serialize)]
44struct EnvelopeSigningPayloadV1<'a> {
45 domain: &'static str,
46 schema_version: u16,
47 id: &'a str,
48 sender_did: &'a Did,
49 recipient_did: &'a Did,
50 ephemeral_public_key: &'a [u8; 32],
51 ciphertext: &'a [u8],
52 content_type: u8,
53 release_on_death: bool,
54 release_delay_hours: u32,
55 created: &'a Timestamp,
56}
57
58#[derive(Serialize)]
59struct EnvelopeSigningPayloadV2<'a> {
60 domain: &'static str,
61 schema_version: u16,
62 id: &'a str,
63 sender_did: &'a Did,
64 recipient_did: &'a Did,
65 ephemeral_public_key: &'a [u8; 32],
66 kdf_version: u16,
67 ciphertext: &'a [u8],
68 content_type: u8,
69 release_on_death: bool,
70 release_delay_hours: u32,
71 created: &'a Timestamp,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[repr(u8)]
82pub enum ContentType {
83 Text = 0,
85 Password = 1,
87 Secret = 2,
89 AfterlifeMessage = 3,
91 Template = 4,
93 Attachment = 5,
95}
96
97impl From<ContentType> for u8 {
98 fn from(ct: ContentType) -> Self {
99 match ct {
100 ContentType::Text => 0,
101 ContentType::Password => 1,
102 ContentType::Secret => 2,
103 ContentType::AfterlifeMessage => 3,
104 ContentType::Template => 4,
105 ContentType::Attachment => 5,
106 }
107 }
108}
109
110#[derive(Clone, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct EncryptedEnvelope {
118 pub id: String,
120 pub sender_did: Did,
122 pub recipient_did: Did,
124 pub ephemeral_public_key: [u8; 32],
126 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub kdf_version: Option<u16>,
132 #[serde(deserialize_with = "deserialize_bounded_ciphertext")]
134 pub ciphertext: Vec<u8>,
135 pub content_type: ContentType,
137 pub signature: Signature,
139 pub release_on_death: bool,
141 pub release_delay_hours: u32,
143 pub created: Timestamp,
145}
146
147impl fmt::Debug for EncryptedEnvelope {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 f.debug_struct("EncryptedEnvelope")
150 .field("id", &self.id)
151 .field("sender_did", &self.sender_did)
152 .field("recipient_did", &self.recipient_did)
153 .field("ephemeral_public_key", &"<redacted>")
154 .field("kdf_version", &self.kdf_version)
155 .field("ciphertext_len", &self.ciphertext.len())
156 .field("content_type", &self.content_type)
157 .field("release_on_death", &self.release_on_death)
158 .field("release_delay_hours", &self.release_delay_hours)
159 .field("created", &self.created)
160 .finish()
161 }
162}
163
164fn deserialize_bounded_ciphertext<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
165where
166 D: Deserializer<'de>,
167{
168 struct BoundedCiphertextVisitor;
169
170 impl<'de> Visitor<'de> for BoundedCiphertextVisitor {
171 type Value = Vec<u8>;
172
173 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174 write!(
175 formatter,
176 "ciphertext no longer than {MAX_ENVELOPE_CIPHERTEXT_LEN} bytes"
177 )
178 }
179
180 fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
181 where
182 E: de::Error,
183 {
184 validate_ciphertext_len(value.len()).map_err(E::custom)?;
185 Ok(value.to_vec())
186 }
187
188 fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E>
189 where
190 E: de::Error,
191 {
192 validate_ciphertext_len(value.len()).map_err(E::custom)?;
193 Ok(value)
194 }
195
196 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
197 where
198 A: SeqAccess<'de>,
199 {
200 if let Some(size_hint) = seq.size_hint() {
201 validate_ciphertext_len(size_hint).map_err(de::Error::custom)?;
202 }
203
204 let capacity = seq
205 .size_hint()
206 .unwrap_or(0)
207 .min(MAX_ENVELOPE_CIPHERTEXT_LEN);
208 let mut ciphertext = Vec::with_capacity(capacity);
209 while let Some(byte) = seq.next_element::<u8>()? {
210 if ciphertext.len() == MAX_ENVELOPE_CIPHERTEXT_LEN {
211 return Err(de::Error::custom(format!(
212 "ciphertext length exceeds {MAX_ENVELOPE_CIPHERTEXT_LEN} bytes"
213 )));
214 }
215 ciphertext.push(byte);
216 }
217 Ok(ciphertext)
218 }
219 }
220
221 deserializer.deserialize_byte_buf(BoundedCiphertextVisitor)
222}
223
224fn validate_ciphertext_len(len: usize) -> Result<(), String> {
225 if len > MAX_ENVELOPE_CIPHERTEXT_LEN {
226 return Err(format!(
227 "ciphertext length {len} exceeds {MAX_ENVELOPE_CIPHERTEXT_LEN} bytes"
228 ));
229 }
230 Ok(())
231}
232
233impl EncryptedEnvelope {
234 pub fn signing_payload(&self) -> Result<Vec<u8>, MessagingError> {
243 let mut buf = Vec::new();
244 match self.kdf_version {
245 Some(kdf_version) => {
246 validate_kdf_version(kdf_version)?;
247 let payload = EnvelopeSigningPayloadV2 {
248 domain: ENVELOPE_SIGNING_DOMAIN,
249 schema_version: ENVELOPE_SIGNING_SCHEMA_VERSION_KDF_VERSIONED,
250 id: &self.id,
251 sender_did: &self.sender_did,
252 recipient_did: &self.recipient_did,
253 ephemeral_public_key: &self.ephemeral_public_key,
254 kdf_version,
255 ciphertext: &self.ciphertext,
256 content_type: u8::from(self.content_type),
257 release_on_death: self.release_on_death,
258 release_delay_hours: self.release_delay_hours,
259 created: &self.created,
260 };
261 ciborium::ser::into_writer(&payload, &mut buf)
262 .map_err(|e| MessagingError::EnvelopeSigningPayloadEncoding(e.to_string()))?;
263 }
264 None => {
265 let payload = EnvelopeSigningPayloadV1 {
266 domain: ENVELOPE_SIGNING_DOMAIN,
267 schema_version: ENVELOPE_SIGNING_SCHEMA_VERSION_LEGACY,
268 id: &self.id,
269 sender_did: &self.sender_did,
270 recipient_did: &self.recipient_did,
271 ephemeral_public_key: &self.ephemeral_public_key,
272 ciphertext: &self.ciphertext,
273 content_type: u8::from(self.content_type),
274 release_on_death: self.release_on_death,
275 release_delay_hours: self.release_delay_hours,
276 created: &self.created,
277 };
278 ciborium::ser::into_writer(&payload, &mut buf)
279 .map_err(|e| MessagingError::EnvelopeSigningPayloadEncoding(e.to_string()))?;
280 }
281 }
282 Ok(buf)
283 }
284}
285
286pub fn validate_kdf_version(kdf_version: u16) -> Result<(), MessagingError> {
287 match kdf_version {
288 KDF_VERSION_LEGACY_UNSALTED | KDF_VERSION_TRANSCRIPT_SALTED => Ok(()),
289 other => Err(MessagingError::InvalidEnvelope(format!(
290 "unsupported envelope KDF version {other}"
291 ))),
292 }
293}
294
295pub fn explicit_kdf_version(envelope: &EncryptedEnvelope) -> Result<Option<u16>, MessagingError> {
296 match envelope.kdf_version {
297 Some(kdf_version) => {
298 validate_kdf_version(kdf_version)?;
299 Ok(Some(kdf_version))
300 }
301 None => Ok(None),
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use exo_core::Hash256;
308 use serde::Deserialize;
309
310 use super::*;
311
312 #[test]
313 fn content_type_serde_round_trip() {
314 for ct in [
315 ContentType::Text,
316 ContentType::Password,
317 ContentType::Secret,
318 ContentType::AfterlifeMessage,
319 ContentType::Template,
320 ContentType::Attachment,
321 ] {
322 let json = serde_json::to_string(&ct).unwrap();
323 let recovered: ContentType = serde_json::from_str(&json).unwrap();
324 assert_eq!(ct, recovered);
325 }
326 }
327
328 #[test]
329 fn content_type_wire_conversion_uses_explicit_mapping() {
330 assert_eq!(u8::from(ContentType::Text), 0);
331 assert_eq!(u8::from(ContentType::Password), 1);
332 assert_eq!(u8::from(ContentType::Secret), 2);
333 assert_eq!(u8::from(ContentType::AfterlifeMessage), 3);
334 assert_eq!(u8::from(ContentType::Template), 4);
335 assert_eq!(u8::from(ContentType::Attachment), 5);
336
337 let source = include_str!("envelope.rs");
338 let conversion_source = source
339 .split("fn from(ct: ContentType) -> Self")
340 .nth(1)
341 .expect("content type conversion exists")
342 .split("/// An encrypted message envelope")
343 .next()
344 .expect("content type conversion ends before envelope struct");
345 let forbidden_cast = ["ct", " as ", "u8"].concat();
346
347 assert!(
348 !conversion_source.contains("clippy::as_conversions"),
349 "content type wire conversion must not suppress checked conversion lints"
350 );
351 assert!(
352 !conversion_source.contains(&forbidden_cast),
353 "content type wire conversion must not rely on an unchecked numeric cast"
354 );
355 }
356
357 #[derive(Debug, Deserialize)]
358 struct DecodedEnvelopeSigningPayload {
359 domain: String,
360 schema_version: u16,
361 id: String,
362 sender_did: Did,
363 recipient_did: Did,
364 ephemeral_public_key: [u8; 32],
365 #[serde(default)]
366 kdf_version: Option<u16>,
367 ciphertext: Vec<u8>,
368 content_type: u8,
369 release_on_death: bool,
370 release_delay_hours: u32,
371 created: Timestamp,
372 }
373
374 fn sample_envelope() -> EncryptedEnvelope {
375 EncryptedEnvelope {
376 id: "018f7a96-8ad0-7c4f-8e0f-111111111199".to_string(),
377 sender_did: Did::new("did:exo:alice").unwrap(),
378 recipient_did: Did::new("did:exo:bob").unwrap(),
379 ephemeral_public_key: [7; 32],
380 kdf_version: None,
381 ciphertext: vec![1, 1, 2, 3, 5, 8],
382 content_type: ContentType::Secret,
383 signature: Signature::empty(),
384 release_on_death: true,
385 release_delay_hours: 72,
386 created: Timestamp::new(9_000, 3),
387 }
388 }
389
390 #[test]
391 fn envelope_signing_payload_is_domain_separated_cbor() {
392 let envelope = sample_envelope();
393 let payload = envelope
394 .signing_payload()
395 .expect("canonical envelope signing payload");
396 let decoded: DecodedEnvelopeSigningPayload =
397 ciborium::from_reader(&payload[..]).expect("decode envelope signing payload");
398
399 assert_eq!(decoded.domain, "exo.messaging.envelope.v1");
400 assert_eq!(decoded.schema_version, 1);
401 assert_eq!(decoded.id, envelope.id);
402 assert_eq!(decoded.sender_did, envelope.sender_did);
403 assert_eq!(decoded.recipient_did, envelope.recipient_did);
404 assert_eq!(decoded.ephemeral_public_key, envelope.ephemeral_public_key);
405 assert_eq!(decoded.kdf_version, None);
406 assert_eq!(decoded.ciphertext, envelope.ciphertext);
407 assert_eq!(decoded.content_type, u8::from(envelope.content_type));
408 assert_eq!(decoded.release_on_death, envelope.release_on_death);
409 assert_eq!(decoded.release_delay_hours, envelope.release_delay_hours);
410 assert_eq!(decoded.created, envelope.created);
411 }
412
413 #[test]
414 fn versioned_envelope_signing_payload_binds_kdf_version() {
415 let mut envelope = sample_envelope();
416 envelope.kdf_version = Some(KDF_VERSION_TRANSCRIPT_SALTED);
417
418 let payload = envelope
419 .signing_payload()
420 .expect("versioned envelope signing payload");
421 let decoded: DecodedEnvelopeSigningPayload =
422 ciborium::from_reader(&payload[..]).expect("decode versioned payload");
423
424 assert_eq!(decoded.schema_version, 2);
425 assert_eq!(decoded.kdf_version, Some(KDF_VERSION_TRANSCRIPT_SALTED));
426
427 let mut tampered = envelope.clone();
428 tampered.kdf_version = Some(KDF_VERSION_LEGACY_UNSALTED);
429 let tampered_payload = tampered
430 .signing_payload()
431 .expect("tampered KDF version is still supported");
432
433 assert_ne!(payload, tampered_payload);
434 }
435
436 #[test]
437 fn envelope_signing_payload_rejects_unknown_kdf_version() {
438 let mut envelope = sample_envelope();
439 envelope.kdf_version = Some(99);
440
441 let err = envelope
442 .signing_payload()
443 .expect_err("unknown KDF version must fail closed");
444
445 assert!(
446 matches!(err, MessagingError::InvalidEnvelope(reason) if reason.contains("unsupported envelope KDF version 99"))
447 );
448 }
449
450 #[test]
451 fn encrypted_envelope_debug_redacts_ciphertext_and_signature() {
452 let envelope = sample_envelope();
453
454 let debug = format!("{envelope:?}");
455
456 assert!(debug.contains("EncryptedEnvelope"));
457 assert!(debug.contains("ciphertext_len"));
458 assert!(!debug.contains("ciphertext: [1, 1, 2, 3, 5, 8]"));
459 assert!(!debug.contains("signature:"));
460 assert!(!debug.contains("plaintext_hash:"));
461 }
462
463 #[test]
464 fn encrypted_envelope_wire_format_does_not_expose_plaintext_hash() {
465 let envelope = sample_envelope();
466 let value = serde_json::to_value(&envelope).expect("serialize envelope");
467
468 assert!(
469 value.get("plaintext_hash").is_none(),
470 "encrypted envelopes must not publish a deterministic plaintext hash"
471 );
472 }
473
474 #[test]
475 fn encrypted_envelope_deserialization_rejects_legacy_plaintext_hash_field() {
476 let envelope = sample_envelope();
477 let mut value = serde_json::to_value(&envelope).expect("serialize envelope");
478 value["plaintext_hash"] =
479 serde_json::to_value(Hash256::digest(b"plaintext")).expect("serialize hash");
480
481 let decoded: Result<EncryptedEnvelope, _> = serde_json::from_value(value);
482
483 assert!(
484 decoded.is_err(),
485 "encrypted envelopes must reject legacy plaintext_hash metadata"
486 );
487 }
488
489 #[test]
490 fn encrypted_envelope_deserialization_rejects_oversized_ciphertext() {
491 let mut envelope = sample_envelope();
492 envelope.ciphertext = vec![0xab; 16 * 1024 * 1024 + 1];
493 let mut encoded = Vec::new();
494 if let Err(error) = ciborium::into_writer(&envelope, &mut encoded) {
495 panic!("encode oversized envelope failed: {error}");
496 }
497
498 let decoded: Result<EncryptedEnvelope, _> = ciborium::from_reader(&encoded[..]);
499
500 assert!(
501 decoded.is_err(),
502 "oversized ciphertext must be rejected during envelope deserialization"
503 );
504 }
505}