Skip to main content

exo_messaging/
envelope.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Encrypted message envelope — the wire format for VitalLock messages.
18//!
19//! An `EncryptedEnvelope` contains everything a recipient needs to decrypt
20//! and verify a message: the ephemeral public key, ciphertext, sender DID,
21//! recipient DID, content type, and Ed25519 signature.
22
23use 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
33/// Domain tag for encrypted-envelope signatures.
34pub 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;
37/// Pre-versioned unversioned KDF: X25519 ECDH expanded with unsalted HKDF.
38pub const KDF_VERSION_LEGACY_UNSALTED: u16 = 1;
39/// Current KDF: X25519 ECDH expanded with transcript-salted HKDF.
40pub 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/// The type of content in the encrypted message.
75///
76/// `#[repr(u8)]` with explicit discriminants so the wire-format byte value is
77/// stable and independent of declaration order. The `From<ContentType> for u8`
78/// implementation below repeats those discriminants explicitly so the wire
79/// mapping stays obvious without relying on a numeric cast.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[repr(u8)]
82pub enum ContentType {
83    /// General text message.
84    Text = 0,
85    /// Password or credential.
86    Password = 1,
87    /// Generic secret (API keys, 2FA seeds, etc.).
88    Secret = 2,
89    /// Message to be delivered after sender's death.
90    AfterlifeMessage = 3,
91    /// Pre-populated template message.
92    Template = 4,
93    /// Binary attachment (file).
94    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/// An encrypted message envelope — the complete wire format.
111///
112/// The ciphertext is produced by X25519 ECDH + HKDF + XChaCha20-Poly1305.
113/// Format: `[24-byte nonce][ciphertext][16-byte Poly1305 tag]`
114/// (same layout as `VaultEncryptor` in `exo-identity`).
115#[derive(Clone, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct EncryptedEnvelope {
118    /// Unique message ID.
119    pub id: String,
120    /// Sender's DID.
121    pub sender_did: Did,
122    /// Recipient's DID.
123    pub recipient_did: Did,
124    /// Ephemeral X25519 public key used for this message's ECDH.
125    pub ephemeral_public_key: [u8; 32],
126    /// KDF version used to derive the symmetric key.
127    ///
128    /// `None` means the envelope was created before KDF versioning existed.
129    /// New envelopes must set [`KDF_VERSION_TRANSCRIPT_SALTED`].
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub kdf_version: Option<u16>,
132    /// Encrypted payload: `[nonce][ciphertext][tag]`.
133    #[serde(deserialize_with = "deserialize_bounded_ciphertext")]
134    pub ciphertext: Vec<u8>,
135    /// Content type classification.
136    pub content_type: ContentType,
137    /// Ed25519 signature over the canonical envelope bytes (excl. signature field).
138    pub signature: Signature,
139    /// Whether this message should be released after the sender's death.
140    pub release_on_death: bool,
141    /// Delay in hours after death verification before release (0 = immediate).
142    pub release_delay_hours: u32,
143    /// Creation timestamp (hybrid logical clock).
144    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    /// Compute the domain-separated canonical CBOR payload for signing.
235    ///
236    /// The payload covers every envelope field except the signature itself.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`MessagingError::EnvelopeSigningPayloadEncoding`] if the CBOR
241    /// encoder rejects the payload.
242    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}