1use chacha20poly1305::aead::{Aead, KeyInit, Payload};
62use chacha20poly1305::{ChaCha20Poly1305, XChaCha20Poly1305, XNonce};
63use ed25519_dalek::Signer;
64use hkdf::Hkdf;
65use rand::RngCore;
66use rand::rngs::OsRng;
67use sha2::Sha256;
68use zeroize::Zeroizing;
69
70pub use x25519_dalek::{
71 PublicKey as EncryptionPublicKey, StaticSecret as EncryptionSecretKey,
72};
73
74use crate::crypto::{Signature, SigningKey, VerifyingKey};
75use crate::ids::{AgentId, MessageId};
76
77pub const ENVELOPE_VERSION: u8 = 1;
79
80const ENC_KEY_CONTEXT: &[u8] = b"agora/enc-key/v1";
82const MSG_CONTEXT: &[u8] = b"agora/msg/v1";
84const WRAP_INFO: &[u8] = b"agora/wrap/v1";
86
87const XNONCE_LEN: usize = 24;
88const PUB_LEN: usize = 32;
89const TAG_LEN: usize = 16;
90const SIG_LEN: usize = 64;
91pub const WRAPPED_KEY_LEN: usize = 1 + PUB_LEN + 32 + TAG_LEN;
94pub const MIN_CIPHERTEXT_LEN: usize = 1 + XNONCE_LEN + SIG_LEN + TAG_LEN;
98
99#[derive(Debug, thiserror::Error)]
101pub enum EnvelopeError {
102 #[error("unsupported envelope version {0}")]
104 Version(u8),
105 #[error("envelope blob too short: {0} bytes")]
107 Truncated(usize),
108 #[error("decryption failed")]
111 Decrypt,
112 #[error("non-contributory X25519 public key")]
115 NonContributory,
116 #[error("message signature verification failed")]
119 BadSignature,
120 #[error("invalid hex: {0}")]
122 Hex(#[from] hex::FromHexError),
123 #[error("message key must be 32 bytes, got {0}")]
125 KeyLength(usize),
126}
127
128pub struct MessageKey(Zeroizing<[u8; 32]>);
131
132impl MessageKey {
133 pub fn generate() -> Self {
135 let mut k = Zeroizing::new([0u8; 32]);
136 OsRng.fill_bytes(k.as_mut());
137 Self(k)
138 }
139
140 pub fn to_hex(&self) -> String {
142 hex::encode(self.0.as_ref())
143 }
144
145 pub fn from_hex(hex_str: &str) -> Result<Self, EnvelopeError> {
147 let bytes = hex::decode(hex_str.trim())?;
148 if bytes.len() != 32 {
149 return Err(EnvelopeError::KeyLength(bytes.len()));
150 }
151 let mut k = Zeroizing::new([0u8; 32]);
152 k.copy_from_slice(&bytes);
153 Ok(Self(k))
154 }
155}
156
157impl std::fmt::Debug for MessageKey {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 f.write_str("MessageKey([REDACTED])")
160 }
161}
162
163impl std::fmt::Display for MessageKey {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 f.write_str("[REDACTED]")
166 }
167}
168
169#[derive(Debug, Clone, Copy)]
174pub struct MessageContext {
175 pub message_id: MessageId,
176 pub sender_id: AgentId,
177 pub recipient_id: AgentId,
178 pub timestamp: i64,
181}
182
183impl MessageContext {
184 fn signing_bytes(&self, plaintext: &[u8]) -> Vec<u8> {
187 let mut out = Vec::with_capacity(
188 MSG_CONTEXT.len() + 16 * 3 + 8 + plaintext.len(),
189 );
190 out.extend_from_slice(MSG_CONTEXT);
191 out.extend_from_slice(self.message_id.as_uuid().as_bytes());
192 out.extend_from_slice(self.sender_id.as_uuid().as_bytes());
193 out.extend_from_slice(self.recipient_id.as_uuid().as_bytes());
194 out.extend_from_slice(&self.timestamp.to_le_bytes());
195 out.extend_from_slice(plaintext);
196 out
197 }
198
199 fn aad(&self) -> [u8; 48] {
202 let mut aad = [0u8; 48];
203 aad[..16].copy_from_slice(self.message_id.as_uuid().as_bytes());
204 aad[16..32].copy_from_slice(self.sender_id.as_uuid().as_bytes());
205 aad[32..].copy_from_slice(self.recipient_id.as_uuid().as_bytes());
206 aad
207 }
208}
209
210pub struct SealedMessage {
212 pub ciphertext: Vec<u8>,
214 pub wrapped_key_recipient: Vec<u8>,
216 pub wrapped_key_sender: Vec<u8>,
218}
219
220pub fn generate_encryption_keypair()
222-> (EncryptionSecretKey, EncryptionPublicKey) {
223 let secret = EncryptionSecretKey::random_from_rng(OsRng);
224 let public = EncryptionPublicKey::from(&secret);
225 (secret, public)
226}
227
228pub fn encryption_secret_to_hex(secret: &EncryptionSecretKey) -> String {
231 hex::encode(secret.to_bytes())
232}
233
234pub fn encryption_secret_from_hex(
236 hex_str: &str,
237) -> Result<EncryptionSecretKey, EnvelopeError> {
238 let bytes = Zeroizing::new(hex::decode(hex_str.trim())?);
239 if bytes.len() != 32 {
240 return Err(EnvelopeError::KeyLength(bytes.len()));
241 }
242 let mut key = Zeroizing::new([0u8; 32]);
243 key.copy_from_slice(&bytes);
244 Ok(EncryptionSecretKey::from(*key))
245}
246
247pub fn encryption_public_from_hex(
249 hex_str: &str,
250) -> Result<EncryptionPublicKey, EnvelopeError> {
251 let bytes = hex::decode(hex_str.trim())?;
252 let arr: [u8; 32] = bytes
253 .as_slice()
254 .try_into()
255 .map_err(|_| EnvelopeError::KeyLength(bytes.len()))?;
256 Ok(EncryptionPublicKey::from(arr))
257}
258
259pub fn sign_encryption_key(
263 signing_key: &SigningKey,
264 encryption_public: &EncryptionPublicKey,
265) -> Signature {
266 let mut msg = Vec::with_capacity(ENC_KEY_CONTEXT.len() + PUB_LEN);
267 msg.extend_from_slice(ENC_KEY_CONTEXT);
268 msg.extend_from_slice(encryption_public.as_bytes());
269 signing_key.sign(&msg)
270}
271
272pub fn verify_encryption_key(
274 verifying_key: &VerifyingKey,
275 encryption_public: &EncryptionPublicKey,
276 signature: &Signature,
277) -> bool {
278 let mut msg = Vec::with_capacity(ENC_KEY_CONTEXT.len() + PUB_LEN);
279 msg.extend_from_slice(ENC_KEY_CONTEXT);
280 msg.extend_from_slice(encryption_public.as_bytes());
281 verifying_key.verify_strict(&msg, signature).is_ok()
282}
283
284pub fn seal(
288 ctx: &MessageContext,
289 plaintext: &[u8],
290 sender_signing_key: &SigningKey,
291 sender_pub: &EncryptionPublicKey,
292 recipient_pub: &EncryptionPublicKey,
293) -> Result<SealedMessage, EnvelopeError> {
294 let key = MessageKey::generate();
295 let mut xnonce = [0u8; XNONCE_LEN];
296 OsRng.fill_bytes(&mut xnonce);
297
298 let signature = sender_signing_key.sign(&ctx.signing_bytes(plaintext));
301 let mut blob = Vec::with_capacity(plaintext.len() + SIG_LEN);
302 blob.extend_from_slice(plaintext);
303 blob.extend_from_slice(&signature.to_bytes());
304
305 let cipher = XChaCha20Poly1305::new(key.0.as_ref().into());
306 let ct = cipher
307 .encrypt(
308 XNonce::from_slice(&xnonce),
309 Payload {
310 msg: &blob,
311 aad: &ctx.aad(),
312 },
313 )
314 .expect(
315 "XChaCha20-Poly1305 encryption is infallible for in-memory buffers",
316 );
317
318 let mut ciphertext = Vec::with_capacity(1 + XNONCE_LEN + ct.len());
319 ciphertext.push(ENVELOPE_VERSION);
320 ciphertext.extend_from_slice(&xnonce);
321 ciphertext.extend_from_slice(&ct);
322
323 Ok(SealedMessage {
324 ciphertext,
325 wrapped_key_recipient: wrap_key(&key, recipient_pub)?,
326 wrapped_key_sender: wrap_key(&key, sender_pub)?,
327 })
328}
329
330fn wrap_key(
332 key: &MessageKey,
333 to_pub: &EncryptionPublicKey,
334) -> Result<Vec<u8>, EnvelopeError> {
335 let ephemeral = EncryptionSecretKey::random_from_rng(OsRng);
336 let ephemeral_pub = EncryptionPublicKey::from(&ephemeral);
337 let kek =
338 derive_kek(ephemeral.diffie_hellman(to_pub), &ephemeral_pub, to_pub)?;
339
340 let cipher = ChaCha20Poly1305::new(kek.as_ref().into());
341 let ct = cipher
343 .encrypt(&Default::default(), key.0.as_ref() as &[u8])
344 .expect(
345 "ChaCha20-Poly1305 encryption is infallible for in-memory buffers",
346 );
347
348 let mut out = Vec::with_capacity(WRAPPED_KEY_LEN);
349 out.push(ENVELOPE_VERSION);
350 out.extend_from_slice(ephemeral_pub.as_bytes());
351 out.extend_from_slice(&ct);
352 Ok(out)
353}
354
355pub fn unwrap_key(
359 wrapped: &[u8],
360 own_secret: &EncryptionSecretKey,
361) -> Result<MessageKey, EnvelopeError> {
362 if wrapped.len() != WRAPPED_KEY_LEN {
363 return Err(EnvelopeError::Truncated(wrapped.len()));
364 }
365 if wrapped[0] != ENVELOPE_VERSION {
366 return Err(EnvelopeError::Version(wrapped[0]));
367 }
368 let ephemeral_pub = EncryptionPublicKey::from(
369 <[u8; 32]>::try_from(&wrapped[1..1 + PUB_LEN]).expect("length checked"),
370 );
371 let own_pub = EncryptionPublicKey::from(own_secret);
372 let kek = derive_kek(
373 own_secret.diffie_hellman(&ephemeral_pub),
374 &ephemeral_pub,
375 &own_pub,
376 )?;
377
378 let cipher = ChaCha20Poly1305::new(kek.as_ref().into());
379 let k = cipher
380 .decrypt(&Default::default(), &wrapped[1 + PUB_LEN..])
381 .map_err(|_| EnvelopeError::Decrypt)?;
382 let mut key = Zeroizing::new([0u8; 32]);
383 key.copy_from_slice(&k);
384 Ok(MessageKey(key))
385}
386
387fn derive_kek(
390 shared: x25519_dalek::SharedSecret,
391 ephemeral_pub: &EncryptionPublicKey,
392 to_pub: &EncryptionPublicKey,
393) -> Result<Zeroizing<[u8; 32]>, EnvelopeError> {
394 if !shared.was_contributory() {
395 return Err(EnvelopeError::NonContributory);
396 }
397 let mut salt = [0u8; 64];
398 salt[..32].copy_from_slice(ephemeral_pub.as_bytes());
399 salt[32..].copy_from_slice(to_pub.as_bytes());
400 let hk = Hkdf::<Sha256>::new(Some(&salt), shared.as_bytes());
401 let mut kek = Zeroizing::new([0u8; 32]);
402 hk.expand(WRAP_INFO, kek.as_mut())
403 .expect("32-byte HKDF output is always valid");
404 Ok(kek)
405}
406
407pub fn open(
415 ciphertext: &[u8],
416 key: &MessageKey,
417 ctx: &MessageContext,
418 sender_verifying_key: &VerifyingKey,
419) -> Result<Vec<u8>, EnvelopeError> {
420 if ciphertext.len() < 1 + XNONCE_LEN + TAG_LEN + SIG_LEN {
421 return Err(EnvelopeError::Truncated(ciphertext.len()));
422 }
423 if ciphertext[0] != ENVELOPE_VERSION {
424 return Err(EnvelopeError::Version(ciphertext[0]));
425 }
426 let (xnonce, ct) = ciphertext[1..].split_at(XNONCE_LEN);
427
428 let cipher = XChaCha20Poly1305::new(key.0.as_ref().into());
429 let blob = cipher
430 .decrypt(
431 XNonce::from_slice(xnonce),
432 Payload {
433 msg: ct,
434 aad: &ctx.aad(),
435 },
436 )
437 .map_err(|_| EnvelopeError::Decrypt)?;
438
439 if blob.len() < SIG_LEN {
440 return Err(EnvelopeError::Truncated(blob.len()));
441 }
442 let (plaintext, sig_bytes) = blob.split_at(blob.len() - SIG_LEN);
443 let signature =
444 Signature::from_bytes(sig_bytes.try_into().expect("length checked"));
445 sender_verifying_key
448 .verify_strict(&ctx.signing_bytes(plaintext), &signature)
449 .map_err(|_| EnvelopeError::BadSignature)?;
450 Ok(plaintext.to_vec())
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use uuid::Uuid;
457
458 fn fixed_ctx() -> MessageContext {
459 MessageContext {
460 message_id: MessageId::from(Uuid::from_u128(0x1111)),
461 sender_id: AgentId::from(Uuid::from_u128(0x2222)),
462 recipient_id: AgentId::from(Uuid::from_u128(0x3333)),
463 timestamp: 1_753_920_000,
464 }
465 }
466
467 struct Party {
468 signing: SigningKey,
469 enc_secret: EncryptionSecretKey,
470 enc_pub: EncryptionPublicKey,
471 }
472
473 fn party() -> Party {
474 let (signing, _) = crate::crypto::generate_keypair();
475 let (enc_secret, enc_pub) = generate_encryption_keypair();
476 Party {
477 signing,
478 enc_secret,
479 enc_pub,
480 }
481 }
482
483 #[test]
484 fn round_trip_recipient() {
485 let sender = party();
486 let recipient = party();
487 let ctx = fixed_ctx();
488
489 let sealed = seal(
490 &ctx,
491 b"hello, encrypted agora",
492 &sender.signing,
493 &sender.enc_pub,
494 &recipient.enc_pub,
495 )
496 .unwrap();
497
498 let k =
499 unwrap_key(&sealed.wrapped_key_recipient, &recipient.enc_secret)
500 .unwrap();
501 let plaintext = open(
502 &sealed.ciphertext,
503 &k,
504 &ctx,
505 &sender.signing.verifying_key(),
506 )
507 .unwrap();
508 assert_eq!(plaintext, b"hello, encrypted agora");
509 }
510
511 #[test]
512 fn round_trip_sender_outbox() {
513 let sender = party();
514 let recipient = party();
515 let ctx = fixed_ctx();
516
517 let sealed = seal(
518 &ctx,
519 b"my own outbox copy",
520 &sender.signing,
521 &sender.enc_pub,
522 &recipient.enc_pub,
523 )
524 .unwrap();
525
526 let k =
527 unwrap_key(&sealed.wrapped_key_sender, &sender.enc_secret).unwrap();
528 let plaintext = open(
529 &sealed.ciphertext,
530 &k,
531 &ctx,
532 &sender.signing.verifying_key(),
533 )
534 .unwrap();
535 assert_eq!(plaintext, b"my own outbox copy");
536 }
537
538 #[test]
539 fn reveal_by_key_verifies_at_server() {
540 let sender = party();
543 let recipient = party();
544 let ctx = fixed_ctx();
545
546 let sealed = seal(
547 &ctx,
548 b"abusive content",
549 &sender.signing,
550 &sender.enc_pub,
551 &recipient.enc_pub,
552 )
553 .unwrap();
554
555 let k =
556 unwrap_key(&sealed.wrapped_key_recipient, &recipient.enc_secret)
557 .unwrap();
558 let revealed = MessageKey::from_hex(&k.to_hex()).unwrap();
559 let plaintext = open(
560 &sealed.ciphertext,
561 &revealed,
562 &ctx,
563 &sender.signing.verifying_key(),
564 )
565 .unwrap();
566 assert_eq!(plaintext, b"abusive content");
567 }
568
569 #[test]
570 fn wrong_key_reveal_is_rejected() {
571 let sender = party();
572 let recipient = party();
573 let sealed = seal(
574 &fixed_ctx(),
575 b"content",
576 &sender.signing,
577 &sender.enc_pub,
578 &recipient.enc_pub,
579 )
580 .unwrap();
581
582 let wrong = MessageKey::generate();
583 assert!(matches!(
584 open(
585 &sealed.ciphertext,
586 &wrong,
587 &fixed_ctx(),
588 &sender.signing.verifying_key()
589 ),
590 Err(EnvelopeError::Decrypt)
591 ));
592 }
593
594 #[test]
595 fn surreptitious_forwarding_is_rejected() {
596 let a = party();
601 let b = party();
602 let c = party();
603
604 let original_ctx = fixed_ctx();
605 let sealed = seal(
606 &original_ctx,
607 b"for B's eyes",
608 &a.signing,
609 &a.enc_pub,
610 &b.enc_pub,
611 )
612 .unwrap();
613 let k =
614 unwrap_key(&sealed.wrapped_key_recipient, &b.enc_secret).unwrap();
615
616 let forged_ctx = MessageContext {
618 recipient_id: AgentId::from(Uuid::from_u128(0x4444)),
619 ..original_ctx
620 };
621 let plaintext = open(
625 &sealed.ciphertext,
626 &k,
627 &original_ctx,
628 &a.signing.verifying_key(),
629 )
630 .unwrap();
631 let resealed = seal(
632 &forged_ctx,
633 &plaintext,
634 &b.signing, &b.enc_pub,
636 &c.enc_pub,
637 )
638 .unwrap();
639 let k2 =
640 unwrap_key(&resealed.wrapped_key_recipient, &c.enc_secret).unwrap();
641 assert!(matches!(
643 open(
644 &resealed.ciphertext,
645 &k2,
646 &forged_ctx,
647 &a.signing.verifying_key()
648 ),
649 Err(EnvelopeError::BadSignature)
650 ));
651 }
652
653 #[test]
654 fn tampered_context_fields_are_rejected() {
655 let sender = party();
656 let recipient = party();
657 let ctx = fixed_ctx();
658 let sealed = seal(
659 &ctx,
660 b"content",
661 &sender.signing,
662 &sender.enc_pub,
663 &recipient.enc_pub,
664 )
665 .unwrap();
666 let k =
667 unwrap_key(&sealed.wrapped_key_recipient, &recipient.enc_secret)
668 .unwrap();
669
670 let forged = MessageContext {
673 sender_id: AgentId::from(Uuid::from_u128(0x9999)),
674 ..ctx
675 };
676 assert!(matches!(
677 open(
678 &sealed.ciphertext,
679 &k,
680 &forged,
681 &sender.signing.verifying_key()
682 ),
683 Err(EnvelopeError::Decrypt)
684 ));
685
686 let forged_ts = MessageContext {
688 timestamp: ctx.timestamp + 1,
689 ..ctx
690 };
691 assert!(matches!(
692 open(
693 &sealed.ciphertext,
694 &k,
695 &forged_ts,
696 &sender.signing.verifying_key()
697 ),
698 Err(EnvelopeError::BadSignature)
699 ));
700 }
701
702 #[test]
703 fn encryption_key_binding_round_trip() {
704 let (signing, verifying) = crate::crypto::generate_keypair();
705 let (_, enc_pub) = generate_encryption_keypair();
706 let sig = sign_encryption_key(&signing, &enc_pub);
707 assert!(verify_encryption_key(&verifying, &enc_pub, &sig));
708
709 let (_, other_pub) = generate_encryption_keypair();
712 assert!(!verify_encryption_key(&verifying, &other_pub, &sig));
713
714 let (_, other_verifying) = crate::crypto::generate_keypair();
716 assert!(!verify_encryption_key(&other_verifying, &enc_pub, &sig));
717 }
718
719 #[test]
720 fn secret_key_hex_round_trip() {
721 let (secret, public) = generate_encryption_keypair();
722 let recovered =
723 encryption_secret_from_hex(&encryption_secret_to_hex(&secret))
724 .unwrap();
725 assert_eq!(
726 EncryptionPublicKey::from(&recovered).as_bytes(),
727 public.as_bytes()
728 );
729 }
730
731 #[test]
732 fn small_order_recipient_key_is_rejected() {
733 let sender = party();
737 let identity = EncryptionPublicKey::from([0u8; 32]);
738 assert!(matches!(
739 seal(
740 &fixed_ctx(),
741 b"content",
742 &sender.signing,
743 &sender.enc_pub,
744 &identity,
745 ),
746 Err(EnvelopeError::NonContributory)
747 ));
748 }
749
750 #[test]
756 fn version1_wrap_test_vector() {
757 let own_secret = EncryptionSecretKey::from([
759 1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
760 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
761 ]);
762 let own_pub = EncryptionPublicKey::from(&own_secret);
763 let eph_secret = EncryptionSecretKey::from([
765 33u8, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48,
766 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64,
767 ]);
768 let eph_pub = EncryptionPublicKey::from(&eph_secret);
769
770 let kek =
771 derive_kek(eph_secret.diffie_hellman(&own_pub), &eph_pub, &own_pub)
772 .unwrap();
773 assert_eq!(
774 hex::encode(kek.as_ref()),
775 "6f8d2f628f8da34c43e61aa77f0c2295683ca4c604bd0fcefb854bf961099998",
776 "HKDF wrap derivation changed — version-1 format violation"
777 );
778
779 let k = MessageKey(Zeroizing::new([0xAB; 32]));
782 let cipher = ChaCha20Poly1305::new(kek.as_ref().into());
783 let ct = cipher
784 .encrypt(&Default::default(), k.0.as_ref() as &[u8])
785 .unwrap();
786 let mut wrapped = vec![ENVELOPE_VERSION];
787 wrapped.extend_from_slice(eph_pub.as_bytes());
788 wrapped.extend_from_slice(&ct);
789 assert_eq!(
790 hex::encode(&wrapped),
791 "015869aff450549732cbaaed5e5df9b30a6da31cb0e574\
792 2bad5ad4a1a768f1a67bd58d0a30ef9b0c6ec54e24c9c820d54bac9c7daa9a5a\
793 964bff0d660621ee29d472ab21f1417c46946714c61d5d13bd32",
794 "wrapped-key blob changed — version-1 format violation"
795 );
796 let unwrapped = unwrap_key(&wrapped, &own_secret).unwrap();
797 assert_eq!(unwrapped.0.as_ref(), &[0xAB; 32]);
798 }
799}