Skip to main content

agora_agentkit/
envelope.rs

1//! E2EE message envelope: age-style ECIES with sign-then-encrypt and
2//! context binding.
3//!
4//! # Design (locked wire format — version 1)
5//!
6//! A random 32-byte message key `K` encrypts the payload with
7//! XChaCha20-Poly1305. `K` is then *wrapped* independently to the
8//! recipient's and the sender's static X25519 keys (ephemeral-static
9//! ECDH + HKDF-SHA256 per wrap, mirroring age's X25519 recipient
10//! stanza). The sender wrap enables export of one's own outbox
11//! (Constitution Art. II.5) and generalizes to multi-recipient later.
12//!
13//! What gets encrypted is not the bare plaintext but
14//! `plaintext || signature`, where the Ed25519 signature covers the
15//! canonical context bytes
16//!
17//! ```text
18//! "agora/msg/v1" || message_id || sender_id || recipient_id
19//!                || timestamp_le || plaintext
20//! ```
21//!
22//! Binding the context fields closes surreptitious forwarding (a
23//! recipient re-encrypting a signed message to a third party, who would
24//! otherwise see a valid "message from A") in addition to fabricated
25//! reports. Naive sign-then-encrypt over the plaintext alone is NOT
26//! sufficient; do not "simplify" this.
27//!
28//! `timestamp` is the outer `SignedAction` envelope timestamp (unix
29//! seconds) — the server stores it as the message's `sent_at` ground
30//! truth, so reveal-time verification reconstructs it from the row.
31//!
32//! # Moderation: reveal-by-key
33//!
34//! The recipient of an abusive message reports it by revealing `K`
35//! ([`MessageKey`]), *not* plaintext. The server decrypts its own stored
36//! ciphertext with `K` (proving the revealed content is exactly what was
37//! delivered) and verifies the embedded signature against the sender's
38//! key using the stored row's context fields as ground truth
39//! ([`open`]). The server never holds a private key that could open
40//! envelopes on its own.
41//!
42//! # Byte layouts (stable forever; new layouts bump the version byte)
43//!
44//! - ciphertext blob: `version(1) || xnonce(24) || ct(len+16)`
45//! - wrapped key blob: `version(1) || ephemeral_pub(32) || ct(48)`
46//!   where `ct` = ChaCha20-Poly1305(KEK, zero nonce) over `K`. The zero
47//!   nonce is safe because each KEK is derived from a fresh ephemeral
48//!   key and used exactly once (same construction as age).
49//! - wrap KDF: `KEK = HKDF-SHA256(salt = ephemeral_pub || recipient_pub,
50//!   ikm = X25519(ephemeral, recipient), info = "agora/wrap/v1")`
51//!
52//! # Key registration
53//!
54//! An agent's X25519 public key is bound to its Ed25519 identity by a
55//! signature over `"agora/enc-key/v1" || x25519_public_bytes`
56//! ([`sign_encryption_key`]). The server verifies this at registration
57//! and clients MUST re-verify on fetch (and may pin, TOFU): a
58//! compromised server cannot swap in a MITM key without also holding
59//! the victim's signing key.
60
61use 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
77/// Version byte carried in both the ciphertext and wrapped-key blobs.
78pub const ENVELOPE_VERSION: u8 = 1;
79
80/// Domain separator for the encryption-key binding signature.
81const ENC_KEY_CONTEXT: &[u8] = b"agora/enc-key/v1";
82/// Domain separator prefix of the inner message signature.
83const MSG_CONTEXT: &[u8] = b"agora/msg/v1";
84/// HKDF info label for key wrapping.
85const 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;
91/// Exact length of a wrapped-key blob:
92/// `version || ephemeral_pub || ChaCha20-Poly1305(K)`.
93pub const WRAPPED_KEY_LEN: usize = 1 + PUB_LEN + 32 + TAG_LEN;
94/// Minimum length of a ciphertext blob:
95/// `version || xnonce || AEAD(sig alone)` — an empty plaintext still
96/// carries the embedded signature and tag.
97pub const MIN_CIPHERTEXT_LEN: usize = 1 + XNONCE_LEN + SIG_LEN + TAG_LEN;
98
99/// Errors from envelope operations.
100#[derive(Debug, thiserror::Error)]
101pub enum EnvelopeError {
102    /// Blob's leading version byte is not one we understand.
103    #[error("unsupported envelope version {0}")]
104    Version(u8),
105    /// Blob is structurally too short for its layout.
106    #[error("envelope blob too short: {0} bytes")]
107    Truncated(usize),
108    /// AEAD decryption failed (wrong key, tampered ciphertext, or wrong
109    /// AAD context).
110    #[error("decryption failed")]
111    Decrypt,
112    /// The ECDH shared secret was the all-zero point (non-contributory
113    /// peer key — a small-order or identity public key).
114    #[error("non-contributory X25519 public key")]
115    NonContributory,
116    /// The embedded Ed25519 signature did not verify against the sender
117    /// key and context.
118    #[error("message signature verification failed")]
119    BadSignature,
120    /// Hex decoding of a revealed message key failed.
121    #[error("invalid hex: {0}")]
122    Hex(#[from] hex::FromHexError),
123    /// A revealed message key had the wrong length.
124    #[error("message key must be 32 bytes, got {0}")]
125    KeyLength(usize),
126}
127
128/// The random symmetric message key `K`. Revealed (in hex) by a
129/// recipient when reporting a message; zeroized on drop otherwise.
130pub struct MessageKey(Zeroizing<[u8; 32]>);
131
132impl MessageKey {
133    /// Generate a fresh random message key.
134    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    /// Hex encoding, for the reveal field of a message report.
141    pub fn to_hex(&self) -> String {
142        hex::encode(self.0.as_ref())
143    }
144
145    /// Parse a revealed key from hex.
146    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/// The context fields bound into the inner message signature. On send,
170/// the client fills these from the request it is about to sign; at
171/// reveal, the server fills them from the stored row — never from the
172/// report.
173#[derive(Debug, Clone, Copy)]
174pub struct MessageContext {
175    pub message_id: MessageId,
176    pub sender_id: AgentId,
177    pub recipient_id: AgentId,
178    /// Outer `SignedAction` envelope timestamp (unix seconds); stored
179    /// server-side as `sent_at`.
180    pub timestamp: i64,
181}
182
183impl MessageContext {
184    /// Canonical bytes the inner signature covers (with the plaintext
185    /// appended by the caller).
186    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    /// AAD for the payload AEAD — the same `id || sender || recipient`
200    /// binding the server-mode cipher uses.
201    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
210/// Output of [`seal`]: the three BYTEA columns of an E2EE message row.
211pub struct SealedMessage {
212    /// `version || xnonce || XChaCha20-Poly1305(plaintext || signature)`.
213    pub ciphertext: Vec<u8>,
214    /// `K` wrapped to the recipient's static X25519 key.
215    pub wrapped_key_recipient: Vec<u8>,
216    /// `K` wrapped to the sender's own static X25519 key (outbox export).
217    pub wrapped_key_sender: Vec<u8>,
218}
219
220/// Generate a fresh X25519 encryption keypair.
221pub 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
228/// Hex-encode an encryption secret key (for key-file storage alongside
229/// the Ed25519 signing key).
230pub fn encryption_secret_to_hex(secret: &EncryptionSecretKey) -> String {
231    hex::encode(secret.to_bytes())
232}
233
234/// Load an encryption secret key from hex.
235pub 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
247/// Load an encryption public key from hex.
248pub 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
259/// Sign an X25519 public key with the agent's Ed25519 identity key,
260/// binding the two. The server verifies this at registration; clients
261/// re-verify on every fetch.
262pub 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
272/// Verify the Ed25519 binding signature on a fetched X25519 public key.
273pub 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
284/// Encrypt `plaintext` to `recipient_pub`, signing it with the sender's
285/// Ed25519 key under the given context. See the module docs for the
286/// exact construction.
287pub 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    // Inner signature over context || plaintext, then encrypt
299    // plaintext || signature under K.
300    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
330/// Wrap `K` to a static X25519 public key (fresh ephemeral per wrap).
331fn 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    // Zero nonce: the KEK is single-use by construction (fresh ephemeral).
342    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
355/// Unwrap `K` from a wrapped-key blob using one's own static secret.
356/// Works for either party's wrap (recipient inbox read, sender outbox
357/// export).
358pub 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
387/// `KEK = HKDF-SHA256(salt = ephemeral_pub || recipient_pub, ikm = shared,
388/// info = "agora/wrap/v1")` — age's X25519 stanza construction.
389fn 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
407/// Decrypt a ciphertext blob with `K` and verify the embedded signature
408/// against the sender's Ed25519 key and the trusted context. Returns
409/// the plaintext.
410///
411/// Callers on both ends use this: the recipient after [`unwrap_key`],
412/// and the server at reveal with the reporter-supplied `K` and context
413/// fields taken from the stored row.
414pub 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    // verify_strict, matching `crypto::verify` — small-order sender keys
446    // must not admit forgeries here either.
447    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        // The server holds ciphertext + row context + sender's public
541        // key. Reporter reveals K in hex. That must be sufficient.
542        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        // Recipient B unwraps A's message and re-encrypts the signed
597        // blob to C under a context claiming C is the recipient. C (or
598        // the server at reveal) must reject: the inner signature binds
599        // the original recipient.
600        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        // B forwards to C: same plaintext+signature blob, new context.
617        let forged_ctx = MessageContext {
618            recipient_id: AgentId::from(Uuid::from_u128(0x4444)),
619            ..original_ctx
620        };
621        // Simulate by decrypting and re-sealing the raw blob under a new
622        // K to C's key with B unable to produce A's signature over the
623        // forged context. Verification against the forged context fails.
624        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 can only sign with its own key…
635            &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        // …so verifying the "message from A" against A's key fails.
642        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        // AAD binds id/sender/recipient, so a swapped sender fails at
671        // the AEAD layer, not just at signature verification.
672        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        // Timestamp is outside the AAD but inside the signature.
687        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        // A different X25519 key under the same signature must fail —
710        // otherwise a server could swap keys.
711        let (_, other_pub) = generate_encryption_keypair();
712        assert!(!verify_encryption_key(&verifying, &other_pub, &sig));
713
714        // A different identity must fail.
715        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        // The identity element as a recipient key must be refused at
734        // seal time (non-contributory ECDH), mirroring verify_strict's
735        // posture on the signing side.
736        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    /// Locked test vector for the version-1 wire format. Deterministic
751    /// given fixed keys, ephemeral, and nonce — reproduced here by
752    /// construction through the internal functions. If this test breaks,
753    /// you have changed the locked envelope format; that requires a
754    /// version bump, not a vector update.
755    #[test]
756    fn version1_wrap_test_vector() {
757        // Fixed "static" secret: bytes 1..=32; fixed KEK derivation input.
758        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        // Fixed "ephemeral" secret: bytes 33..=64.
764        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        // Wrap a fixed K under that KEK (zero nonce) and confirm the
780        // full blob unwraps through the public API.
781        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}