use exo_core::{Did, PublicKey, SecretKey, Signature, Timestamp};
use exo_identity::vault::{VAULT_NONCE_SIZE, VaultEncryptor};
use hkdf::Hkdf;
use sha2::Sha256;
use uuid::Uuid;
use crate::{
envelope::{ContentType, EncryptedEnvelope, KDF_VERSION_TRANSCRIPT_SALTED},
error::MessagingError,
kex::{self, X25519KeyPair, X25519PublicKey},
};
const MESSAGE_KEX_CONTEXT: &[u8] = b"vitallock-message-v1";
const MESSAGE_VAULT_NONCE_DOMAIN: &[u8] = b"exo.messaging.vault-nonce.v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ComposeMetadata {
id: Uuid,
created: Timestamp,
}
impl ComposeMetadata {
pub fn new(id: Uuid, created: Timestamp) -> Result<Self, MessagingError> {
let metadata = Self { id, created };
metadata.validate()?;
Ok(metadata)
}
#[must_use]
pub fn id(&self) -> Uuid {
self.id
}
#[must_use]
pub fn created(&self) -> Timestamp {
self.created
}
fn validate(&self) -> Result<(), MessagingError> {
if self.id.is_nil() {
return Err(MessagingError::InvalidEnvelope(
"message id must be caller-supplied and non-nil".into(),
));
}
if self.created == Timestamp::ZERO {
return Err(MessagingError::InvalidEnvelope(
"message timestamp must be caller-supplied and non-zero".into(),
));
}
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
pub fn lock_and_send(
plaintext: &[u8],
content_type: ContentType,
sender_did: &Did,
recipient_did: &Did,
sender_signing_key: &SecretKey,
recipient_x25519_public: &X25519PublicKey,
metadata: ComposeMetadata,
release_on_death: bool,
release_delay_hours: u32,
) -> Result<EncryptedEnvelope, MessagingError> {
let _ = (
plaintext,
content_type,
sender_did,
recipient_did,
sender_signing_key,
recipient_x25519_public,
metadata,
release_on_death,
release_delay_hours,
);
Err(caller_supplied_ephemeral_required())
}
#[allow(clippy::too_many_arguments)]
pub fn lock_and_send_with_ephemeral(
plaintext: &[u8],
content_type: ContentType,
sender_did: &Did,
recipient_did: &Did,
sender_signing_key: &SecretKey,
recipient_x25519_public: &X25519PublicKey,
ephemeral_x25519_keypair: &X25519KeyPair,
metadata: ComposeMetadata,
release_on_death: bool,
release_delay_hours: u32,
) -> Result<EncryptedEnvelope, MessagingError> {
let envelope = prepare_envelope_for_signing_with_ephemeral(
plaintext,
content_type,
sender_did,
recipient_did,
recipient_x25519_public,
ephemeral_x25519_keypair,
metadata,
release_on_death,
release_delay_hours,
)?;
sign_prepared_envelope(envelope, sender_signing_key)
}
#[allow(clippy::too_many_arguments)]
pub fn prepare_envelope_for_signing(
plaintext: &[u8],
content_type: ContentType,
sender_did: &Did,
recipient_did: &Did,
recipient_x25519_public: &X25519PublicKey,
metadata: ComposeMetadata,
release_on_death: bool,
release_delay_hours: u32,
) -> Result<EncryptedEnvelope, MessagingError> {
let _ = (
plaintext,
content_type,
sender_did,
recipient_did,
recipient_x25519_public,
metadata,
release_on_death,
release_delay_hours,
);
Err(caller_supplied_ephemeral_required())
}
#[allow(clippy::too_many_arguments)]
pub fn prepare_envelope_for_signing_with_ephemeral(
plaintext: &[u8],
content_type: ContentType,
sender_did: &Did,
recipient_did: &Did,
recipient_x25519_public: &X25519PublicKey,
ephemeral_x25519_keypair: &X25519KeyPair,
metadata: ComposeMetadata,
release_on_death: bool,
release_delay_hours: u32,
) -> Result<EncryptedEnvelope, MessagingError> {
metadata.validate()?;
let shared_key = kex::derive_shared_key(
&ephemeral_x25519_keypair.secret,
recipient_x25519_public,
MESSAGE_KEX_CONTEXT,
)?;
let nonce = derive_vault_nonce(
&shared_key,
&metadata,
content_type,
sender_did,
recipient_did,
ephemeral_x25519_keypair.public.as_bytes(),
release_on_death,
release_delay_hours,
)?;
let encryptor = VaultEncryptor::from_key(shared_key);
let ciphertext = encryptor
.encrypt_with_nonce(plaintext, recipient_did.as_str().as_bytes(), &nonce)
.map_err(|e| MessagingError::EncryptionFailed(e.to_string()))?;
let envelope = EncryptedEnvelope {
id: metadata.id.to_string(),
sender_did: sender_did.clone(),
recipient_did: recipient_did.clone(),
ephemeral_public_key: *ephemeral_x25519_keypair.public.as_bytes(),
kdf_version: Some(KDF_VERSION_TRANSCRIPT_SALTED),
ciphertext,
content_type,
signature: exo_core::Signature::empty(),
release_on_death,
release_delay_hours,
created: metadata.created,
};
Ok(envelope)
}
fn caller_supplied_ephemeral_required() -> MessagingError {
MessagingError::KeyExchangeFailed(
"message composition requires caller-supplied ephemeral X25519 keypair".to_owned(),
)
}
#[allow(clippy::too_many_arguments)]
fn derive_vault_nonce(
shared_key: &[u8; 32],
metadata: &ComposeMetadata,
content_type: ContentType,
sender_did: &Did,
recipient_did: &Did,
ephemeral_public_key: &[u8; 32],
release_on_death: bool,
release_delay_hours: u32,
) -> Result<[u8; VAULT_NONCE_SIZE], MessagingError> {
let mut transcript = Vec::new();
append_len_prefixed(&mut transcript, "id", metadata.id.as_bytes())?;
transcript.extend_from_slice(&metadata.created.physical_ms.to_le_bytes());
transcript.extend_from_slice(&metadata.created.logical.to_le_bytes());
append_len_prefixed(
&mut transcript,
"sender_did",
sender_did.as_str().as_bytes(),
)?;
append_len_prefixed(
&mut transcript,
"recipient_did",
recipient_did.as_str().as_bytes(),
)?;
transcript.extend_from_slice(ephemeral_public_key);
transcript.push(u8::from(content_type));
transcript.push(u8::from(release_on_death));
transcript.extend_from_slice(&release_delay_hours.to_le_bytes());
let hk = Hkdf::<Sha256>::new(Some(MESSAGE_VAULT_NONCE_DOMAIN), shared_key);
let mut nonce = [0u8; VAULT_NONCE_SIZE];
hk.expand(&transcript, &mut nonce)
.map_err(|e| MessagingError::EncryptionFailed(e.to_string()))?;
Ok(nonce)
}
fn append_len_prefixed(
transcript: &mut Vec<u8>,
label: &'static str,
value: &[u8],
) -> Result<(), MessagingError> {
transcript.extend_from_slice(label.as_bytes());
let len = u64::try_from(value.len())
.map_err(|_| MessagingError::InvalidEnvelope(format!("{label} length exceeds u64::MAX")))?;
transcript.extend_from_slice(&len.to_le_bytes());
transcript.extend_from_slice(value);
Ok(())
}
pub fn sign_prepared_envelope(
mut envelope: EncryptedEnvelope,
sender_signing_key: &SecretKey,
) -> Result<EncryptedEnvelope, MessagingError> {
let signable = envelope.signing_payload()?;
let signature = exo_core::crypto::sign(&signable, sender_signing_key);
envelope.signature = signature;
Ok(envelope)
}
pub fn attach_verified_signature(
mut envelope: EncryptedEnvelope,
signature: Signature,
sender_public_key: &PublicKey,
) -> Result<EncryptedEnvelope, MessagingError> {
if signature.is_empty() {
return Err(MessagingError::SignatureVerificationFailed);
}
let signable = envelope.signing_payload()?;
if !exo_core::crypto::verify(&signable, &signature, sender_public_key) {
return Err(MessagingError::SignatureVerificationFailed);
}
envelope.signature = signature;
Ok(envelope)
}
#[cfg(test)]
mod tests {
use exo_core::{Hash256, Timestamp, crypto::generate_keypair};
use uuid::Uuid;
use super::*;
fn metadata() -> ComposeMetadata {
ComposeMetadata::new(
Uuid::parse_str("018f7a96-8ad0-7c4f-8e0f-111111111111").unwrap(),
Timestamp::new(7_000, 2),
)
.expect("valid compose metadata")
}
fn x25519_keypair(seed: u8) -> kex::X25519KeyPair {
kex::X25519KeyPair::from_secret_bytes([seed; 32])
.expect("valid deterministic X25519 keypair")
}
#[allow(clippy::too_many_arguments)]
fn legacy_public_plaintext_hash_nonce(
metadata: &ComposeMetadata,
content_type: ContentType,
sender_did: &Did,
recipient_did: &Did,
ephemeral_public_key: &[u8; 32],
plaintext: &[u8],
release_on_death: bool,
release_delay_hours: u32,
) -> [u8; VAULT_NONCE_SIZE] {
let plaintext_nonce_input = Hash256::digest(plaintext);
let mut transcript = Vec::new();
transcript.extend_from_slice(MESSAGE_VAULT_NONCE_DOMAIN);
append_len_prefixed(&mut transcript, "id", metadata.id.as_bytes())
.expect("append id to legacy nonce transcript");
transcript.extend_from_slice(&metadata.created.physical_ms.to_le_bytes());
transcript.extend_from_slice(&metadata.created.logical.to_le_bytes());
append_len_prefixed(
&mut transcript,
"sender_did",
sender_did.as_str().as_bytes(),
)
.expect("append sender did to legacy nonce transcript");
append_len_prefixed(
&mut transcript,
"recipient_did",
recipient_did.as_str().as_bytes(),
)
.expect("append recipient did to legacy nonce transcript");
transcript.extend_from_slice(ephemeral_public_key);
transcript.extend_from_slice(plaintext_nonce_input.as_bytes());
transcript.push(u8::from(content_type));
transcript.push(u8::from(release_on_death));
transcript.extend_from_slice(&release_delay_hours.to_le_bytes());
let digest = Hash256::digest(&transcript);
let mut nonce = [0u8; VAULT_NONCE_SIZE];
nonce.copy_from_slice(&digest.as_bytes()[..VAULT_NONCE_SIZE]);
nonce
}
#[test]
fn lock_and_send_produces_valid_envelope() {
let sender_did = Did::new("did:exo:alice").unwrap();
let recipient_did = Did::new("did:exo:bob").unwrap();
let (_, sender_sk) = generate_keypair();
let recipient_kp = x25519_keypair(0x21);
let ephemeral_kp = x25519_keypair(0x31);
let metadata = metadata();
let envelope = lock_and_send_with_ephemeral(
b"my secret password: hunter2",
ContentType::Password,
&sender_did,
&recipient_did,
&sender_sk,
&recipient_kp.public,
&ephemeral_kp,
metadata,
false,
0,
)
.expect("lock_and_send");
assert_eq!(
envelope.id,
"018f7a96-8ad0-7c4f-8e0f-111111111111".to_string()
);
assert_eq!(envelope.created, Timestamp::new(7_000, 2));
assert_eq!(envelope.sender_did, sender_did);
assert_eq!(envelope.recipient_did, recipient_did);
assert_eq!(envelope.content_type, ContentType::Password);
assert!(!envelope.ciphertext.is_empty());
assert!(!envelope.release_on_death);
assert_ne!(envelope.signature, exo_core::Signature::empty());
}
#[test]
fn prepare_envelope_for_signing_returns_canonical_payload_without_signature() {
let sender_did = Did::new("did:exo:alice").unwrap();
let recipient_did = Did::new("did:exo:bob").unwrap();
let recipient_kp = x25519_keypair(0x22);
let ephemeral_kp = x25519_keypair(0x32);
let envelope = prepare_envelope_for_signing_with_ephemeral(
b"external signer",
ContentType::Secret,
&sender_did,
&recipient_did,
&recipient_kp.public,
&ephemeral_kp,
metadata(),
false,
0,
)
.expect("prepare envelope");
assert_eq!(envelope.signature, exo_core::Signature::empty());
assert!(
!envelope
.signing_payload()
.expect("signing payload")
.is_empty(),
"prepared envelopes must expose canonical bytes for external signing"
);
}
#[test]
fn attach_verified_signature_accepts_external_signature() {
let sender_did = Did::new("did:exo:alice").unwrap();
let recipient_did = Did::new("did:exo:bob").unwrap();
let (sender_pk, sender_sk) = generate_keypair();
let recipient_kp = x25519_keypair(0x23);
let ephemeral_kp = x25519_keypair(0x33);
let envelope = prepare_envelope_for_signing_with_ephemeral(
b"external signer",
ContentType::Secret,
&sender_did,
&recipient_did,
&recipient_kp.public,
&ephemeral_kp,
metadata(),
false,
0,
)
.expect("prepare envelope");
let signature = exo_core::crypto::sign(
&envelope.signing_payload().expect("signing payload"),
&sender_sk,
);
let signed =
attach_verified_signature(envelope, signature, &sender_pk).expect("attach signature");
assert_ne!(signed.signature, exo_core::Signature::empty());
}
#[test]
fn attach_verified_signature_rejects_wrong_sender_key() {
let sender_did = Did::new("did:exo:alice").unwrap();
let recipient_did = Did::new("did:exo:bob").unwrap();
let (_, sender_sk) = generate_keypair();
let (wrong_pk, _) = generate_keypair();
let recipient_kp = x25519_keypair(0x24);
let ephemeral_kp = x25519_keypair(0x34);
let envelope = prepare_envelope_for_signing_with_ephemeral(
b"external signer",
ContentType::Secret,
&sender_did,
&recipient_did,
&recipient_kp.public,
&ephemeral_kp,
metadata(),
false,
0,
)
.expect("prepare envelope");
let signature = exo_core::crypto::sign(
&envelope.signing_payload().expect("signing payload"),
&sender_sk,
);
let result = attach_verified_signature(envelope, signature, &wrong_pk);
assert!(matches!(
result,
Err(MessagingError::SignatureVerificationFailed)
));
}
#[test]
fn afterlife_message_flags() {
let sender_did = Did::new("did:exo:alice").unwrap();
let recipient_did = Did::new("did:exo:bob").unwrap();
let (_, sender_sk) = generate_keypair();
let recipient_kp = x25519_keypair(0x25);
let ephemeral_kp = x25519_keypair(0x35);
let metadata = metadata();
let envelope = lock_and_send_with_ephemeral(
b"Read this after I'm gone",
ContentType::AfterlifeMessage,
&sender_did,
&recipient_did,
&sender_sk,
&recipient_kp.public,
&ephemeral_kp,
metadata,
true,
72,
)
.expect("lock_and_send");
assert!(envelope.release_on_death);
assert_eq!(envelope.release_delay_hours, 72);
assert_eq!(envelope.content_type, ContentType::AfterlifeMessage);
}
#[test]
fn compose_metadata_rejects_nil_message_id() {
let result = ComposeMetadata::new(Uuid::nil(), Timestamp::new(7_000, 2));
assert!(
matches!(result, Err(MessagingError::InvalidEnvelope(reason)) if reason.contains("message id"))
);
}
#[test]
fn compose_metadata_rejects_zero_timestamp() {
let result = ComposeMetadata::new(
Uuid::parse_str("018f7a96-8ad0-7c4f-8e0f-222222222222").unwrap(),
Timestamp::ZERO,
);
assert!(
matches!(result, Err(MessagingError::InvalidEnvelope(reason)) if reason.contains("timestamp"))
);
}
#[test]
fn prepare_envelope_rejects_directly_constructed_invalid_metadata() {
let sender_did = Did::new("did:exo:alice").unwrap();
let recipient_did = Did::new("did:exo:bob").unwrap();
let recipient_kp = x25519_keypair(0x28);
let ephemeral_kp = x25519_keypair(0x38);
let invalid_metadata = ComposeMetadata {
id: Uuid::nil(),
created: Timestamp::ZERO,
};
let result = prepare_envelope_for_signing_with_ephemeral(
b"constructor bypass",
ContentType::Secret,
&sender_did,
&recipient_did,
&recipient_kp.public,
&ephemeral_kp,
invalid_metadata,
false,
0,
);
assert!(
matches!(result, Err(MessagingError::InvalidEnvelope(reason)) if reason.contains("message id"))
);
}
#[test]
fn compose_metadata_fields_are_not_public_constructor_bypass() {
let source = include_str!("compose.rs");
let metadata_section = source
.split("pub struct ComposeMetadata")
.nth(1)
.and_then(|section| section.split("impl ComposeMetadata").next())
.expect("metadata struct section");
assert!(!metadata_section.contains("pub id:"));
assert!(!metadata_section.contains("pub created:"));
}
#[test]
fn compose_path_does_not_fabricate_envelope_metadata() {
let source = include_str!("compose.rs");
let production = source
.split("// ===========================================================================")
.next()
.expect("production section");
assert!(
!production.contains("Uuid::new_v4"),
"compose production path must not fabricate message IDs"
);
let forbidden_clock = ["HybridClock", "::new()"].concat();
assert!(
!production.contains(&forbidden_clock),
"compose production path must not fabricate HLC timestamps"
);
}
#[test]
fn compose_path_supplies_explicit_vault_nonce() {
let source = include_str!("compose.rs");
let production = source
.split("// ===========================================================================")
.next()
.expect("production section");
assert!(
production.contains("encrypt_with_nonce"),
"compose must pass an explicit deterministic nonce into vault encryption"
);
assert!(
!production.contains(".encrypt("),
"compose must not call the implicit vault encryption entrypoint"
);
}
#[test]
fn encrypted_envelope_nonce_is_not_public_plaintext_hash_oracle() {
let sender_did = Did::new("did:exo:alice").unwrap();
let recipient_did = Did::new("did:exo:bob").unwrap();
let recipient_kp = x25519_keypair(0x27);
let ephemeral_kp = x25519_keypair(0x37);
let metadata = metadata();
let plaintext = b"known plaintext candidate";
let content_type = ContentType::Secret;
let release_on_death = true;
let release_delay_hours = 24;
let envelope = prepare_envelope_for_signing_with_ephemeral(
plaintext,
content_type,
&sender_did,
&recipient_did,
&recipient_kp.public,
&ephemeral_kp,
metadata,
release_on_death,
release_delay_hours,
)
.expect("prepare envelope");
let legacy_nonce = legacy_public_plaintext_hash_nonce(
&metadata,
content_type,
&sender_did,
&recipient_did,
ephemeral_kp.public.as_bytes(),
plaintext,
release_on_death,
release_delay_hours,
);
assert_ne!(
&envelope.ciphertext[..VAULT_NONCE_SIZE],
&legacy_nonce[..],
"visible ciphertext nonce must not be derived from public metadata plus guessed plaintext"
);
}
#[test]
fn compose_path_does_not_feed_plaintext_hash_into_visible_nonce() {
let source = include_str!("compose.rs");
let production = source
.split("// ===========================================================================")
.next()
.expect("production section");
for pattern in [
"Hash256::digest(plaintext)",
"plaintext_nonce_input",
"transcript.extend_from_slice(plaintext",
] {
assert!(
!production.contains(pattern),
"compose production path must not expose plaintext-derived material through the visible vault nonce via {pattern}"
);
}
}
#[test]
fn prepare_envelope_for_signing_requires_caller_supplied_ephemeral_key() {
let sender_did = Did::new("did:exo:alice").unwrap();
let recipient_did = Did::new("did:exo:bob").unwrap();
let recipient_kp = x25519_keypair(0x26);
let result = prepare_envelope_for_signing(
b"external signer",
ContentType::Secret,
&sender_did,
&recipient_did,
&recipient_kp.public,
metadata(),
false,
0,
);
assert!(
matches!(result, Err(MessagingError::KeyExchangeFailed(reason)) if reason.contains("caller-supplied ephemeral")),
"message composition must fail closed unless the caller supplies the ephemeral X25519 keypair"
);
}
#[test]
fn compose_path_requires_caller_supplied_ephemeral_key() {
let source = include_str!("compose.rs");
let production = source
.split("// ===========================================================================")
.next()
.expect("production section");
assert!(
production.contains("prepare_envelope_for_signing_with_ephemeral"),
"compose must expose an explicit ephemeral-key entrypoint"
);
for pattern in ["generate_ephemeral", "X25519KeyPair::generate"] {
assert!(
!production.contains(pattern),
"compose production path must not fabricate X25519 ephemeral key material via {pattern}"
);
}
}
}