Skip to main content

exo_messaging/
compose.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//! Compose & Lock — sender-side message encryption.
18//!
19//! Requires caller-supplied ephemeral X25519 key material, performs ECDH with
20//! the recipient's public key, derives a symmetric key via HKDF, encrypts the
21//! plaintext with XChaCha20-Poly1305, and signs the envelope with the sender's
22//! Ed25519 key.
23
24use exo_core::{Did, PublicKey, SecretKey, Signature, Timestamp};
25use exo_identity::vault::{VAULT_NONCE_SIZE, VaultEncryptor};
26use hkdf::Hkdf;
27use sha2::Sha256;
28use uuid::Uuid;
29
30use crate::{
31    envelope::{ContentType, EncryptedEnvelope, KDF_VERSION_TRANSCRIPT_SALTED},
32    error::MessagingError,
33    kex::{self, X25519KeyPair, X25519PublicKey},
34};
35
36/// The HKDF context string for message encryption key derivation.
37const MESSAGE_KEX_CONTEXT: &[u8] = b"vitallock-message-v1";
38const MESSAGE_VAULT_NONCE_DOMAIN: &[u8] = b"exo.messaging.vault-nonce.v1";
39
40/// Caller-supplied provenance metadata for an encrypted envelope.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct ComposeMetadata {
43    /// Unique message ID assigned by the caller's deterministic boundary.
44    id: Uuid,
45    /// Non-zero HLC timestamp assigned by the caller's deterministic boundary.
46    created: Timestamp,
47}
48
49impl ComposeMetadata {
50    /// Validate caller-supplied envelope metadata.
51    pub fn new(id: Uuid, created: Timestamp) -> Result<Self, MessagingError> {
52        let metadata = Self { id, created };
53        metadata.validate()?;
54        Ok(metadata)
55    }
56
57    /// Return the validated message ID.
58    #[must_use]
59    pub fn id(&self) -> Uuid {
60        self.id
61    }
62
63    /// Return the validated message creation timestamp.
64    #[must_use]
65    pub fn created(&self) -> Timestamp {
66        self.created
67    }
68
69    fn validate(&self) -> Result<(), MessagingError> {
70        if self.id.is_nil() {
71            return Err(MessagingError::InvalidEnvelope(
72                "message id must be caller-supplied and non-nil".into(),
73            ));
74        }
75        if self.created == Timestamp::ZERO {
76            return Err(MessagingError::InvalidEnvelope(
77                "message timestamp must be caller-supplied and non-zero".into(),
78            ));
79        }
80        Ok(())
81    }
82}
83
84/// Legacy Lock & Send entrypoint.
85///
86/// This fails closed because EXOCHAIN message composition must not fabricate
87/// X25519 key material internally. Use [`lock_and_send_with_ephemeral`] with a
88/// caller-supplied one-time X25519 keypair.
89///
90/// # Arguments
91///
92/// * `plaintext` — The message content to encrypt.
93/// * `content_type` — Classification of the message content.
94/// * `sender_did` — The sender's DID.
95/// * `recipient_did` — The recipient's DID.
96/// * `sender_signing_key` — The sender's Ed25519 secret key for signing.
97/// * `recipient_x25519_public` — The recipient's X25519 public key.
98/// * `metadata` — Caller-supplied non-nil ID and non-zero HLC timestamp.
99/// * `release_on_death` — Whether to release after sender's death.
100/// * `release_delay_hours` — Hours to wait after death verification.
101///
102/// # Returns
103///
104/// A fail-closed error directing callers to the explicit ephemeral-key API.
105#[allow(clippy::too_many_arguments)]
106// 8 args is the minimum for a sender→recipient envelope with
107// death-trigger semantics: plaintext + content_type + sender DID +
108// recipient DID + sender key + recipient pubkey + release_on_death +
109// release_delay_hours. Grouping into a struct would add boilerplate
110// for every single call site with zero safety benefit — every field
111// is semantically required and independently typed.
112pub fn lock_and_send(
113    plaintext: &[u8],
114    content_type: ContentType,
115    sender_did: &Did,
116    recipient_did: &Did,
117    sender_signing_key: &SecretKey,
118    recipient_x25519_public: &X25519PublicKey,
119    metadata: ComposeMetadata,
120    release_on_death: bool,
121    release_delay_hours: u32,
122) -> Result<EncryptedEnvelope, MessagingError> {
123    let _ = (
124        plaintext,
125        content_type,
126        sender_did,
127        recipient_did,
128        sender_signing_key,
129        recipient_x25519_public,
130        metadata,
131        release_on_death,
132        release_delay_hours,
133    );
134    Err(caller_supplied_ephemeral_required())
135}
136
137/// Lock & Send with caller-supplied X25519 ephemeral key material.
138#[allow(clippy::too_many_arguments)]
139pub fn lock_and_send_with_ephemeral(
140    plaintext: &[u8],
141    content_type: ContentType,
142    sender_did: &Did,
143    recipient_did: &Did,
144    sender_signing_key: &SecretKey,
145    recipient_x25519_public: &X25519PublicKey,
146    ephemeral_x25519_keypair: &X25519KeyPair,
147    metadata: ComposeMetadata,
148    release_on_death: bool,
149    release_delay_hours: u32,
150) -> Result<EncryptedEnvelope, MessagingError> {
151    let envelope = prepare_envelope_for_signing_with_ephemeral(
152        plaintext,
153        content_type,
154        sender_did,
155        recipient_did,
156        recipient_x25519_public,
157        ephemeral_x25519_keypair,
158        metadata,
159        release_on_death,
160        release_delay_hours,
161    )?;
162    sign_prepared_envelope(envelope, sender_signing_key)
163}
164
165/// Legacy unsigned-envelope entrypoint.
166///
167/// This fails closed because EXOCHAIN message composition must not fabricate
168/// X25519 key material internally. Use
169/// [`prepare_envelope_for_signing_with_ephemeral`] with a caller-supplied
170/// one-time X25519 keypair.
171#[allow(clippy::too_many_arguments)]
172pub fn prepare_envelope_for_signing(
173    plaintext: &[u8],
174    content_type: ContentType,
175    sender_did: &Did,
176    recipient_did: &Did,
177    recipient_x25519_public: &X25519PublicKey,
178    metadata: ComposeMetadata,
179    release_on_death: bool,
180    release_delay_hours: u32,
181) -> Result<EncryptedEnvelope, MessagingError> {
182    let _ = (
183        plaintext,
184        content_type,
185        sender_did,
186        recipient_did,
187        recipient_x25519_public,
188        metadata,
189        release_on_death,
190        release_delay_hours,
191    );
192    Err(caller_supplied_ephemeral_required())
193}
194
195/// Encrypt a message with caller-supplied X25519 ephemeral key material and
196/// return the unsigned envelope whose signing payload can be signed externally.
197#[allow(clippy::too_many_arguments)]
198pub fn prepare_envelope_for_signing_with_ephemeral(
199    plaintext: &[u8],
200    content_type: ContentType,
201    sender_did: &Did,
202    recipient_did: &Did,
203    recipient_x25519_public: &X25519PublicKey,
204    ephemeral_x25519_keypair: &X25519KeyPair,
205    metadata: ComposeMetadata,
206    release_on_death: bool,
207    release_delay_hours: u32,
208) -> Result<EncryptedEnvelope, MessagingError> {
209    metadata.validate()?;
210
211    // 1. ECDH: derive shared symmetric key using caller-supplied ephemeral key.
212    let shared_key = kex::derive_shared_key(
213        &ephemeral_x25519_keypair.secret,
214        recipient_x25519_public,
215        MESSAGE_KEX_CONTEXT,
216    )?;
217
218    let nonce = derive_vault_nonce(
219        &shared_key,
220        &metadata,
221        content_type,
222        sender_did,
223        recipient_did,
224        ephemeral_x25519_keypair.public.as_bytes(),
225        release_on_death,
226        release_delay_hours,
227    )?;
228
229    // 3. Encrypt plaintext with XChaCha20-Poly1305
230    //    Associated data = recipient DID (binds ciphertext to intended recipient)
231    let encryptor = VaultEncryptor::from_key(shared_key);
232    let ciphertext = encryptor
233        .encrypt_with_nonce(plaintext, recipient_did.as_str().as_bytes(), &nonce)
234        .map_err(|e| MessagingError::EncryptionFailed(e.to_string()))?;
235
236    // 4. Build envelope (without signature first)
237    let envelope = EncryptedEnvelope {
238        id: metadata.id.to_string(),
239        sender_did: sender_did.clone(),
240        recipient_did: recipient_did.clone(),
241        ephemeral_public_key: *ephemeral_x25519_keypair.public.as_bytes(),
242        kdf_version: Some(KDF_VERSION_TRANSCRIPT_SALTED),
243        ciphertext,
244        content_type,
245        signature: exo_core::Signature::empty(),
246        release_on_death,
247        release_delay_hours,
248        created: metadata.created,
249    };
250
251    Ok(envelope)
252}
253
254fn caller_supplied_ephemeral_required() -> MessagingError {
255    MessagingError::KeyExchangeFailed(
256        "message composition requires caller-supplied ephemeral X25519 keypair".to_owned(),
257    )
258}
259
260#[allow(clippy::too_many_arguments)]
261fn derive_vault_nonce(
262    shared_key: &[u8; 32],
263    metadata: &ComposeMetadata,
264    content_type: ContentType,
265    sender_did: &Did,
266    recipient_did: &Did,
267    ephemeral_public_key: &[u8; 32],
268    release_on_death: bool,
269    release_delay_hours: u32,
270) -> Result<[u8; VAULT_NONCE_SIZE], MessagingError> {
271    let mut transcript = Vec::new();
272    append_len_prefixed(&mut transcript, "id", metadata.id.as_bytes())?;
273    transcript.extend_from_slice(&metadata.created.physical_ms.to_le_bytes());
274    transcript.extend_from_slice(&metadata.created.logical.to_le_bytes());
275    append_len_prefixed(
276        &mut transcript,
277        "sender_did",
278        sender_did.as_str().as_bytes(),
279    )?;
280    append_len_prefixed(
281        &mut transcript,
282        "recipient_did",
283        recipient_did.as_str().as_bytes(),
284    )?;
285    transcript.extend_from_slice(ephemeral_public_key);
286    transcript.push(u8::from(content_type));
287    transcript.push(u8::from(release_on_death));
288    transcript.extend_from_slice(&release_delay_hours.to_le_bytes());
289
290    let hk = Hkdf::<Sha256>::new(Some(MESSAGE_VAULT_NONCE_DOMAIN), shared_key);
291    let mut nonce = [0u8; VAULT_NONCE_SIZE];
292    hk.expand(&transcript, &mut nonce)
293        .map_err(|e| MessagingError::EncryptionFailed(e.to_string()))?;
294    Ok(nonce)
295}
296
297fn append_len_prefixed(
298    transcript: &mut Vec<u8>,
299    label: &'static str,
300    value: &[u8],
301) -> Result<(), MessagingError> {
302    transcript.extend_from_slice(label.as_bytes());
303    let len = u64::try_from(value.len())
304        .map_err(|_| MessagingError::InvalidEnvelope(format!("{label} length exceeds u64::MAX")))?;
305    transcript.extend_from_slice(&len.to_le_bytes());
306    transcript.extend_from_slice(value);
307    Ok(())
308}
309
310/// Sign a prepared envelope with an in-process Ed25519 secret key.
311pub fn sign_prepared_envelope(
312    mut envelope: EncryptedEnvelope,
313    sender_signing_key: &SecretKey,
314) -> Result<EncryptedEnvelope, MessagingError> {
315    let signable = envelope.signing_payload()?;
316    let signature = exo_core::crypto::sign(&signable, sender_signing_key);
317    envelope.signature = signature;
318
319    Ok(envelope)
320}
321
322/// Attach and verify a caller-produced Ed25519 signature to a prepared envelope.
323pub fn attach_verified_signature(
324    mut envelope: EncryptedEnvelope,
325    signature: Signature,
326    sender_public_key: &PublicKey,
327) -> Result<EncryptedEnvelope, MessagingError> {
328    if signature.is_empty() {
329        return Err(MessagingError::SignatureVerificationFailed);
330    }
331
332    let signable = envelope.signing_payload()?;
333    if !exo_core::crypto::verify(&signable, &signature, sender_public_key) {
334        return Err(MessagingError::SignatureVerificationFailed);
335    }
336    envelope.signature = signature;
337    Ok(envelope)
338}
339
340// ===========================================================================
341// Tests
342// ===========================================================================
343
344#[cfg(test)]
345mod tests {
346    use exo_core::{Hash256, Timestamp, crypto::generate_keypair};
347    use uuid::Uuid;
348
349    use super::*;
350
351    fn metadata() -> ComposeMetadata {
352        ComposeMetadata::new(
353            Uuid::parse_str("018f7a96-8ad0-7c4f-8e0f-111111111111").unwrap(),
354            Timestamp::new(7_000, 2),
355        )
356        .expect("valid compose metadata")
357    }
358
359    fn x25519_keypair(seed: u8) -> kex::X25519KeyPair {
360        kex::X25519KeyPair::from_secret_bytes([seed; 32])
361            .expect("valid deterministic X25519 keypair")
362    }
363
364    #[allow(clippy::too_many_arguments)]
365    fn legacy_public_plaintext_hash_nonce(
366        metadata: &ComposeMetadata,
367        content_type: ContentType,
368        sender_did: &Did,
369        recipient_did: &Did,
370        ephemeral_public_key: &[u8; 32],
371        plaintext: &[u8],
372        release_on_death: bool,
373        release_delay_hours: u32,
374    ) -> [u8; VAULT_NONCE_SIZE] {
375        let plaintext_nonce_input = Hash256::digest(plaintext);
376        let mut transcript = Vec::new();
377        transcript.extend_from_slice(MESSAGE_VAULT_NONCE_DOMAIN);
378        append_len_prefixed(&mut transcript, "id", metadata.id.as_bytes())
379            .expect("append id to legacy nonce transcript");
380        transcript.extend_from_slice(&metadata.created.physical_ms.to_le_bytes());
381        transcript.extend_from_slice(&metadata.created.logical.to_le_bytes());
382        append_len_prefixed(
383            &mut transcript,
384            "sender_did",
385            sender_did.as_str().as_bytes(),
386        )
387        .expect("append sender did to legacy nonce transcript");
388        append_len_prefixed(
389            &mut transcript,
390            "recipient_did",
391            recipient_did.as_str().as_bytes(),
392        )
393        .expect("append recipient did to legacy nonce transcript");
394        transcript.extend_from_slice(ephemeral_public_key);
395        transcript.extend_from_slice(plaintext_nonce_input.as_bytes());
396        transcript.push(u8::from(content_type));
397        transcript.push(u8::from(release_on_death));
398        transcript.extend_from_slice(&release_delay_hours.to_le_bytes());
399
400        let digest = Hash256::digest(&transcript);
401        let mut nonce = [0u8; VAULT_NONCE_SIZE];
402        nonce.copy_from_slice(&digest.as_bytes()[..VAULT_NONCE_SIZE]);
403        nonce
404    }
405
406    #[test]
407    fn lock_and_send_produces_valid_envelope() {
408        let sender_did = Did::new("did:exo:alice").unwrap();
409        let recipient_did = Did::new("did:exo:bob").unwrap();
410        let (_, sender_sk) = generate_keypair();
411        let recipient_kp = x25519_keypair(0x21);
412        let ephemeral_kp = x25519_keypair(0x31);
413        let metadata = metadata();
414
415        let envelope = lock_and_send_with_ephemeral(
416            b"my secret password: hunter2",
417            ContentType::Password,
418            &sender_did,
419            &recipient_did,
420            &sender_sk,
421            &recipient_kp.public,
422            &ephemeral_kp,
423            metadata,
424            false,
425            0,
426        )
427        .expect("lock_and_send");
428
429        assert_eq!(
430            envelope.id,
431            "018f7a96-8ad0-7c4f-8e0f-111111111111".to_string()
432        );
433        assert_eq!(envelope.created, Timestamp::new(7_000, 2));
434        assert_eq!(envelope.sender_did, sender_did);
435        assert_eq!(envelope.recipient_did, recipient_did);
436        assert_eq!(envelope.content_type, ContentType::Password);
437        assert!(!envelope.ciphertext.is_empty());
438        assert!(!envelope.release_on_death);
439        assert_ne!(envelope.signature, exo_core::Signature::empty());
440    }
441
442    #[test]
443    fn prepare_envelope_for_signing_returns_canonical_payload_without_signature() {
444        let sender_did = Did::new("did:exo:alice").unwrap();
445        let recipient_did = Did::new("did:exo:bob").unwrap();
446        let recipient_kp = x25519_keypair(0x22);
447        let ephemeral_kp = x25519_keypair(0x32);
448
449        let envelope = prepare_envelope_for_signing_with_ephemeral(
450            b"external signer",
451            ContentType::Secret,
452            &sender_did,
453            &recipient_did,
454            &recipient_kp.public,
455            &ephemeral_kp,
456            metadata(),
457            false,
458            0,
459        )
460        .expect("prepare envelope");
461
462        assert_eq!(envelope.signature, exo_core::Signature::empty());
463        assert!(
464            !envelope
465                .signing_payload()
466                .expect("signing payload")
467                .is_empty(),
468            "prepared envelopes must expose canonical bytes for external signing"
469        );
470    }
471
472    #[test]
473    fn attach_verified_signature_accepts_external_signature() {
474        let sender_did = Did::new("did:exo:alice").unwrap();
475        let recipient_did = Did::new("did:exo:bob").unwrap();
476        let (sender_pk, sender_sk) = generate_keypair();
477        let recipient_kp = x25519_keypair(0x23);
478        let ephemeral_kp = x25519_keypair(0x33);
479
480        let envelope = prepare_envelope_for_signing_with_ephemeral(
481            b"external signer",
482            ContentType::Secret,
483            &sender_did,
484            &recipient_did,
485            &recipient_kp.public,
486            &ephemeral_kp,
487            metadata(),
488            false,
489            0,
490        )
491        .expect("prepare envelope");
492        let signature = exo_core::crypto::sign(
493            &envelope.signing_payload().expect("signing payload"),
494            &sender_sk,
495        );
496
497        let signed =
498            attach_verified_signature(envelope, signature, &sender_pk).expect("attach signature");
499
500        assert_ne!(signed.signature, exo_core::Signature::empty());
501    }
502
503    #[test]
504    fn attach_verified_signature_rejects_wrong_sender_key() {
505        let sender_did = Did::new("did:exo:alice").unwrap();
506        let recipient_did = Did::new("did:exo:bob").unwrap();
507        let (_, sender_sk) = generate_keypair();
508        let (wrong_pk, _) = generate_keypair();
509        let recipient_kp = x25519_keypair(0x24);
510        let ephemeral_kp = x25519_keypair(0x34);
511
512        let envelope = prepare_envelope_for_signing_with_ephemeral(
513            b"external signer",
514            ContentType::Secret,
515            &sender_did,
516            &recipient_did,
517            &recipient_kp.public,
518            &ephemeral_kp,
519            metadata(),
520            false,
521            0,
522        )
523        .expect("prepare envelope");
524        let signature = exo_core::crypto::sign(
525            &envelope.signing_payload().expect("signing payload"),
526            &sender_sk,
527        );
528
529        let result = attach_verified_signature(envelope, signature, &wrong_pk);
530
531        assert!(matches!(
532            result,
533            Err(MessagingError::SignatureVerificationFailed)
534        ));
535    }
536
537    #[test]
538    fn afterlife_message_flags() {
539        let sender_did = Did::new("did:exo:alice").unwrap();
540        let recipient_did = Did::new("did:exo:bob").unwrap();
541        let (_, sender_sk) = generate_keypair();
542        let recipient_kp = x25519_keypair(0x25);
543        let ephemeral_kp = x25519_keypair(0x35);
544        let metadata = metadata();
545
546        let envelope = lock_and_send_with_ephemeral(
547            b"Read this after I'm gone",
548            ContentType::AfterlifeMessage,
549            &sender_did,
550            &recipient_did,
551            &sender_sk,
552            &recipient_kp.public,
553            &ephemeral_kp,
554            metadata,
555            true,
556            72,
557        )
558        .expect("lock_and_send");
559
560        assert!(envelope.release_on_death);
561        assert_eq!(envelope.release_delay_hours, 72);
562        assert_eq!(envelope.content_type, ContentType::AfterlifeMessage);
563    }
564
565    #[test]
566    fn compose_metadata_rejects_nil_message_id() {
567        let result = ComposeMetadata::new(Uuid::nil(), Timestamp::new(7_000, 2));
568
569        assert!(
570            matches!(result, Err(MessagingError::InvalidEnvelope(reason)) if reason.contains("message id"))
571        );
572    }
573
574    #[test]
575    fn compose_metadata_rejects_zero_timestamp() {
576        let result = ComposeMetadata::new(
577            Uuid::parse_str("018f7a96-8ad0-7c4f-8e0f-222222222222").unwrap(),
578            Timestamp::ZERO,
579        );
580
581        assert!(
582            matches!(result, Err(MessagingError::InvalidEnvelope(reason)) if reason.contains("timestamp"))
583        );
584    }
585
586    #[test]
587    fn prepare_envelope_rejects_directly_constructed_invalid_metadata() {
588        let sender_did = Did::new("did:exo:alice").unwrap();
589        let recipient_did = Did::new("did:exo:bob").unwrap();
590        let recipient_kp = x25519_keypair(0x28);
591        let ephemeral_kp = x25519_keypair(0x38);
592        let invalid_metadata = ComposeMetadata {
593            id: Uuid::nil(),
594            created: Timestamp::ZERO,
595        };
596
597        let result = prepare_envelope_for_signing_with_ephemeral(
598            b"constructor bypass",
599            ContentType::Secret,
600            &sender_did,
601            &recipient_did,
602            &recipient_kp.public,
603            &ephemeral_kp,
604            invalid_metadata,
605            false,
606            0,
607        );
608
609        assert!(
610            matches!(result, Err(MessagingError::InvalidEnvelope(reason)) if reason.contains("message id"))
611        );
612    }
613
614    #[test]
615    fn compose_metadata_fields_are_not_public_constructor_bypass() {
616        let source = include_str!("compose.rs");
617        let metadata_section = source
618            .split("pub struct ComposeMetadata")
619            .nth(1)
620            .and_then(|section| section.split("impl ComposeMetadata").next())
621            .expect("metadata struct section");
622
623        assert!(!metadata_section.contains("pub id:"));
624        assert!(!metadata_section.contains("pub created:"));
625    }
626
627    #[test]
628    fn compose_path_does_not_fabricate_envelope_metadata() {
629        let source = include_str!("compose.rs");
630        let production = source
631            .split("// ===========================================================================")
632            .next()
633            .expect("production section");
634
635        assert!(
636            !production.contains("Uuid::new_v4"),
637            "compose production path must not fabricate message IDs"
638        );
639        let forbidden_clock = ["HybridClock", "::new()"].concat();
640        assert!(
641            !production.contains(&forbidden_clock),
642            "compose production path must not fabricate HLC timestamps"
643        );
644    }
645
646    #[test]
647    fn compose_path_supplies_explicit_vault_nonce() {
648        let source = include_str!("compose.rs");
649        let production = source
650            .split("// ===========================================================================")
651            .next()
652            .expect("production section");
653
654        assert!(
655            production.contains("encrypt_with_nonce"),
656            "compose must pass an explicit deterministic nonce into vault encryption"
657        );
658        assert!(
659            !production.contains(".encrypt("),
660            "compose must not call the implicit vault encryption entrypoint"
661        );
662    }
663
664    #[test]
665    fn encrypted_envelope_nonce_is_not_public_plaintext_hash_oracle() {
666        let sender_did = Did::new("did:exo:alice").unwrap();
667        let recipient_did = Did::new("did:exo:bob").unwrap();
668        let recipient_kp = x25519_keypair(0x27);
669        let ephemeral_kp = x25519_keypair(0x37);
670        let metadata = metadata();
671        let plaintext = b"known plaintext candidate";
672        let content_type = ContentType::Secret;
673        let release_on_death = true;
674        let release_delay_hours = 24;
675
676        let envelope = prepare_envelope_for_signing_with_ephemeral(
677            plaintext,
678            content_type,
679            &sender_did,
680            &recipient_did,
681            &recipient_kp.public,
682            &ephemeral_kp,
683            metadata,
684            release_on_death,
685            release_delay_hours,
686        )
687        .expect("prepare envelope");
688
689        let legacy_nonce = legacy_public_plaintext_hash_nonce(
690            &metadata,
691            content_type,
692            &sender_did,
693            &recipient_did,
694            ephemeral_kp.public.as_bytes(),
695            plaintext,
696            release_on_death,
697            release_delay_hours,
698        );
699
700        assert_ne!(
701            &envelope.ciphertext[..VAULT_NONCE_SIZE],
702            &legacy_nonce[..],
703            "visible ciphertext nonce must not be derived from public metadata plus guessed plaintext"
704        );
705    }
706
707    #[test]
708    fn compose_path_does_not_feed_plaintext_hash_into_visible_nonce() {
709        let source = include_str!("compose.rs");
710        let production = source
711            .split("// ===========================================================================")
712            .next()
713            .expect("production section");
714
715        for pattern in [
716            "Hash256::digest(plaintext)",
717            "plaintext_nonce_input",
718            "transcript.extend_from_slice(plaintext",
719        ] {
720            assert!(
721                !production.contains(pattern),
722                "compose production path must not expose plaintext-derived material through the visible vault nonce via {pattern}"
723            );
724        }
725    }
726
727    #[test]
728    fn prepare_envelope_for_signing_requires_caller_supplied_ephemeral_key() {
729        let sender_did = Did::new("did:exo:alice").unwrap();
730        let recipient_did = Did::new("did:exo:bob").unwrap();
731        let recipient_kp = x25519_keypair(0x26);
732
733        let result = prepare_envelope_for_signing(
734            b"external signer",
735            ContentType::Secret,
736            &sender_did,
737            &recipient_did,
738            &recipient_kp.public,
739            metadata(),
740            false,
741            0,
742        );
743
744        assert!(
745            matches!(result, Err(MessagingError::KeyExchangeFailed(reason)) if reason.contains("caller-supplied ephemeral")),
746            "message composition must fail closed unless the caller supplies the ephemeral X25519 keypair"
747        );
748    }
749
750    #[test]
751    fn compose_path_requires_caller_supplied_ephemeral_key() {
752        let source = include_str!("compose.rs");
753        let production = source
754            .split("// ===========================================================================")
755            .next()
756            .expect("production section");
757
758        assert!(
759            production.contains("prepare_envelope_for_signing_with_ephemeral"),
760            "compose must expose an explicit ephemeral-key entrypoint"
761        );
762        for pattern in ["generate_ephemeral", "X25519KeyPair::generate"] {
763            assert!(
764                !production.contains(pattern),
765                "compose production path must not fabricate X25519 ephemeral key material via {pattern}"
766            );
767        }
768    }
769}