Skip to main content

venice_e2ee_proxy/
e2ee.rs

1//! Venice E2EE encryption/decryption codec.
2//!
3//! Implements the Venice E2EE field codec:
4//!
5//! ```text
6//! ephemeral_public_key[65 bytes] || nonce[12 bytes] || ciphertext_and_gcm_tag
7//! ```
8//!
9//! The packed bytes are serialized as lowercase hex strings in request message
10//! content fields and in encrypted Venice streaming response `delta.content`
11//! fields.
12
13use std::fmt;
14
15use aes_gcm::{
16    Aes256Gcm,
17    aead::{Aead, KeyInit},
18};
19use hkdf::Hkdf;
20use k256::{
21    PublicKey, SecretKey,
22    ecdh::{SharedSecret, diffie_hellman},
23    elliptic_curve::sec1::ToEncodedPoint,
24};
25use rand_core::{OsRng, RngCore};
26use serde::{Deserialize, Serialize};
27use sha2::Sha256;
28use thiserror::Error;
29use zeroize::{ZeroizeOnDrop, Zeroizing};
30
31use crate::config::E2eeConfig;
32
33pub const EPHEMERAL_PUBLIC_KEY_LEN: usize = 65;
34pub const NONCE_LEN: usize = 12;
35pub const AES_256_KEY_LEN: usize = 32;
36pub const AES_GCM_TAG_LEN: usize = 16;
37pub const PACKED_PREFIX_LEN: usize = EPHEMERAL_PUBLIC_KEY_LEN + NONCE_LEN;
38pub const MIN_PACKED_PAYLOAD_LEN: usize = PACKED_PREFIX_LEN + AES_GCM_TAG_LEN;
39
40/// E2EE codec configured with the HKDF info string and fail-closed response policy.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct E2eeCodec {
43    hkdf_info: Vec<u8>,
44    require_encrypted_response_content: bool,
45}
46
47impl E2eeCodec {
48    /// Builds an E2EE codec from validated proxy configuration.
49    pub fn from_config(config: &E2eeConfig) -> Result<Self, E2eeCodecError> {
50        Self::new(
51            config.hkdf_info.as_bytes(),
52            config.require_encrypted_response_content,
53        )
54    }
55
56    /// Builds an E2EE codec from HKDF context bytes and the missing-content response policy.
57    pub fn new(
58        hkdf_info: impl AsRef<[u8]>,
59        require_encrypted_response_content: bool,
60    ) -> Result<Self, E2eeCodecError> {
61        let hkdf_info = hkdf_info.as_ref();
62        if hkdf_info.is_empty() {
63            return Err(E2eeCodecError::EmptyHkdfInfo);
64        }
65
66        Ok(Self {
67            hkdf_info: hkdf_info.to_vec(),
68            require_encrypted_response_content,
69        })
70    }
71
72    /// Returns whether response chunks must contain encrypted content fields.
73    pub fn require_encrypted_response_content(&self) -> bool {
74        self.require_encrypted_response_content
75    }
76
77    /// Derives a Venice AES-256-GCM content key from a local secp256k1 private
78    /// key and a peer uncompressed SEC1 public key hex string.
79    pub fn derive_content_key(
80        &self,
81        local_private_key: &SecretKey,
82        peer_public_key_hex: &str,
83    ) -> Result<ContentEncryptionKey, E2eeCodecError> {
84        let peer_public_key = decode_uncompressed_public_key_hex(peer_public_key_hex)?;
85        Ok(self.derive_content_key_from_public_key(local_private_key, &peer_public_key))
86    }
87
88    /// Encrypts one normalized model-visible text field for a Venice E2EE request.
89    pub fn encrypt_content(
90        &self,
91        plaintext: &str,
92        peer_public_key_hex: &str,
93    ) -> Result<EncryptedPayload, E2eeCodecError> {
94        let peer_public_key = decode_uncompressed_public_key_hex(peer_public_key_hex)?;
95        let ephemeral_private_key = SecretKey::random(&mut OsRng);
96        let nonce = Nonce::generate();
97
98        self.encrypt_content_with_parts(plaintext, &peer_public_key, ephemeral_private_key, nonce)
99    }
100
101    /// Decrypts a packed Venice E2EE hex payload into UTF-8 text.
102    pub fn decrypt_content(
103        &self,
104        payload: &EncryptedPayload,
105        recipient_private_key: &SecretKey,
106    ) -> Result<String, E2eeCodecError> {
107        let packed = PackedEncryptedPayload::unpack(payload)?;
108        let key = self.derive_content_key_from_public_key(
109            recipient_private_key,
110            &packed.ephemeral_public_key,
111        );
112        let cipher = aes256_gcm_from_key(&key);
113        let plaintext = Zeroizing::new(
114            cipher
115                .decrypt((&packed.nonce).into(), packed.ciphertext_and_tag.as_slice())
116                .map_err(|_| E2eeCodecError::AuthenticationFailed)?,
117        );
118
119        String::from_utf8(plaintext.to_vec()).map_err(|_| E2eeCodecError::InvalidPlaintextUtf8)
120    }
121
122    /// Extracts and decrypts `choices[0].delta.content`-style encrypted response
123    /// content. Missing content fails closed when configured to require encrypted
124    /// response content, and otherwise returns `Ok(None)`.
125    pub fn decrypt_response_content(
126        &self,
127        content: Option<&str>,
128        recipient_private_key: &SecretKey,
129    ) -> Result<Option<String>, E2eeCodecError> {
130        let Some(content) = content else {
131            return if self.require_encrypted_response_content {
132                Err(E2eeCodecError::MissingEncryptedContent)
133            } else {
134                Ok(None)
135            };
136        };
137
138        let payload = EncryptedPayload::from_hex(content)?;
139        self.decrypt_content(&payload, recipient_private_key)
140            .map(Some)
141    }
142
143    /// Encrypts plaintext using caller-supplied peer key, ephemeral key, and nonce values.
144    fn encrypt_content_with_parts(
145        &self,
146        plaintext: &str,
147        peer_public_key: &PublicKey,
148        ephemeral_private_key: SecretKey,
149        nonce: Nonce,
150    ) -> Result<EncryptedPayload, E2eeCodecError> {
151        let ephemeral_public_key = ephemeral_private_key.public_key();
152        let ephemeral_public_key = ephemeral_public_key.to_encoded_point(false);
153        let ephemeral_public_key_bytes = ephemeral_public_key.as_bytes();
154        debug_assert_eq!(ephemeral_public_key_bytes.len(), EPHEMERAL_PUBLIC_KEY_LEN);
155
156        let key = self.derive_content_key_from_public_key(&ephemeral_private_key, peer_public_key);
157        let cipher = aes256_gcm_from_key(&key);
158        let ciphertext_and_tag = cipher
159            .encrypt(nonce.as_bytes().into(), plaintext.as_bytes())
160            .map_err(|_| E2eeCodecError::EncryptionFailed)?;
161
162        let mut packed = Vec::with_capacity(PACKED_PREFIX_LEN + ciphertext_and_tag.len());
163        packed.extend_from_slice(ephemeral_public_key_bytes);
164        packed.extend_from_slice(nonce.as_bytes());
165        packed.extend_from_slice(&ciphertext_and_tag);
166
167        Ok(EncryptedPayload::from_packed_bytes_unchecked(&packed))
168    }
169
170    /// Derives a content key from a local private key and parsed peer public key.
171    fn derive_content_key_from_public_key(
172        &self,
173        local_private_key: &SecretKey,
174        peer_public_key: &PublicKey,
175    ) -> ContentEncryptionKey {
176        let shared_secret = diffie_hellman(
177            local_private_key.to_nonzero_scalar(),
178            peer_public_key.as_affine(),
179        );
180        derive_aes_key(&shared_secret, &self.hkdf_info)
181    }
182}
183
184impl Default for E2eeCodec {
185    /// Returns the codec for the default E2EE configuration.
186    fn default() -> Self {
187        Self::from_config(&E2eeConfig::default()).expect("default E2EE config is valid")
188    }
189}
190
191/// An AES-256-GCM content-encryption key derived from ECDH + HKDF-SHA256.
192///
193/// Debug output is redacted and the key bytes are zeroized on drop.
194pub struct ContentEncryptionKey(Zeroizing<[u8; AES_256_KEY_LEN]>);
195
196impl ContentEncryptionKey {
197    /// Wraps derived key bytes in the content-encryption key type.
198    fn new(bytes: Zeroizing<[u8; AES_256_KEY_LEN]>) -> Self {
199        Self(bytes)
200    }
201
202    /// Returns the raw key bytes for cipher construction.
203    fn as_slice(&self) -> &[u8] {
204        &self.0[..]
205    }
206}
207
208impl ZeroizeOnDrop for ContentEncryptionKey {}
209
210impl fmt::Debug for ContentEncryptionKey {
211    /// Formats the key without exposing secret key bytes.
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        f.write_str("ContentEncryptionKey([redacted])")
214    }
215}
216
217impl PartialEq for ContentEncryptionKey {
218    /// Compares two content keys by their raw key bytes.
219    fn eq(&self, other: &Self) -> bool {
220        self.as_slice() == other.as_slice()
221    }
222}
223
224impl Eq for ContentEncryptionKey {}
225
226/// Builds an AES-256-GCM cipher from a derived content-encryption key.
227fn aes256_gcm_from_key(key: &ContentEncryptionKey) -> Aes256Gcm {
228    // Zeroization note for the resolved RustCrypto stack used here:
229    // - `aes-gcm` with `zeroize` wipes its temporary GHASH key during init.
230    // - the direct `aes` dependency enables `aes/zeroize`, so AES-256 retained
231    //   round keys implement safe zeroizing drop behavior.
232    // - the direct `ghash` dependency enables zeroizing of GHASH temporary
233    //   conversion buffers.
234    // - the direct `polyval` dependency enables zeroizing drops for POLYVAL
235    //   backends on targets/configurations where those drops are reachable.
236    //
237    // Residual concern: in the resolved `polyval` 0.6.x x86/x86_64 autodetect
238    // path, the public wrapper uses `ManuallyDrop` and does not expose a safe
239    // `Drop`/`ZeroizeOnDrop` implementation. That means `Aes256Gcm` still cannot
240    // be proven to zeroize every retained GHASH/POLYVAL byte on this target. We
241    // avoid brittle unsafe whole-object wiping and instead keep the cipher scoped
242    // to one encrypt/decrypt operation while zeroizing the derived key material
243    // and relying on verified safe drop behavior where the crates expose it.
244    Aes256Gcm::new((&*key.0).into())
245}
246
247/// AES-GCM nonce used by the Venice field codec.
248#[derive(Clone, Copy, PartialEq, Eq)]
249pub struct Nonce([u8; NONCE_LEN]);
250
251impl Nonce {
252    /// Generates a fresh random AES-GCM nonce.
253    pub fn generate() -> Self {
254        let mut bytes = [0_u8; NONCE_LEN];
255        OsRng.fill_bytes(&mut bytes);
256        Self(bytes)
257    }
258
259    /// Builds a nonce from exactly 12 bytes.
260    pub fn from_bytes(bytes: [u8; NONCE_LEN]) -> Self {
261        Self(bytes)
262    }
263
264    /// Returns the nonce bytes for AES-GCM encryption or decryption.
265    pub fn as_bytes(&self) -> &[u8; NONCE_LEN] {
266        &self.0
267    }
268}
269
270impl fmt::Debug for Nonce {
271    /// Formats the nonce as hex for diagnostics.
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        f.debug_tuple("Nonce")
274            .field(&hex::encode(self.as_bytes()))
275            .finish()
276    }
277}
278
279/// Venice encrypted payload serialized as a lowercase hex string.
280#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(transparent)]
282pub struct EncryptedPayload(String);
283
284impl EncryptedPayload {
285    /// Validates a packed encrypted payload hex string and stores it in lowercase form.
286    pub fn from_hex(value: impl Into<String>) -> Result<Self, E2eeCodecError> {
287        let value = value.into();
288        validate_packed_payload_hex(&value)?;
289        Ok(Self(value.to_ascii_lowercase()))
290    }
291
292    /// Returns the packed encrypted payload as a hex string slice.
293    pub fn as_hex(&self) -> &str {
294        &self.0
295    }
296
297    /// Consumes the payload and returns the owned packed hex string.
298    pub fn into_hex(self) -> String {
299        self.0
300    }
301
302    /// Encodes already-packed encrypted payload bytes as lowercase hex.
303    fn from_packed_bytes_unchecked(bytes: &[u8]) -> Self {
304        Self(hex::encode(bytes))
305    }
306}
307
308impl fmt::Debug for EncryptedPayload {
309    /// Formats payload metadata without printing the ciphertext bytes.
310    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311        f.debug_struct("EncryptedPayload")
312            .field("hex_len", &self.0.len())
313            .finish()
314    }
315}
316
317/// Parsed parts of a packed Venice E2EE payload.
318struct PackedEncryptedPayload {
319    ephemeral_public_key: PublicKey,
320    nonce: [u8; NONCE_LEN],
321    ciphertext_and_tag: Vec<u8>,
322}
323
324impl PackedEncryptedPayload {
325    /// Splits a validated encrypted payload into public key, nonce, and ciphertext/tag parts.
326    fn unpack(payload: &EncryptedPayload) -> Result<Self, E2eeCodecError> {
327        let bytes = hex::decode(payload.as_hex()).map_err(|error| {
328            E2eeCodecError::MalformedEncryptedPayload {
329                message: error.to_string(),
330            }
331        })?;
332        if bytes.len() < MIN_PACKED_PAYLOAD_LEN {
333            return Err(E2eeCodecError::MalformedEncryptedPayload {
334                message: format!(
335                    "packed encrypted payload is too short: got {} bytes, need at least {MIN_PACKED_PAYLOAD_LEN}",
336                    bytes.len()
337                ),
338            });
339        }
340
341        if bytes[0] != 0x04 {
342            return Err(E2eeCodecError::MalformedEncryptedPayload {
343                message: "ephemeral public key must be uncompressed SEC1 format".to_owned(),
344            });
345        }
346        let ephemeral_public_key = PublicKey::from_sec1_bytes(&bytes[..EPHEMERAL_PUBLIC_KEY_LEN])
347            .map_err(|_| E2eeCodecError::MalformedEncryptedPayload {
348            message: "ephemeral public key is not a valid secp256k1 key".to_owned(),
349        })?;
350
351        let mut nonce = [0_u8; NONCE_LEN];
352        nonce.copy_from_slice(&bytes[EPHEMERAL_PUBLIC_KEY_LEN..PACKED_PREFIX_LEN]);
353
354        Ok(Self {
355            ephemeral_public_key,
356            nonce,
357            ciphertext_and_tag: bytes[PACKED_PREFIX_LEN..].to_vec(),
358        })
359    }
360}
361
362/// Errors returned by E2EE request encryption and response decryption.
363#[derive(Debug, Error, PartialEq, Eq)]
364pub enum E2eeCodecError {
365    #[error("configured E2EE HKDF info must not be empty")]
366    EmptyHkdfInfo,
367    #[error("encrypted response content is required but missing")]
368    MissingEncryptedContent,
369    #[error("encrypted payload is malformed: {message}")]
370    MalformedEncryptedPayload { message: String },
371    #[error("encrypted payload authentication failed")]
372    AuthenticationFailed,
373    #[error("invalid E2EE public key: {message}")]
374    InvalidPublicKey { message: String },
375    #[error("decrypted E2EE payload is not valid UTF-8")]
376    InvalidPlaintextUtf8,
377    #[error("E2EE encryption failed")]
378    EncryptionFailed,
379}
380
381/// Derives an AES-256 content key from an ECDH shared secret and HKDF context bytes.
382fn derive_aes_key(shared_secret: &SharedSecret, hkdf_info: &[u8]) -> ContentEncryptionKey {
383    let hkdf = Hkdf::<Sha256>::new(None, shared_secret.raw_secret_bytes());
384    let mut output_key = Zeroizing::new([0_u8; AES_256_KEY_LEN]);
385    hkdf.expand(hkdf_info, output_key.as_mut_slice())
386        .expect("32-byte HKDF-SHA256 output length is always valid");
387    ContentEncryptionKey::new(output_key)
388}
389
390/// Parses an uncompressed SEC1 secp256k1 public key from a hex string.
391fn decode_uncompressed_public_key_hex(value: &str) -> Result<PublicKey, E2eeCodecError> {
392    let bytes = hex::decode(value).map_err(|error| E2eeCodecError::InvalidPublicKey {
393        message: error.to_string(),
394    })?;
395
396    if bytes.len() != EPHEMERAL_PUBLIC_KEY_LEN {
397        return Err(E2eeCodecError::InvalidPublicKey {
398            message: format!(
399                "expected {EPHEMERAL_PUBLIC_KEY_LEN} uncompressed SEC1 bytes, got {}",
400                bytes.len()
401            ),
402        });
403    }
404    if bytes.first() != Some(&0x04) {
405        return Err(E2eeCodecError::InvalidPublicKey {
406            message: "public key must be uncompressed SEC1 format".to_owned(),
407        });
408    }
409
410    PublicKey::from_sec1_bytes(&bytes).map_err(|_| E2eeCodecError::InvalidPublicKey {
411        message: "public key is not a valid secp256k1 key".to_owned(),
412    })
413}
414
415/// Validates that a hex string can contain the minimum packed Venice E2EE payload.
416fn validate_packed_payload_hex(value: &str) -> Result<(), E2eeCodecError> {
417    if value.is_empty() {
418        return Err(E2eeCodecError::MalformedEncryptedPayload {
419            message: "encrypted payload hex string is empty".to_owned(),
420        });
421    }
422    if !value.len().is_multiple_of(2) {
423        return Err(E2eeCodecError::MalformedEncryptedPayload {
424            message: "encrypted payload hex string has odd length".to_owned(),
425        });
426    }
427    if value.len() < MIN_PACKED_PAYLOAD_LEN * 2 {
428        return Err(E2eeCodecError::MalformedEncryptedPayload {
429            message: format!(
430                "encrypted payload hex string is too short: got {} chars, need at least {}",
431                value.len(),
432                MIN_PACKED_PAYLOAD_LEN * 2
433            ),
434        });
435    }
436    if let Some((index, ch)) = value.char_indices().find(|(_, ch)| !ch.is_ascii_hexdigit()) {
437        return Err(E2eeCodecError::MalformedEncryptedPayload {
438            message: format!(
439                "encrypted payload hex string contains non-hex character {ch:?} at index {index}"
440            ),
441        });
442    }
443    Ok(())
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    const FIXED_NONCE: [u8; NONCE_LEN] = [
451        0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab,
452    ];
453    const FIXED_RECIPIENT_PRIVATE_KEY_HEX: &str =
454        "1111111111111111111111111111111111111111111111111111111111111111";
455    const FIXED_EPHEMERAL_PRIVATE_KEY_HEX: &str =
456        "2222222222222222222222222222222222222222222222222222222222222222";
457    const DETERMINISTIC_PLAINTEXT: &str = "deterministic Venice E2EE fixture";
458    const DETERMINISTIC_CIPHERTEXT_HEX: &str = "04466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f276728176c3c6431f8eeda4538dc37c865e2784f3a9e77d044f33e407797e1278aa0a1a2a3a4a5a6a7a8a9aaab3b364a6560dc6246955e1379bac6c7a0f453c5b2d9be6eabb00cad9955278b4c401f6793813d7f98ba8f163a5c51b87686";
459
460    fn secret_key_from_hex(value: &str) -> SecretKey {
461        let bytes = hex::decode(value).expect("test key hex should decode");
462        SecretKey::from_slice(&bytes).expect("test key should be valid")
463    }
464
465    fn public_key_hex(secret_key: &SecretKey) -> String {
466        hex::encode(secret_key.public_key().to_encoded_point(false).as_bytes())
467    }
468
469    #[test]
470    fn encrypt_decrypt_round_trip() {
471        let codec = E2eeCodec::default();
472        let recipient_private_key = SecretKey::random(&mut OsRng);
473        let recipient_public_key_hex = public_key_hex(&recipient_private_key);
474
475        let encrypted = codec
476            .encrypt_content("hello from local proxy", &recipient_public_key_hex)
477            .expect("encryption should succeed");
478        let decrypted = codec
479            .decrypt_content(&encrypted, &recipient_private_key)
480            .expect("decryption should succeed");
481
482        assert_eq!(decrypted, "hello from local proxy");
483        assert!(
484            encrypted
485                .as_hex()
486                .chars()
487                .all(|ch| !ch.is_ascii_uppercase())
488        );
489    }
490
491    #[test]
492    fn decryption_with_wrong_key_fails_authentication() {
493        let codec = E2eeCodec::default();
494        let recipient_private_key = SecretKey::random(&mut OsRng);
495        let wrong_private_key = SecretKey::random(&mut OsRng);
496        let recipient_public_key_hex = public_key_hex(&recipient_private_key);
497        let encrypted = codec
498            .encrypt_content("secret", &recipient_public_key_hex)
499            .expect("encryption should succeed");
500
501        let err = codec
502            .decrypt_content(&encrypted, &wrong_private_key)
503            .expect_err("wrong key must fail closed");
504
505        assert_eq!(err, E2eeCodecError::AuthenticationFailed);
506    }
507
508    #[test]
509    fn tampered_ciphertext_fails_authentication() {
510        let codec = E2eeCodec::default();
511        let recipient_private_key = SecretKey::random(&mut OsRng);
512        let recipient_public_key_hex = public_key_hex(&recipient_private_key);
513        let encrypted = codec
514            .encrypt_content("secret", &recipient_public_key_hex)
515            .expect("encryption should succeed");
516        let mut packed = hex::decode(encrypted.as_hex()).expect("ciphertext should decode");
517        let last = packed.last_mut().expect("ciphertext has tag byte");
518        *last ^= 0x01;
519        let tampered = EncryptedPayload::from_packed_bytes_unchecked(&packed);
520
521        let err = codec
522            .decrypt_content(&tampered, &recipient_private_key)
523            .expect_err("tampered ciphertext must fail closed");
524
525        assert_eq!(err, E2eeCodecError::AuthenticationFailed);
526    }
527
528    #[test]
529    fn malformed_payload_fails_closed() {
530        let codec = E2eeCodec::default();
531        let recipient_private_key = SecretKey::random(&mut OsRng);
532
533        let err = codec
534            .decrypt_response_content(Some("not encrypted"), &recipient_private_key)
535            .expect_err("non-hex payload should fail closed");
536        assert!(matches!(
537            err,
538            E2eeCodecError::MalformedEncryptedPayload { .. }
539        ));
540
541        let too_short = "04".repeat(EPHEMERAL_PUBLIC_KEY_LEN + NONCE_LEN);
542        let err =
543            EncryptedPayload::from_hex(too_short).expect_err("short payload should be rejected");
544        assert!(matches!(
545            err,
546            E2eeCodecError::MalformedEncryptedPayload { .. }
547        ));
548    }
549
550    #[test]
551    fn missing_encrypted_response_content_respects_config() {
552        let recipient_private_key = SecretKey::random(&mut OsRng);
553
554        let required = E2eeCodec::new("ecdsa_encryption", true).expect("config should be valid");
555        let err = required
556            .decrypt_response_content(None, &recipient_private_key)
557            .expect_err("missing required encrypted content should fail");
558        assert_eq!(err, E2eeCodecError::MissingEncryptedContent);
559
560        let optional = E2eeCodec::new("ecdsa_encryption", false).expect("config should be valid");
561        let decrypted = optional
562            .decrypt_response_content(None, &recipient_private_key)
563            .expect("missing optional content should be allowed");
564        assert_eq!(decrypted, None);
565    }
566
567    #[test]
568    fn deterministic_test_vector_with_fixed_nonce_and_ephemeral_key() {
569        let codec = E2eeCodec::default();
570        let recipient_private_key = secret_key_from_hex(FIXED_RECIPIENT_PRIVATE_KEY_HEX);
571        let recipient_public_key = recipient_private_key.public_key();
572        let ephemeral_private_key = secret_key_from_hex(FIXED_EPHEMERAL_PRIVATE_KEY_HEX);
573
574        let encrypted = codec
575            .encrypt_content_with_parts(
576                DETERMINISTIC_PLAINTEXT,
577                &recipient_public_key,
578                ephemeral_private_key,
579                Nonce::from_bytes(FIXED_NONCE),
580            )
581            .expect("deterministic encryption should succeed");
582
583        assert_eq!(encrypted.as_hex(), DETERMINISTIC_CIPHERTEXT_HEX);
584        let decrypted = codec
585            .decrypt_content(&encrypted, &recipient_private_key)
586            .expect("deterministic fixture should decrypt");
587        assert_eq!(decrypted, DETERMINISTIC_PLAINTEXT);
588    }
589
590    #[test]
591    fn derived_keys_match_from_both_sides_and_debug_is_redacted() {
592        fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
593
594        assert_zeroize_on_drop::<SecretKey>();
595        assert_zeroize_on_drop::<SharedSecret>();
596        assert_zeroize_on_drop::<aes::Aes256>();
597        assert_zeroize_on_drop::<ContentEncryptionKey>();
598
599        let codec = E2eeCodec::default();
600        let local_private_key = SecretKey::random(&mut OsRng);
601        let peer_private_key = SecretKey::random(&mut OsRng);
602        let local_public_key_hex = public_key_hex(&local_private_key);
603        let peer_public_key_hex = public_key_hex(&peer_private_key);
604
605        let local_key = codec
606            .derive_content_key(&local_private_key, &peer_public_key_hex)
607            .expect("local derivation should succeed");
608        let peer_key = codec
609            .derive_content_key(&peer_private_key, &local_public_key_hex)
610            .expect("peer derivation should succeed");
611
612        assert_eq!(local_key, peer_key);
613        assert_eq!(format!("{local_key:?}"), "ContentEncryptionKey([redacted])");
614    }
615}