1use 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
36const MESSAGE_KEX_CONTEXT: &[u8] = b"vitallock-message-v1";
38const MESSAGE_VAULT_NONCE_DOMAIN: &[u8] = b"exo.messaging.vault-nonce.v1";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct ComposeMetadata {
43 id: Uuid,
45 created: Timestamp,
47}
48
49impl ComposeMetadata {
50 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 #[must_use]
59 pub fn id(&self) -> Uuid {
60 self.id
61 }
62
63 #[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#[allow(clippy::too_many_arguments)]
106pub 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#[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#[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#[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 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 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 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
310pub 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
322pub 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#[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}