use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, XChaCha20Poly1305, XNonce};
use ed25519_dalek::Signer;
use hkdf::Hkdf;
use rand::RngCore;
use rand::rngs::OsRng;
use sha2::Sha256;
use zeroize::Zeroizing;
pub use x25519_dalek::{
PublicKey as EncryptionPublicKey, StaticSecret as EncryptionSecretKey,
};
use crate::crypto::{Signature, SigningKey, VerifyingKey};
use crate::ids::{AgentId, MessageId};
pub const ENVELOPE_VERSION: u8 = 1;
const ENC_KEY_CONTEXT: &[u8] = b"agora/enc-key/v1";
const MSG_CONTEXT: &[u8] = b"agora/msg/v1";
const WRAP_INFO: &[u8] = b"agora/wrap/v1";
const XNONCE_LEN: usize = 24;
const PUB_LEN: usize = 32;
const TAG_LEN: usize = 16;
const SIG_LEN: usize = 64;
pub const WRAPPED_KEY_LEN: usize = 1 + PUB_LEN + 32 + TAG_LEN;
pub const MIN_CIPHERTEXT_LEN: usize = 1 + XNONCE_LEN + SIG_LEN + TAG_LEN;
#[derive(Debug, thiserror::Error)]
pub enum EnvelopeError {
#[error("unsupported envelope version {0}")]
Version(u8),
#[error("envelope blob too short: {0} bytes")]
Truncated(usize),
#[error("decryption failed")]
Decrypt,
#[error("non-contributory X25519 public key")]
NonContributory,
#[error("message signature verification failed")]
BadSignature,
#[error("invalid hex: {0}")]
Hex(#[from] hex::FromHexError),
#[error("message key must be 32 bytes, got {0}")]
KeyLength(usize),
}
pub struct MessageKey(Zeroizing<[u8; 32]>);
impl MessageKey {
pub fn generate() -> Self {
let mut k = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(k.as_mut());
Self(k)
}
pub fn to_hex(&self) -> String {
hex::encode(self.0.as_ref())
}
pub fn from_hex(hex_str: &str) -> Result<Self, EnvelopeError> {
let bytes = hex::decode(hex_str.trim())?;
if bytes.len() != 32 {
return Err(EnvelopeError::KeyLength(bytes.len()));
}
let mut k = Zeroizing::new([0u8; 32]);
k.copy_from_slice(&bytes);
Ok(Self(k))
}
}
impl std::fmt::Debug for MessageKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("MessageKey([REDACTED])")
}
}
impl std::fmt::Display for MessageKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
#[derive(Debug, Clone, Copy)]
pub struct MessageContext {
pub message_id: MessageId,
pub sender_id: AgentId,
pub recipient_id: AgentId,
pub timestamp: i64,
}
impl MessageContext {
fn signing_bytes(&self, plaintext: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(
MSG_CONTEXT.len() + 16 * 3 + 8 + plaintext.len(),
);
out.extend_from_slice(MSG_CONTEXT);
out.extend_from_slice(self.message_id.as_uuid().as_bytes());
out.extend_from_slice(self.sender_id.as_uuid().as_bytes());
out.extend_from_slice(self.recipient_id.as_uuid().as_bytes());
out.extend_from_slice(&self.timestamp.to_le_bytes());
out.extend_from_slice(plaintext);
out
}
fn aad(&self) -> [u8; 48] {
let mut aad = [0u8; 48];
aad[..16].copy_from_slice(self.message_id.as_uuid().as_bytes());
aad[16..32].copy_from_slice(self.sender_id.as_uuid().as_bytes());
aad[32..].copy_from_slice(self.recipient_id.as_uuid().as_bytes());
aad
}
}
pub struct SealedMessage {
pub ciphertext: Vec<u8>,
pub wrapped_key_recipient: Vec<u8>,
pub wrapped_key_sender: Vec<u8>,
}
pub fn generate_encryption_keypair()
-> (EncryptionSecretKey, EncryptionPublicKey) {
let secret = EncryptionSecretKey::random_from_rng(OsRng);
let public = EncryptionPublicKey::from(&secret);
(secret, public)
}
pub fn encryption_secret_to_hex(secret: &EncryptionSecretKey) -> String {
hex::encode(secret.to_bytes())
}
pub fn encryption_secret_from_hex(
hex_str: &str,
) -> Result<EncryptionSecretKey, EnvelopeError> {
let bytes = Zeroizing::new(hex::decode(hex_str.trim())?);
if bytes.len() != 32 {
return Err(EnvelopeError::KeyLength(bytes.len()));
}
let mut key = Zeroizing::new([0u8; 32]);
key.copy_from_slice(&bytes);
Ok(EncryptionSecretKey::from(*key))
}
pub fn encryption_public_from_hex(
hex_str: &str,
) -> Result<EncryptionPublicKey, EnvelopeError> {
let bytes = hex::decode(hex_str.trim())?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| EnvelopeError::KeyLength(bytes.len()))?;
Ok(EncryptionPublicKey::from(arr))
}
pub fn sign_encryption_key(
signing_key: &SigningKey,
encryption_public: &EncryptionPublicKey,
) -> Signature {
let mut msg = Vec::with_capacity(ENC_KEY_CONTEXT.len() + PUB_LEN);
msg.extend_from_slice(ENC_KEY_CONTEXT);
msg.extend_from_slice(encryption_public.as_bytes());
signing_key.sign(&msg)
}
pub fn verify_encryption_key(
verifying_key: &VerifyingKey,
encryption_public: &EncryptionPublicKey,
signature: &Signature,
) -> bool {
let mut msg = Vec::with_capacity(ENC_KEY_CONTEXT.len() + PUB_LEN);
msg.extend_from_slice(ENC_KEY_CONTEXT);
msg.extend_from_slice(encryption_public.as_bytes());
verifying_key.verify_strict(&msg, signature).is_ok()
}
pub fn seal(
ctx: &MessageContext,
plaintext: &[u8],
sender_signing_key: &SigningKey,
sender_pub: &EncryptionPublicKey,
recipient_pub: &EncryptionPublicKey,
) -> Result<SealedMessage, EnvelopeError> {
let key = MessageKey::generate();
let mut xnonce = [0u8; XNONCE_LEN];
OsRng.fill_bytes(&mut xnonce);
let signature = sender_signing_key.sign(&ctx.signing_bytes(plaintext));
let mut blob = Vec::with_capacity(plaintext.len() + SIG_LEN);
blob.extend_from_slice(plaintext);
blob.extend_from_slice(&signature.to_bytes());
let cipher = XChaCha20Poly1305::new(key.0.as_ref().into());
let ct = cipher
.encrypt(
XNonce::from_slice(&xnonce),
Payload {
msg: &blob,
aad: &ctx.aad(),
},
)
.expect(
"XChaCha20-Poly1305 encryption is infallible for in-memory buffers",
);
let mut ciphertext = Vec::with_capacity(1 + XNONCE_LEN + ct.len());
ciphertext.push(ENVELOPE_VERSION);
ciphertext.extend_from_slice(&xnonce);
ciphertext.extend_from_slice(&ct);
Ok(SealedMessage {
ciphertext,
wrapped_key_recipient: wrap_key(&key, recipient_pub)?,
wrapped_key_sender: wrap_key(&key, sender_pub)?,
})
}
fn wrap_key(
key: &MessageKey,
to_pub: &EncryptionPublicKey,
) -> Result<Vec<u8>, EnvelopeError> {
let ephemeral = EncryptionSecretKey::random_from_rng(OsRng);
let ephemeral_pub = EncryptionPublicKey::from(&ephemeral);
let kek =
derive_kek(ephemeral.diffie_hellman(to_pub), &ephemeral_pub, to_pub)?;
let cipher = ChaCha20Poly1305::new(kek.as_ref().into());
let ct = cipher
.encrypt(&Default::default(), key.0.as_ref() as &[u8])
.expect(
"ChaCha20-Poly1305 encryption is infallible for in-memory buffers",
);
let mut out = Vec::with_capacity(WRAPPED_KEY_LEN);
out.push(ENVELOPE_VERSION);
out.extend_from_slice(ephemeral_pub.as_bytes());
out.extend_from_slice(&ct);
Ok(out)
}
pub fn unwrap_key(
wrapped: &[u8],
own_secret: &EncryptionSecretKey,
) -> Result<MessageKey, EnvelopeError> {
if wrapped.len() != WRAPPED_KEY_LEN {
return Err(EnvelopeError::Truncated(wrapped.len()));
}
if wrapped[0] != ENVELOPE_VERSION {
return Err(EnvelopeError::Version(wrapped[0]));
}
let ephemeral_pub = EncryptionPublicKey::from(
<[u8; 32]>::try_from(&wrapped[1..1 + PUB_LEN]).expect("length checked"),
);
let own_pub = EncryptionPublicKey::from(own_secret);
let kek = derive_kek(
own_secret.diffie_hellman(&ephemeral_pub),
&ephemeral_pub,
&own_pub,
)?;
let cipher = ChaCha20Poly1305::new(kek.as_ref().into());
let k = cipher
.decrypt(&Default::default(), &wrapped[1 + PUB_LEN..])
.map_err(|_| EnvelopeError::Decrypt)?;
let mut key = Zeroizing::new([0u8; 32]);
key.copy_from_slice(&k);
Ok(MessageKey(key))
}
fn derive_kek(
shared: x25519_dalek::SharedSecret,
ephemeral_pub: &EncryptionPublicKey,
to_pub: &EncryptionPublicKey,
) -> Result<Zeroizing<[u8; 32]>, EnvelopeError> {
if !shared.was_contributory() {
return Err(EnvelopeError::NonContributory);
}
let mut salt = [0u8; 64];
salt[..32].copy_from_slice(ephemeral_pub.as_bytes());
salt[32..].copy_from_slice(to_pub.as_bytes());
let hk = Hkdf::<Sha256>::new(Some(&salt), shared.as_bytes());
let mut kek = Zeroizing::new([0u8; 32]);
hk.expand(WRAP_INFO, kek.as_mut())
.expect("32-byte HKDF output is always valid");
Ok(kek)
}
pub fn open(
ciphertext: &[u8],
key: &MessageKey,
ctx: &MessageContext,
sender_verifying_key: &VerifyingKey,
) -> Result<Vec<u8>, EnvelopeError> {
if ciphertext.len() < 1 + XNONCE_LEN + TAG_LEN + SIG_LEN {
return Err(EnvelopeError::Truncated(ciphertext.len()));
}
if ciphertext[0] != ENVELOPE_VERSION {
return Err(EnvelopeError::Version(ciphertext[0]));
}
let (xnonce, ct) = ciphertext[1..].split_at(XNONCE_LEN);
let cipher = XChaCha20Poly1305::new(key.0.as_ref().into());
let blob = cipher
.decrypt(
XNonce::from_slice(xnonce),
Payload {
msg: ct,
aad: &ctx.aad(),
},
)
.map_err(|_| EnvelopeError::Decrypt)?;
if blob.len() < SIG_LEN {
return Err(EnvelopeError::Truncated(blob.len()));
}
let (plaintext, sig_bytes) = blob.split_at(blob.len() - SIG_LEN);
let signature =
Signature::from_bytes(sig_bytes.try_into().expect("length checked"));
sender_verifying_key
.verify_strict(&ctx.signing_bytes(plaintext), &signature)
.map_err(|_| EnvelopeError::BadSignature)?;
Ok(plaintext.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn fixed_ctx() -> MessageContext {
MessageContext {
message_id: MessageId::from(Uuid::from_u128(0x1111)),
sender_id: AgentId::from(Uuid::from_u128(0x2222)),
recipient_id: AgentId::from(Uuid::from_u128(0x3333)),
timestamp: 1_753_920_000,
}
}
struct Party {
signing: SigningKey,
enc_secret: EncryptionSecretKey,
enc_pub: EncryptionPublicKey,
}
fn party() -> Party {
let (signing, _) = crate::crypto::generate_keypair();
let (enc_secret, enc_pub) = generate_encryption_keypair();
Party {
signing,
enc_secret,
enc_pub,
}
}
#[test]
fn round_trip_recipient() {
let sender = party();
let recipient = party();
let ctx = fixed_ctx();
let sealed = seal(
&ctx,
b"hello, encrypted agora",
&sender.signing,
&sender.enc_pub,
&recipient.enc_pub,
)
.unwrap();
let k =
unwrap_key(&sealed.wrapped_key_recipient, &recipient.enc_secret)
.unwrap();
let plaintext = open(
&sealed.ciphertext,
&k,
&ctx,
&sender.signing.verifying_key(),
)
.unwrap();
assert_eq!(plaintext, b"hello, encrypted agora");
}
#[test]
fn round_trip_sender_outbox() {
let sender = party();
let recipient = party();
let ctx = fixed_ctx();
let sealed = seal(
&ctx,
b"my own outbox copy",
&sender.signing,
&sender.enc_pub,
&recipient.enc_pub,
)
.unwrap();
let k =
unwrap_key(&sealed.wrapped_key_sender, &sender.enc_secret).unwrap();
let plaintext = open(
&sealed.ciphertext,
&k,
&ctx,
&sender.signing.verifying_key(),
)
.unwrap();
assert_eq!(plaintext, b"my own outbox copy");
}
#[test]
fn reveal_by_key_verifies_at_server() {
let sender = party();
let recipient = party();
let ctx = fixed_ctx();
let sealed = seal(
&ctx,
b"abusive content",
&sender.signing,
&sender.enc_pub,
&recipient.enc_pub,
)
.unwrap();
let k =
unwrap_key(&sealed.wrapped_key_recipient, &recipient.enc_secret)
.unwrap();
let revealed = MessageKey::from_hex(&k.to_hex()).unwrap();
let plaintext = open(
&sealed.ciphertext,
&revealed,
&ctx,
&sender.signing.verifying_key(),
)
.unwrap();
assert_eq!(plaintext, b"abusive content");
}
#[test]
fn wrong_key_reveal_is_rejected() {
let sender = party();
let recipient = party();
let sealed = seal(
&fixed_ctx(),
b"content",
&sender.signing,
&sender.enc_pub,
&recipient.enc_pub,
)
.unwrap();
let wrong = MessageKey::generate();
assert!(matches!(
open(
&sealed.ciphertext,
&wrong,
&fixed_ctx(),
&sender.signing.verifying_key()
),
Err(EnvelopeError::Decrypt)
));
}
#[test]
fn surreptitious_forwarding_is_rejected() {
let a = party();
let b = party();
let c = party();
let original_ctx = fixed_ctx();
let sealed = seal(
&original_ctx,
b"for B's eyes",
&a.signing,
&a.enc_pub,
&b.enc_pub,
)
.unwrap();
let k =
unwrap_key(&sealed.wrapped_key_recipient, &b.enc_secret).unwrap();
let forged_ctx = MessageContext {
recipient_id: AgentId::from(Uuid::from_u128(0x4444)),
..original_ctx
};
let plaintext = open(
&sealed.ciphertext,
&k,
&original_ctx,
&a.signing.verifying_key(),
)
.unwrap();
let resealed = seal(
&forged_ctx,
&plaintext,
&b.signing, &b.enc_pub,
&c.enc_pub,
)
.unwrap();
let k2 =
unwrap_key(&resealed.wrapped_key_recipient, &c.enc_secret).unwrap();
assert!(matches!(
open(
&resealed.ciphertext,
&k2,
&forged_ctx,
&a.signing.verifying_key()
),
Err(EnvelopeError::BadSignature)
));
}
#[test]
fn tampered_context_fields_are_rejected() {
let sender = party();
let recipient = party();
let ctx = fixed_ctx();
let sealed = seal(
&ctx,
b"content",
&sender.signing,
&sender.enc_pub,
&recipient.enc_pub,
)
.unwrap();
let k =
unwrap_key(&sealed.wrapped_key_recipient, &recipient.enc_secret)
.unwrap();
let forged = MessageContext {
sender_id: AgentId::from(Uuid::from_u128(0x9999)),
..ctx
};
assert!(matches!(
open(
&sealed.ciphertext,
&k,
&forged,
&sender.signing.verifying_key()
),
Err(EnvelopeError::Decrypt)
));
let forged_ts = MessageContext {
timestamp: ctx.timestamp + 1,
..ctx
};
assert!(matches!(
open(
&sealed.ciphertext,
&k,
&forged_ts,
&sender.signing.verifying_key()
),
Err(EnvelopeError::BadSignature)
));
}
#[test]
fn encryption_key_binding_round_trip() {
let (signing, verifying) = crate::crypto::generate_keypair();
let (_, enc_pub) = generate_encryption_keypair();
let sig = sign_encryption_key(&signing, &enc_pub);
assert!(verify_encryption_key(&verifying, &enc_pub, &sig));
let (_, other_pub) = generate_encryption_keypair();
assert!(!verify_encryption_key(&verifying, &other_pub, &sig));
let (_, other_verifying) = crate::crypto::generate_keypair();
assert!(!verify_encryption_key(&other_verifying, &enc_pub, &sig));
}
#[test]
fn secret_key_hex_round_trip() {
let (secret, public) = generate_encryption_keypair();
let recovered =
encryption_secret_from_hex(&encryption_secret_to_hex(&secret))
.unwrap();
assert_eq!(
EncryptionPublicKey::from(&recovered).as_bytes(),
public.as_bytes()
);
}
#[test]
fn small_order_recipient_key_is_rejected() {
let sender = party();
let identity = EncryptionPublicKey::from([0u8; 32]);
assert!(matches!(
seal(
&fixed_ctx(),
b"content",
&sender.signing,
&sender.enc_pub,
&identity,
),
Err(EnvelopeError::NonContributory)
));
}
#[test]
fn version1_wrap_test_vector() {
let own_secret = EncryptionSecretKey::from([
1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
]);
let own_pub = EncryptionPublicKey::from(&own_secret);
let eph_secret = EncryptionSecretKey::from([
33u8, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48,
49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64,
]);
let eph_pub = EncryptionPublicKey::from(&eph_secret);
let kek =
derive_kek(eph_secret.diffie_hellman(&own_pub), &eph_pub, &own_pub)
.unwrap();
assert_eq!(
hex::encode(kek.as_ref()),
"6f8d2f628f8da34c43e61aa77f0c2295683ca4c604bd0fcefb854bf961099998",
"HKDF wrap derivation changed — version-1 format violation"
);
let k = MessageKey(Zeroizing::new([0xAB; 32]));
let cipher = ChaCha20Poly1305::new(kek.as_ref().into());
let ct = cipher
.encrypt(&Default::default(), k.0.as_ref() as &[u8])
.unwrap();
let mut wrapped = vec![ENVELOPE_VERSION];
wrapped.extend_from_slice(eph_pub.as_bytes());
wrapped.extend_from_slice(&ct);
assert_eq!(
hex::encode(&wrapped),
"015869aff450549732cbaaed5e5df9b30a6da31cb0e574\
2bad5ad4a1a768f1a67bd58d0a30ef9b0c6ec54e24c9c820d54bac9c7daa9a5a\
964bff0d660621ee29d472ab21f1417c46946714c61d5d13bd32",
"wrapped-key blob changed — version-1 format violation"
);
let unwrapped = unwrap_key(&wrapped, &own_secret).unwrap();
assert_eq!(unwrapped.0.as_ref(), &[0xAB; 32]);
}
}